Developers

    Get naira (NGN) exchange rates by API in Python and JavaScript

    Most exchange-rate APIs give you one number per pair — a mid nobody transacts at. For African corridors that number is not the market. This walkthrough fetches what named providers actually quote for GBP→NGN, with spread and an independent mid, in a few lines of Python and JavaScript.

    Updated 28 Sep 2026 · 7 min read · by Modan

    1. Get a key

    Sign up — no card. Your first key is minted automatically and shown under Account → API keys. A free key allows 50 requests per UTC day; Individual allows 250 and Team 1,000. Every request carries the key in the X-API-Key header.

    2. The request

    One call returns every tracked provider's latest quote on the corridor, each with rate, fee, provider_type, spread_bps (distance below the best current rate of the same kind: the same provider type and rate type), stale (true once the quote has gone 24 hours without a check, in which case spread_bps is null) and last_checked (when it was last confirmed), plus mid_rate and per-provider vs_mid_bps when the independent mid is available. The response also states its freshness: data_freshness is hourly on Free and Individual keys (with an as_of timestamp) and realtime on Team keys.

    Add &provider_type=imto to get only the money-transfer operators, or a comma list such as imto,fintech_psp; leave it out for every type. An unknown type is refused with 400 before the key is checked, so a typo costs no quota.

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

    3. Python

    Print the current quotes ranked by spread within each kind (provider type and rate type), the stale ones separately with the time they were last checked, and the mid if there is one.

    import os, requests
    
    BASE = "https://modan.io/api/v1"
    HEADERS = {"X-API-Key": os.environ["MODAN_API_KEY"]}
    
    r = requests.get(f"{BASE}/rates", params={"from": "GBP", "to": "NGN"}, headers=HEADERS, timeout=15)
    r.raise_for_status()
    book = r.json()
    
    print(book["corridor"], "as of", book.get("as_of") or book["timestamp"], f"({book['data_freshness']})")
    if book.get("mid_rate"):
        print("independent mid:", book["mid_rate"], "from", book["mid_source"])
    
    # A stale quote (not checked for 24 hours) has spread_bps = None: list it, never rank it.
    current = [p for p in book["providers"] if not p["stale"]]
    stale = [p for p in book["providers"] if p["stale"]]
    
    # spread_bps is measured within a provider type and rate type, so each kind is ranked on its own.
    def kind(p):
        return (p["provider_type"] or "~", p["rate_type"])  # no type recorded: null, sorted last
    
    for p in sorted(current, key=lambda p: (*kind(p), p["spread_bps"])):
        fee = "n/a" if p["fee"] is None else p["fee"]
        vs_mid = f'{p["vs_mid_bps"]:+.1f} bps vs mid' if "vs_mid_bps" in p else "no mid"
        print(f'{p["provider_name"]:<16} {p["provider_type"] or "-":<15} {p["rate_type"]:<9} {p["rate"]:>12,.2f}  fee {fee:>6}  {p["spread_bps"]:>6.1f} bps  {vs_mid}')
    
    for p in stale:
        print(f'{p["provider_name"]:<16} {p["provider_type"] or "-":<15} {p["rate_type"]:<9} {p["rate"]:>12,.2f}  stale, last checked {p["last_checked"]}')
    
    print("remaining today:", r.headers.get("X-RateLimit-Remaining"))

    4. JavaScript

    The same call with fetch, in Node 18+ or the browser, narrowed to money-transfer operators with provider_type=imto, picking the best current executable quote among them.

    const BASE = "https://modan.io/api/v1";
    const headers = { "X-API-Key": process.env.MODAN_API_KEY };
    
    // Money-transfer operators only, so the best is chosen like for like.
    const res = await fetch(`${BASE}/rates?from=GBP&to=NGN&provider_type=imto`, { headers });
    if (res.status === 429) throw new Error(`quota exhausted until ${res.headers.get("X-RateLimit-Reset")}`);
    if (!res.ok) throw new Error(`HTTP ${res.status}`);
    const book = await res.json();
    
    // Only a current, executable quote can be the best: a stale one (not checked for 24 hours) is listed, never ranked.
    const eligible = book.providers.filter((p) => !p.stale && !["official", "parallel"].includes(p.rate_type ?? "retail"));
    if (eligible.length === 0) {
      console.log(`${book.corridor}: no current executable IMTO quote (${book.stale_count} stale)`);
    } else {
      const best = eligible.reduce((a, b) => (b.rate > a.rate ? b : a));
      console.log(`${book.corridor}: best current IMTO ${best.provider_name} ${best.rate}, checked ${best.last_checked} (${book.data_freshness})`);
    }

    5. Convert an amount, net of fees

    /convert?from=GBP&to=NGN&amount=1000 returns, for every provider, the gross converted amount and the amount net of the provider's fee, and names the provider that delivers the most — among current executable quotes only — overall in best and within each provider type in best_by_type. A central bank's official reference and a stale quote are returned and labelled but never chosen as best, and best is null when no quote qualifies; so is a type's entry in best_by_type when none of that type does.

    6. History and quotas

    /rates/history?from=GBP&to=NGN&period=30d returns timestamped observations (oldest first, paginated); /time-series gives the mid and the best current executable quote at the end of each day or hour; /historical?date=… returns the book as it stood on a past date. Each takes provider_type as well. Every response carries X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset; past the daily limit you receive 429 until midnight UTC. /status needs no key and does not count.

    If your consumer is an AI agent rather than a script, the MCP server exposes the same data as tools, on the same key and quota.

    Frequently asked questions

    Is there a free naira exchange rate API?
    Yes. A Modan key is free on signup and allows 50 requests per UTC day, with rates as of the top of the current hour. Paid plans raise the limit to 250 (Individual) and 1,000 (Team, real-time).
    How fresh is the data on the free plan?
    Hourly: responses on Free and Individual keys are as of the top of the current UTC hour and say so in data_freshness and as_of. Team keys receive every observation in real time. How often a provider is checked depends on how its rates are collected — some automatically through the day, others by hand, banks on weekdays only — and every quote carries its last_checked time.
    Which other currencies are covered?
    Every corridor Modan tracks is listed at /corridors and returned by GET /corridors — 80 pairs at the time of writing across NGN, GHS, KES, XOF, ZAR, EGP and more, plus USDT and USDC corridors.

    See the data

    More guides