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:
| 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
- Browse. Convert the catalog price for display, using a cached snapshot.
- Quote. At checkout, fetch the rate for the order and store it with the order.
- Charge. Capture the amount derived from the stored rate.
- 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 |
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 as2026-11-06requestedDate: the date you asked for, such as2026-11-07asOf: 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_rateandcharge_ratefor 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.00as 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.
Worked example
A 49.00 USD order, displayed in EUR, charged in USD:
- Quote
/v1/convert?from=USD&to=EUR&amount=49. - Receive
display_rate = 0.9214,charge_rate = 1,result = 45.15,date = 2026-11-10. - Store the rate and both amounts with the order.
- Charge
49.00 USDthrough the processor. - If the shopper refunds, reverse
49.00 USDusing the stored order record. - If the catalog price later changes to 55.00 USD, the old order still keeps its original rate and amounts.