African FX exchange rate API — documentation
Integrate live African FX and provider pricing data into your applications using the Modan REST API.
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.
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
/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/ratesEvery 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
fromstringyesSource currency (ISO-4217), e.g. GBPtostringyesTarget currency (ISO-4217), e.g. NGNprovider_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/convertConvert 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
fromstringyesSource currency (ISO-4217), e.g. GBPtostringyesTarget currency (ISO-4217), e.g. NGNamountnumberyesAmount 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-oneA single base→quote pair: mid + the best current executable provider quote.
Query parameters
fromstringyesBase currencytostringyesQuote currencyprovider_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-multiOne base against up to 20 quote currencies in a single call.
Query parameters
fromstringyesBase currencytostringyes1–20 quote currencies, comma-separatedprovider_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-matrixFull cross matrix of up to 10 bases × 10 quotes.
Query parameters
fromstringyes1–10 base currencies, comma-separatedtostringyes1–10 quote currencies, comma-separatedprovider_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-oneUp to 20 base currencies into a single quote currency.
Query parameters
fromstringyes1–20 base currencies, comma-separatedtostringyesSingle quote currencyprovider_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/historyRaw historical provider observations for a corridor, paginated and oldest-first by default.
Query parameters
fromstringyesSource currency (ISO-4217), e.g. GBPtostringyesTarget currency (ISO-4217), e.g. NGNperiodstringno1d | 7d | 30d (default) | 90dorderstringnoasc (default, oldest-first) | desclimitintegernoPage size, ≤ 5000 (default 500)offsetintegernoRows to skip (default 0). Response carries has_more.providerstringnoFilter to a single provider_idprovider_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-seriesThe 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
fromstringyesSource currency (ISO-4217), e.g. GBPtostringyesTarget currency (ISO-4217), e.g. NGNintervalstringnoP1D (daily, default, ≤366 buckets) | PT1H (hourly, ≤168)periodstringno1d | 7d | 30d | 90d — or pass start & endstartstringnoISO-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/historicalThe 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
fromstringyesSource currency (ISO-4217), e.g. GBPtostringyesTarget currency (ISO-4217), e.g. NGNdatestringyesCalendar 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/changeAbsolute and percentage change of the mid and of the best rate over a period.
Query parameters
fromstringyesSource currency (ISO-4217), e.g. GBPtostringyesTarget currency (ISO-4217), e.g. NGNperiodstringno1d | 7d (default) | 30d | 90dprovider_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/providerEvery corridor and current rate a single provider quotes, each flagged stale or not.
Query parameters
providerstringyesA provider_id — see GET /providersExample 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/corridorsAll 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
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/providersAll 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/currenciesActive 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/usageYour 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/statusPublic 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/ratesIngest 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
50 req / day
Hourly rates
free tier
250 req / day
Hourly rates
pro tier
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
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}