Integrations

Multi-Currency Invoicing

Price an invoice at a fixed date, store the rate and response metadata, and reproduce the same calculation for audit.


The job

Issue an invoice in one currency and report or settle it in another. Price the invoice once, at the invoice date, and keep enough of the response to prove how the number was produced months later.

Use /v1/convert for the amount, /v1/rates for a dated snapshot, and /v1/history for the series behind a prior calculation. CurrencyCore does not create invoices, calculate tax, or send documents.

The data model

Store the original amount, the applied rate, and the date the rate actually came from. Do not derive the rate again when the invoice is displayed.

FieldExampleWhy it matters
invoice_idINV-2026-0042Ties the rate to one document
invoice_date2026-11-10The business date being priced
from_currencyUSDThe invoice currency
to_currencyEURThe reporting or settlement currency
source_amount12500.00The pre-conversion total
rate0.9214The exact factor applied
converted_amount11517.50The amount shown on the document
rate_date2026-11-10The snapshot date actually used
requested_date2026-11-10The date you asked for, when it differs
as_of{"USD":"2026-11-06"}Currencies carried forward, when present
fetched_at2026-11-10T14:22:08ZWhen your system recorded the response

A useful table definition:

create table invoice_fx (
  invoice_id        text primary key,
  invoice_date      date not null,
  from_currency     char(3) not null,
  to_currency       char(3) not null,
  source_amount     numeric(18,2) not null,
  rate              numeric(18,8) not null,
  converted_amount  numeric(18,2) not null,
  rate_date         date not null,
  requested_date    date,
  as_of             jsonb,
  fetched_at        timestamptz not null default now()
);

Price once, at the invoice date

Calculate the total at the invoice date, store the result, and stop. Do not recalculate when the invoice is viewed, approved, or paid. A later rate is a different economic event, not a correction.

The API takes the request date as a UTC calendar date. When that date has no snapshot, it resolves to the most recent earlier date and reports both values: date is the date actually used, and requestedDate appears when it differs. asOf lists individual currencies whose value was carried forward. Store all three.

A complete pricing function

This function runs server-side, validates the response, and returns a row ready for the table above. It uses a test key during development and a live key on a paid plan.

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

type InvoiceRate = {
  invoiceId: string;
  invoiceDate: string;
  from: string;
  to: string;
  sourceAmount: number;
  rate: number;
  convertedAmount: number;
  rateDate: string;
  requestedDate?: string;
  asOf?: Record<string, string>;
  fetchedAt: string;
};

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));
  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 priceInvoice(input: {
  invoiceId: string;
  invoiceDate: string;
  from: string;
  to: string;
  sourceAmount: number;
}): Promise<InvoiceRate> {
  const response = await fetchConversion(
    input.from,
    input.to,
    input.sourceAmount,
    input.invoiceDate,
  );

  const target = response.results.find((row) => row.to === input.to);
  if (!target) throw new Error(`No ${input.to} result in the response`);

  return {
    invoiceId: input.invoiceId,
    invoiceDate: input.invoiceDate,
    from: input.from,
    to: input.to,
    sourceAmount: input.sourceAmount,
    rate: target.rate,
    convertedAmount: target.result,
    rateDate: response.date,
    requestedDate: response.requestedDate,
    asOf: response.asOf,
    fetchedAt: new Date().toISOString(),
  };
}

What the response tells you

A priced invoice for 12,500 USD on 2026-11-10 returns:

{
  "from": "USD",
  "amount": 12500,
  "date": "2026-11-10",
  "results": [{ "to": "EUR", "result": 11517.5, "rate": 0.9214 }]
}
FieldUse
results[].rateStore the exact factor, not a rounded display value
results[].resultThe converted amount before your rounding policy
dateThe snapshot date actually used
requestedDatePresent only when the requested date had no snapshot
asOfPresent only when at least one rate was carried forward

If you request a Saturday, date can be the preceding Friday and requestedDate will be the Saturday. That is normal for a daily fixing. Do not treat it as an error, and do not silently replace the invoice date with the rate date. Store both so the audit trail shows the business date and the data date.

Rounding and storage rules

  • Store rate with at least eight decimal places. Do not store the rounded display value as the source of truth.
  • Round the converted amount once, at the boundary where the invoice is issued, using a documented rule such as half-up to the currency’s minor unit.
  • Keep the original source amount. Do not recalculate it from the converted amount and the rounded rate.
  • For zero-decimal currencies such as JPY and KRW, round to zero decimals. Do not assume every currency has two.
  • If your accounting system requires a fixed rate with a fixed number of decimals, store both the raw rate and the posted rate.

Reproduce the invoice for audit

For an audit, issue the identical request with the original date and compare results[].rate. The response is deterministic for a stored snapshot.

/v1/history is available on every plan, but Free is limited to the last 30 days and older dates return 403 forbidden. On Starter and above you can retrieve the full history. If you are on Free, retain the original response alongside the invoice so old documents remain reproducible after the window closes.

Failure handling

StatusWhat to do
400 invalid_inputFix the currency, amount, or date. Do not retry unchanged
401 unauthorizedCheck the key and the Bearer prefix
403 forbiddenA Free key is asking for a date outside the 30-day window, or a plan gate blocks the call
429 rate_limitedHonor Retry-After. Per-minute limits count every request, including errors
5xxRetry with backoff. Do not issue a new rate for the same invoice

The monthly quota counts only successful billable requests. The per-minute limit counts every request, including retries and errors. See rate limits for the plan table.

Worked example

An invoice dated 2026-11-10 for 12,500 USD, reported in EUR:

  1. Request /v1/convert?from=USD&to=EUR&amount=12500&date=2026-11-10.
  2. Receive rate = 0.9214 and result = 11517.5.
  3. Store the raw rate, the converted amount, date, and fetched_at.
  4. Round the posted amount once, for example to 11517.50.
  5. On review, re-run the same request and compare the rate. If the stored value and the new value differ, keep both and flag the later observation rather than overwriting history.

Next steps