Regions
Bind countries to a currency, tax-display mode, and payment providers, managed from the dashboard or the admin SDK.
A region is a group of countries that share a currency, a tax-display mode,
and a set of payment providers. A store selling into the EU, the UK, and the US
might have three regions, each with its own currency and its own enabled
gateways. Buyers whose country maps to no region fall back to the isDefault
region.
Single-currency stores don't need regions, because the store's default region is used transparently.
The model
A Region has:
countries: ISO 3166-1 alpha-2 codes (['DE', 'FR', 'IT']). A country belongs to at most one region per store.currency: ISO 4217 (EUR).taxInclusive: whether prices are shown tax-inclusive in this region.isDefault: the fallback for buyers whose country maps to no region.isActive: inactive regions are hidden from storefronts.- payment providers: the
AppInstallations enabled here.
Managing regions (admin SDK)
storeId is derived from the API key. Requires the regions:read /
regions:write scopes.
import { BrainerceClient } from 'brainerce';
const admin = new BrainerceClient({ apiKey: process.env.BRAINERCE_API_KEY });
// List + inspect
const { data: regions } = await admin.getRegions();
const region = await admin.getRegion(regionId);
// Create — paymentProviderIds are AppInstallation IDs to enable in this region
const eu = await admin.createRegion({
name: 'European Union',
currency: 'EUR',
countries: ['DE', 'FR', 'IT', 'ES'],
taxInclusive: true,
paymentProviderIds: ['app_inst_stripe'],
});
await admin.updateRegion(eu.id, { isActive: true });
await admin.setDefaultRegion(eu.id); // exactly one default per store
// Country set
await admin.addRegionCountries(eu.id, ['NL', 'BE']);
await admin.removeRegionCountry(eu.id, 'BE');
await admin.deleteRegion(eu.id);Payment providers
A region exposes only the providers you enable for it. The set is replaced
wholesale by updateRegionPaymentProviders:
await admin.updateRegionPaymentProviders(eu.id, ['app_inst_stripe', 'app_inst_paypal']);Not every installed provider can serve every country, because some apps declare
excludedCountries in their manifest. Ask which installed providers are
compatible with a region's countries before enabling them:
const compatible = await admin.getRegionCompatibleProviders(eu.id);
// [{ id, appId, name }, ...] — only providers whose manifest covers these countriesStorefront (public, no API key)
A storefront fetches regions without an API key, in either storeId mode or
vibe-coded mode (salesChannelId: 'vc_*'). The public endpoints expose only
storefront-safe fields (no internal flags) and only active regions, and are
gated on the products:read scope every connection already has, so no extra
scope is needed in vibe-coded mode. Use them to detect the buyer's region for
currency display:
import { BrainerceClient } from 'brainerce';
const store = new BrainerceClient({ storeId: 'store_123' });
// — or in a vibe-coded storefront —
// const store = new BrainerceClient({ salesChannelId: 'vc_abc123' });
// List active regions (default region first)
const { data: regions } = await store.getStoreRegions();
// One region + its enabled payment providers
const region = await store.getStoreRegion(regions[0].id);
// region.paymentProviders → [{ id, appId, name }, ...]Resolving a buyer to a region
Two complementary helpers. Use whichever fits your runtime:
A. Client-side: detectRegion(country, regions), pure and with no network
Map a buyer's country to a region from a list you already fetched (works with
both getRegions() and getStoreRegions() results):
const region = store.detectRegion('DE', regions);
// → the region whose `countries` includes the code, else the default, else nullB. Server-side: getAutoRegion(country), a single round trip
When you only need the resolved region (not the full list), this returns the
matched region, or the default, in one call. Pair it with the geo-IP header
your edge provider injects (Cloudflare CF-IPCountry, Vercel request.geo.country,
Fastly client-geo-country, etc.):
// In a Next.js storefront — middleware / server component
const country = headers().get('cf-ipcountry') ?? request.geo?.country ?? 'US';
const { region, matched } = await store.getAutoRegion(country);
// matched === true → buyer's country was explicitly in a region's `countries`
// matched === false → fell back to the default region (or `region === null`)Where does
countrycome from? Brainerce never derives the buyer's country from the request IP server-side, because your storefront server is what the backend sees, not the end-customer. The storefront's edge runtime (Cloudflare / Vercel / Fastly) extracts the buyer's country and passes it explicitly. Geo-IP is ~95-99% accurate at country level; always offer a manual switcher for VPN/travel cases.
Checkout integration: pass
regionIdtocreateCheckoutto associate a checkout with a region (the region must belong to the store). It is recorded for reporting + payment-provider scoping. FX-at-checkout: when the region currency differs from the store base and its payment provider can settle that currency (presentment-enabled, meaning Stripe today), the buyer is charged in the region currency and the checkout response carries apresentmentoverlay (see below). Otherwise the checkout is charged in the store base currency (safe fallback). The Order is stored in the base currency with a presentment snapshot; thePayment(and refunds) are in the charged currency.
Display prices in the buyer's currency (FX overlay)
Pass regionId to getProducts and the SDK returns additive displayPrice /
displayCurrency fields computed from the daily FX snapshot. The canonical
basePrice / salePrice stay in the store currency, and display fields are
additive, display-only:
const { data: products } = await client.getProducts({ regionId: region.id });
products[0].basePrice; // "399.00" (ILS, store currency — never mutated)
products[0].displayPrice; // "97.76" (EUR, FX-converted for display)
products[0].displayCurrency; // "EUR" (the region's currency)Render with the headless formatter, which has no JSX or styling lock-in:
import { formatProductPrice } from 'brainerce';
const label = formatProductPrice(products[0], {
locale: 'de',
storeCurrency: 'ILS', // fallback when no display* fields (same-currency region)
});
// → "97,76 €"No displayPrice field? Three causes (silent fallback every time):
- No
regionIdpassed → store-currency response (current behavior). - Region's
currencyequals the store currency → no conversion needed. - No FX snapshot yet for the store/region currency pair in either direction → falls back to base price. (The daily ECB cron stores one direction; the overlay inverts it when needed, so a single fresh rate row for the pair is enough.)
For display-only regions (provider can't settle the region currency, or not
presentment-enabled) the payment provider does the real customer-side FX at
capture; the displayed displayPrice uses the mid-market rate. For
presentment-enabled regions (see next section) the displayed price uses the
same buffered rate the checkout charges, so the browsed price equals the checkout
total.
Charging in the buyer's currency (presentment)
When a region's currency differs from the store base and its payment provider
can natively settle it (presentment-enabled, meaning Stripe today; PayPal/Cardcom
roll out as their settlement paths are verified), the checkout is charged in
the region currency, not just displayed in it. The charging rate (mid-market ×
1 + fxBufferPercent, default 2% credited to the merchant to cover the gateway's
settlement fee) is pinned on the checkout at creation so what the buyer sees
equals what they pay.
getCheckout / createCheckout then return an additive presentment overlay.
Render it (instead of the base total/currency) when present:
const checkout = await client.getCheckout(checkoutId);
checkout.currency; // "ILS" (store base — always present)
checkout.total; // "6326.00" (base)
checkout.presentment?.currency; // "EUR" (charged currency — present only for presentment)
checkout.presentment?.total; // "1613.13" (what the buyer is charged, to the cent)
const shown = checkout.presentment
? formatPrice(checkout.presentment.total, { currency: checkout.presentment.currency })
: formatPrice(checkout.total, { currency: checkout.currency });No presentment field? The checkout is charged in the store base currency.
Same silent-fallback rule as the display overlay (no FX rate, same-currency
region, or the region's provider can't settle the currency).
Manual regional prices (admin)
Automatic conversion gives ₪89 × rate, which is rarely a "nice" number. Regional
pricing lets the merchant hand-set the price pair per product (or variant)
per region, in the region's currency: $24.99 instead of $24.63. A manual
pair replaces the whole converted pair (regular + sale together, so a manual
regular price is never mixed with a converted sale), and a product with no
entry keeps automatic conversion.
Manual prices apply only when the region actually charges its currency
(presentment), so a price that would not be charged is never shown. At checkout,
lines with a manual price are charged at it verbatim (pinned at checkout
creation); other lines and shipping/tax/discounts convert at the pinned rate.
Storefronts need no changes: the same displayPrice / displaySalePrice /
displayCurrency fields and the presentment overlay carry the manual values
automatically.
Managed from the dashboard (Products → region filter, or the price overlay
modal) or via the admin SDK (regions:read / regions:write scopes):
// List a region's manual prices (region currency; variantId null = product-level)
const { data } = await client.getRegionPrices('region_us', { page: 1 });
// Bulk upsert — price = regular, salePrice = sale (must be lower)
await client.upsertRegionPrices('region_us', [
{ productId: 'prod_tshirt', price: 24.99, salePrice: 19.99 },
{ productId: 'prod_tshirt', variantId: 'var_l', price: 26.99 },
{ productId: 'prod_mug', remove: true }, // back to automatic conversion
]);
await client.deleteRegionPrice('region_us', 'rp_123'); // same, by row idValidation: price > 0; salePrice < price (an inverted "sale" is rejected);
every product/variant must belong to the store and the variant to its product;
at most 200 entries per call (more returns 400).
Show an estimated tax before checkout
For PDP / PLP / cart preview you can ask the backend for an estimate of the tax that will apply for a country. This is a non-binding preview, and the authoritative calculation still runs at checkout against the buyer's full shipping address:
const estimate = await store.estimateTax({ country: 'IT', subtotal: 100 });
// {
// appliesTax: true,
// rate: 22, // percent — null when no matching rule
// estimatedTax: 22, // null when appliesTax === false
// currency: 'EUR', // store currency, unchanged by FX display overlay
// note: 'Estimate — final tax calculated at checkout'
// }Why isn't this the final number? Tax can depend on state / postal code (US sales tax), B2B VAT exemption (EU reverse-charge), and per-class rates (food / books / digital). The IP-derived country alone is enough for a buyer- friendly preview, never enough to legally bind the charge. Render with a "Estimate" affordance and recalculate at checkout.
Region-restricted shipping
A shipping zone normally matches purely on the buyer's destination country. You
can additionally limit a zone to one or more regions via regionIds, and the zone
is then only offered when the region resolved for that checkout is in the list,
even if the buyer's country is otherwise inside the zone.
That region is resolved destination-first, not from the region the buyer is
browsing or being charged in: the active region whose countries[] contains the
shipping address's country wins, and only if none matches does the checkout's
own regionId apply, then the store's default region. A shipping zone is a
statement about where a parcel goes, so the market it belongs to follows the
destination. A shopper browsing in your USD region who ships to Israel is judged
against the ILS region, and an ILS-restricted zone does match them.
// A zone covering DE/FR/IT, but only for buyers who came through the EU region:
await admin.createShippingZone({
name: 'EU Express',
countries: ['DE', 'FR', 'IT'],
regionIds: [eu.id],
});- Empty / omitted
regionIds: the zone is available for any region (default; unchanged behavior for stores that don't use regions). - Non-empty
regionIds: the zone is hidden unless the region resolved for that checkout (destination-first, as above) is in the list. A checkout that passes noregionIdstill resolves one, from the delivery country or else your default region, so it can still match a region-restricted zone. Only a store with no default region can have a checkout that sees no region-restricted zones at all. - Each id must reference a region of the same store (else
400).
Shipping prices stay in a single currency in this release; per-region shipping prices are a later enhancement.
Channel-restricted shipping
A shipping zone can independently be limited to one or more sales channels via
salesChannelIds, and the zone is then only offered to checkouts from one of
those channels (e.g. a rate that should only apply to a specific vibe-coded
storefront, not the whole store).
// A zone that only shows up for checkouts from one vibe-coded storefront:
await admin.createShippingZone({
name: 'US Domestic (mobile app only)',
countries: ['US'],
salesChannelIds: ['vc_abc123'],
});- Empty / omitted
salesChannelIds: the zone is available on every channel (default; unchanged behavior for stores that don't scope by channel). - Non-empty
salesChannelIds: the zone is hidden from checkouts on any other channel. Accepts either the internalSalesChannel.idor the publicvc_*connectionId; both resolve to the same underlying channel. regionIdsandsalesChannelIdsrestrictions are independent and both apply, so a zone with both set is only offered when the checkout matches both.
Polygon/geometry-restricted shipping
Instead of (or in addition to) countries/regionIds, a zone can cover a
hand-drawn area on a map, a GeoJSON Polygon or MultiPolygon, via
geometry. This is set from the dashboard's map editor (ShapeEditor); the
SDK only needs to know the shape when reading a zone back.
// A polygon zone ("draw on map") — its own coverage, no countries needed:
await admin.createShippingZone({
name: 'Downtown same-day',
geometry: {
type: 'Polygon',
coordinates: [
[
[34.78, 32.08],
[34.79, 32.08],
[34.79, 32.09],
[34.78, 32.09],
[34.78, 32.08],
],
],
},
});- At checkout, the buyer's address is geocoded to a
[lng, lat]point (only for stores that actually have at least one zone withgeometryset, so a store with none pays no geocoding cost) and matched against the polygon with a standard point-in-polygon test. geometry: null/ omitted: unaffected; the zone matches purely oncountries/regionIds/salesChannelIdsas usual.- Coordinates are
[lng, lat]pairs (GeoJSON order, not[lat, lng]), capped at ~500 points and ~10 rings per zone. - There's no way to specify polygon coordinates through the AI assistant (chat). Hand-drawn zones can only be created from the dashboard's map editor.
Endpoints
| Method | Path | SDK |
|---|---|---|
| GET | /api/v1/regions | getRegions() |
| GET | /api/v1/regions/:id | getRegion(id) |
| POST | /api/v1/regions | createRegion(dto) |
| PATCH | /api/v1/regions/:id | updateRegion(id, dto) |
| DELETE | /api/v1/regions/:id | deleteRegion(id) |
| PATCH | /api/v1/regions/:id/set-default | setDefaultRegion(id) |
| PUT | /api/v1/regions/:id/payment-providers | updateRegionPaymentProviders(id, ids) |
| POST | /api/v1/regions/:id/countries | addRegionCountries(id, codes) |
| DELETE | /api/v1/regions/:id/countries/:code | removeRegionCountry(id, code) |
| GET | /api/v1/regions/:id/compatible-providers | getRegionCompatibleProviders(id) |
| GET | /api/v1/regions/:id/prices | getRegionPrices(id, params?) |
| PUT | /api/v1/regions/:id/prices | upsertRegionPrices(id, entries) |
| DELETE | /api/v1/regions/:id/prices/:priceId | deleteRegionPrice(id, priceId) |
Public storefront endpoints (no API key)
Available in both storeId mode and vibe-coded mode (vc_*). The vibe-coded
routes are gated on the products:read scope every connection already has, so no
extra scope is needed.
| Method | Path | SDK |
|---|---|---|
| GET | /api/stores/:storeId/regions | getStoreRegions() |
| GET | /api/stores/:storeId/regions/auto?country=XX | getAutoRegion(country) |
| GET | /api/stores/:storeId/regions/:regionId | getStoreRegion(regionId) |
| GET | /api/vc/:connectionId/regions | getStoreRegions() |
| GET | /api/vc/:connectionId/regions/auto?country=XX | getAutoRegion(country) |
| GET | /api/vc/:connectionId/regions/:regionId | getStoreRegion(regionId) |
The vibe-coded API also exposes the buyer-facing tax helpers gated on the same
products:read scope:
| Method | Path | SDK |
|---|---|---|
| GET | /api/vc/:connectionId/tax/estimate?subtotal=&country= | estimateTax({ ... }) |
| GET | /api/vc/:connectionId/tax-classes | getStoreTaxClasses() |
See also Tax Classes for per-product-type rates.