DEV Community

Gio Rich
Gio Rich

Posted on

Official exchange rates in Python without an API key (ECB, NBP, CNB, Bank of Canada, Norges Bank)

I built this actor; it's a paid tool on Apify with a free trial credit.

Most "free currency API" results want a key, cap your requests, and don't say where their numbers come from. For accounting that last part matters: VAT and bookkeeping rules often name a specific central bank's rate for a specific day. In Poland, for example, you convert a foreign-currency invoice at the NBP average rate from the last business day before the invoice date.

Central banks publish these rates for free, but every bank does it differently: the ECB speaks SDMX, NBP limits requests to 93 days, the CNB serves pipe-separated text with "100 JPY" columns, and Norges Bank has a unit multiplier. I'm an 18-year-old engineering student, and I wrapped five of them in one actor called Central Bank Exchange Rates. This post shows how to call it and one real use: converting invoices at the NBP rate.

How it works, briefly

The actor calls each bank's official open-data endpoint over plain HTTP, parses the rates into "home currency per 1 unit", and re-expresses them in whatever base currency you ask for. Pairs that don't involve the bank's own currency are calculated as cross rates from that bank's same-day fixing and labeled cross rate via EUR (or PLN, CZK, ...), so you always know whether a number is official or derived.

Python example

pip install apify-client
Enter fullscreen mode Exit fullscreen mode
from apify_client import ApifyClient

client = ApifyClient("<YOUR_APIFY_TOKEN>")

run = client.actor("rel8ble/central-bank-exchange-rates").call(run_input={
    "sources": ["ecb"],
    "baseCurrency": "USD",
    "currencies": ["EUR", "GBP", "JPY", "CAD", "CHF"],
})

for r in client.dataset(run["defaultDatasetId"]).iterate_items():
    print(r["date"], r["pair"], r["rate"], r["calculation"])
Enter fullscreen mode Exit fullscreen mode

Leave startDate empty and you get the latest published rates (about 2 seconds per run).

Node example

npm install apify-client
Enter fullscreen mode Exit fullscreen mode
import { ApifyClient } from "apify-client";

const client = new ApifyClient({ token: "<YOUR_APIFY_TOKEN>" });

const run = await client.actor("rel8ble/central-bank-exchange-rates").call({
  sources: ["ecb", "nbp", "cnb", "boc", "norgesbank"],
  baseCurrency: "USD",
  currencies: ["JPY"],
  startDate: "2026-09-24",
  endDate: "2026-09-24",
});

const { items } = await client.dataset(run.defaultDatasetId).listItems();
for (const r of items) console.log(r.source, r.pair, r.rate);
Enter fullscreen mode Exit fullscreen mode

What comes back

One row per currency pair, per day, per bank. Real rows from a test run on 24 Sep 2026, trimmed:

[
  {
    "date": "2026-09-24",
    "pair": "USD/EUR",
    "rate": 0.8797396,
    "inverseRate": 1.1367,
    "source": "ecb",
    "calculation": "official",
    "sourcePublishedRate": 1.1367,
    "sourceQuoteConvention": "1 EUR = x USD"
  },
  {
    "date": "2026-09-24",
    "pair": "USD/PLN",
    "rate": 3.857,
    "source": "nbp",
    "calculation": "official",
    "nbpTable": "A 186/A/NBP/2026"
  },
  {
    "date": "2026-09-24",
    "pair": "USD/JPY",
    "rate": 158.83146,
    "source": "boc",
    "calculation": "cross rate via CAD"
  }
]
Enter fullscreen mode Exit fullscreen mode

The Node example above, run for real, gave USD/JPY on that day as 158.48 (NBP), 158.83 (Bank of Canada), 158.85 (CNB), 158.85 (ECB) and 158.86 (Norges Bank). The banks fix at different times of day, so they never match exactly. That's why every row says which bank it came from.

Use case: convert invoices at the NBP rate from the previous business day

Say you have invoices.csv with number,date,currency,amount and need each amount in PLN at the NBP table A rate from the last business day before the invoice date.

import csv
from datetime import date, timedelta
from apify_client import ApifyClient

client = ApifyClient("<YOUR_APIFY_TOKEN>")

invoices = list(csv.DictReader(open("invoices.csv", encoding="utf-8")))
first = min(date.fromisoformat(i["date"]) for i in invoices) - timedelta(days=10)
last = max(date.fromisoformat(i["date"]) for i in invoices)

run = client.actor("rel8ble/central-bank-exchange-rates").call(run_input={
    "sources": ["nbp"],
    "currencies": sorted({i["currency"] for i in invoices}),
    "startDate": first.isoformat(),
    "endDate": last.isoformat(),
})

# With no baseCurrency, NBP rows are "1 PLN = x CUR"; inverseRate is PLN per 1 unit of CUR.
rates = {}
for r in client.dataset(run["defaultDatasetId"]).iterate_items():
    rates[(r["quote"], r["date"])] = (r["inverseRate"], r["nbpTable"])

def rate_before(cur, d):
    day = d - timedelta(days=1)
    while (cur, day.isoformat()) not in rates:  # skip weekends and holidays
        day -= timedelta(days=1)
    return day, *rates[(cur, day.isoformat())]

for inv in invoices:
    d = date.fromisoformat(inv["date"])
    day, rate, table = rate_before(inv["currency"], d)
    pln = round(float(inv["amount"]) * rate, 2)
    print(f'{inv["number"]}: {inv["amount"]} {inv["currency"]} x {rate} ({table}, {day}) = {pln} PLN')
Enter fullscreen mode Exit fullscreen mode

The 10-day buffer before the first invoice covers long weekends. The table number goes straight onto the invoice, which Polish rules also ask for. The same pattern works for the ECB (euro-area VAT) or the CNB (Czech accounting): change sources.

What it costs

Pricing is $0.50 per 1,000 results, and one result is one currency pair on one day from one bank.

  • A daily job for 30 currencies from the ECB: ~660 rows a month = $0.33/month
  • A year of daily history for 10 pairs from one bank: ~2,550 rows = $1.28
  • Latest rates from all five banks (~270 rows) = $0.14

Filter with currencies so you only pay for the pairs you use.

Limits

These come straight from my testing:

  • Business days only. No rows for weekends or bank holidays, which is exactly how the banks publish.
  • NBP table B is weekly. It covers about 116 exotic currencies, but only on Wednesdays.
  • Cross rates are arithmetic, not a published figure. They're labeled, and if your rules need the strictly official number, set the base to the bank's own currency.
  • These are daily reference rates, not live market quotes. Don't trade on them.
  • History depth differs by bank: ECB from 1999, NBP from 2002, CNB from 1991, Bank of Canada from 2017, Norges Bank from 1980.

If something breaks or a field is wrong, the Issues tab on the actor page reaches me directly.

Central Bank Exchange Rates on Apify


This article was drafted with AI and published by me.

Top comments (0)