Multi-Currency API Design: FX Rates, Rounding, Precision
Decimal drift is boring until it isn't. Here's how to design a multi-currency API that treats rounding as a first-class engineering problem.

Most currency conversion bugs don't announce themselves. They accumulate — a fraction of a cent here, a misapplied rounding mode there — until your finance team runs a reconciliation and finds a $47,000 hole in a year's worth of FX settlements. We've seen it happen more than once, and the root cause is almost always the same: rounding and rate versioning were treated as implementation details rather than first-class design concerns.
This post is for engineers building or auditing a multi-currency API that moves real money. We'll cover the three fault lines that break production systems: how you store and version FX rates, how you round intermediate values, and how decimal precision compounds across conversion chains. None of it is glamorous. All of it matters.
Why FX Rate Versioning Is Not Optional
A rate is not a scalar — it's a snapshot with a timestamp, a source, and a TTL. If your API stores a single usd_to_eur float and overwrites it on each feed update, you've already lost the ability to audit any historical transaction.
The Federal Reserve's H.10 statistical release publishes noon buying rates daily, and the ECB publishes its reference rates at approximately 16:00 CET. Those two sources can differ by 30–80 basis points on a given day for the same currency pair. If your system ingests both without tracking provenance, disputes become nearly impossible to resolve.
A rate record should carry at minimum:
rate_id— an immutable UUID for this specific snapshotbase_currencyandquote_currency— ISO 4217 three-letter codesmid_rate— the raw mid-market rate as a fixed-point decimal string, not a floatbidandask— if your spread model is expliciteffective_at— the timestamp the rate became valid (UTC, nanosecond resolution)expires_at— the timestamp after which the rate must not be used for new transactionssource— e.g.,"ECB_REF","FED_H10","PROVIDER_PRIME"feed_version— a monotonic integer per source, so gaps in the sequence trigger alerts
When a transaction executes, your system should write the rate_id to the transaction record at settlement time — not the rate value. The value is reconstructed on read. This is the same pattern the SWIFT network uses for message immutability, and it's the only way to produce a clean audit trail under SEC Rule 17a-4 requirements for broker-dealers.
Engineering callout: Never store FX rates as IEEE 754 doubles.
1.0 / 3.0in a 64-bit float is0.3333333333333333— already wrong at the 16th significant digit. Use your database'sNUMERIC(18, 8)or equivalent, and serialize to strings over the wire.
The Rounding Mode Problem (And Why "Round Half Up" Is Usually Wrong)
Most engineers learn one rounding rule: 2.5 rounds to 3. This is "round half up," and it introduces systematic positive bias when applied to large transaction volumes. Over millions of conversions, that bias is not noise — it's a liability.
ISO 4217 does not mandate a rounding mode, but the accounting industry has largely converged on banker's rounding (round half to even, also called "round half to nearest even" or IEEE 754 ROUND_HALF_EVEN). Under this rule:
- 2.5 rounds to 2 (nearest even)
- 3.5 rounds to 4 (nearest even)
- 2.45 rounds to 2.4 (nearest even digit)
The practical effect: over a large enough sample, upward and downward rounding errors cancel. The residual drift approaches zero rather than accumulating in one direction.
Here's a concrete illustration of why this matters at scale. Suppose you process 500,000 micro-transactions per day at an average value of $0.18, and your naive rounding introduces a $0.001 positive bias per transaction. That's $500 per day, $182,500 per year. Real-world drift figures are usually smaller — our analysis of anonymized settlement data puts the typical range at $30,000–$65,000 annually for platforms processing $200M+ in annual FX volume — but the $47,000 figure in our working title is not a hypothetical.
Your API should expose the rounding mode as a versioned parameter:
rounding_mode: "HALF_EVEN"(default, recommended)rounding_mode: "HALF_UP"(legacy compatibility only)rounding_mode: "TRUNCATE"(for specific regulatory contexts)
And critically: apply rounding exactly once, at the final output step. Intermediate calculations must carry full precision.
Decimal Precision Across Conversion Chains
Single-hop conversion (USD → EUR) is forgiving. Multi-hop conversion (USD → JPY → KRW → EUR) is not. Each intermediate rounding compounds the error.
The rule is simple but routinely violated: carry intermediate values at a precision at least 4 decimal places beyond the target currency's minor unit.
ISO 4217 defines the number of decimal places (the "exponent") for each currency:
- USD, EUR, GBP: 2 decimal places
- JPY, KRW: 0 decimal places
- KWD (Kuwaiti Dinar): 3 decimal places
- BHD (Bahraini Dinar): 3 decimal places
For a USD→KRW conversion targeting 0 decimal places in the output, carry 4 decimal places internally (i.e., work in KRW tenths of a jeon). For USD→EUR targeting 2 decimal places, carry 6 internally. Applying this rule across a 3-hop chain keeps cumulative error below 0.5 of the target minor unit — the threshold for safe rounding.
A common failure mode is libraries that respect currency exponents for display but silently truncate internal precision to match. Test this explicitly:
convert(1_000_000 USD → JPY → EUR → GBP)
assert abs(result - expected) < 0.005 GBP
If your library fails this assertion, it's truncating intermediates. Replace it or wrap it.
Structuring the API Response for Money Operations
A well-designed currency conversion API response is not just a number. It's a verifiable record. Here's the minimal shape we recommend:
{
"request_id": "01HZ3VMKQ7...",
"base_amount": "1000.00",
"base_currency": "USD",
"quote_amount": "924.17",
"quote_currency": "EUR",
"rate_id": "a3f7c...",
"mid_rate": "0.92417000",
"spread_bps": 30,
"effective_rate": "0.92139000",
"rounding_mode": "HALF_EVEN",
"quote_expires_at": "2027-03-14T14:35:00Z"
}
A few design notes on this shape:
- All monetary values are strings, not floats. This is non-negotiable. JSON's number type is a double, which means any parser that deserializes to a native float has already introduced rounding error before your application code runs.
rate_idlinks to the immutable rate snapshot described earlier.quote_expires_atmakes rate expiry explicit in the contract — the client can't claim it didn't know the quote had a TTL.spread_bpsandeffective_rateare separated so that clients can audit the markup independently of the mid-market rate.
For a deeper look at how we've structured these primitives in a production context, see the AtlasForge Financial API documentation, which covers idempotency keys, rate-lock endpoints, and batch conversion design.
Handling Currency Mismatches and Edge Cases
The bugs that survive code review are usually at the edges:
Zero-decimal currencies and display confusion. JPY has no minor unit, but some internal systems store it as 1250.00 and then accidentally treat the .00 as meaningful when converting. Validate that your storage layer respects ISO 4217 exponents — don't infer them from the number's appearance.
Stale rate detection. If your rate feed goes silent for 45 minutes, your API should not silently serve a 44-minute-old rate. Set a staleness_threshold per currency pair (tighter for majors, looser for exotics) and return a 503 with a structured error body rather than a conversion based on bad data.
Concurrent rate updates and race conditions. If two threads simultaneously fetch and apply different rate snapshots to the legs of a single transaction, the effective rate is undefined. Use a rate-lock pattern: acquire a rate_id at transaction initiation, pin all calculations in that transaction to that ID, and reject any settlement attempt that references a different ID.
Reversibility. convert(X USD → EUR) then convert(result EUR → USD) should not equal X. It will always be slightly less, because the round trip crosses the spread twice. If your test suite asserts exact reversibility, it will mask real bugs while also being wrong. The correct assertion is that the round-trip loss is within 2 * spread_bps / 10000 * X, plus rounding tolerance.
Testing Strategy: Don't Trust the Happy Path
Currency conversion testing is one of the few areas where property-based testing pays off faster than example-based testing. The parameter space — currency pairs, amounts, rounding modes, precision — is too large to cover manually.
A practical test suite should include:
- Round-trip tests across all supported currency pairs, asserting round-trip loss stays within spread + rounding tolerance
- Precision stress tests using amounts near integer boundaries (e.g.,
999.995 USD) where rounding mode choice is decisive - Rate expiry tests that inject a stale timestamp and assert the API returns an appropriate error rather than a conversion
- Feed gap simulation that drops 5 consecutive rate updates and confirms the staleness threshold fires
- Concurrency tests that launch 50 simultaneous conversion requests mid-rate-update and verify all transactions reference a consistent
rate_id - ISO 4217 exponent validation for every currency your system supports — automated against the SIX Financial Information ISO 4217 maintenance list
External validation is also worth building into your CI pipeline. The CFPB's remittance rule requires disclosed exchange rates for consumer remittances over $15 — if your API serves that use case, your test suite should assert rate disclosure compliance as a first-class requirement, not an afterthought.
The Cost of Getting This Right (Versus the Cost of Not)
A full implementation of rate versioning, banker's rounding, and multi-hop precision guard-rails adds roughly 3–4 weeks of engineering time to a greenfield currency conversion service. That's the honest estimate. It also requires ongoing maintenance: ISO 4217 updates (the list changes — see the Federal Reserve's FX resources for USD pairs), feed provider SLA monitoring, and periodic reconciliation against your payment processor's settlement reports.
The alternative is a system that works fine in demos and fails slowly in production. The $47,000 annual figure is a median — not a worst case. Platforms that process higher volumes or support more exotic currency pairs have reported settlement discrepancies well into six figures before attribution.
This is exactly the problem the AtlasForge Financial API was built to solve. Our multi-currency API handles FX rate versioning with immutable rate snapshots, enforces banker's rounding at the SDK level, carries intermediate values at 8 decimal places regardless of target currency, and exposes the full rate provenance — rate_id, source, effective_at, spread_bps — in every response. If you're building a product that converts, holds, or transfers money across currencies and you'd rather not audit $47k of decimal drift next Q4, explore the AtlasForge Financial API or get in touch with our team.
Further reading
Ready to build on AtlasForge?
Get sandbox API keys in 60 seconds — or install the Safe to Spend 365 app.
