Send "amount": 500 in Icelandic krónur to Adyen and you charge 5 ISK. Send the same integer to Checkout.com and you charge 500 ISK. Both APIs document their amount field as "minor units", and both are telling the truth, because they disagree about how many minor units a króna has.
That is the part the "store money as integers" posts leave out. Integers in minor units are the right call. But "minor units" is not one number per currency. There is the ISO 4217 number, and then there is each payment provider's number, and they differ for at least seven currencies. Any team that stores one exponent table and sends it to every provider is carrying a 100x pricing bug, and it only fires in whichever currency they launch next.
What Are ISO 4217 Minor Units?
ISO 4217 assigns every currency a three-letter code, a three-digit numeric code and a minor unit: the power of ten between the major unit and the smallest unit the standard recognises. GBP has a minor unit of 2, so £12.34 is 1234. JPY has 0, so ¥1,234 is 1234. KWD has 3, so 1.234 dinars is 1234.
SIX Group maintains the list on ISO's behalf and publishes it as XML (list-one.xml). The copy I parsed carries a publication date of 17 September 2026. It breaks down like this:
| Minor units | Count | Currencies |
|---|---|---|
| 2 | 139 | GBP, EUR, USD, HUF, TWD, IDR, CVE and most others |
| 0 | 17 | JPY, KRW, VND, ISK, CLP, UGX, PYG, XAF, XOF, XPF and others |
| 3 | 7 | BHD, IQD, JOD, KWD, LYD, OMR, TND |
| 4 | 2 | CLF, UYW (Chilean and Uruguayan units of account) |
| N.A. | 13 | XAU, XAG, XDR, XTS, XXX and other metals and fund codes |
The list moves more than people expect. Amendment 180 took effect on 1 January 2026 when Bulgaria adopted the euro; BGN is now in the historic list at a fixed EUR 1 = BGN 1.95583. Before that, ZWG (Zimbabwe Gold) arrived in June 2024, XCG replaced ANG from 31 March 2025, and HRK went in January 2023. If your currency table is a constant someone pasted in 2021, it is wrong today.
Which Currencies Have 0, 3 or 4 Decimal Places?
The zero-decimal group is where most production bugs come from, because it contains big consumer markets: Japan, South Korea, Vietnam, Chile, Iceland. A formatter that assumes two decimals turns ¥3,000 into ¥300,000. That exact bug shipped in the Drupal Commerce Stripe module in 2017 (issue #2913605): a helper "presupposes all currencies have two decimal places". The reverse happened in Cal.com's HitPay integration, where JPY, KRW and VND bookings were charged a hundredth of the price.
The three-decimal group is small and concentrated in the Gulf and North Africa. Kuwaiti and Bahraini dinars are among the highest-value currencies in the world, so a 10x error there is expensive.
The four-decimal codes (CLF, UYW) are units of account, not spendable cash. You will mostly meet them in Chilean loan and mortgage flows.
Stripe vs Adyen vs Checkout.com vs PayPal: Amount Formats Compared
All from each provider's own documentation, checked 18 September 2026.
| Stripe | Adyen | Checkout.com | PayPal Orders v2 | Square | Wise | |
|---|---|---|---|---|---|---|
| Field type | integer | integer | integer | string | integer (64-bit) | JSON number |
| Unit | minor | minor | minor | major, decimal | smallest denomination | major |
| Example (£10) | 1000 | 1000 | 1000 | "10.00" | 1000 | 10.00 |
| Max size | 8 digits (non-card), 12 (most cards), 9 (Amex) | not stated on the codes page | 9 digits | 32 chars | int64 | not stated |
PayPal's value is validated by the regex ^((-?[0-9]+)|(-?([0-9]+)?[.][0-9]+))$ in its OpenAPI spec. That is the only design here that sidesteps the exponent question on the wire, at the cost of making you format decimals correctly for each currency instead.
Wise sending a bare JSON number in major units ("targetAmount": 129.24) is the one I would push back on hardest. It works because the values are small. It also means your JSON parser decides the precision.
Where PSPs Disagree With ISO 4217 in 2026
This is the table worth pinning above any multi-acquirer integration.
| Code | ISO 4217 | Stripe | Adyen | Checkout.com |
|---|---|---|---|---|
| ISK | 0 | 2, decimals always 00 | 2 | as ISO |
| CLP | 0 | as ISO | 2 | 2, last two digits 00 |
| UGX | 0 | 0 and 2 (its docs say both) | as ISO | as ISO |
| IDR | 2 | as ISO | 0 | as ISO |
| CVE | 2 | as ISO | 0 | as ISO |
| MGA | 2 | 0 | as ISO | as ISO |
| HUF, TWD | 2 | 2 for charges; payouts divisible by 100 | as ISO | as ISO |
| KWD, BHD, JOD, OMR, TND, IQD, LYD | 3 | see below | as ISO | 3, last digit must be 0 |
"As ISO" means the provider documents no deviation. PayPal sidesteps the table by using decimal strings, but it has its own rule: its currency page marks HUF, JPY and TWD as "no decimal places or fractions", so "10.50" in forints fails even though ISO gives HUF two decimals.
A few of these need the provider's own words.
Adyen says it outright. Its currency page: "For CLP, CVE, IDR, and ISK the ISO 4217 standard has a different number of decimals than shown in our currency codes table", and "the decimals in the table on this page are leading". Adyen also still lists CNH (offshore yuan, not an ISO code) and VEF, which ISO withdrew in 2018. Stripe's ISK rule is a backwards-compatibility fossil. The docs say ISK must be represented "as a two-decimal value, where the decimal amount is always00", so 5 ISK is 500. Stripe's page also lists UGX among zero-decimal currencies while its special-cases table gives UGX the same two-decimal treatment. When a provider's documentation contradicts itself, trust the sandbox.
Stripe's three-decimal rule has vanished from its docs. A June 2024 copy of the currencies page said the last digit must be 0 ("5.124 KWD must be rounded to 5120 or 5130") and listed only five of the seven three-decimal currencies. The current page says nothing. I would not assume the rule went away. Test it.
Checkout.com enforces the dinar rule in validation. "The last digit must always be a 0. For example, an amount value of 1001 is invalid." For CLP, "the last two digits of the amount must be 00".
Should You Store Money as Integer or Decimal?
Store an integer count of ISO minor units, next to the currency code, and keep the exponent in a table you can update. That is the answer for ledgers settling in fiat. It gives you exact addition, exact equality and no rounding until you choose to round.
In Postgres specifically:
bigint: 8 bytes, range to about 9.2 quintillion. Enough for any fiat ledger. Pair it with achar(3)currency column and a check constraint.numeric(p, s): exact decimal, up to 131,072 digits before the point. Use it where you genuinely need fractional minor units: FX rates, interest accrual, per-unit pricing of 0.0004p API calls. Round to minor units when you post to the ledger.money: avoid it. Its fractional precision comes from the database'slc_monetarysetting, and the Postgres docs warn that "it might not work to load money data into a database that has a different setting of lc_monetary". A restore onto a server with a different locale can silently change what your stored values mean.
new BigDecimal(0.1) equals 0.1000000000000000055511151231257827021181583404541015625, which is the whole argument in one line.
Is JSON Safe for Money Amounts?
Only below 2⁵³. RFC 8259 §6 says integers in the range [-(2⁵³)+1, (2⁵³)-1] "are interoperable in the sense that implementations will agree exactly on their numeric values". That ceiling is Number.MAX_SAFE_INTEGER, 9,007,199,254,740,991. In pence it is about £90 trillion, which feels safe until you remember that 18-decimal token amounts cross it at 0.009 of a token.
My rule: fiat minor units can travel as JSON integers. Anything with more than about 12 significant digits, or any crypto amount, travels as a string. PayPal's string choice looks fussy until the first time a client parses a wei value into a double.
ISO 20022 Amount Rules in 2026: 18 Digits, 5 Decimals
ISO 20022 does not use minor units at all. Amounts are decimals with a mandatory currency attribute:
1234.56
The base XSD type (ActiveCurrencyAndAmount, and ActiveOrHistoricCurrencyAndAmount for instructed amounts) allows totalDigits 18 and fractionDigits 5, with Ccy matching [A-Z]{3,3}. The schema permits 5 decimals for GBP. The business rule says otherwise: "The number of fractional digits (or minor unit of currency) must comply with ISO 4217." Schema validation will pass 12.345 GBP. The receiving bank will reject it.
Schemes narrow it further. The EPC's SEPA Credit Transfer guidelines allow only EUR, from 0.01 to 999,999,999.99, with "a maximum of two digits" after the point. A CBPR+ usage guideline for pacs.008 I checked cuts total digits from 18 to 14. I covered which message carries which amount in pain.001 vs pacs.008.
So a system that talks both card PSPs and bank rails converts between two representations: integer minor units in JSON, decimal strings in XML. That conversion is where the exponent table earns its keep.
How to Split an Amount Without Losing a Penny
Martin Fowler's Money pattern names the classic case: split 5p 70/30. You get 3.5p and 1.5p. Round both to nearest and you pay out 6p from a 5p balance. Round both down and 1p disappears. His fix is an allocate method that hands the remainder out explicitly:
function allocate(total: bigint, ratios: bigint[]): bigint[] {
const sum = ratios.reduce((a, b) => a + b, 0n);
const shares = ratios.map((r) => (total * r) / sum); // BigInt division truncates
let remainder = total - shares.reduce((a, b) => a + b, 0n);
for (let i = 0; remainder > 0n; i++, remainder--) shares[i % shares.length] += 1n;
return shares;
}
allocate(5n, [7n, 3n]); // [4n, 1n]
allocate(10000n, [1n, 1n, 1n]); // [3334n, 3333n, 3333n]
This handles positive totals. For refunds, allocate the absolute value and flip the signs, so the same leg absorbs the extra penny both ways.
The quieter problem is that your stack does not agree on what "round" means:
| Runtime | 2.5 rounds to | Default mode |
|---|---|---|
Postgres round(numeric) | 3 | half away from zero |
Postgres round(double precision) | 2 (usually) | platform-dependent, commonly half-even |
Python decimal | 2 | ROUND_HALF_EVEN, precision 28 |
Rust rust_decimal::round_dp | 2 | banker's rounding |
JavaScript Math.round | 3 (but -2.5 → -2) | half towards +∞ |
Java BigDecimal.setScale(n) | throws | ArithmeticException if rounding is needed |
A Python service computing fees with half-even and a Postgres view recomputing them with half-away will disagree on every value ending in exactly 5. At volume that is a reconciliation break every day. Java has the best default here: it refuses to guess.
Rounding direction also compounds. The Vancouver Stock Exchange index launched at 1,000 in 1982 and was truncated instead of rounded about 3,000 times a day. After 22 months it sat near 525; recalculated, it reopened at 1098.892, up 574.081 points.
Stablecoin Decimals: USDC 6, DAI 18, WBTC 8
Tokens have their own exponent, read from the contract's decimals(). On Ethereum mainnet today: USDC returns 6, DAI 18, WBTC 8. Solana USDC is also 6. EIP-20 marks decimals() as optional and says other contracts "MUST NOT expect these values to be present", so hard-coding 18 is a guess, not a standard.
A single asset can even have two exponents. On Circle's Arc chain, native USDC has 18 decimals while the ERC-20 view has 6, which I went through in the Arc mainnet piece. Treat the token exponent like a PSP exponent: a property of the rail, stored per asset, never inferred from the ticker.
My Take: Keep the PSP Exponent Out of Your Ledger
This is opinion, but I hold it firmly. Your ledger should store integers at the ISO 4217 exponent (or the token's on-chain exponent) and nothing else. Each PSP adapter owns a small override table and converts at the edge, the same place you already translate idempotency key formats and status enums.
// Documented deviations from ISO 4217, checked September 2026
const PSP_EXPONENT: Record> = {
adyen: { CLP: 2, ISK: 2, IDR: 0, CVE: 0 },
stripe: { ISK: 2, MGA: 0 },
checkout: { CLP: 2 },
};
function toWire(minor: bigint, ccy: string, psp: string): bigint {
const iso = ISO_EXPONENT[ccy]; // loaded from SIX list-one.xml
if (iso === undefined) throw new Error(unknown currency ${ccy});
const wire = PSP_EXPONENT[psp]?.[ccy] ?? iso;
if (wire >= iso) return minor 10n * BigInt(wire - iso);
const factor = 10n ** BigInt(iso - wire);
if (minor % factor !== 0n) throw new Error(${ccy} ${minor} not representable on ${psp});
return minor / factor;
}
The throw matters. IDR 1,000.50 is a legal ISO amount and cannot be sent to Adyen. You want that as an error before authorisation, not a silent truncation you find in reconciliation.
My prediction: none of these deviations will be fixed. Adyen's ISK and CLP rows, and Stripe's ISK 00 rule, survive because changing them would reprice every live integration by 100x overnight. They are frozen for good. Build for permanent divergence.
What This Means for Payments Engineers
1. Load ISO 4217 from SIX, not from memory. Parse list-one.xml in CI and diff it. Amendment 180 (BGN, 1 January 2026) is the most recent change that would have broken a hard-coded table.
2. Store the currency on every amount. An integer without a currency code is not money. Make it a composite type or a non-null column with a check constraint.
3. Put a PSP exponent override table in each adapter, seeded from the deviation table above, with a test per currency per provider.
4. Reject amounts the rail cannot carry before you call the PSP: IDR fractions on Adyen, KWD not ending in 0 on Checkout.com, CLP not ending in 00.
5. Pick one rounding mode and pin it everywhere, including SQL views and analytics jobs. Write a test for 2.5 and -2.5 in every language in the path.
6. Send crypto and high-precision amounts as strings. Keep JSON integers for fiat minor units only.
7. Run a sandbox charge in every zero- and three-decimal currency you sell in before launch. The docs contradict themselves in at least one place; the sandbox does not.
The ISK row is the classic case: it passes every test suite written in pounds and euros, and nobody tests in krónur. If you are planning a multi-acquirer setup and want a second opinion on the currency layer, you can find me via Tom Wang.
Key Takeaways
- ISO 4217 defines 17 zero-decimal, 139 two-decimal, 7 three-decimal and 2 four-decimal currencies; SIX republished the list on 17 September 2026.
- Adyen deviates from ISO on CLP, CVE, IDR and ISK. Stripe deviates on ISK and MGA, and contradicts itself on UGX. Checkout.com deviates on CLP. PayPal allows no decimals in HUF, JPY or TWD.
- Checkout.com requires three-decimal amounts to end in 0; Stripe documented the same rule until 2024 and no longer does.
- JSON integers are exact only up to 2⁵³ - 1; send crypto amounts as strings.
- ISO 20022 permits 5 decimals in the schema but requires ISO 4217 precision in practice; SEPA caps at 2.
- Postgres
round(numeric)and Pythondecimalround 2.5 differently. Pick one mode and enforce it.