ConceptsRegions

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 countries

Storefront (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 null

B. 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 country come 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 regionId to createCheckout to 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 a presentment overlay (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; the Payment (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):

  1. No regionId passed → store-currency response (current behavior).
  2. Region's currency equals the store currency → no conversion needed.
  3. 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 id

Validation: 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 no regionId still 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 internal SalesChannel.id or the public vc_* connectionId; both resolve to the same underlying channel.
  • regionIds and salesChannelIds restrictions 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 with geometry set, 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 on countries/regionIds/salesChannelIds as 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

MethodPathSDK
GET/api/v1/regionsgetRegions()
GET/api/v1/regions/:idgetRegion(id)
POST/api/v1/regionscreateRegion(dto)
PATCH/api/v1/regions/:idupdateRegion(id, dto)
DELETE/api/v1/regions/:iddeleteRegion(id)
PATCH/api/v1/regions/:id/set-defaultsetDefaultRegion(id)
PUT/api/v1/regions/:id/payment-providersupdateRegionPaymentProviders(id, ids)
POST/api/v1/regions/:id/countriesaddRegionCountries(id, codes)
DELETE/api/v1/regions/:id/countries/:coderemoveRegionCountry(id, code)
GET/api/v1/regions/:id/compatible-providersgetRegionCompatibleProviders(id)
GET/api/v1/regions/:id/pricesgetRegionPrices(id, params?)
PUT/api/v1/regions/:id/pricesupsertRegionPrices(id, entries)
DELETE/api/v1/regions/:id/prices/:priceIddeleteRegionPrice(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.

MethodPathSDK
GET/api/stores/:storeId/regionsgetStoreRegions()
GET/api/stores/:storeId/regions/auto?country=XXgetAutoRegion(country)
GET/api/stores/:storeId/regions/:regionIdgetStoreRegion(regionId)
GET/api/vc/:connectionId/regionsgetStoreRegions()
GET/api/vc/:connectionId/regions/auto?country=XXgetAutoRegion(country)
GET/api/vc/:connectionId/regions/:regionIdgetStoreRegion(regionId)

The vibe-coded API also exposes the buyer-facing tax helpers gated on the same products:read scope:

MethodPathSDK
GET/api/vc/:connectionId/tax/estimate?subtotal=&country=estimateTax({ ... })
GET/api/vc/:connectionId/tax-classesgetStoreTaxClasses()

See also Tax Classes for per-product-type rates.