# Modan API — Complete Reference for LLMs and AI Agents > Modan provides live and historical African FX / remittance pricing at the > provider level (e.g. GBP→NGN across named providers such as banks, IMTOs and > fintechs), benchmarked against an independent mid-market reference where > available. This file is the complete machine-readable reference: everything > an agent needs to authenticate, call every endpoint, and interpret every > field. Human docs: https://modan.io/docs/api · OpenAPI 3.1: > https://modan.io/openapi.json ## Authentication - Get a key: sign up free at https://modan.io/signup — the first API key is minted automatically (shown once; keys look like `mdn_live_` + 40 chars). Manage keys at https://modan.io/app/api. - Every REST request: pass the key in the `X-API-Key` header. - Missing/invalid/revoked key → `401 {"error": "Invalid or missing API key"}`. ## Quotas & data freshness (per account per day, reset 00:00 UTC) | Tier | Requests/day | Data freshness | | ---------- | ------------ | -------------- | | free | 50 | hourly | | pro | 250 | hourly | | enterprise | 1,000 | real-time | - Quota is shared across all of an account's keys and includes MCP tool calls. - Every response carries `X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset` (epoch seconds of next UTC midnight). - Exceeding the quota → `429 {"error": "Rate limit exceeded", "limit": N, "reset": epoch}`. - Data freshness: hourly tiers (free, pro) serve rates as of the top of the current UTC hour — rate responses include `data_freshness: "hourly"` and an `as_of` timestamp, plus an `X-Data-Freshness: hourly` header. Team-plan keys (tier id `enterprise`) serve every observation in real time (`data_freshness: "realtime"`). The delay is always stated explicitly, never silent. ## Stale quotes (every current read, REST and MCP) Every quote carries two clocks: `last_checked` (when we last confirmed the provider was still offering the price) and `last_changed` (when the quote last changed: its rate moved, or its fee changed). A quote's age is judged on `last_checked` only, at the moment the response describes: now on Team keys, `as_of` on free and Individual keys, the requested date on /historical, the end of each bucket on /time-series. - current: checked within the last 24 hours. Ranked, counted, eligible for best. - stale: not checked for 24 hours. Still returned, with `stale: true` and its timestamps, and `spread_bps: null`. Never `best` or a `best_by_type` entry on /convert, never `best_rate`, a `best_rate_by_type` value or `provider_count` on /corridors, never `provider_best` on /fetch-*, and never the benchmark another quote's spread is measured against. Responses that list quotes carry `stale_count`. - dropped: not checked for 7 days. Not returned by any current read. Its history stays in /rates/history and /time-series. Commercial-bank and central-bank quotes (`provider_type` `commercial_bank` or `central_bank`) age on a weekday clock: Saturday and Sunday (UTC) hours do not count toward either threshold, because those boards publish on weekdays only. Friday's close stays current through the weekend; a missed weekday update still goes stale after 24 weekday hours. Every other provider type keeps the plain clock, because IMTOs and fintechs quote at weekends. On free and Individual keys a quote confirmed after the `as_of` hour reports `last_checked` equal to `as_of` (it was provably still on offer then), so no timestamp in an hourly response is later than its `as_of`. ## Provider types (the rate reads, REST and MCP) An IMTO's quote, a fintech's in-app rate, a commercial bank's board rate and a central bank's official print behave differently, so Modan never ranks one kind of provider against another. - `provider_type` narrows a read to the kinds you name: one id or a comma-separated list in REST (`provider_type=imto,fintech_psp`), an array in MCP (`["imto", "fintech_psp"]`). The ids, in the order every response lists them: `imto`, `fintech_psp`, `commercial_bank`, `central_bank`, `non_bank_lp`, `bureau_de_change`, `crypto_venue`, `aggregator`. REST ids are case-insensitive, and duplicates are ignored. Omitted, every type is returned, including providers with no type recorded; a filter never includes those. - Taken by /rates, /convert, /corridors, /rates/history, /time-series, /historical, /change and /fetch-one · /fetch-multi · /fetch-matrix · /fetch-many-to-one, and by the MCP tools `get_rates`, `convert`, `fetch_rates`, `get_history` and `list_corridors`. Not taken by /rates/provider (one provider has one type), /providers, /currencies or /status, which ignore it. - A filtered response echoes the types it applied as a top-level `provider_types`, lower-cased and in taxonomy order. An unfiltered response has no top-level `provider_types`. - An unknown id is refused rather than dropped: REST answers `400 {"error": "Unknown provider_type: banks. Valid values: imto, fintech_psp, commercial_bank, central_bank, non_bank_lp, bureau_de_change, crypto_venue, aggregator.", "invalid": ["banks"]}` and MCP returns a tool error with the same message. Both are answered before the key is checked, so the call spends no quota. - `spread_bps` is measured within a provider type AND a rate type, among the quotes returned. A filter keeps or drops whole types, so it never changes a returned quote's `spread_bps`. Until 28 Sep 2026 spread was measured within `rate_type` alone, across every provider type. - By-type answers: `best_by_type` on /convert and `best_rate_by_type` on /corridors give the best within each type present, null where no current executable quote of that type qualifies. `best` on /convert, `best_rate` on /corridors, `provider_best` on /fetch-* and `best` on /change are the best across the types returned (every type unless you filter), so read the by-type fields, or filter, for a like-for-like answer. `best` on /time-series is a plain maximum over observations; see that endpoint. - By-type keys and a corridor's `provider_types` list a provider with no type recorded as `unclassified`. It is not a filter value. - The independent mid never depends on `provider_type`. ## MCP server (preferred for AI agents) - Endpoint: `https://modan.io/api/mcp` — MCP streamable HTTP, stateless (plain JSON responses, no SSE stream or session ids). JSON-RPC 2.0. - Auth: `X-API-Key` header, or `Authorization: Bearer mdn_live_...`. `initialize` / `tools/list` / `ping` are open; `tools/call` requires a key and consumes one request of the daily quota. - Tools (same data as REST): `get_rates(from,to,provider_type?)` and `convert(from,to,amount,provider_type?)` (the /rates and /convert shapes, `stale` flags and `best_by_type` included), `fetch_rates(base,quotes[],provider_type?)` (mid-market rates for one base against ≤20 quotes, plus the best current executable provider quote per covered corridor among the types asked for, null when none qualifies), `get_history(from,to,days?,provider?,provider_type?)`, `list_corridors(provider_type?)` (per corridor: `providers` = providers with a current quote, `stale_count` = how many more are stale, as on /corridors, and `provider_types` = the types quoting it; corridors with nothing checked in 7 days are left out), `list_providers()`, `list_currencies()`. `provider_type` is an array of ids; see "Provider types" above. - Tool failures come back as `isError: true` results with an actionable message (how to get a key, fix a currency code or a provider type, when quota resets). - Claude Code: `claude mcp add --transport http modan https://modan.io/api/mcp --header "X-API-Key: mdn_live_YOUR_KEY_HERE"` - Generic MCP client config: `{"mcpServers": {"modan": {"type": "http", "url": "https://modan.io/api/mcp", "headers": {"X-API-Key": "mdn_live_YOUR_KEY_HERE"}}}}` ## REST endpoints Base URL: `https://modan.io/api/v1`. All read endpoints are GET and require `X-API-Key` (exceptions: `/status` needs no key; `/admin/usage` and `/status` never consume quota); the one write endpoint is POST /rates (admin/treasury keys only). All responses are JSON. Currency codes are uppercase, 3-letter ISO-4217 fiat plus 4–5 char stablecoins (GBP, USD, NGN, KES, GHS, XOF, USDT, USDC, ...). Unknown `/api/*` paths return a JSON 404 (never HTML). ### GET /rates?from=GBP&to=NGN Latest rate from every provider quoting the corridor. Response (a Team key; see the end of the block for free and Individual keys): { "corridor": "GBP/NGN", "providers": [ { "provider_id": "lemfi", "provider_name": "LemFi", "rate": 2050.50, "fee": 0, "fee_currency": "GBP", "spread_bps": 0, // the best current fintech_psp retail quote "stale": false, "vs_mid_bps": 12.1, "rate_type": "retail", "provider_type": "fintech_psp", "transfer_time": "In Minutes", "last_checked": "2026-07-06T09:15: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, // target units per 1 source unit "fee": 2.99, // provider fee, may be null "fee_currency": "GBP", // currency of the fee, may be null "spread_bps": 24.4, // bps BELOW the best current quote of the same provider_type and rate_type (0 = best); null when stale "stale": false, // true = not checked for 24 hours: returned, never ranked "vs_mid_bps": -12.3, // vs independent mid (present only when mid exists) "rate_type": "retail", // official | interbank | retail | p2p | parallel "provider_type": "fintech_psp", // imto | fintech_psp | commercial_bank | central_bank | … ; null if none recorded "transfer_time": "1 - 2 business days", "last_checked": "2026-07-06T09:28:00.000Z", // freshness: when we last confirmed it "last_changed": "2026-07-05T16:10:00.000Z", // when the quote last CHANGED (rate or fee) "last_updated": "2026-07-05T16:10:00.000Z" // deprecated alias of last_changed }, { "provider_id": "worldremit", "provider_name": "WorldRemit", "rate": 2031.10, "fee": 1.99, "fee_currency": "GBP", "spread_bps": null, // stale: not ranked "stale": true, // last checked more than 24 hours before this response "vs_mid_bps": -82.6, "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, // how many of providers are stale "mid_rate": 2048.02, // independent mid-market reference (optional) "mid_source": "open.er-api.com", "mid_fetched_at": "2026-07-06T09:05:00.000Z", "timestamp": "2026-07-06T09:30:05.000Z", "data_freshness": "realtime" // free/Individual: "hourly" plus "as_of", the top of the hour } With `&provider_type=fintech_psp` the same call returns LemFi and Wise only, with the same spreads, `count: 2`, `stale_count: 0`, and `"provider_types": ["fintech_psp"]` after `corridor`. Semantics: - `spread_bps` is dispersion vs the best CURRENT quote in the corridor at that moment from the SAME `provider_type` AND the same `rate_type`, NOT vs mid: WorldRemit, an IMTO, is never the benchmark for Wise, a fintech. It is null for a stale quote, and a stale quote never sets the benchmark for the others. `vs_mid_bps` is vs the independent mid (negative = provider pays out less than mid, which is typical). - `stale` follows the rule in "Stale quotes" above: not checked for 24 hours (weekday hours for commercial and central banks). Quotes not checked for 7 days are left out, so `count` includes stale quotes and never dropped ones. - `rate_type` is what kind of price it is — official | interbank | retail | p2p | parallel — and `provider_type` is what kind of institution published it. They are orthogonal. A central bank's `official` reference is real and not obtainable, so it is excluded from `best` and `best_by_type` on /convert, from `best_rate` and `best_rate_by_type` on /corridors and from `provider_best` on /fetch-*; it is still returned, labelled. Absent `rate_type` means `retail`. - The mid is cross-computed through USD from the reference feed, so every fiat corridor we track is priced; a crossed mid is timestamped with its staler leg. Currencies the feed does not quote (stablecoins: USDT, USDC) have none. - `mid_*` fields and `vs_mid_bps` are omitted entirely when no recent (≤48h) reference exists — absence is explicit, never fabricated. - 400 if `from`/`to` missing, or if `provider_type` names an unknown type. ### GET /convert?from=GBP&to=NGN&amount=1000 What an amount actually delivers, per provider, net of fees. Response: { "from": "GBP", "to": "NGN", "amount": 1000, "mid": { "rate": 2048.02, "converted": 2048020, "source": "...", "fetched_at": "..." }, // or null "best": { /* the executable, non-stale entry with the highest net_converted, or null */ }, "best_by_type": { "fintech_psp": "wise" }, // the same question within each provider type; null where none qualifies "providers": [ { "provider_id": "wise", "provider_name": "Wise", "rate": 2045.50, "fee": 2.99, "fee_currency": "GBP", "converted": 2045500, // amount * rate, before fees "net_converted": 2039383.955, // converted minus fee expressed in target currency "spread_bps": 0, // within its provider_type and rate_type; null when stale "rate_type": "retail", "provider_type": "fintech_psp", "executable": true, // false for official and parallel prints "stale": false, // true = not checked for 24 hours "last_checked": "2026-07-06T09:28:00.000Z", "last_changed": "2026-07-05T16:10:00.000Z", "vs_mid_bps": -12.3 } ], "count": 1, "stale_count": 0, "timestamp": "..." } Semantics: `best` = highest `net_converted` (best value to the recipient) among quotes a customer could deal on today: `executable` and not `stale`, across every provider type returned. It is NOT always the highest headline rate once fees count, it is never an official or parallel print or a stale quote, and it is null when no quote qualifies. `best_by_type` asks the same question within each provider type present, keyed in taxonomy order: the winning `provider_id`, or null when that type has no current executable quote (a central bank's official print, or only stale quotes). It is `{}` when no quote is returned. For a like-for-like answer, read `best_by_type` or pass `provider_type`. Stale and non-executable entries are still listed in `providers`. Fees quoted in the source currency are converted at that provider's rate. /convert entries carry `provider_type`, `last_checked` and `last_changed` (no deprecated `last_updated`). ### GET /fetch-one · /fetch-multi · /fetch-matrix · /fetch-many-to-one fastforex-style convenience lookups. Every pair returns the independent MID-MARKET rate (cross-computed through the freshest USD reference snapshot, ≤48h old) plus `provider_best` — the best CURRENT EXECUTABLE provider quote: the highest interbank, retail or p2p rate checked within 24 hours (weekday hours for commercial and central banks), among the provider types asked for (every type unless `provider_type` narrows it; the response then echoes them as `provider_types`). It is null when the pair is not a covered corridor or no quote of those types qualifies; it is never an official print or a stale quote. The mid never depends on `provider_type`. One call = ONE quota unit regardless of pair count. - GET /fetch-one?from=USD&to=NGN → { base, quote, mid, provider_best, source, fetched_at, timestamp } - GET /fetch-multi?from=USD&to=NGN,KES,GHS (≤20 quotes) → { base, results: { NGN: { mid, provider_best }, ... }, count, source, fetched_at, timestamp } - GET /fetch-matrix?from=USD,GBP&to=NGN,KES (≤10×10) → { bases, quotes, results: { USD: { NGN: {...}, ... }, ... }, ... } - GET /fetch-many-to-one?from=USD,GBP,CAD&to=NGN → { quote, results: { USD: {...}, GBP: {...}, CAD: {...} }, count, ... } `provider_best` = { rate, provider_id, last_checked, last_changed, last_updated }. Same-currency pairs return mid = 1 with provider_best null. Unsupported currencies → 400 { error, invalid: [...], supported: [...] } — only currencies the reference feed quotes are cross-computable (see /currencies for the live list). ### GET /time-series?from=GBP&to=NGN&period=30d&interval=P1D Bucketed series of the independent mid and the best current executable provider quote, each read at the END of its bucket. - `interval`: P1D (daily, default, ≤366 buckets) | PT1H (hourly, ≤168) - Window: `period` (1d|7d|30d|90d, default 30d) OR explicit `start`/`end` ISO dates. Over-long windows are clamped (a `note` says so). - `provider_type` (optional): narrows `best` and `samples` to those provider types; the mid never depends on it. - Response: { corridor, interval, start, end, tracked_corridor, data: [{ t, mid, best, samples }], count }. `t` = bucket start (UTC). - `best` = the highest quote that was current and executable at the bucket's end (the window's end for the last, partial bucket): each provider's latest price at or before that moment, counted only if it was checked within the 24 hours before it (on the weekday clock above for banks) and its rate_type is interbank, retail or p2p. A provider holding a steady price all day counts although it recorded no new price that day; official and parallel prints never count. null when nothing qualified. With `provider_type`, only providers of those types count. - `samples` = how many quotes `best` was taken from; 0 exactly when `best` is null. `mid` = last reference mid in the bucket (null before the reference feed's history begins). - A bucket is listed while the corridor has any quote (of the types returned) checked within 7 days of its end. Untracked pairs return a mid-only series with `tracked_corridor: false` and an explanatory `note`. With `provider_type`, that happens whenever none of those types has a quote in the window, even on a corridor other types quote; the note says so. ### GET /historical?from=GBP&to=NGN&date=2026-07-01 Corridor snapshot as of end-of-day UTC on `date` (YYYY-MM-DD, not future): same shape as /rates plus `date` and `as_of`. Each provider's latest quote set on or before `as_of`, however long before it, judged at `as_of`: `stale` means not checked for 24 hours before `as_of`, and a quote not checked for 7 days before it is left out. `mid_*` fields appear only when the reference feed covers that date. Takes `provider_type`, as /rates does. ### GET /change?from=GBP&to=NGN&period=7d Movement over a period (1d|7d|30d|90d, default 7d): { corridor, period, start: { at, mid, best }, end: { at, mid, best }, change: { mid: { abs, pct }, best: { abs, pct } } }. `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 asked for (every type unless `provider_type` narrows it). Legs with no data are null (e.g. mid before the reference feed existed, or best when no quote qualified). The mid never depends on `provider_type`. ### GET /rates/provider?provider=lemfi Every corridor and current rate one provider quotes: { provider: { id, name, provider_type, region, transfer_time, website_url }, corridors: [{ from, to, rate, fee, fee_currency, stale, last_checked, last_changed, last_updated }], count, stale_count }. `stale` is true when this provider's quote on that corridor has not been checked for 24 hours; a corridor not checked for 7 days is left out. Unknown/inactive provider → 404 with guidance to GET /providers. Takes no `provider_type`: the provider named has one type. ### GET /admin/usage Your account's metering state — does NOT consume quota: { plan, period_start, period_end, limit, used, remaining, reset, key: { id, name }, keys_today: [{ key_id, name, requests }], daily_history: [{ date, requests }] }. Quota is per-account per UTC day. ### GET /status (no key, no quota) Public platform health: { status, version, corridors, providers, last_rate_update, last_rate_change, mid_feed: { source, last_fetched, age_seconds }, timestamp }. `corridors` counts corridors with at least one quote checked in the last 7 days. `last_rate_update` is the most recent check across every quote (the liveness signal to watch); `last_rate_change` is the last time any price moved, legitimately hours old on a calm day. Safe to poll for monitoring — cached 30s, never counted. ### GET /rates/history?from=GBP&to=NGN&period=30d&order=asc&limit=500&offset=0 Historical time series of provider observations. - `period`: 1d | 7d | 30d (default) | 90d - `order`: asc (default, oldest-first) | desc - `limit`: ≤ 5000 (default 500); `offset` for paging - `provider`: optional provider_id filter - `provider_type`: optional, as above. The filter runs before paging, so `limit`, `offset` and `has_more` page through the filtered history. - Response: { corridor, period, from_date, to_date, order, limit, offset, has_more, data: [{ timestamp, provider_id, provider_name, rate, fee, fee_currency, rate_type, provider_type, spread_bps }], count }, plus `provider_types` when filtered. Use `has_more` to page. - `spread_bps` here is measured against the best rate set in the same minute, among the rows returned, by a provider of the same `provider_type` and `rate_type`. History is not judged for staleness. ### GET /currencies Active currencies and the corridors currently served. Response: { currencies: [{ code, name, type, flag_url, is_active, sort_order }], corridors: [{ from, to }], currency_count, corridor_count, timestamp }. `type` is "source" | "target" | "both". ### GET /corridors Every corridor with at least one quote checked in the last 7 days: { corridors: [{ from, to, provider_count, stale_count, best_rate, best_rate_by_type, provider_types, avg_spread_bps, last_checked, last_changed, last_updated }], count, timestamp }. - `provider_count` counts providers with a current quote (checked within 24 hours); `stale_count` counts the ones that are stale. - `provider_types` lists the provider types quoting the corridor (current or stale), in taxonomy order. - `best_rate` is the best current executable rate (interbank, retail or p2p) across the provider types returned, and `avg_spread_bps` the average spread over current executable quotes, each measured within its provider type and rate type. Both are null, not 0, when the corridor has no current executable quote (only reference prints, or only stale quotes). Until 28 Sep 2026 they were 0 in that case, so a client doing arithmetic on them must now handle null. - `best_rate_by_type` gives the best current executable rate within each of the corridor's `provider_types`, null for a type with none. It is the like-for-like read: `best_rate` ranks kinds of provider against each other. - `provider_type` (optional) returns only the corridors those types quote, each counted over their quotes alone, and adds a top-level `provider_types` echoing the filter. - `last_checked` / `last_changed` are the most recent across the corridor's quotes. ### GET /providers Active provider metadata: id, name, provider_type, rate_type, region, transfer_time, website_url, payment methods. The whole catalogue; it takes no `provider_type` filter. provider_type is the institution: imto | fintech_psp | commercial_bank | central_bank | non_bank_lp | bureau_de_change | crypto_venue | aggregator (null when none is recorded). rate_type is the KIND of price it publishes: official | interbank | retail | p2p | parallel. The two are orthogonal — a commercial bank may post a retail board rate or an interbank one — and a quote is only ever ranked against quotes that match it on both. ### POST /rates (data-team only) Ingest rate observations programmatically. Requires an API key whose OWNING ACCOUNT holds the admin or treasury role — regular data keys get 403. Body: one object or an array (≤100) of: { "provider_id": "wise", "from": "GBP", "to": "NGN", "rate": 2045.5, "fee": 2.99, "fee_currency": "GBP", "notes": "...", "effective_from": "..." } - `from`/`to` aliases for source_currency/target_currency; fee/notes/ effective_from optional (effective_from defaults to now; max 5y backfill, no future timestamps). - The batch is ATOMIC: any invalid row → 422 with per-row errors and nothing inserted. Success → 201 {"inserted": n}. - Ingestion does NOT consume the daily read quota. ## Errors | Status | Meaning | | ------ | ------------------------------------------------------ | | 400 | Missing/invalid query params (message says which) | | 401 | API key missing, invalid, or revoked | | 404 | Unknown endpoint (JSON body, lists valid endpoints) | | 429 | Daily quota exceeded (body carries limit + reset epoch) | | 500 | Server error — retry after a moment | All error bodies are JSON: `{"error": "..."}` with an actionable message. An unknown `provider_type` adds `invalid` (the ids refused) and is answered before the key is checked, so it spends no quota. ## Caching Responses send `Cache-Control: private, max-age=15` (rates/convert) to `max-age=300` (providers/currencies). Live rates change intraday; do not cache beyond those windows. ## Public pages (real HTML, no JavaScript needed) - https://modan.io/corridors — every corridor with the best executable quote. - https://modan.io/currency/{code} — one page per currency, e.g. /currency/ngn. - https://modan.io/{from}/{to} — one page per corridor, e.g. /gbp/ngn. - https://modan.io/{provider_id}/{from}/{to} — one page per provider per corridor. - https://modan.io/providers and /providers/{provider_id}. - https://modan.io/docs/mcp — MCP setup for Claude Code, Cursor, VS Code and others. - https://modan.io/sitemap.xml — every public URL, with last-modified times. ## Links - Human docs: https://modan.io/docs/api - OpenAPI 3.1: https://modan.io/openapi.json - Index for LLMs: https://modan.io/llms.txt - Pricing/tiers: https://modan.io/pricing - Sign up (free key, auto-minted): https://modan.io/signup