Business & Legal 12 min read

Scraping Currency Exchange Rates: APIs, Code, Storage

Get currency exchange rates the right way: central bank sources, free APIs, precise decimal handling, caching, and storage, with practical code examples.

ST
Scraping.Pro Team
Data collection for business needs
Published: 18 September 2025

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
<?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)

python
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)

javascript
// 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)

go
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)

c#
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:

sql
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.