9 Keyless Currency APIs, and the Yen That Breaks Your Cents


Five keyless currency APIs, one pair, one day. I asked each for USD to EUR on July 25, 2026 and lined up what came back:

frankfurter    0.87897
vatcomply      0.878966
open.er-api    0.878195
floatrates     0.87874477
fawazahmed0    0.87880655

Five endpoints, no keys, five different numbers. The reflex is to ask which one is right. That is the smaller problem, and I want to talk you out of it fast, because even if I hand you a perfect rate, the very next line of your code is where the money actually leaks.

The one idea

A keyless currency API turns a base currency into exchange rates with no API key, no signup and no card. I verified nine with a live curl on July 25, 2026, and the table is one screen down. The catch is not the rate. Money is a rate applied to an amount and then rounded to a scale, and the scale is the one thing not a single one of these endpoints sends you. ISO 4217 assigns every currency a minor-unit exponent: the US dollar is 2, the yen is 0, the Kuwaiti and Bahraini dinar are 3. The habit that is correct for the dollar, round to two places and multiply by 100 to store “cents”, is silently wrong the moment the currency is not the dollar.

The rate they send. The scale they never send. And the default everyone reaches for is a decimal count that fits exactly one third of nothing.

The USD to EUR spread, and why it is the appetizer

The five numbers above run from 0.878195 (open.er-api) to 0.87897 (Frankfurter). That is a spread of 0.000775, or 0.088%. On a 1,000,000 USD conversion, computed straight off those two rates, it is 775 EUR of difference, call it 880 dollars. Read that as a ceiling, not a clean attribution to the source: I pulled the five feeds seconds apart rather than in one atomic snapshot, and some are indicative mid-rates while others may already carry a spread, so part of the 0.088% is intraday drift and not-quite-like-for-like feeds, not only which endpoint you picked. Still real money, and a fair reason to care which source you trust.

But chase that and you have picked the fight you can see instead of the one that bills you. Suppose I settle the argument and hand you the single correct rate. You still have to turn amount * rate into a number you can store, charge and reconcile. That step has nothing to do with the provider. It has to do with the currency, and the currency is where the naive version breaks without ever raising an error.

The 9 keyless endpoints, verified

Every row is a live check on July 25, 2026. I ran the negative controls first, so I know the probe can report failure and not just echo the happy path. exchangerate.host and data.fixer.io both returned a clean HTTP 200 carrying {"success":false, ... "missing_access_key"}, so “keyless” is falsifiable and I dropped both. A nonexistent host returned curl exit 35 and HTTP 000. A bogus path on Frankfurter returned an honest 404. The probe can tell live from dead and keyless from key-walled.

#ProviderEndpoint (keyless)Number formUSD to EUR, this runTell
1Frankfurterapi.frankfurter.dev/v1/latest?base=USDnumber, 0.878970.87897ECB source, 30 currencies, does not quote KWD or BHD
2vatcomplyapi.vatcomply.com/rates?base=USDnumber, 0.8789660.878966same ECB numbers as Frankfurter, more decimals
3open.er-apiopen.er-api.com/v6/latest/USDnumber, 0.8781950.878195~160 currencies, quotes JPY, KWD, BHD, OMR; daily
4floatratesfloatrates.com/daily/usd.jsonstring, "0.87874477"0.87874477ships rate and inverseRate as JSON strings
5fawazahmed0cdn.jsdelivr.net/npm/@fawazahmed0/currency-api@latest/v1/currencies/usd.min.jsonnumber, 8 dp0.87880655fiat and crypto share one namespace
6ECB daily XMLecb.europa.eu/stats/eurofxref/eurofxref-daily.xmlXML attr rate='1.1377'base is EURnot JSON; base is always EUR, USD came back 1.1377
7Bank of Canada Valetbankofcanada.ca/valet/observations/FXUSDCAD/json?recent=1string "v":"1.4083"USD/CAD paircentral bank, one currency pair per series
8NBP Polandapi.nbp.pl/api/exchangerates/rates/A/USD?format=jsonnumber, 3.8000USD/PLN midcentral bank, has effectiveDate and a table number
9cbr-xml-dailycbr-xml-daily.ru/latest.jsnumberbase is RUBcentral bank of Russia mirror, does not list KWD

Nine sources, no keys. Notice they do not even agree on what a rate looks like: a float here, a quoted string there, an XML attribute, a central-bank series that only knows one pair. Before you can round money you have to normalize all of that into one decimal. And the moment you do, you hit the part nobody hands you.

The scale nobody sends

Here is open.er-api’s answer for three currencies from one response on July 25, 2026:

"JPY": 163.692064,
"KWD": 0.309508,
"BHD": 0.376

Same JSON shape, three currencies, three incompatible scales, and nothing in the response tells them apart. ISO 4217, the standard that names these codes, also fixes their minor unit: the yen has 0 decimal digits (there is no circulating sub-yen coin), the Kuwaiti dinar and the Bahraini dinar have 3 (one dinar is 1000 fils). The dollar you built your code around has 2. The rate is in the payload. The exponent is not, and it never is.

