Methodology

Multi-Currency Attribution — ISO 4217 conventions, decimal-vs-cents, cross-platform rate consistency

Methodology for multi-currency operators running server-side conversion attribution across regions. ISO 4217 currency codes, decimal-versus-cents conventions per platform, FX rate consistency across the five-platform fanout, and the four most common failure patterns that produce silently bad attribution.

A multi-currency operator can run a technically perfect attribution stack and still feed the bidder numbers that are quietly, expensively wrong. The whole problem comes down to one decision: where the currency conversion happens.

For a business transacting in more than one currency, server-side attribution adds a failure mode single-currency operators never see — silently bad value signal from mixed currency codes, cents-versus-decimal corruption, and FX-rate drift across the five platforms. The fix is one discipline: normalize to ISO 4217 at ingestion, convert FX exactly once at the canonical boundary, and propagate the same reporting-currency value to every platform. Here are the conventions, the per-platform value table, the canonical value object, and the four ways it silently breaks.

Why multi-currency hurts attribution

Three failure shapes recur. Bad bidder signal: a conversion fired as value: 7500.00, currency: "USD" into a EUR-reporting account gets converted at the platform's FX rate, which may differ from the rate you booked the revenue at — and over months that drift teaches the bidder your average conversion is worth materially more or less than it is. Reporting-vs-transaction confusion: a £5,000 sale should fire to a GBP-reporting account as 5000 GBP, but firing it to a USD-reporting account requires conversion first — misreading which the platform wants is the single most common error. Cents-as-decimal corruption: a legacy value of 750000 (cents for $7,500) into an endpoint expecting decimal becomes a $750,000 conversion; no error fires, and the bidder responds enthusiastically until you notice the absurdity in week 3.

ISO 4217 — the canonical representation

The canonical form is the alpha-3 uppercase code: USD, EUR, GBP, JPY, and so on. Numeric codes (840, 978) exist but are incompatible with the API surfaces here. Three rules: uppercase always (Google Ads silently rejects lowercase usd; others normalize inconsistently — so validate alpha-3 uppercase at ingestion and reject anything else); alpha-3 always (legacy CRM exports surface $, , Euro, or 840 — normalize via a per-source table at ingestion); and explicit per event (never default to the home currency anywhere in the pipeline).

Decimal versus cents

As of 2026, all five platforms accept decimal-formatted values:

PlatformFieldExample for $7,500.00
Google Ads APIconversion_value7500.00
Meta CAPIcustom_data.value7500.00
Microsoft Adsrevenue_value7500.00
TikTok Events APIproperties.value7500.00
GA4 Measurement Protocolparams.value7500.00

The canonical schema specifies decimal with two-place precision and passes the same value through to every envelope. Two legacy patterns to catch at ingestion: Stripe surfaces amounts as integers in the smallest unit (cents for USD, pence for GBP), so a Stripe webhook needs amount_total / 100 at ingestion — except JPY, which has no sub-unit and passes through undivided. And legacy gtag.js sometimes used cents; any code surfacing cents into the canonical event needs a normalization shim before fanout.

FX consistency — one source of truth

The most consequential decision is where the FX conversion happens. The wrong pattern converts per-platform at the Worker boundary — each transform looks up the current rate independently, so Google sees one converted value, Meta a slightly different one, and the bidder signal diverges by the FX spread on every conversion, every day. The right pattern converts once, at the canonical boundary, carrying both representations:

type ConversionValue = {
  transaction_currency: string   // ISO 4217 of the actual transaction
  transaction_amount: number     // decimal, in transaction currency
  reporting_currency: string     // ISO 4217 of your reporting standard
  reporting_amount: number       // decimal, in reporting currency
  fx_rate: number                // applied rate (transaction → reporting)
  fx_rate_source: string         // "openexchangerates" | "european_central_bank" | "stripe" | "manual"
  fx_rate_timestamp: number      // Unix seconds the rate was captured
}

Ingestion converts once, captures the rate source and timestamp, and propagates reporting_amount to every platform's value field — so all five see the same number. Three rate sources are practitioner-grade: Open Exchange Rates (free tier covers most; commercial adds intraday), European Central Bank reference rates (free, daily at 16:00 CET — fine without intraday precision), and Stripe (if you already process through it, the rate Stripe used at transaction time is authoritative — read it straight off the webhook).

Want multi-currency attribution wired correctly across all five platforms? Talk to the team that runs it. →

Reporting vs. transaction currency — the right field per platform

Every platform expects the value denominated in the account's reporting currency, and its currency field carries that reporting currency — not the transaction's. So a UK operator's GBP-reporting account receives GBP-denominated values regardless of whether the sale was in GBP, EUR, or USD:

// Google Ads
{ conversion_value: c.reporting_amount, currency_code: c.reporting_currency }
// Meta CAPI
{ value: c.reporting_amount, currency: c.reporting_currency }
// Microsoft Ads
{ revenue_value: c.reporting_amount, revenue_currency: c.reporting_currency }
// TikTok
{ properties: { value: c.reporting_amount, currency: c.reporting_currency } }
// GA4 MP
{ params: { value: c.reporting_amount, currency: c.reporting_currency } }

An operator with multiple ad accounts in different reporting currencies needs per-account mapping at the Worker boundary — one canonical event fans out with a different reporting_amount and reporting_currency per destination account.

The four failure modes

  • Mixed code casing. usd / USD / 840 arriving together. Validate alpha-3 uppercase at ingestion, normalize per source, reject the rest.
  • Cents-as-decimal corruption. A Stripe 750000 (cents) read as decimal inflates the value 100×. Source-of-truth the format-by-source mapping; unit-test the cents-to-decimal conversion against real Stripe payloads.
  • Stale FX rates. Fetching once at cold-start and reusing for hours drifts during volatility. Cache for an hour, refresh on the next request after expiry, log every refresh.
  • Per-platform rate drift. Two transforms fetching the rate independently produce two values for one event. Convert once at the boundary; propagate reporting_amount, never transaction_amount.

Post-launch verification

Three checks before you call the cluster complete: per-platform value parity — sample 100 recent conversions; each platform's value should match the canonical reporting_amount within 0.01, or you have rate drift or precision loss; CRM-to-canonical parity — the CRM amount matches transaction_amount, reporting_amount equals transaction_amount × fx_rate, and the source and timestamp are present; and GA4 cross-currency aggregation — GA4 aggregates across currencies when each event carries its own currency, and the total should match CRM-recorded revenue within the rate source's published spread.

Closing

Multi-currency is where rigor in the canonical event schema pays back the loudest. Convert once at the boundary and the bidder signal stays consistent across the five-platform fanout no matter how many currencies you transact in. Convert per-platform and you get drift nobody notices until a CFO asks why Google Ads says the average customer is worth $1,200 and Meta says $1,180 for the same conversions. One conversion, captured with its rate and timestamp, propagated everywhere — the boring stack that prints.

Ready to ship multi-currency attribution correctly the first time? Book a 30-minute call →

Share X LinkedIn
Build it yourself?

Get the kit, not just the theory.

We'll send the build checklist behind this post — and the next pillar when it ships. One email, no drip sequence. Unsubscribe in one click.

Want this built for you?

Book a discovery call. We'll walk your numbers.

20 minutes. Tell us what's broken, hear what we'd ship in the next 90 days. No pitch deck.