# Multi-Currency Pricing (/en/docs/billing-multi-currency)



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 [#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 [#prerequisites]

* Stripe is configured and you can run a product sync (see the [Stripe integration guide](/docs/billing-stripe-payment))
* You know what an Entitlement Mapping is (see [Billing Architecture](/docs/billing-overview) if not)

## Core concepts [#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 [#step-1-build-a-multi-currency-catalog-in-stripe]

1. In the [Stripe Dashboard → Products](https://dashboard.stripe.com/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 [#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 [#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:

```bash
curl -H "X-API-Key: your-api-key" \
  "https://your-herald-domain/api/ext/{realmId}/entitlements/{entitlementKey}/currencies"
```

```json
{
  "entitlementKey": "pro-plan",
  "currencies": ["USD", "EUR", "CNY"]
}
```

Resolve the default price row for a currency, optionally narrowed by billing type and period:

```bash
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"
```

```json
{
  "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 [#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 [#troubleshooting]

### `default-price` returns 404 for a currency I just added [#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 [#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 [#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 [#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
