African FX exchange rate API — documentation

    Integrate live African FX and provider pricing data into your applications using the Modan REST API.

    Base URL: https://modan.io/api/v1

    Quick Start

    Get live GBP→NGN provider rates in one request. Replace mdn_live_YOUR_KEY_HERE with your API key — created free in the terminal under Developers → My Keys.

    curl "https://modan.io/api/v1/rates?from=GBP&to=NGN" \
    -H "X-API-Key: mdn_live_YOUR_KEY_HERE"

    Authentication

    All endpoints require an API key passed via the X-API-Key request header.

    X-API-Key: mdn_live_YOUR_KEY_HERE

    Keys are generated server-side and shown only once — store yours securely. Requests return 401 Unauthorized if the key is missing, invalid, or revoked.

    Endpoints

    MethodPathDescriptionAuth
    GET
    /rates?from=GBP&to=NGN
    Latest provider rates for a currency corridor
    Required
    GET
    /convert?from=GBP&to=NGN&amount=1000
    Convert an amount across a corridor, per provider (net of fees)
    Required
    GET
    /fetch-one?from=USD&to=NGN
    Single pair: mid-market rate + best current executable provider quote
    Required
    GET
    /fetch-multi?from=USD&to=NGN,KES,GHS
    One base to up to 20 quotes in one call
    Required
    GET
    /fetch-matrix?from=USD,GBP&to=NGN,KES
    Full cross matrix (up to 10×10 pairs)
    Required
    GET
    /fetch-many-to-one?from=USD,GBP,CAD&to=NGN
    Many bases into a single quote currency
    Required
    GET
    /rates/history?from=GBP&to=NGN&period=30d&order=asc&limit=500&offset=0
    Historical rate time-series (paginated, oldest-first by default)
    Required
    GET
    /time-series?from=GBP&to=NGN&period=30d&interval=P1D
    Daily/hourly series: mid + best current executable quote at each bucket's end
    Required
    GET
    /historical?from=GBP&to=NGN&date=2026-07-01
    Corridor snapshot as of a past date (mid + per-provider)
    Required
    GET
    /change?from=GBP&to=NGN&period=7d
    Absolute and % change of mid + best rate over a period
    Required
    GET
    /rates/provider?provider=lemfi
    Every corridor and current rate one provider quotes
    Required
    GET
    /corridors
    All covered corridors with current provider counts and best rates
    Required
    GET
    /providers
    All active providers with metadata
    Required
    GET
    /currencies
    Active currencies and the corridors currently served
    Required
    GET
    /admin/usage
    Your account's usage, quota and per-key breakdown (does not consume quota)
    Required
    GET
    /status
    Public platform health: corridor count, data freshness (no key needed)
    POST
    /rates
    Ingest rate observations (data-team keys: admin/treasury accounts only)
    Required

    /rates/history: period = 1d/7d/30d (default)/90d, order = asc (default, oldest-first)/desc, limit (≤5000, default 500), offset, optional provider. The response includes has_more for paging. /convert takes amount and returns per-provider converted + net-of-fee values and the best among quotes that are executable and not stale (null when none qualifies), overall and within each provider type (best_by_type). The rate reads, all but /rates/provider, take an optional provider_type; see Provider types under Response Format. POST /rates ingests observations (JSON object or array ≤100, atomic) — it requires a key owned by an admin/treasury account and does not consume the read quota. Machine-readable spec: openapi.json.

    The /fetch-* family returns the independent mid-market rate per pair (cross-computed through the freshest USD reference snapshot), plus provider_best: the best current executable provider quote, or null when the pair is not a tracked corridor or no quote qualifies. One call = one quota unit regardless of pair count. Unsupported currencies return 400 with the supported list. /time-series accepts interval = P1D (daily, default, ≤366 buckets) or PT1H (hourly, ≤168), with period or explicit start/end; each point's best is the best current executable quote standing at the end of its bucket, or null when there was none. /admin/usage and /status never consume quota.

    Endpoint reference

    Full parameters, an example request and an example response for every endpoint. All paths are relative to https://modan.io/api/v1.

    Rates & conversion

    GET /api/v1/rates
    Key required

    Every tracked provider's current rate for a corridor, plus fee, spread vs the best current quote of the same provider type and rate type (bps), a stale flag, and the independent mid-market reference when available.

    Query parameters

    NameTypeReqDescription
    fromstringyesSource currency (ISO-4217), e.g. GBP
    tostringyesTarget currency (ISO-4217), e.g. NGN
    provider_typestringnoOne provider type, or a comma list: imto, fintech_psp, commercial_bank, central_bank, non_bank_lp, bureau_de_change, crypto_venue, aggregator. Omit it for every type. An unknown type returns 400 with the refused ids in invalid, before the key is checked, so it spends no quota.

    Example request

    curl "https://modan.io/api/v1/rates?from=GBP&to=NGN" \
    -H "X-API-Key: mdn_live_YOUR_KEY_HERE"

    Example response

    {
    "corridor": "GBP/NGN",
    "providers": [
    {
    "provider_id": "lemfi",
    "provider_name": "LemFi",
    "rate": 2050.50,
    "fee": 0,
    "fee_currency": "GBP",
    "spread_bps": 0,
    "stale": false,
    "vs_mid_bps": 12.1,
    "rate_type": "retail",
    "provider_type": "fintech_psp",
    "transfer_time": "In Minutes",
    "last_checked": "2026-07-06T09:00:00.000Z",
    "last_changed": "2026-07-06T07:45:00.000Z",
    "last_updated": "2026-07-06T07:45:00.000Z"
    },
    {
    "provider_id": "wise",
    "provider_name": "Wise",
    "rate": 2045.50,
    "fee": 2.99,
    "fee_currency": "GBP",
    "spread_bps": 24.4,
    "stale": false,
    "vs_mid_bps": -12.3,
    "rate_type": "retail",
    "provider_type": "fintech_psp",
    "transfer_time": "1 - 2 business days",
    "last_checked": "2026-07-06T09:00:00.000Z",
    "last_changed": "2026-07-05T16:10:00.000Z",
    "last_updated": "2026-07-05T16:10:00.000Z"
    },
    {
    "provider_id": "worldremit",
    "provider_name": "WorldRemit",
    "rate": 2058.00,
    "fee": 1.99,
    "fee_currency": "GBP",
    "spread_bps": null,
    "stale": true,
    "vs_mid_bps": 48.7,
    "rate_type": "retail",
    "provider_type": "imto",
    "transfer_time": "Same day",
    "last_checked": "2026-07-05T06:15:00.000Z",
    "last_changed": "2026-07-04T11:02:00.000Z",
    "last_updated": "2026-07-04T11:02:00.000Z"
    }
    ],
    "count": 3,
    "stale_count": 1,
    "mid_rate": 2048.02,
    "mid_source": "open.er-api.com",
    "mid_fetched_at": "2026-07-06T08:05:00.000Z",
    "timestamp": "2026-07-06T09:30:05.000Z",
    "data_freshness": "hourly",
    "as_of": "2026-07-06T09:00:00.000Z"
    }

    A quote not checked for 24 hours before the moment the response describes is returned with "stale": true and a null spread_bps, and is never a best rate; a quote not checked for 7 days is not returned. commercial_bank and central_bank quotes age on a weekday clock: Saturday and Sunday (UTC) hours do not count. stale_count says how many of the providers are stale. provider_type narrows the answer to those provider types, and the response echoes them as provider_types. spread_bps is measured within each provider type and rate type, so narrowing never changes it.

    GET /api/v1/convert
    Key required

    Convert an amount across a corridor for every provider — gross, net-of-fee delivered value, the best net amount for the recipient among quotes that are executable and not stale (best, across the provider types returned), and the same answer within each provider type (best_by_type).

    Query parameters

    NameTypeReqDescription
    fromstringyesSource currency (ISO-4217), e.g. GBP
    tostringyesTarget currency (ISO-4217), e.g. NGN
    amountnumberyesAmount in the source currency (> 0)
    provider_typestringnoOne provider type, or a comma list: imto, fintech_psp, commercial_bank, central_bank, non_bank_lp, bureau_de_change, crypto_venue, aggregator. Omit it for every type. An unknown type returns 400 with the refused ids in invalid, before the key is checked, so it spends no quota.

    Example request

    curl "https://modan.io/api/v1/convert?from=GBP&to=NGN&amount=1000" \
    -H "X-API-Key: mdn_live_YOUR_KEY_HERE"

    Example response

    {
    "from": "GBP",
    "to": "NGN",
    "amount": 1000,
    "mid": { "rate": 2048.02, "converted": 2048020, "source": "open.er-api.com", "fetched_at": "2026-07-06T08:05:00.000Z" },
    "best": { "provider_id": "lemfi", "provider_name": "LemFi", "rate": 2050.50, "fee": 0, "fee_currency": "GBP", "converted": 2050500, "net_converted": 2050500, "spread_bps": 0, "rate_type": "retail", "provider_type": "fintech_psp", "executable": true, "stale": false, "last_checked": "2026-07-06T09:00:00.000Z", "last_changed": "2026-07-06T07:45:00.000Z", "vs_mid_bps": 12.1 },
    "best_by_type": { "imto": null, "fintech_psp": "lemfi" },
    "providers": [
    { "provider_id": "lemfi", "provider_name": "LemFi", "rate": 2050.50, "fee": 0, "fee_currency": "GBP", "converted": 2050500, "net_converted": 2050500, "spread_bps": 0, "rate_type": "retail", "provider_type": "fintech_psp", "executable": true, "stale": false, "last_checked": "2026-07-06T09:00:00.000Z", "last_changed": "2026-07-06T07:45:00.000Z", "vs_mid_bps": 12.1 },
    { "provider_id": "wise", "provider_name": "Wise", "rate": 2045.50, "fee": 2.99, "fee_currency": "GBP", "converted": 2045500, "net_converted": 2039383.955, "spread_bps": 24.4, "rate_type": "retail", "provider_type": "fintech_psp", "executable": true, "stale": false, "last_checked": "2026-07-06T09:00:00.000Z", "last_changed": "2026-07-05T16:10:00.000Z", "vs_mid_bps": -12.3 },
    { "provider_id": "worldremit", "provider_name": "WorldRemit", "rate": 2058.00, "fee": 1.99, "fee_currency": "GBP", "converted": 2058000, "net_converted": 2053904.58, "spread_bps": null, "rate_type": "retail", "provider_type": "imto", "executable": true, "stale": true, "last_checked": "2026-07-05T06:15:00.000Z", "last_changed": "2026-07-04T11:02:00.000Z", "vs_mid_bps": 48.7 }
    ],
    "count": 3,
    "stale_count": 1,
    "timestamp": "2026-07-06T09:30:05.000Z",
    "data_freshness": "hourly",
    "as_of": "2026-07-06T09:00:00.000Z"
    }

    best is never an official or parallel print and never a stale quote, and it is null when no quote qualifies. best_by_type names the best within each provider type present, in taxonomy order, and null for a type where nothing qualifies: read it, or pass provider_type, for a like-for-like answer. provider_type narrows the answer to those provider types, and the response echoes them as provider_types. spread_bps is measured within each provider type and rate type, so narrowing never changes it. A quote not checked for 24 hours before the moment the response describes is returned with "stale": true and a null spread_bps, and is never a best rate; a quote not checked for 7 days is not returned. commercial_bank and central_bank quotes age on a weekday clock: Saturday and Sunday (UTC) hours do not count.

    Fetch — multi-pair lookups

    fastforex-style convenience lookups. Every pair returns the independent mid (cross-computed through the freshest USD reference snapshot) plus provider_best: the best current executable provider quote (interbank, retail or p2p, checked within 24 hours; weekday hours for commercial_bank and central_bank) among the provider types asked for (every type unless provider_type narrows it, and then the response echoes them as provider_types), or null when the pair is not covered or no quote qualifies. The mid never depends on provider_type. One call = one quota unit regardless of pair count. Unsupported currencies return 400 with the supported list.

    GET /api/v1/fetch-one
    Key required

    A single base→quote pair: mid + the best current executable provider quote.

    Query parameters

    NameTypeReqDescription
    fromstringyesBase currency
    tostringyesQuote currency
    provider_typestringnoOne provider type, or a comma list: imto, fintech_psp, commercial_bank, central_bank, non_bank_lp, bureau_de_change, crypto_venue, aggregator. Omit it for every type. An unknown type returns 400 with the refused ids in invalid, before the key is checked, so it spends no quota.

    Example request

    curl "https://modan.io/api/v1/fetch-one?from=USD&to=NGN" \
    -H "X-API-Key: mdn_live_YOUR_KEY_HERE"

    Example response

    {
    "base": "USD",
    "quote": "NGN",
    "mid": 1377.0469,
    "provider_best": { "rate": 1385, "provider_id": "worldremit", "last_checked": "2026-07-10T17:00:00.000Z", "last_changed": "2026-07-10T16:34:49.256Z", "last_updated": "2026-07-10T16:34:49.256Z" },
    "source": "open.er-api.com",
    "fetched_at": "2026-07-10T16:05:01.477Z",
    "timestamp": "2026-07-10T17:45:45.058Z",
    "data_freshness": "hourly",
    "as_of": "2026-07-10T17:00:00.000Z"
    }
    GET /api/v1/fetch-multi
    Key required

    One base against up to 20 quote currencies in a single call.

    Query parameters

    NameTypeReqDescription
    fromstringyesBase currency
    tostringyes1–20 quote currencies, comma-separated
    provider_typestringnoOne provider type, or a comma list: imto, fintech_psp, commercial_bank, central_bank, non_bank_lp, bureau_de_change, crypto_venue, aggregator. Omit it for every type. An unknown type returns 400 with the refused ids in invalid, before the key is checked, so it spends no quota.

    Example request

    curl "https://modan.io/api/v1/fetch-multi?from=USD&to=NGN,KES,GHS" \
    -H "X-API-Key: mdn_live_YOUR_KEY_HERE"

    Example response

    {
    "base": "USD",
    "results": {
    "NGN": {
    "mid": 1377.0469,
    "provider_best": { "rate": 1385, "provider_id": "worldremit", "last_checked": "2026-07-10T17:00:00.000Z", "last_changed": "2026-07-10T16:34:49.256Z", "last_updated": "2026-07-10T16:34:49.256Z" }
    },
    "KES": {
    "mid": 129.16139,
    "provider_best": null
    }
    },
    "count": 2,
    "source": "open.er-api.com",
    "fetched_at": "2026-07-10T16:05:01.477Z",
    "timestamp": "2026-07-10T17:45:45.058Z",
    "data_freshness": "hourly",
    "as_of": "2026-07-10T17:00:00.000Z"
    }
    GET /api/v1/fetch-matrix
    Key required

    Full cross matrix of up to 10 bases × 10 quotes.

    Query parameters

    NameTypeReqDescription
    fromstringyes1–10 base currencies, comma-separated
    tostringyes1–10 quote currencies, comma-separated
    provider_typestringnoOne provider type, or a comma list: imto, fintech_psp, commercial_bank, central_bank, non_bank_lp, bureau_de_change, crypto_venue, aggregator. Omit it for every type. An unknown type returns 400 with the refused ids in invalid, before the key is checked, so it spends no quota.

    Example request

    curl "https://modan.io/api/v1/fetch-matrix?from=USD,GBP&to=NGN,KES" \
    -H "X-API-Key: mdn_live_YOUR_KEY_HERE"

    Example response

    {
    "bases": ["USD", "GBP"],
    "quotes": ["NGN", "KES"],
    "results": {
    "USD": {
    "NGN": { "mid": 1377.0469, "provider_best": { "rate": 1385, "provider_id": "worldremit", "last_checked": "2026-07-10T17:00:00.000Z", "last_changed": "2026-07-10T16:34:49.256Z", "last_updated": "2026-07-10T16:34:49.256Z" } },
    "KES": { "mid": 129.16139, "provider_best": null }
    },
    "GBP": {
    "NGN": { "mid": 1846.85, "provider_best": { "rate": 1851.65, "provider_id": "accrue", "last_checked": "2026-07-10T17:00:00.000Z", "last_changed": "2026-07-10T15:12:30.000Z", "last_updated": "2026-07-10T15:12:30.000Z" } },
    "KES": { "mid": 173.24, "provider_best": null }
    }
    },
    "source": "open.er-api.com",
    "fetched_at": "2026-07-10T16:05:01.477Z",
    "timestamp": "2026-07-10T17:45:45.058Z",
    "data_freshness": "hourly",
    "as_of": "2026-07-10T17:00:00.000Z"
    }
    GET /api/v1/fetch-many-to-one
    Key required

    Up to 20 base currencies into a single quote currency.

    Query parameters

    NameTypeReqDescription
    fromstringyes1–20 base currencies, comma-separated
    tostringyesSingle quote currency
    provider_typestringnoOne provider type, or a comma list: imto, fintech_psp, commercial_bank, central_bank, non_bank_lp, bureau_de_change, crypto_venue, aggregator. Omit it for every type. An unknown type returns 400 with the refused ids in invalid, before the key is checked, so it spends no quota.

    Example request

    curl "https://modan.io/api/v1/fetch-many-to-one?from=USD,GBP&to=NGN" \
    -H "X-API-Key: mdn_live_YOUR_KEY_HERE"

    Example response

    {
    "quote": "NGN",
    "results": {
    "USD": { "mid": 1377.0469, "provider_best": { "rate": 1385, "provider_id": "worldremit", "last_checked": "2026-07-10T17:00:00.000Z", "last_changed": "2026-07-10T16:34:49.256Z", "last_updated": "2026-07-10T16:34:49.256Z" } },
    "GBP": { "mid": 1846.85, "provider_best": { "rate": 1851.65, "provider_id": "accrue", "last_checked": "2026-07-10T17:00:00.000Z", "last_changed": "2026-07-10T15:12:30.000Z", "last_updated": "2026-07-10T15:12:30.000Z" } }
    },
    "count": 2,
    "source": "open.er-api.com",
    "fetched_at": "2026-07-10T16:05:01.477Z",
    "timestamp": "2026-07-10T17:45:45.058Z",
    "data_freshness": "hourly",
    "as_of": "2026-07-10T17:00:00.000Z"
    }

    Time series & history

    GET /api/v1/rates/history
    Key required

    Raw historical provider observations for a corridor, paginated and oldest-first by default.

    Query parameters

    NameTypeReqDescription
    fromstringyesSource currency (ISO-4217), e.g. GBP
    tostringyesTarget currency (ISO-4217), e.g. NGN
    periodstringno1d | 7d | 30d (default) | 90d
    orderstringnoasc (default, oldest-first) | desc
    limitintegernoPage size, ≤ 5000 (default 500)
    offsetintegernoRows to skip (default 0). Response carries has_more.
    providerstringnoFilter to a single provider_id
    provider_typestringnoOne provider type, or a comma list: imto, fintech_psp, commercial_bank, central_bank, non_bank_lp, bureau_de_change, crypto_venue, aggregator. Omit it for every type. An unknown type returns 400 with the refused ids in invalid, before the key is checked, so it spends no quota.

    Example request

    curl "https://modan.io/api/v1/rates/history?from=GBP&to=NGN&period=30d&limit=500&provider_type=fintech_psp" \
    -H "X-API-Key: mdn_live_YOUR_KEY_HERE"

    Example response

    {
    "provider_types": ["fintech_psp"],
    "corridor": "GBP/NGN",
    "period": "30d",
    "from_date": "2026-06-10T09:30:00.000Z",
    "to_date": "2026-07-10T09:00:00.000Z",
    "order": "asc",
    "limit": 500,
    "offset": 0,
    "has_more": false,
    "data": [
    { "timestamp": "2026-06-10T11:05:12.000Z", "provider_id": "lemfi", "provider_name": "LemFi", "rate": 2040.63, "fee": 0, "fee_currency": "GBP", "rate_type": "retail", "provider_type": "fintech_psp", "spread_bps": 0 },
    { "timestamp": "2026-06-10T11:05:40.000Z", "provider_id": "wise", "provider_name": "Wise", "rate": 2038.10, "fee": 2.99, "fee_currency": "GBP", "rate_type": "retail", "provider_type": "fintech_psp", "spread_bps": 12.4 }
    ],
    "count": 2,
    "data_freshness": "hourly",
    "as_of": "2026-07-10T09:00:00.000Z"
    }

    Each observation carries provider_type, and a spread_bps measured against the best rate set in the same minute by a provider of the same provider type and rate type. provider_type narrows the answer to those provider types, and the response echoes them as provider_types. The filter runs before paging, so limit, offset and has_more page through the filtered history.

    GET /api/v1/time-series
    Key required

    The mid and the best current executable quote at the end of each day or hour. Untracked pairs return a mid-only series with a note.

    Query parameters

    NameTypeReqDescription
    fromstringyesSource currency (ISO-4217), e.g. GBP
    tostringyesTarget currency (ISO-4217), e.g. NGN
    intervalstringnoP1D (daily, default, ≤366 buckets) | PT1H (hourly, ≤168)
    periodstringno1d | 7d | 30d | 90d — or pass start & end
    startstringnoISO-8601 window start (with end)
    endstringnoISO-8601 window end (defaults to now)
    provider_typestringnoOne provider type, or a comma list: imto, fintech_psp, commercial_bank, central_bank, non_bank_lp, bureau_de_change, crypto_venue, aggregator. Omit it for every type. An unknown type returns 400 with the refused ids in invalid, before the key is checked, so it spends no quota.

    Example request

    curl "https://modan.io/api/v1/time-series?from=GBP&to=NGN&period=7d&interval=P1D" \
    -H "X-API-Key: mdn_live_YOUR_KEY_HERE"

    Example response

    {
    "corridor": "GBP/NGN",
    "interval": "P1D",
    "start": "2026-07-03T17:45:45.058Z",
    "end": "2026-07-10T17:00:00.000Z",
    "tracked_corridor": true,
    "data": [
    { "t": "2026-07-03T00:00:00+00:00", "mid": 2038.52, "best": 2037.10, "samples": 13 },
    { "t": "2026-07-04T00:00:00+00:00", "mid": 2040.18, "best": 2039.00, "samples": 13 },
    { "t": "2026-07-05T00:00:00+00:00", "mid": 2040.35, "best": null, "samples": 0 },
    { "t": "2026-07-06T00:00:00+00:00", "mid": 2042.77, "best": 2041.30, "samples": 12 },
    { "t": "2026-07-07T00:00:00+00:00", "mid": 2043.90, "best": 2044.25, "samples": 13 },
    { "t": "2026-07-08T00:00:00+00:00", "mid": 2045.02, "best": 2046.80, "samples": 13 },
    { "t": "2026-07-09T00:00:00+00:00", "mid": 2046.11, "best": 2047.50, "samples": 13 },
    { "t": "2026-07-10T00:00:00+00:00", "mid": 2048.02, "best": 2050.50, "samples": 13 }
    ],
    "count": 8,
    "timestamp": "2026-07-10T17:45:45.058Z",
    "data_freshness": "hourly",
    "as_of": "2026-07-10T17:00:00.000Z"
    }

    Each point is read at the end of its bucket (the window's end for the last one). best is the highest executable quote (interbank, retail or p2p) that was current then: each provider's latest price at or before that moment, checked within 24 hours before it, on a weekday clock for commercial_bank and central_bank quotes. A provider holding a steady price counts although it recorded no new price in the bucket. samples is how many quotes best was taken from; best is null and samples 0 when none was current. A bucket is listed while the corridor has a quote checked within 7 days of its end. provider_type narrows the answer to those provider types, and the response echoes them as provider_types. With it, only quotes of those types count toward best, samples and whether a bucket is listed, so tracked_corridor is false when none of them has a quote in the window, and the note says so. The mid never depends on it.

    GET /api/v1/historical
    Key required

    The corridor snapshot as of a past date — mid plus each provider's most recent rate at that point, judged stale or current as of that date.

    Query parameters

    NameTypeReqDescription
    fromstringyesSource currency (ISO-4217), e.g. GBP
    tostringyesTarget currency (ISO-4217), e.g. NGN
    datestringyesCalendar date, YYYY-MM-DD (not in the future)
    provider_typestringnoOne provider type, or a comma list: imto, fintech_psp, commercial_bank, central_bank, non_bank_lp, bureau_de_change, crypto_venue, aggregator. Omit it for every type. An unknown type returns 400 with the refused ids in invalid, before the key is checked, so it spends no quota.

    Example request

    curl "https://modan.io/api/v1/historical?from=GBP&to=NGN&date=2026-07-01" \
    -H "X-API-Key: mdn_live_YOUR_KEY_HERE"

    Example response

    {
    "corridor": "GBP/NGN",
    "providers": [
    { "provider_id": "wise", "provider_name": "Wise", "rate": 2041.30, "fee": 2.99, "fee_currency": "GBP", "spread_bps": 0, "stale": false, "vs_mid_bps": -10.3, "rate_type": "retail", "provider_type": "fintech_psp", "transfer_time": "1 - 2 business days", "last_checked": "2026-07-02T00:00:00.000Z", "last_changed": "2026-07-01T21:50:00.000Z", "last_updated": "2026-07-01T21:50:00.000Z" },
    { "provider_id": "worldremit", "provider_name": "WorldRemit", "rate": 2046.80, "fee": 1.99, "fee_currency": "GBP", "spread_bps": null, "stale": true, "vs_mid_bps": 16.6, "rate_type": "retail", "provider_type": "imto", "transfer_time": "Same day", "last_checked": "2026-06-30T08:05:00.000Z", "last_changed": "2026-06-29T10:20:00.000Z", "last_updated": "2026-06-29T10:20:00.000Z" }
    ],
    "count": 2,
    "stale_count": 1,
    "mid_rate": 2043.40,
    "mid_source": "open.er-api.com",
    "mid_fetched_at": "2026-07-01T21:05:00.000Z",
    "timestamp": "2026-07-10T17:45:45.058Z",
    "date": "2026-07-01",
    "as_of": "2026-07-02T00:00:00.000Z",
    "data_freshness": "hourly"
    }

    Same shape as /rates, judged at as_of: stale means not checked for 24 hours before it, and a quote not checked for 7 days before it is left out. A quote confirmed after as_of reports as_of as its last_checked. provider_type narrows the answer to those provider types, and the response echoes them as provider_types. spread_bps is measured within each provider type and rate type, so narrowing never changes it.

    GET /api/v1/change
    Key required

    Absolute and percentage change of the mid and of the best rate over a period.

    Query parameters

    NameTypeReqDescription
    fromstringyesSource currency (ISO-4217), e.g. GBP
    tostringyesTarget currency (ISO-4217), e.g. NGN
    periodstringno1d | 7d (default) | 30d | 90d
    provider_typestringnoOne provider type, or a comma list: imto, fintech_psp, commercial_bank, central_bank, non_bank_lp, bureau_de_change, crypto_venue, aggregator. Omit it for every type. An unknown type returns 400 with the refused ids in invalid, before the key is checked, so it spends no quota.

    Example request

    curl "https://modan.io/api/v1/change?from=GBP&to=NGN&period=7d" \
    -H "X-API-Key: mdn_live_YOUR_KEY_HERE"

    Example response

    {
    "corridor": "GBP/NGN",
    "period": "7d",
    "start": { "at": "2026-07-03T17:00:00.000Z", "mid": 2038.02, "best": 2036.50 },
    "end": { "at": "2026-07-10T17:00:00.000Z", "mid": 2048.02, "best": 2050.50 },
    "change": {
    "mid": { "abs": 10, "pct": 0.4907 },
    "best": { "abs": 14, "pct": 0.6875 }
    },
    "timestamp": "2026-07-10T17:45:45.058Z",
    "data_freshness": "hourly",
    "as_of": "2026-07-10T17:00:00.000Z"
    }

    best at each end is the best executable quote that was current at that moment (checked within 24 hours before it) among the provider types returned, and null when none qualifies. provider_type narrows the answer to those provider types, and the response echoes them as provider_types. The mid never depends on it.

    Discovery

    GET /api/v1/rates/provider
    Key required

    Every corridor and current rate a single provider quotes, each flagged stale or not.

    Query parameters

    NameTypeReqDescription
    providerstringyesA provider_id — see GET /providers

    Example request

    curl "https://modan.io/api/v1/rates/provider?provider=lemfi" \
    -H "X-API-Key: mdn_live_YOUR_KEY_HERE"

    Example response

    {
    "provider": { "id": "lemfi", "name": "LemFi", "provider_type": "fintech_psp", "region": "West Africa", "transfer_time": "In Minutes", "website_url": "https://lemfi.com" },
    "corridors": [
    { "from": "GBP", "to": "NGN", "rate": 2050.50, "fee": 0, "fee_currency": "GBP", "stale": false, "last_checked": "2026-07-10T17:00:00.000Z", "last_changed": "2026-07-10T16:34:49.256Z", "last_updated": "2026-07-10T16:34:49.256Z" },
    { "from": "USD", "to": "KES", "rate": 129.40, "fee": 0, "fee_currency": "USD", "stale": true, "last_checked": "2026-07-09T14:20:00.000Z", "last_changed": "2026-07-08T09:12:00.000Z", "last_updated": "2026-07-08T09:12:00.000Z" }
    ],
    "count": 2,
    "stale_count": 1,
    "timestamp": "2026-07-10T17:45:45.058Z",
    "data_freshness": "hourly",
    "as_of": "2026-07-10T17:00:00.000Z"
    }

    stale is true when this provider's quote on a corridor has not been checked for 24 hours; a corridor not checked for 7 days is left out. stale_count says how many corridors are stale. It takes no provider_type: the provider named already has one type.

    GET /api/v1/corridors
    Key required

    All covered corridors with the number of current and stale quotes, the provider types quoting each, the best current executable rate overall and within each type, and the average spread.

    Query parameters

    NameTypeReqDescription
    provider_typestringnoOne provider type, or a comma list: imto, fintech_psp, commercial_bank, central_bank, non_bank_lp, bureau_de_change, crypto_venue, aggregator. Omit it for every type. An unknown type returns 400 with the refused ids in invalid, before the key is checked, so it spends no quota.

    Example request

    curl "https://modan.io/api/v1/corridors" \
    -H "X-API-Key: mdn_live_YOUR_KEY_HERE"

    Example response

    {
    "corridors": [
    { "from": "GBP", "to": "NGN", "provider_count": 2, "stale_count": 1, "best_rate": 2050.50, "best_rate_by_type": { "imto": null, "fintech_psp": 2050.50 }, "provider_types": ["imto", "fintech_psp"], "avg_spread_bps": 12.2, "last_checked": "2026-07-10T17:00:00.000Z", "last_changed": "2026-07-10T16:34:49.256Z", "last_updated": "2026-07-10T16:34:49.256Z" },
    { "from": "USDC", "to": "KES", "provider_count": 0, "stale_count": 1, "best_rate": null, "best_rate_by_type": { "crypto_venue": null }, "provider_types": ["crypto_venue"], "avg_spread_bps": null, "last_checked": "2026-07-08T15:40:00.000Z", "last_changed": "2026-07-07T09:00:00.000Z", "last_updated": "2026-07-07T09:00:00.000Z" }
    ],
    "count": 2,
    "timestamp": "2026-07-10T17:45:45.058Z",
    "data_freshness": "hourly",
    "as_of": "2026-07-10T17:00:00.000Z"
    }

    provider_count counts quotes checked within 24 hours and stale_count the rest; a corridor with nothing checked in 7 days is omitted. Each corridor lists the provider types quoting it in provider_types. best_rate, best_rate_by_type and avg_spread_bps cover current executable quotes only and are null, not 0, when there are none. best_rate is the best across the provider types returned; best_rate_by_type gives the best within each of the corridor's provider_types, and is the like-for-like read. provider_type narrows the answer to those provider types, echoed at the top level as provider_types: only corridors those types quote are returned, counted over their quotes alone.

    GET /api/v1/providers
    Key required

    All active providers with type, region, transfer time and payment methods.

    Example request

    curl "https://modan.io/api/v1/providers" \
    -H "X-API-Key: mdn_live_YOUR_KEY_HERE"

    Example response

    {
    "providers": [
    { "id": "wise", "name": "Wise", "logo_url": "https://cdn.modan.io/providers/wise.png", "website_url": "https://wise.com", "provider_type": "fintech_psp", "rate_type": "retail", "region": "Global", "transfer_time": "1 - 2 business days", "payment_methods": ["bank_transfer", "card"] }
    ],
    "count": 1,
    "timestamp": "2026-07-10T17:45:45.058Z"
    }

    The whole catalogue, every provider type included; it takes no provider_type filter.

    GET /api/v1/currencies
    Key required

    Active currencies and the corridors currently served.

    Example request

    curl "https://modan.io/api/v1/currencies" \
    -H "X-API-Key: mdn_live_YOUR_KEY_HERE"

    Example response

    {
    "currencies": [
    { "code": "GBP", "name": "British Pound", "type": "source", "flag_url": "https://cdn.modan.io/flags/gb.svg", "is_active": true, "sort_order": 1 },
    { "code": "NGN", "name": "Nigerian Naira", "type": "target", "flag_url": "https://cdn.modan.io/flags/ng.svg", "is_active": true, "sort_order": 2 }
    ],
    "corridors": [ { "from": "GBP", "to": "NGN" } ],
    "currency_count": 2,
    "corridor_count": 1,
    "timestamp": "2026-07-10T17:45:45.058Z"
    }

    Account & status

    GET /api/v1/admin/usage
    Key required
    No quota

    Your account's metering: plan, limit, used, remaining, reset, a per-key breakdown and 7-day history. Checking usage never consumes quota.

    Example request

    curl "https://modan.io/api/v1/admin/usage" \
    -H "X-API-Key: mdn_live_YOUR_KEY_HERE"

    Example response

    {
    "plan": "free",
    "period_start": "2026-07-10T00:00:00.000Z",
    "period_end": "2026-07-11T00:00:00.000Z",
    "limit": 50,
    "used": 4,
    "remaining": 21,
    "reset": 1783728000,
    "key": { "id": "b1c2d3e4", "name": "default" },
    "keys_today": [ { "key_id": "b1c2d3e4", "name": "default", "requests": 4 } ],
    "daily_history": [
    { "date": "2026-07-09", "requests": 12 },
    { "date": "2026-07-10", "requests": 4 }
    ],
    "timestamp": "2026-07-10T17:45:45.058Z"
    }
    GET /api/v1/status
    No key
    No quota

    Public platform health — the number of corridors with a quote checked in the last 7 days, provider count, last check (last_rate_update), last price move (last_rate_change) and mid-feed freshness. No API key required.

    Example request

    curl "https://modan.io/api/v1/status"

    Example response

    {
    "status": "ok",
    "version": "v1",
    "corridors": 12,
    "providers": 19,
    "last_rate_update": "2026-07-10T17:44:12.000Z",
    "last_rate_change": "2026-07-10T17:34:49.256Z",
    "mid_feed": { "source": "open.er-api.com", "last_fetched": "2026-07-10T17:05:01.477Z", "age_seconds": 2444 },
    "timestamp": "2026-07-10T17:45:45.058Z"
    }

    Ingestion

    Data-team accounts push observations. The batch is atomic (any invalid row → 422, nothing inserted). Ingestion requires a key owned by an admin or treasury account and does NOT consume the read quota.

    POST /api/v1/rates
    Key required
    No quota

    Ingest one rate observation or an array of up to 100. from/to alias source_currency/target_currency; fee, fee_currency, notes and effective_from are optional.

    Example request

    curl -X POST "https://modan.io/api/v1/rates" \
    -H "X-API-Key: mdn_live_TEAM_KEY_HERE" \
    -H "Content-Type: application/json" \
    -d '[
    { "provider_id": "wise", "from": "GBP", "to": "NGN", "rate": 2045.5, "fee": 2.99 },
    { "provider_id": "lemfi", "from": "USD", "to": "KES", "rate": 129.4 }
    ]'

    Example response

    { "inserted": 2 }

    Rate Limits

    Free

    50 req / day

    Hourly rates

    free tier

    Individual

    250 req / day

    Hourly rates

    pro tier

    Team

    1,000 req / day

    Real-time rates

    enterprise tier

    Limits reset at midnight UTC. Every response includes X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset headers. Exceeding your daily limit returns 429 Too Many Requests.

    Data freshness is tiered. Free and Individual keys serve rates as of the top of the current UTC hour; Team (enterprise) keys serve every observation in real time. Responses state which you got via the data_freshness field (plus as_of when hourly) and an X-Data-Freshness header — the delay is always explicit, never silent.

    Code Examples

    Fetching rates for GBP → NGN using different languages.

    curl "https://modan.io/api/v1/rates?from=GBP&to=NGN" \
    -H "X-API-Key: mdn_live_YOUR_KEY_HERE"

    For AI Agents & LLMs

    Client-by-client setup (Claude Code, Cursor, VS Code, any MCP client), the tool list and example prompts: modan.io/docs/mcp.

    Modan is built to be consumed by AI systems. Claude, Cursor, Codex and any MCP (Model Context Protocol) client can call Modan natively — live rates, conversion, history and discovery exposed as tools (get_rates, convert, fetch_rates, get_history, list_corridors, list_providers, list_currencies), authenticated with the same API key and daily quota.

    Claude Code

    claude mcp add --transport http modan https://modan.io/api/mcp \
    --header "X-API-Key: mdn_live_YOUR_KEY_HERE"

    Claude Desktop / Cursor / any MCP client

    {
    "mcpServers": {
    "modan": {
    "type": "http",
    "url": "https://modan.io/api/mcp",
    "headers": { "X-API-Key": "mdn_live_YOUR_KEY_HERE" }
    }
    }
    }

    Prefer plain HTTP? Point your agent at the machine-readable docs: llms.txt (index), llms-full.txt (complete reference in one file) and openapi.json (OpenAPI 3.1). Every error response is JSON with an actionable message, so agents can self-correct.

    Response Format

    // GET /rates?from=GBP&to=NGN → 200 OK
    {
    "corridor": "GBP/NGN",
    "providers": [
    {
    "provider_id": "lemfi",
    "provider_name": "LemFi",
    "rate": 2050.50,
    "fee": 0,
    "fee_currency": "GBP",
    "spread_bps": 0,
    "stale": false,
    "vs_mid_bps": 12.1,
    "rate_type": "retail",
    "provider_type": "fintech_psp",
    "transfer_time": "In Minutes",
    "last_checked": "2026-07-06T09:00:00.000Z",
    "last_changed": "2026-07-06T07:45:00.000Z",
    "last_updated": "2026-07-06T07:45:00.000Z"
    },
    {
    "provider_id": "wise",
    "provider_name": "Wise",
    "rate": 2045.50,
    "fee": 2.99,
    "fee_currency": "GBP",
    "spread_bps": 24.4,
    "stale": false,
    "vs_mid_bps": -12.3,
    "rate_type": "retail",
    "provider_type": "fintech_psp",
    "transfer_time": "1 - 2 business days",
    "last_checked": "2026-07-06T09:00:00.000Z",
    "last_changed": "2026-07-05T16:10:00.000Z",
    "last_updated": "2026-07-05T16:10:00.000Z"
    },
    {
    "provider_id": "worldremit",
    "provider_name": "WorldRemit",
    "rate": 2058.00,
    "fee": 1.99,
    "fee_currency": "GBP",
    "spread_bps": null,
    "stale": true,
    "vs_mid_bps": 48.7,
    "rate_type": "retail",
    "provider_type": "imto",
    "transfer_time": "Same day",
    "last_checked": "2026-07-05T06:15:00.000Z",
    "last_changed": "2026-07-04T11:02:00.000Z",
    "last_updated": "2026-07-04T11:02:00.000Z"
    }
    ],
    "count": 3,
    "stale_count": 1,
    "mid_rate": 2048.02,
    "mid_source": "open.er-api.com",
    "mid_fetched_at": "2026-07-06T08:05:00.000Z",
    "timestamp": "2026-07-06T09:30:05.000Z",
    "data_freshness": "hourly",
    "as_of": "2026-07-06T09:00:00.000Z"
    }

    spread_bps is the distance, in basis points, below the best current rate of the same kind observed in the corridor at that moment: the same provider_type and the same rate_type, so an IMTO is measured against IMTOs and a bank's board rate against banks' board rates (0 = best of its kind). It is null for a stale quote. It is not a spread against an independent mid-market rate. Until 28 Sep 2026 it was measured within the rate type alone, across every kind of provider.

    Provider types. IMTO, fintech, bank and central-bank prices behave differently, so kinds of provider are never ranked against each other. The rate reads (/rates, /convert, /fetch-one, /fetch-multi, /fetch-matrix, /fetch-many-to-one, /rates/history, /time-series, /historical, /change and /corridors) take an optional provider_type: one type, or a comma list, of imto, fintech_psp, commercial_bank, central_bank, non_bank_lp, bureau_de_change, crypto_venue and aggregator. Leave it out and every type is returned. A filtered response echoes the types it applied as provider_types, and narrowing never changes a quote's spread_bps. best_by_type on /convert and best_rate_by_type on /corridors give the best within each type present, null where none qualifies; best, best_rate and provider_best are the best across the types returned, so read the by-type fields, or narrow, for a like-for-like answer. An unknown type is refused with 400 and the refused ids in invalid, before the key is checked, so it spends no quota. /rates/provider, /providers, /currencies, /admin/usage and /status do not take it.

    stale is true when a quote has not been checked for 24 hours before the moment the response describes: now on Team keys, the as_of hour on Free and Individual keys, the requested date on GET /historical. A stale quote is still returned, with its timestamps, but it carries no spread and is never a best rate: not best on GET /convert, not best_rate on GET /corridors, not provider_best on GET /fetch-*. A quote not checked for 7 days is not returned at all. Quotes from commercial_bank and central_bank providers age on a weekday clock: Saturday and Sunday (UTC) hours do not count, because those boards publish on weekdays only, so Friday's close stays current through the weekend. Responses that list quotes carry stale_count, and GET /corridors returns best_rate and avg_spread_bps as null, never 0, when no current executable quote exists.

    rate_type is what kind of price it is — official, interbank, retail, p2p or parallel — and provider_type is what kind of institution published it. They are separate questions: a commercial bank may post a retail board rate or an interbank one. Ranking across kinds is meaningless, so a central bank's official reference is never a corridor's best rate and never the best value on /convert — it is real, and nobody can deal on it. It is still returned, labelled for what it is.

    Each quote carries two clocks, and they answer different questions. last_checked is when we last confirmed that provider was still offering that rate — read it to decide whether the data is current. last_changed is when the quote last changed: its rate moved, or its fee changed. A corridor holding a steady price has an old last_changed and a last_checked from minutes ago; that means the price is flat, not that the feed is stale. How often a quote is checked depends on how the provider's rates are collected (some automatically through the day, some by hand), so no cadence is implied: staleness is judged on last_checked alone. On Free and Individual keys a check made after the as_of hour is reported as that hour, never later. last_updated is a deprecated alias of last_changed, kept so existing clients do not break. All rates are timestamped, append-only observations.

    When an independent mid-market reference is available for the corridor, the response additionally carries top-level mid_rate, mid_source and mid_fetched_at, and each provider entry gains vs_mid_bps (basis points vs that mid; negative means below mid). The mid is cross-computed through USD from the reference feed, so every fiat corridor we track carries one; a crossed mid is timestamped with its staler leg. Stablecoins (USDT, USDC) are not quoted by a fiat reference feed, so these fields are omitted there — absence is explicit, never fabricated.

    Errors

    400Bad RequestMissing or malformed query parameters (e.g. from, to, amount, date, or an unsupported currency). An unknown provider_type is refused before the key is checked, so it spends no quota; the body lists the refused ids in invalid
    401UnauthorizedAPI key missing, invalid, or revoked
    403ForbiddenKey not authorized for this action — POST /rates requires an admin/treasury account
    404Not FoundUnknown resource — e.g. an inactive provider on /rates/provider, or an unknown path
    422Unprocessable EntityIngestion validation failed — per-row errors are returned and nothing is inserted
    429Too Many RequestsDaily rate limit exceeded for your tier — see X-RateLimit-Reset
    500Internal Server ErrorServer-side error — retry after a moment
    503Service UnavailableThe mid-market reference feed is temporarily unavailable — retry shortly

    Every error is JSON with an actionable error message, so both humans and AI agents can self-correct. A 429 also echoes the tier limit and reset:

    // 429 Too Many Requests
    {
    "error": "Rate limit exceeded",
    "limit": 50,
    "reset": 1783728000
    }