Herald

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

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

  1. In the Stripe Dashboard → Products, open your product
  2. Add one Price per currency, e.g. €9.99 / month and ¥69 / month alongside the existing $9.99 / month
  3. 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 malformed
  • 404 — 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 user
  • 409 — several rows match (say USD-monthly and USD-yearly when only currency=USD was passed). Pass billingType or billingPeriod to 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) currencies called before rendering a currency picker; 404/409 handled with a user-facing message

On this page