Integrations

Currency Conversion for E-commerce

Show local prices, fix the rate at checkout, charge in your settlement currency, and refund at the original rate with a complete order record.


The job

Your catalog has one base price, but shoppers browse in many currencies. Show a display price in the shopper’s currency, charge in the currency your payment stack actually settles, and keep every order tied to the rate date you used.

CurrencyCore supplies the reference rate. It does not process payments, hold funds, or issue refunds.

Separate three numbers

Most currency bugs come from mixing these up:

NumberWhat it isSource
Base priceThe catalog price in your accounting currencyYour database
Display priceWhat the shopper seesA dated conversion or a cached snapshot
Charge amountWhat the payment processor capturesThe rate fixed at checkout

The display price is a presentation choice. The charge amount must come from the rate you fixed at checkout, not from a rate fetched again when the card is captured. If the shopper refreshes the page, the display can move. The charge must not.

Checkout lifecycle

  1. Browse. Convert the catalog price for display, using a cached snapshot.
  2. Quote. At checkout, fetch the rate for the order and store it with the order.
  3. Charge. Capture the amount derived from the stored rate.
  4. Refund. Reuse the original order’s rate record. Do not fetch a new rate.

The order record

Store the display amount, the charge amount, and the rate metadata together.

FieldExampleWhy
order_idord_8f2cTies the rate to one order
base_currencyUSDCatalog currency
display_currencyEURShopper currency
charge_currencyUSDSettlement currency
base_amount49.00Catalog price
display_amount45.15What the shopper saw
charge_amount49.00What was captured
display_rate0.9214The factor used for the display amount
charge_rate1The factor used for the charge amount
rate_date2026-11-10The snapshot date used
requested_date2026-11-10Present when it differs from rate_date
as_ofnullCarried-forward currencies, when present
quoted_at2026-11-10T14:22:08ZWhen the quote was recorded
create table order_fx (
  order_id        text primary key,
  base_currency   char(3) not null,
  display_currency char(3) not null,
  charge_currency char(3) not null,
  base_amount     numeric(18,2) not null,
  display_amount  numeric(18,2) not null,
  charge_amount   numeric(18,2) not null,
  display_rate    numeric(18,8) not null,
  charge_rate     numeric(18,8) not null,
  rate_date       date not null,
  requested_date  date,
  as_of           jsonb,
  quoted_at       timestamptz not null
);

A complete quote function

This function fetches the rate, validates the target, and returns the record you persist before capture. Run it server-side with a secret key.

type ConvertResponse = {
  from: string;
  amount: number;
  date: string;
  requestedDate?: string;
  asOf?: Record<string, string>;
  results: { to: string; result: number; rate: number }[];
};

type OrderQuote = {
  orderId: string;
  baseCurrency: string;
  displayCurrency: string;
  chargeCurrency: string;
  baseAmount: number;
  displayAmount: number;
  chargeAmount: number;
  displayRate: number;
  chargeRate: number;
  rateDate: string;
  requestedDate?: string;
  asOf?: Record<string, string>;
  quotedAt: string;
};

const ZERO_DECIMAL = new Set(["JPY", "KRW", "VND", "CLP", "ISK"]);

function roundMoney(value: number, currency: string) {
  const digits = ZERO_DECIMAL.has(currency) ? 0 : 2;
  return Number(value.toFixed(digits));
}

async function fetchConversion(
  from: string,
  to: string,
  amount: number,
  date?: string,
): Promise<ConvertResponse> {
  const url = new URL("https://api.currency-core.com/v1/convert");
  url.searchParams.set("from", from);
  url.searchParams.set("to", to);
  url.searchParams.set("amount", String(amount));
  if (date) url.searchParams.set("date", date);

  const res = await fetch(url, {
    headers: { Authorization: `Bearer ${process.env.CURRENCECORE_API_KEY}` },
    signal: AbortSignal.timeout(8000),
  });
  const body = await res.json();

  if (!res.ok) {
    throw Object.assign(new Error(body.error?.message ?? `HTTP ${res.status}`), {
      status: res.status,
      code: body.error?.code,
    });
  }
  return body as ConvertResponse;
}