Watch what the two-decimals-and-times-100 reflex does to each. This is runnable, standard library only, no network:

# runnable, standard library only, no network
from decimal import Decimal, ROUND_HALF_EVEN

# Rates pulled live from open.er-api.com on 2026-07-25 (USD base).
# One provider, one response. Values drift; re-curl before you trust them.
RATES = {"JPY": Decimal("163.692064"),
         "KWD": Decimal("0.309508"),
         "BHD": Decimal("0.376")}

# ISO 4217 gives each currency a minor-unit exponent: how many decimal places
# its smallest real coin has. It is NOT always 2, and no rate endpoint sends it.
# (This is a subset; the standard defines one exponent per currency.)
MINOR_UNITS = {"USD": 2, "EUR": 2, "GBP": 2,   # the "times 100 = cents" world
               "JPY": 0, "KRW": 0, "ISK": 0,   # no sub-unit at all
               "KWD": 3, "BHD": 3, "OMR": 3}   # 1000 sub-units, not 100

def naive_minor_units(amount_usd, rate):
    """The habit: round to 2 places, then times 100 to store integer 'cents'."""
    foreign = (amount_usd * rate).quantize(Decimal("0.01"), ROUND_HALF_EVEN)
    return int(foreign * 100)

def correct_minor_units(amount_usd, rate, currency):
    """Round to THIS currency's ISO 4217 exponent, then scale by 10**exponent."""
    exp = MINOR_UNITS[currency]          # KeyError on an unknown code = fail loud
    q = Decimal(10) ** -exp
    foreign = (amount_usd * rate).quantize(q, ROUND_HALF_EVEN)
    return int(foreign * (10 ** exp)), exp

amount = Decimal("1000.00")   # convert $1,000.00
for cur, rate in RATES.items():
    naive = naive_minor_units(amount, rate)
    correct, exp = correct_minor_units(amount, rate, cur)
    print(f"{cur} (exp {exp}): naive stores {naive:>10}   correct stores {correct:>8}")

Output, from my machine on July 25, 2026:

JPY (exp 0): naive stores   16369206   correct stores   163692
KWD (exp 3): naive stores      30951   correct stores   309508
BHD (exp 3): naive stores      37600   correct stores   376000

Three currencies, and the naive column is wrong in three different directions of the same mistake.

Read the integer back and the money moves

An integer count of minor units is how most money libraries and payment processors store value. Stripe, for one, publishes a list of zero-decimal currencies precisely so you do not multiply yen by 100. ISO 4217 is the authority for the exponent. So the real test is: hand that stored integer to a system that knows the currency’s true exponent and let it divide back.

JPY: naive 16369206 read as yen  -> 16,369,206 JPY   (correct: 163,692 JPY)
KWD: naive 30951    read as fils -> 30.951 KWD        (correct: 309.508 KWD)
BHD: naive 37600    read as fils -> 37.6 BHD          (correct: 376 BHD)

The yen ledger reads your stored integer as whole yen, because for the yen the smallest unit is the yen, and your “cents” made the number 100 times too big. A 163,692 yen charge becomes 16.4 million. The dinar goes the other way: you rounded to two places and multiplied by 100, but the fils is a thousandth, so the reconciling system reads your number 10 times too small and undercharges by an order of magnitude. Same line of code, same clean 200, opposite failures, and not one test in your dollar-denominated suite ever fires. This is the class of bug that ships because your fixtures are all in USD.

The correct path is one rounding step that respects the currency, and a default that refuses to guess:

exp = MINOR_UNITS[currency]   # KeyError for an unmapped code

Feed that an unknown code and you get a loud KeyError. The naive version has no such line, so it silently assumes 2 for everything, including currencies you never planned for. A crash you can see beats a wrong number you cannot.

This is not the which-rate post, and it is not the float post

Two neighbors are close enough that I should draw the line by hand.

It is not about which unit the value is in. The value is dollars, or dinars, and everyone agrees on that. My complaint is one layer down: how many decimal digits survive when you turn that agreed value into stored money. Different question from “is this milliseconds or seconds”, which is a real trap I have written about elsewhere but a different one.

It is not the floating-point money problem either. I touched money-in-float once, in a single line of an earlier post on a budget brake, where the advice was “accumulate in integer minor units or use Decimal”. That advice assumes you already know how many minor units there are. This post is the assumption underneath it. You can do every arithmetic step in flawless Decimal and still store 16 million yen, because the error is not in the type, it is in the exponent you picked before the type ever mattered.

I will own the irony: in older posts on this blog I wrote balance_cents and PRICE_CENTS = 1999 without a second thought, because everyone knows a dollar has 100 cents. That code is fine until a multi-currency price list walks in, and then it breaks exactly where I was not looking.

floatrates felt the problem and still did not solve it

