# 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.

Canonical: https://currency-core.com/docs/guides/ecommerce/
Updated: 2026-11-12
Published: 2026-11-11

---
## 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:

| Number | What it is | Source |
|---|---|---|
| Base price | The catalog price in your accounting currency | Your database |
| Display price | What the shopper sees | A dated conversion or a cached snapshot |
| Charge amount | What the payment processor captures | The 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.

| Field | Example | Why |
|---|---|---|
| `order_id` | `ord_8f2c` | Ties the rate to one order |
| `base_currency` | `USD` | Catalog currency |
| `display_currency` | `EUR` | Shopper currency |
| `charge_currency` | `USD` | Settlement currency |
| `base_amount` | `49.00` | Catalog price |
| `display_amount` | `45.15` | What the shopper saw |
| `charge_amount` | `49.00` | What was captured |
| `display_rate` | `0.9214` | The factor used for the display amount |
| `charge_rate` | `1` | The factor used for the charge amount |
| `rate_date` | `2026-11-10` | The snapshot date used |
| `requested_date` | `2026-11-10` | Present when it differs from `rate_date` |
| `as_of` | `null` | Carried-forward currencies, when present |
| `quoted_at` | `2026-11-10T14:22:08Z` | When the quote was recorded |

```sql
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.

```ts
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:

```json
{
  "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

| Failure | Checkout behavior |
|---|---|
| `400 invalid_input` | Fix the currency or amount. Do not charge |
| `401 unauthorized` | Check the server-side key |
| `403 forbidden` | A Free key hit the 30-day history window or a plan gate |
| `429 rate_limited` | Honor `Retry-After`; do not guess a rate |
| `5xx` or timeout | Fail closed, or use a last-known snapshot only if your stale-age policy allows it |
| Missing target in `results` | Treat 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](/docs/guides/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

- [Convert endpoint](/docs/convert-endpoint/)
- [Rates endpoint](/docs/rates-endpoint/)
- [Currencies](/docs/currencies/)
- [Invoicing](/docs/guides/invoicing/)
- [Graceful degradation](/docs/guides/graceful-degradation/)
- [All developer guides](/docs/guides/)
