DEV Community

Devil Scrapes
Devil Scrapes

Posted on

Our Chrome fingerprint was the thing getting us blocked

Quick answer

The European Central Bank publishes the official euro reference rate every working day, free, keyless, through an SDMX API. It is the rate that belongs in audited books — not a live-market quote from a commercial FX feed.

Getting it is easy. Getting it from a datacenter turned out to be a lesson worth writing down, because the thing blocking us was our own anti-bot stack.

That is the ECB Euro Exchange Rates Scraper. Ask for currencies and a window; get one flat row per observation.

The 503 that was not about our IP 🚧

First cloud run: five consecutive HTTP 503s, retries exhausted, run dead.

The obvious diagnosis is the one everybody reaches for — datacenter IP, get residential proxies. We checked it instead of assuming it. Plain curl through the same proxy route returned 200. So it was not the IP and it was not the tier.

The variable we had not isolated was the TLS fingerprint. We use curl-cffi, which impersonates a real browser's TLS and HTTP/2 handshake. So we looped it over profiles:

no impersonation           200   1.3 MB
firefox133                 200   1.3 MB
safari17_0                 200   1.3 MB
chrome99                   503    30 KB
chrome110                  503    30 KB
chrome120                  503    30 KB
chrome124                  503    30 KB
chrome131                  503    30 KB
Enter fullscreen mode Exit fullscreen mode

Every Chrome profile blocked. Every non-Chrome profile fine. Same IP, same route, same second.

The anti-blocking measure was the block signal. Some institutional edges appear to treat a Chrome-shaped handshake arriving without Chrome-shaped browsing behaviour as more suspicious than a plain HTTP client that is honest about what it is.

The fix was one constant. The generalisable lesson is bigger: reaching an endpoint with curl is not the same fact as reaching it with the client you ship. If your scraper 503s where your terminal succeeds, isolate the fingerprint before you go buy residential proxies.

SDMX is a cube, and the index order is not the request order 🧊

The ECB's JSON is SDMX-JSON: a dimension-indexed cube, not a list of records.

GET https://data-api.ecb.europa.eu/service/data/EXR/D.USD+GBP.EUR.SP00.A?format=jsondata
Enter fullscreen mode Exit fullscreen mode

Series come back keyed like "0:0:0:0:0" — positional indices into the dimension arrays, not currency codes. To flatten it you join each series key back through structure.dimensions.series, and each observation index through structure.dimensions.observation.

The tempting shortcut is assuming series arrive in the order you requested them, so USD+GBP means series 0 is USD. Do not. Resolve through the structure block. A wrong join here does not error — it silently mislabels every rate, which is the worst failure mode a financial dataset can have.

HTTP 200 with a zero-byte body 📭

Ask for a date with no published observation — a weekend, a TARGET holiday — and the ECB answers 200 with an empty body. Not {"dataSets": []}. Zero bytes.

Call .json() on that and you get a decode error that looks like a transport failure, so a naive client retries a request that will never succeed. An empty body here is a deterministic "no data", so we check content length before decoding and return an empty result immediately. Never retried.

What a row looks like

date, currency, currency_denom, rate, frequency, exr_type, obs_status, series_key. One row per (series, date). No nesting to unpack.

We spot-check rows back against the ECB itself before shipping. GBP on 1999-01-04, 05 and 06 came out as 0.7111, 0.7122 and 0.7076 — which is exactly what the ECB's own API returns for that window. Rows existing is not proof they are real; we verify they are.

Who this is for 🎯

  • Fintech and payments — reconcile transactions against an official reference rate.
  • Treasury and FP&A — historical rates for multi-currency reporting.
  • Accounting and invoicing tools — an audit-defensible rate per invoice.
  • Economists — long-run reference-rate series back to 1999.

The honest limitations 🚧

  • This is the ECB's daily reference rate, published around 16:00 CET. It is not a live-market or intraday quote, and it is not a tradable price.
  • The base is always EUR. Cross-rates are your arithmetic, not ours.
  • Weekends and TARGET holidays have no observation. That is the source's behaviour, not a gap in ours.

Pricing

$0.20 per run plus $0.002 per observation — $2.20 per 1,000. Pay for rows that land.

ECB Euro Exchange Rates Scraper on Apify


Built by Devil Scrapes. We handle the fingerprints, the index-keyed cubes, the empty bodies that are not errors, and the retries that should not happen.

Top comments (0)