export async function quoteCheckout(input: {
  orderId: string;
  baseCurrency: string;
  displayCurrency: string;
  chargeCurrency: string;
  baseAmount: number;
}): Promise<OrderQuote> {
  // If charge and base are the same, no conversion is needed for the charge.
  const needsDisplay = input.displayCurrency !== input.baseCurrency;
  const needsCharge =
    input.chargeCurrency !== input.baseCurrency &&
    input.chargeCurrency !== input.displayCurrency;

  const [displayResponse, chargeResponse] = await Promise.all([
    needsDisplay
      ? fetchConversion(input.baseCurrency, input.displayCurrency, input.baseAmount)
      : Promise.resolve(null),
    needsCharge
      ? fetchConversion(input.baseCurrency, input.chargeCurrency, input.baseAmount)
      : Promise.resolve(null),
  ]);

  const displayTarget = displayResponse?.results.find(
    (row) => row.to === input.displayCurrency,
  );
  const chargeTarget = chargeResponse?.results.find(
    (row) => row.to === input.chargeCurrency,
  );

  const displayAmount = displayTarget
    ? roundMoney(displayTarget.result, input.displayCurrency)
    : roundMoney(input.baseAmount, input.baseCurrency);

  const chargeAmount =
    input.chargeCurrency === input.baseCurrency
      ? roundMoney(input.baseAmount, input.chargeCurrency)
      : input.chargeCurrency === input.displayCurrency
        ? roundMoney(displayTarget?.result ?? input.baseAmount, input.chargeCurrency)
        : roundMoney(chargeTarget?.result ?? input.baseAmount, input.chargeCurrency);

  const displayRate = displayTarget?.rate ?? 1;
  const chargeRate =
    input.chargeCurrency === input.baseCurrency
      ? 1
      : input.chargeCurrency === input.displayCurrency
        ? displayRate
        : chargeTarget?.rate ?? 1;

  return {
    orderId: input.orderId,
    baseCurrency: input.baseCurrency,
    displayCurrency: input.displayCurrency,
    chargeCurrency: input.chargeCurrency,
    baseAmount: input.baseAmount,
    displayAmount,
    chargeAmount,
    displayRate,
    chargeRate,
    rateDate:
      displayResponse?.date ??
      chargeResponse?.date ??
      new Date().toISOString().slice(0, 10),
    requestedDate: displayResponse?.requestedDate ?? chargeResponse?.requestedDate,
    asOf: displayResponse?.asOf ?? chargeResponse?.asOf,
    quotedAt: new Date().toISOString(),
  };
}

If your processor charges in displayCurrency rather than baseCurrency, use displayTarget.result as the charge amount and store it. Do not convert a second time at capture.

What the response looks like

For a 49.00 USD order displayed in EUR:

{
  "from": "USD",
  "amount": 49,
  "date": "2026-11-10",
  "results": [{ "to": "EUR", "result": 45.15, "rate": 0.9214 }]
}

The order record stores display_rate = 0.9214, charge_rate = 1, display_amount = 45.15, and rate_date = 2026-11-10.

Weekends, holidays and carry-forward

A Saturday checkout can resolve to Friday’s snapshot. You get:

  • date: the snapshot actually used, such as 2026-11-06
  • requestedDate: the date you asked for, such as 2026-11-07
  • asOf: per-currency dates when a rate was carried forward

Store all three. The shopper’s order date and the data date are different facts. Do not overwrite the order date with the rate date.

Rounding and zero-decimal currencies

  • Round the display amount for presentation, but keep the raw display_rate and charge_rate for reconciliation.
  • Round the charge amount using the charge currency’s minor unit.
  • JPY, KRW, VND, CLP and ISK have no minor unit. Do not send 45.00 as if it were a two-decimal currency.
  • Apply a documented rounding policy, such as half-up, and use it everywhere.
  • Respect processor minimum and maximum charge amounts after rounding.

Refunds

A refund uses the original order’s rate record. Do not fetch a new rate, and do not use today’s rate, even if the refund happens months later. The customer is reversing the original economic event.

If the payment processor refunds in a different currency, convert from the stored charge_amount and charge_rate, not from a fresh quote. Keep the refund transaction linked to the original order_id.

Failure handling

FailureCheckout behavior
400 invalid_inputFix the currency or amount. Do not charge
401 unauthorizedCheck the server-side key
403 forbiddenA Free key hit the 30-day history window or a plan gate
429 rate_limitedHonor Retry-After; do not guess a rate
5xx or timeoutFail closed, or use a last-known snapshot only if your stale-age policy allows it
Missing target in resultsTreat as a configuration error, not a default rate

For a display-only price, a cached snapshot with a visible date is acceptable. For checkout, fail closed beyond a tight stale age. See graceful degradation.

Worked example

A 49.00 USD order, displayed in EUR, charged in USD:

  1. Quote /v1/convert?from=USD&to=EUR&amount=49.
  2. Receive display_rate = 0.9214, charge_rate = 1, result = 45.15, date = 2026-11-10.
  3. Store the rate and both amounts with the order.
  4. Charge 49.00 USD through the processor.
  5. If the shopper refunds, reverse 49.00 USD using the stored order record.
  6. If the catalog price later changes to 55.00 USD, the old order still keeps its original rate and amounts.

Next steps