An exchange rate looks harmless — it's "just a number." But handle it carelessly and it becomes a source of bugs that are miserable to track down: discrepancies of a cent here and there, a wrong scale factor, a rate that "vanished" over the weekend, rounding error that quietly accumulates across a year-end report. This is a practical guide to where you actually get exchange rate data (central banks and global services), how to scrape it in five languages, what data type to store it in, and why retail bank rates are a separate story with their own aggregators.
When you need a currency exchange rate API or you're building a pipeline around exchange rate data, the mechanics of the HTTP request are the easy part. The hard part is the details below.
1. Where exchange rates actually come from
Start by separating two fundamentally different kinds of rate.
Official central bank rate. One value per date, with no buy/sell split. This is the "reference" or accounting rate — the one used for taxes, customs duties, bookkeeping, and contracts. It is stable, published on a schedule (usually once per business day), and almost every central bank offers a free, machine-readable source.
Commercial bank and bureau rates. Every bank and currency exchange sets its own buy and sell rate with a spread. It moves through the day, differs from the official rate, and depends on the provider, the city, the amount, and whether it's cash or a card transaction. There is no single source for this — it's the job of aggregators (see section 6).
For most tasks — accounting, multi-currency pricing, converters — you want the official reference rate. If the task is "show the user where it's cheapest to buy dollars," you need retail bank rates.
2. Central banks and free official sources
Almost every central bank publishes rates for free and without an API key. Formats vary — clean JSON in some places, XML elsewhere, CSV or a plain-text feed in others.
| Region | Authority | Endpoint (official daily rates) | Format |
|---|---|---|---|
| Eurozone | ECB | https://www.ecb.europa.eu/stats/eurofxref/eurofxref-daily.xml |
XML (UTF-8) |
| USA | Federal Reserve (via FRED) | https://api.stlouisfed.org/fred/series/observations (free key) |
JSON/XML |
| USA | U.S. Treasury Fiscal Data | https://api.fiscaldata.treasury.gov/web-scraping-services/api/fiscal_service/v1/accounting/od/rates_of_exchange |
JSON/CSV |
| UK | Bank of England | Statistical Interactive Database (CSV export) | CSV/XML |
| Canada | Bank of Canada | https://www.bankofcanada.ca/valet/observations/group/FX_RATES_DAILY/json |
JSON |
| Australia | Reserve Bank of Australia | https://www.rba.gov.au/statistics/tables/ (F11) + RSS |
CSV/RSS |
| Switzerland | SNB | https://data.snb.ch/api/cube/devkua/data/json/en |
JSON/CSV |
| Poland | NBP | https://api.nbp.pl/api/exchangerates/tables/A?format=json |
JSON/XML |
| Czechia | CNB | https://www.cnb.cz/en/financial-markets/foreign-exchange-market/central-bank-exchange-rate-fixing/central-bank-exchange-rate-fixing/daily.txt |
text |
Endpoints are current as of writing. Central banks periodically rework their infrastructure (the U.S. Fed's public data now runs primarily through FRED; several banks forced everything to HTTPS years ago), so verify against the official "developer" or "statistics" page before you ship.
What to watch for in central-bank data:
- Scale / nominal / amount. A rate is often quoted not per 1 unit but per 10, 100, or 1,000. The Czech National Bank, for example, quotes JPY and HUF per 100; the ECB quotes everything per 1 EUR. Ignoring the scale column is the classic mistake that gives you a rate inflated by 100x.
- Encoding. Most modern feeds are UTF-8, but some government sources still serve legacy encodings (ISO-8859-1, windows-1252). Read the bytes "as-is" without decoding and you get mojibake in the currency names.
- Decimal separator. A few European feeds use a comma for the fractional part (
74,1234) rather than a dot. Parse blindly and the value breaks. - Schedule. Rates are published in advance for the next business day and/or after a fixing. The ECB, for instance, publishes its reference rates around 16:00 CET on business days only.
3. Global exchange rate APIs
When you need cross-rates, many currencies, or a single "everything against USD/EUR" source, a global service is more convenient than stitching central banks together. These are the go-to forex data APIs for app developers.
| Service | Data source | Key | Free tier | Notes |
|---|---|---|---|---|
Frankfurter (api.frankfurter.dev) |
ECB | none | no monthly cap (abuse protection only) | ~30 currencies, history since 1999, open-source, self-hostable |
ECB direct (eurofxref-daily.xml) |
ECB | none | yes | XML file from the primary source, everything vs EUR |
| open.er-api.com | mix of central banks | none | yes | 160+ currencies, base USD on the free tier |
NBP (Poland, api.nbp.pl) |
Poland's central bank | none | yes | table C has bid/ask |
| exchangerate-api.com | blend of central banks | required | ~1,500/mo | 160+ currencies, averaged midpoint |
| Open Exchange Rates | aggregate | required | 1,000/mo | USD base only on free |
| Fixer.io / currencylayer (apilayer) | ECB and others | required | ~100/mo | |
| currencyapi.com | aggregate | required | ~300/mo | crypto included |
| Twelve Data / Alpha Vantage | market quotes | required | limited | intraday forex, not a "reference" rate |
The key caveat with global aggregators: their rates are an indicative midpoint (no spread). They are great for approximate e-commerce price conversion or dashboards, but not for real forex trading or for computing the exact amount a bank will actually debit on conversion. Frankfurter and the ECB also publish on business days only: a request for January 1 returns the last business day's rate, and the response carries a date field with the actual rate date — trust that, not the date you asked for.
4. Five implementations across languages
To show the variety of sources and languages, each example hits a different source. Two themes run through all of them on purpose: respect the scale factor, and store the value without a binary float.
4.1. PHP — ECB (XML, BCMath)
<?php
declare(strict_types=1);
/**
* Returns ECB reference rates (units of currency per 1 EUR) as strings.
* Strings + BCMath so we never lose precision to a float.
*/
function fetchEcbRates(): array
{
$raw = file_get_contents('https://www.ecb.europa.eu/stats/eurofxref/eurofxref-daily.xml');
if ($raw === false) {
throw new RuntimeException('Failed to fetch ECB data');
}
// ECB XML is UTF-8. If a source used a legacy encoding you would recode here:
// $raw = mb_convert_encoding($raw, 'UTF-8', 'windows-1252');
$xml = simplexml_load_string($raw);
if ($xml === false) {
throw new RuntimeException('XML parse error');
}
$rates = ['EUR' => '1'];
// rates live under gesmes:Envelope > Cube > Cube > Cube
foreach ($xml->Cube->Cube->Cube as $cube) {
$code = (string) $cube['currency']; // USD, GBP, JPY...
$value = (string) $cube['rate']; // per 1 EUR
// keep it as a normalized decimal string, 6 places
$rates[$code] = bcadd($value, '0', 6);
}
return $rates;
}
$rates = fetchEcbRates();
echo "1 EUR = {$rates['USD']} USD\n";
echo "1 EUR = {$rates['GBP']} GBP\n";
The point here is bcadd/BCMath: arithmetic on the rate is done with string math, never a float. Requires the bcmath extension.
4.2. Python — Bank of Canada (JSON, Decimal)
from decimal import Decimal, getcontext
import requests
getcontext().prec = 28 # ample precision headroom
def fetch_boc_rates() -> dict[str, Decimal]:
"""Latest Bank of Canada FX rates (foreign currency per 1 CAD context)."""
url = "https://www.bankofcanada.ca/valet/observations/group/FX_RATES_DAILY/json"
resp = requests.get(url, params={"recent": 1}, timeout=10)
resp.raise_for_status()
payload = resp.json()
latest = payload["observations"][-1] # newest observation
rates: dict[str, Decimal] = {}
for key, cell in latest.items():
if key == "d": # 'd' is the date field
continue
# series look like FXUSDCAD -> USD expressed in CAD
code = key[2:5]
rates[code] = Decimal(str(cell["v"])) # str() pins the exact decimal
return rates
if __name__ == "__main__":
rates = fetch_boc_rates()
print(f"1 USD = {rates['USD']:.4f} CAD")
print(f"1 EUR = {rates['EUR']:.4f} CAD")
The critical detail is Decimal(str(value)), not Decimal(value). If the number already arrived as a float, wrapping it in str first locks in exactly the decimal representation that was in the JSON.
4.3. JavaScript / Node.js — open.er-api.com (JSON, native fetch)
// Node 18+ — fetch is built in
async function fetchUsdRates() {
const res = await fetch("https://open.er-api.com/v6/latest/USD");
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json(); // { base_code, rates: {...}, time_last_update_utc }
const rates = {};
for (const [code, value] of Object.entries(data.rates)) {
rates[code] = {
raw: String(value), // string, lossless
perOne: Number(value), // for display only
};
}
return { rates, date: data.time_last_update_utc };
}
fetchUsdRates().then(({ rates, date }) => {
console.log(`1 USD = ${rates.EUR.perOne} EUR (as of ${date})`);
console.log(`1 USD = ${rates.GBP.perOne} GBP`);
});
JavaScript has no native decimal type — a number is an IEEE 754 double. So here we keep both the raw string raw and a numeric value for display. For actual monetary math (not just showing a rate), reach for decimal.js or big.js and compute on strings.
4.4. Go — Czech National Bank (pipe-delimited text, strong typing)
package main
import (
"bufio"
"fmt"
"net/http"
"strings"
"github.com/shopspring/decimal"
)
// CNB daily.txt format (pipe-delimited):
// line 1: date
// line 2: header Country|Currency|Amount|Code|Rate
// line 3+: Australia|dollar|1|AUD|9.812
func fetchCnbRates() (map[string]decimal.Decimal, error) {
url := "https://www.cnb.cz/en/financial-markets/foreign-exchange-market/" +
"central-bank-exchange-rate-fixing/central-bank-exchange-rate-fixing/daily.txt"
resp, err := http.Get(url)
if err != nil {
return nil, err
}
defer resp.Body.Close()
rates := make(map[string]decimal.Decimal)
sc := bufio.NewScanner(resp.Body)
sc.Scan() // skip date line
sc.Scan() // skip header
for sc.Scan() {
cols := strings.Split(sc.Text(), "|")
if len(cols) != 5 {
continue
}
amount, _ := decimal.NewFromString(cols[2]) // scale: 1 or 100
rate, _ := decimal.NewFromString(cols[4]) // rate per `amount` units
code := cols[3]
// normalize to a rate per ONE unit — this is the scale gotcha
rates[code] = rate.Div(amount)
}
return rates, sc.Err()
}
func main() {
rates, err := fetchCnbRates()
if err != nil {
panic(err)
}
fmt.Printf("1 USD = %s CZK\n", rates["USD"])
fmt.Printf("1 JPY = %s CZK\n", rates["JPY"]) // JPY is quoted per 100 — divided out above
}
CNB quotes JPY and HUF per 100, so the Amount column matters: dividing the rate by it normalizes everything to "per one unit." Go has no built-in decimal, so we use github.com/shopspring/decimal. On weekends and holidays the feed returns the last published fixing — handle the empty/stale case.
4.5. C# / .NET — Frankfurter (ECB, global source, decimal)
using System.Net.Http.Json;
using System.Text.Json.Serialization;
public record FrankfurterResponse(
[property: JsonPropertyName("base")] string Base,
[property: JsonPropertyName("date")] string Date,
[property: JsonPropertyName("rates")] Dictionary<string, decimal> Rates
);
public static class CurrencyClient
{
private static readonly HttpClient Http = new();
public static async Task<FrankfurterResponse> FetchEcbRatesAsync(string baseCcy = "EUR")
{
var url = $"https://api.frankfurter.dev/v1/latest?base={baseCcy}";
return await Http.GetFromJsonAsync<FrankfurterResponse>(url)
?? throw new InvalidOperationException("Empty response from Frankfurter");
}
}
class Program
{
static async Task Main()
{
var data = await CurrencyClient.FetchEcbRatesAsync("USD");
Console.WriteLine($"Rate date: {data.Date}"); // the actual ECB date
Console.WriteLine($"USD->EUR: {data.Rates["EUR"]}");
Console.WriteLine($"USD->GBP: {data.Rates["GBP"]}");
}
}
System.Text.Json deserializes JSON numbers straight into decimal (reading the text literal), so precision is preserved. In .NET, decimal is the correct type for both rates and money.
5. What data type to store a rate in
This is arguably the single most important technical question in the whole topic — and the one people get wrong most often.
Why not float/double
Binary floating-point numbers (IEEE 754) physically cannot represent most decimal fractions exactly. 0.1 + 0.2 does not equal 0.3. On one rate that's invisible, but multiply by amounts, re-convert repeatedly, and aggregate over a period, and the errors accumulate — producing unexplained penny discrepancies in a financial report that fail reconciliation. For money and rates, float/double is off the table.
What to use instead
| Layer | Correct choice |
|---|---|
| Database | DECIMAL / NUMERIC with fixed precision and scale |
| Python | decimal.Decimal |
| PHP | BCMath / strings (or a Money library) |
| Java / C# | BigDecimal / decimal |
| Go | github.com/shopspring/decimal |
| JavaScript | decimal.js / big.js (store as a string) |
How many decimal places
Rates are usually published with 4–6 fractional digits, but high-denomination currencies (IDR, some others) produce large integer parts. A safe database compromise is NUMERIC(20, 6), and for a normalized "rate per 1 unit" it's sometimes worth more fractional digits — say NUMERIC(24, 10) — so that dividing by the scale factor doesn't lose precision.
What to store besides the number itself
A rate without context is useless. A minimally useful record captures the source, both currencies, the scale, the effective date, and the rate type:
CREATE TABLE exchange_rates (
id BIGSERIAL PRIMARY KEY,
source VARCHAR(32) NOT NULL, -- 'ECB', 'BOC', 'NBP', 'CNB', 'FRED'
base_ccy CHAR(3) NOT NULL, -- quote base: EUR, CAD, USD...
quote_ccy CHAR(3) NOT NULL, -- what we quote: USD, GBP...
nominal INTEGER NOT NULL DEFAULT 1,
rate NUMERIC(20,6) NOT NULL, -- rate per `nominal` units (as the source gives it)
rate_per_one NUMERIC(24,10) NOT NULL, -- normalized rate per 1 unit
rate_type VARCHAR(8) NOT NULL DEFAULT 'official', -- official | buy | sell
effective_date DATE NOT NULL, -- date the rate applies to
fetched_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (source, base_ccy, quote_ccy, rate_type, effective_date)
);
Storing both the "raw" rate (as the source gave it, with its scale) and the normalized rate_per_one is convenient: the first lets you reconcile against the primary source, the second is what you compute with. Use ISO 4217 three-letter currency codes throughout — it removes any ambiguity between different spellings.
A note on monetary amounts (not rates): these are often stored as an integer count of minor units — cents, pence. So 19.99 USD =
1999. That eliminates fractional arithmetic entirely. But rates themselves aren't stored that way — they need a decimal fraction.
6. Retail bank rates and aggregators
The official rate is a single number. But the person walking into a bank to change money sees something else entirely: every bank has its own buy and sell rate, with a spread, moving through the day. Collecting the rates of dozens of banks and bureaus into one place is exactly the job of aggregators and comparison sites.
Examples by market:
- US: Bankrate, XE.com, and Wise show mid-market rates alongside what providers actually charge.
- UK: MoneySavingExpert, Compare the Market, and Wise compare travel-money and transfer rates.
- Global money transfer: Monito and similar sites compare the effective rate across remittance providers.
- Crypto / P2P: Binance P2P, CoinGecko, and CoinMarketCap aggregate rates across exchanges and peer-to-peer markets.
Technical note: many aggregators have no open API — the data has to be scraped from HTML. That makes the ground rules non-negotiable: respect robots.txt and terms of use, don't hammer the site (rate limiting, caching), and cite the source where possible. Some services do offer an API, but a paid one. Central bank data, by contrast, is generally free to reuse. If you're scraping many providers at scale, you'll also want rotating proxies and a clear read on whether the scrape is legal for your use case.
If you're building your own aggregator, a sensible architecture is a set of independent collectors (one per source) → a normalization layer (map to ISO 4217, normalize to per-unit, split buy/sell) → a single store → your own API on top. Unlike central-bank rates, the data model here must include extra dimensions: the bank, the operation type (buy/sell, cash/card), sometimes the city and amount, and almost always the exact timestamp the rate was captured, because it lives for minutes.
7. Practical tips
- Cache. Central banks refresh once per day (occasionally twice). Polling an API more often than the data changes is pointless and rude — cache on the application side.
- Account for weekends and holidays. On non-business days no new rate is published — usually the last one is returned. Always read the rate date in the response, not the date you requested.
- Build a fallback. A single source can go down. It's worth having a backup (a global API like Frankfurter, or another central bank) plus timeouts and retries with exponential backoff.
- Normalize on ingest. Re-encoding, swapping comma for dot, dividing by the scale factor, mapping to ISO 4217 — do all of it at load time and put clean data in the database.
- Monitor for anomalies. A rate that jumps several-fold is far more often a source error or a parsing bug (a forgotten scale factor!) than a real event. A simple "no more than N% deviation from yesterday" check catches most of them.
8. Where this gets used
- E-commerce — multi-currency pricing, localized price lists for the buyer.
- Accounting and finance — converting transactions at the reference rate for a date, taxes, customs.
- Fintech, wallets, P2P — conversion and balance display.
- Analytics and BI dashboards — bringing multi-currency revenue to a single currency.
- Converters and travel tools — quick conversion for the user.
- Contracts and billing — locking the rate at the invoice date.
Wrapping up
Scraping exchange rates is a task where 80% of the difficulty is not in the HTTP request but in the details: scale factor, encoding, decimal separator, non-business days, and — most important — choosing a decimal data type instead of float all the way from parser to database. For official rates there's almost always a free source from the central bank itself; for retail rates you need aggregators and careful, source-respecting scraping. Layer caching and normalization on top and you get data you can actually trust in financial calculations.
If you'd rather not run and babysit this pipeline yourself, scraping.pro can deliver clean, normalized exchange rate and market data on a schedule as a data as a service feed — you get the numbers, we handle the sources, retries, and monitoring.