One provider on the list, floatrates, is the odd one out: it ships the rate as a JSON string, "0.87874477", not a number, along with a string inverseRate. I read that as someone who got burned once and decided a JSON number is not a safe container for money, so they moved it into a string to stop JSON.parse from turning it into a lossy float. Reasonable instinct.

It still does not send the scale. A string rate protects the rate from the parser. It says nothing about how many digits the result keeps when you convert and store. floatrates hands you "0.30886877" for the dinar, which is more precise than open.er-api’s 0.309508, and if you round that product to two places for “cents” you have lost a fils just the same. The string fixes the wrong end of the pipe.

The fix, in one function

You cannot make the endpoints send the exponent. You can stop your own code from assuming it. The rule I use: never store money without rounding to the currency’s ISO 4217 exponent, and never let an unmapped currency default silently to 2.

# runnable, standard library only, no network
from decimal import Decimal, ROUND_HALF_EVEN

MINOR_UNITS = {"USD": 2, "EUR": 2, "GBP": 2, "JPY": 0, "KRW": 0,
               "ISK": 0, "KWD": 3, "BHD": 3, "OMR": 3}  # subset of ISO 4217

def to_minor_units(amount, rate, currency):
    if currency not in MINOR_UNITS:
        raise KeyError(f"no ISO 4217 exponent mapped for {currency!r}")
    exp = MINOR_UNITS[currency]
    q = Decimal(10) ** -exp
    foreign = (Decimal(str(amount)) * Decimal(str(rate))).quantize(q, ROUND_HALF_EVEN)
    return int(foreign * (10 ** exp)), currency, exp

for cur, rate in [("JPY", "163.692064"), ("KWD", "0.309508"),
                  ("USD", "1.0"), ("ZZZ", "1.0")]:
    try:
        print(to_minor_units("1000.00", rate, cur))
    except KeyError as e:
        print("refused:", e)

Output on July 25, 2026:

(163692, 'JPY', 0)
(309508, 'KWD', 3)
(100000, 'USD', 2)
refused: "no ISO 4217 exponent mapped for 'ZZZ'"

Every stored value leaves the function tagged with the exponent it was rounded to, and an unknown code is refused loudly instead of being quietly treated as dollars. Load the full ISO 4217 minor-unit table (about 180 codes; a few are exponent 0 or 3, the rest are 2) and this covers the world. That is the whole defense: one exponent lookup the API declined to give you.

Where this comes from, and what I did not measure

I run scrapers and data pipelines in production, 32 published actors and something over 2,000 runs, and currency shows up on the enrichment side: you collect prices off pages in a dozen markets and someone downstream wants them normalized. That is where I learned the hard way that the tidiest-looking field, a price with a currency next to it, is soft precisely where it looks solid.

I want to be exact about what I did not do. I did not run these nine endpoints inside those production jobs, and I am not going to invent a date on which a specific yen charge blew up to give this a war story. The honest artifacts are the ones above: live curls you can rerun today, and a rounding demo whose output I pasted byte for byte. If you see a failure count in a roundup like this with no method beside it, ask where the number came from.

Honest limits

  • This is a snapshot. All nine were keyless on July 25, 2026. Free status, rate limits and even the response shape can change without notice. Re-curl before you rely on any of it.
  • The rates drift by the minute. open.er-api updates once a day, floatrates stamped mine at 15:55 GMT, the central-bank feeds publish on their own calendar. Read the specific numbers as one run, not constants.
  • My ISO 4217 table is a subset. I hard-coded the codes I needed (0, 2 and 3 exponents) to keep the demo short. The real mapping has roughly 180 entries and you should load it from a maintained source, not infer the exponent from the size of the rate.
  • The read-back damage assumes a mixed system. The 100-fold and 10-fold errors happen when one component stores “cents” and another reads true minor units. If a single component owns both ends and is consistently wrong, the number can still round-trip inside that component and only break at the boundary to a payment rail. The boundary is where it bites.
  • Frankfurter and vatcomply never expose the worst case. They draw from the ECB reference set, which does not quote the Kuwaiti or Bahraini dinar at all (I requested them and got {"message":"not found"}). You only meet the 3-digit currencies through the broader aggregators. Convenient, until the one job that needs KWD routes through open.er-api.

The open question

The minor-unit fix is easy because the failure is loud once you round to the wrong exponent: the stored integer is off by a clean factor of 10 or 100. What I do not have a tidy answer for is the currencies where the exponent itself is contested or historical, or the ones a processor treats specially against the standard (some rails require three-decimal currency amounts to be a multiple of 10 for legacy reasons). ISO 4217 says one thing, the payment rail does another, and the rate API stays silent through all of it.

If you settle money across many currencies, where do you keep the exponent, and do you trust ISO 4217 or your processor’s table when they disagree? I have a version that treats the rail as the source of truth and logs every mismatch, and I would rather hear how you do it. 👇


Written with AI assistance. Every rate, status code, HTTP result and command output above is from my own live curls and negative controls on July 25, 2026, printed as they came out.