Multi-Currency Pricing
Sell one Stripe product at several currencies. Covers purchase-page currency grouping with explicit selection, and the api-ext currency resolution endpoints for third-party apps.
Sell one Stripe product at several currencies. Covers purchase-page currency grouping with explicit selection, and the api-ext currency resolution endpoints for third-party apps.
Who this is for
- Realm Admins who want to sell the same product in several currencies
- Developers integrating Herald's purchase flow into an app, who need to know which currencies an entitlement supports and how to resolve a price row by currency
Prerequisites
- Stripe is configured and you can run a product sync (see the Stripe integration guide)
- You know what an Entitlement Mapping is (see Billing Architecture if not)
Core concepts
Herald has no local product catalog. Prices live on Stripe Price objects, and each synced Price becomes its own Entitlement Mapping row. A product with USD-monthly, USD-yearly, and EUR-monthly prices shows up as three mapping rows after sync. Multi-currency support builds on exactly this: nothing new is stored, the currency on each Price row does the work.
There is no default currency and no per-user currency preference. The purchase page shows every configured currency and the user explicitly picks one before price rows appear; an app calls the resolution endpoint with an explicit currency parameter. Whichever side selects, the selected mapping row is what gets charged.
Currency alone does not identify a price row. A product can have USD-monthly and USD-yearly at the same time, so currency is a filter, not a key — (entitlement + billing type + billing period + currency) is what pins down one row. Whenever a resolution can't land on exactly one row, Herald refuses instead of guessing. You will never get a silent currency swap or a zero-amount charge.
Step 1: Build a multi-currency catalog in Stripe
- In the Stripe Dashboard → Products, open your product
- Add one Price per currency, e.g.
€9.99 / monthand¥69 / monthalongside the existing$9.99 / month - In Herald, open Entitlement Mappings and run Sync Provider Products
Each Price arrives as its own mapping row, with its currency and amount. Enable the rows users should be able to buy. Disabled rows are excluded from both the purchase page and the currency sets served to third-party apps.
Step 2: What users see on the purchase page
For a Stripe product with prices in more than one currency, the purchase page groups prices by currency and shows every currency as a selectable button. No currency is pre-selected: until the user explicitly picks one, no price rows render — the page shows a "select a currency" prompt instead. Inside a picked group, each billing period is a separate price row (monthly and yearly live side by side under USD).
Products with a single currency don't show a selector — the one currency's rows render directly, since there is nothing to choose.
Products from other channels don't get a selector either. Creem prices per product, IAP prices per store region, and WeChat Pay prices are set manually on the mapping — each shows a single price.
One Stripe-side caveat: if you enable Adaptive Pricing in Stripe, Stripe's checkout page may convert the amount to the buyer's local currency. Herald's purchase page always shows the base currency from the Price, with a note that the final charge follows the payment page. Herald does no conversion itself.
Step 3: Resolve prices by currency from your app
Third-party apps get two endpoints for currency handling. Both take an API key (X-API-Key header) with billing.view scope, and both only consider enabled Stripe mapping rows.
List the currencies an entitlement supports:
curl -H "X-API-Key: your-api-key" \
"https://your-herald-domain/api/ext/{realmId}/entitlements/{entitlementKey}/currencies"{
"entitlementKey": "pro-plan",
"currencies": ["USD", "EUR", "CNY"]
}Resolve the default price row for a currency, optionally narrowed by billing type and period:
curl -H "X-API-Key: your-api-key" \
"https://your-herald-domain/api/ext/{realmId}/entitlements/{entitlementKey}/default-price?currency=EUR&billingType=recurring&billingPeriod=monthly"{
"mappingId": "0198c7e4-...",
"entitlementKey": "pro-plan",
"paymentProvider": "stripe",
"currency": "EUR",
"amount": 999,
"billingType": "recurring",
"billingPeriod": "monthly",
"externalPriceId": "price_..."
}Take mappingId from the response and use it as the targetId of a purchase payment attempt — the same explicit-row path the browser purchase page uses.
The failure modes are deliberate and distinguishable:
400— the currency code is missing or malformed404— no enabled Stripe row for that entitlement in that currency. There is no fallback to any other currency; the caller decides what to tell the user409— several rows match (say USD-monthly and USD-yearly when onlycurrency=USDwas passed). PassbillingTypeorbillingPeriodto narrow it down
Currency codes compare case-insensitively against the catalog, and responses are always uppercase ISO codes (usd and USD are the same currency). Subscription and one-time mappings both carry currency — subscription detail and one-time mapping responses include a currency field.
Rules that catch people out
Currency is always chosen explicitly. There is no default currency on the realm, no per-user preference, and no fallback chain. The purchase page renders price rows only after the user picks a currency; the programmatic endpoint resolves exactly once against the currency you pass and fails loud when it can't. This is what prevents a user from being charged in a currency they never chose.
A Stripe row without price info is rejected, not defaulted. If a Stripe mapping row somehow lacks price data, the purchase is refused with an explicit error rather than sent out at a made-up amount. Explicit targetId purchases of healthy rows are unaffected. Store-priced channels (Creem, IAP, WeChat Pay) are the opposite: their rows legitimately carry no Herald-side price, because the channel itself prices the order. WeChat mappings are the one manual case — price and currency are entered in the create dialog and sent with each order.
Troubleshooting
default-price returns 404 for a currency I just added
Check three things: the mapping row is Enabled, the row's provider is Stripe (Creem/IAP/WeChat rows are never considered), and the sync actually ran after you added the Price in Stripe.
default-price returns 409
The entitlement has more than one row in that currency — usually monthly plus yearly. Pass billingType and/or billingPeriod and retry.
default-price rejects my currency code
Codes must be exactly 3 uppercase ASCII letters, and XXX/XTS are reserved. 美元, usd-with-spaces, and 4-letter codes are all rejected with a 400 before anything is resolved.
Checklist
- Stripe product has one Price per currency you want to sell
- Sync Provider Products run; every currency row visible and Enabled
- Purchased once in a non-default currency; charge currency matches the row picked
- (Apps)
currenciescalled before rendering a currency picker;404/409handled with a user-facing message