Aggregating Exchange Rates from Six Sources Without Losing Your Mind
Most exchange rate APIs pull from a single upstream and call it a day. That works until the upstream goes down at 8am UTC during your customer's biggest sales day, and your fallback is a try/catch that returns last-known-good and prays nobody notices the timestamp.
XRates aggregates from six sources — the European Central Bank (ECB), the Central Bank of Russia (CBR), FloatRates, the fawazahmed0 currency dataset, the IMF SDR feed, and a handful of crypto endpoints. This post is about the boring, useful problem of turning six disagreeing rate feeds into one number you can ship.
Why aggregate at all
Every public source has gaps. ECB publishes ~30 fiat currencies once per business day, no weekends, no minor currencies. CBR covers RUB pairs and a useful tail of post-Soviet currencies, but its XML is quirky. FloatRates fills holes for emerging markets. fawazahmed0's dataset covers crypto and stablecoins. IMF gives you SDR conversion if you ever need it.
Pick one and you'll discover the gaps when a customer asks for MDL → BBD and you don't have it. Pick all six and you get the union — but now you also get to handle them disagreeing.
The aggregation pipeline
Every four hours php artisan rates:fetch runs through the sources in priority order:
- Fetch and parse each source independently. Failures don't cascade — a 500 from CBR doesn't stop ECB.
- Each source emits
{currency_code, rate, source}rows tagged with the fetch timestamp. - A consensus pass per currency picks one canonical rate. The default rule is "highest-priority source that returned a value," with sanity checks that reject rates more than 5% from the median of all sources.
- Anything weird gets logged to
source_logsso the /status page can show degraded sources without taking the API down.
The trick is step 3. "Highest priority wins" sounds clean until ECB publishes EUR=1.0815 and FloatRates publishes 1.082 because they aggregated five minutes apart. We don't want to flip back and forth — that's how you give your customer a 0.5% drift between two requests separated by an hour. So the consensus is sticky: change the canonical rate only when the new value is meaningfully different from the previous one.
What happens when a source dies
Source health drives the /status page directly: every fetch attempt writes a source_logs row with status = success | failed, and the public page rolls these into 24-hour and 7-day uptime per source. A degraded source is fine as long as someone else covers the same currencies.
The dangerous case is silent breakage — the source returns 200 OK but with last week's rates, or empty arrays, or a malformed XML the parser quietly skips. We catch most of these with two cheap checks:
// Reject if currencies_count drops by more than 30% vs the rolling average
if ($currenciesCount < 0.7 * $rollingAverage) {
$log->status = 'failed';
$log->error_message = 'currencies_count anomaly';
}
// Reject if the timestamp inside the upstream feed is more than 48h stale
if ($feedDate->diffInHours(now()) > 48) {
$log->status = 'failed';
$log->error_message = 'feed timestamp stale';
}
Both fire occasionally. Both are worth keeping.
Serving the result
The aggregated rates live in PostgreSQL and are served through /api/v1/latest, /api/v1/{date}, /api/v1/timeseries and friends — see the API documentation for the full surface. The convention follows the ECB tradition: rates[T] is "how many units of T you get for 1 base."
Free tier is 100 requests/month, no card required. If you've been burned by an upstream's free plan disappearing — check the Free Exchange Rate API overview for what we won't pull from under you.
Takeaways
- Treat upstream sources as best-effort and budget for at least one being down at any time.
- Make consensus sticky so customers don't see oscillating rates between consecutive requests.
- Log every fetch attempt with explicit success/failure flags — you need that data the first time someone asks "did it work yesterday?"
- Expose a public status page. Even if nobody reads it, search engines and monitoring services like the freshness signal.
If you're building anything that ships rates to end users — invoices, FX dashboards, multi-currency pricing — multi-source aggregation is one of those investments that looks paranoid until the day it saves you.