# Multi-Currency Invoicing

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

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

---
## 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`](/docs/convert-endpoint/) for the amount,
[`/v1/rates`](/docs/rates-endpoint/) for a dated snapshot, and
[`/v1/history`](/docs/history-endpoint/) 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.

| Field | Example | Why it matters |
|---|---|---|
| `invoice_id` | `INV-2026-0042` | Ties the rate to one document |
| `invoice_date` | `2026-11-10` | The business date being priced |
| `from_currency` | `USD` | The invoice currency |
| `to_currency` | `EUR` | The reporting or settlement currency |
| `source_amount` | `12500.00` | The pre-conversion total |
| `rate` | `0.9214` | The exact factor applied |
| `converted_amount` | `11517.50` | The amount shown on the document |
| `rate_date` | `2026-11-10` | The snapshot date actually used |
| `requested_date` | `2026-11-10` | The date you asked for, when it differs |
| `as_of` | `{"USD":"2026-11-06"}` | Currencies carried forward, when present |
| `fetched_at` | `2026-11-10T14:22:08Z` | When your system recorded the response |

A useful table definition:

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

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

```json
{
  "from": "USD",
  "amount": 12500,
  "date": "2026-11-10",
  "results": [{ "to": "EUR", "result": 11517.5, "rate": 0.9214 }]
}
```

| Field | Use |
|---|---|
| `results[].rate` | Store the exact factor, not a rounded display value |
| `results[].result` | The converted amount before your rounding policy |
| `date` | The snapshot date actually used |
| `requestedDate` | Present only when the requested date had no snapshot |
| `asOf` | Present 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

| Status | What to do |
|---|---|
| `400 invalid_input` | Fix the currency, amount, or date. Do not retry unchanged |
| `401 unauthorized` | Check the key and the `Bearer` prefix |
| `403 forbidden` | A Free key is asking for a date outside the 30-day window, or a plan gate blocks the call |
| `429 rate_limited` | Honor `Retry-After`. Per-minute limits count every request, including errors |
| `5xx` | Retry 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](/docs/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

- [Convert endpoint](/docs/convert-endpoint/)
- [History endpoint](/docs/history-endpoint/)
- [Rates endpoint](/docs/rates-endpoint/)
- [Errors](/docs/errors/)
- [Rate limits](/docs/rate-limits/)
- [Multi-currency e-commerce](/docs/guides/ecommerce/)
- [All developer guides](/docs/guides/)
