Skip to content

Foreign Currency

This guide covers one small route family: /api/v1/fx (an exchange-rate store and an unrealized FX revaluation of open foreign-currency receivables and payables).

Parked module

Multi-currency is a parked product module (hidden from the product for now). Today:

  • The app does not show the Multicurrency page, and the AI assistant's FX tools (list_fx_rates, post_fx_revaluation and the rest) are switched off. The MCP server never offered them.
  • The REST routes below are not blocked. They stay reachable with a normal token for existing data and integrations, with no add-on requirement. Parking hides navigation and tools only and can be reversed by a product change, so treat these endpoints as subject to change.

The family is not in the excluded OpenAPI tags, so it appears in the API Reference as fx-rates.

All requests use Authorization: Bearer dz_your_token_here and x-business-id: YOUR_BUSINESS_ID (see Authentication). Successful responses use the standard envelope {"success": true, "message": …, "code": …, "data": …}.

Permissions

Method Required access
GET read
POST, PUT write

A read_only token gets 403 (AUTH_048) on every non-GET call. Write calls also return 403 (BILLING_*, for example BILLING_002) when the business's trial has ended and it has no active subscription. See Error Handling.

Foreign currency

How currency works across the API

  • Each business has one base currency, default_currency, set when the business is created and editable later (see Businesses). Supported values: USD, CAD, AUD, EUR, GBP.
  • Invoices and bills accept a currency from the same list (see Invoices and Bills). Send it explicitly: the request schemas default it to USD, not to the business currency.
  • Journal entries accept a top-level currency as well.
  • There is no automatic conversion when a document is created or posted through these routes. The FX endpoints below are a separate, manual tool: you maintain a rate table, then run a revaluation of what is still open.

Realized FX at settlement is not implemented; the service documents only unrealized revaluation of open AR and AP.

Rate convention

rate converts one unit of quote_currency into base_currency: base = quote × rate. For a USD business holding EUR documents, base_currency is USD, quote_currency is EUR, and rate: 1.085 means 1 EUR = 1.085 USD. The revaluation only looks up rates whose base_currency equals the business's default_currency; rates stored with another base are ignored.

Upsert a rate

bash
curl -X POST "https://api.ondayzero.com/api/v1/fx/rates" \
  -H "Authorization: Bearer dz_your_token_here" \
  -H "x-business-id: YOUR_BUSINESS_ID" \
  -H "Content-Type: application/json" \
  -d '{
    "base_currency": "USD",
    "quote_currency": "EUR",
    "rate": 1.085,
    "as_of_date": "2026-03-31",
    "source": "ecb"
  }'
Field Required Notes
base_currency, quote_currency Yes Exactly 3 letters; upper-cased for you. Not checked against the five supported currencies
rate Yes Decimal greater than 0 (stored with 8 decimal places)
as_of_date Yes YYYY-MM-DD
source No Free text, up to 50 characters

There is one rate per business, base, quote and date. Posting the same triple again updates that row's rate and source (an omitted source becomes null) and still returns 201. Equal base and quote currencies, or a non-positive rate, return 400 (GEN_002, "FX rate must be a positive number and base/quote currencies must differ.").

json
{
  "success": true,
  "data": {
    "id": "019ab37c-aa01-7000-8000-000000000001",
    "business_id": "019ab37c-b051-7000-8000-000000000001",
    "base_currency": "USD",
    "quote_currency": "EUR",
    "rate": "1.08500000",
    "as_of_date": "2026-03-31",
    "source": "ecb",
    "created_at": "2026-03-31T09:00:00Z",
    "updated_at": "2026-03-31T09:00:00Z"
  }
}

The response is the stored row: id, business_id, base_currency, quote_currency, rate (a decimal; parse it as a decimal rather than a float), as_of_date, source, created_at, updated_at.

List rates

GET /api/v1/fx/rates returns every stored rate for the business as {"items": [...]}, newest as_of_date first, then by quote currency. It is not paginated and has no filters. There is no update-by-id or delete route for rates; correct a rate by upserting the same pair and date.

Preview exposure

bash
curl "https://api.ondayzero.com/api/v1/fx/exposure?as_of_date=2026-03-31" \
  -H "Authorization: Bearer dz_your_token_here" \
  -H "x-business-id: YOUR_BUSINESS_ID"

as_of_date is required. This is read-only and posts nothing. Response data:

json
{
  "as_of_date": "2026-03-31",
  "base_currency": "USD",
  "exposures": [
    {
      "currency": "EUR",
      "receivable_foreign": 1000000,
      "payable_foreign": 250000,
      "booked_rate": "1.0700",
      "current_rate": "1.0850",
      "receivable_gain_loss": 15000,
      "payable_gain_loss": -3750,
      "net_gain_loss": 11250
    }
  ],
  "total_gain_loss": 11250,
  "journal_entry_id": null,
  "posted": false
}

receivable_foreign and payable_foreign are open balances in the foreign currency, in cents. The *_gain_loss fields and total_gain_loss are in base-currency cents, gain positive and loss negative. booked_rate is the balance-weighted average across the open documents of that currency. Decimal fields may serialize as strings or numbers; parse them as decimals.

What counts as exposure:

  • Receivables: invoices whose currency differs from the base currency and whose status is open or partially_paid. Balance is total - total_paid.
  • Payables: bills in a non-base currency with status received or partially_paid. Balance is amount - total_paid.
  • Documents with a zero or negative balance are skipped.
  • For each document the booked rate is the latest stored rate on or before the date the document record was created (not its invoice or bill date), and the current rate is the latest stored rate on or before as_of_date. A document is silently left out if either rate is missing, so a currency with no stored rates produces no exposure row.
  • Gain/loss per document is round(balance × current_rate) - round(balance × booked_rate) in base cents. A receivable gains when the base value rises; a payable loses.
  • Currencies are sorted alphabetically.

Post a revaluation

bash
curl -X POST "https://api.ondayzero.com/api/v1/fx/revalue" \
  -H "Authorization: Bearer dz_your_token_here" \
  -H "x-business-id: YOUR_BUSINESS_ID" \
  -H "Content-Type: application/json" \
  -d '{ "as_of_date": "2026-03-31", "post": true }'

as_of_date is required. post defaults to true, so omitting it posts the entry; send "post": false for a preview identical to GET /fx/exposure. The status is 201, and the response has the same shape as the preview, with journal_entry_id and posted: true once an entry exists.

What posts, when there is a non-zero net AR or AP change: one journal entry dated as_of_date, described FX revaluation 2026-03-31, in the business's base currency, with up to three lines:

Line Memo Direction
Accounts Receivable FX revaluation — AR Debit if the total AR gain is positive, otherwise credit
Accounts Payable FX revaluation — AP Credit if the payable liability grew (a loss), otherwise debit
Foreign Exchange Gain/Loss Foreign exchange gain/loss The balancing line: credit for a net gain, debit for a net loss

The accounts are found by system key (accounts_receivable, accounts_payable, fx_gain_loss); the Foreign Exchange Gain/Loss ledger (an expense-type account) is created if it does not exist yet. The entry then appears in Journal Entries with source: fx_revaluation, and the journal_entry_id in the response points to it. If the net change is zero, or there is no exposure, nothing is posted and posted stays false.

Things to know before you call it with post: true:

  • It is not idempotent. The calculation always compares each document's booked rate with the current rate and never subtracts earlier revaluation entries. Running it twice for the same date, or again at the next period end, posts the full difference again. Run it once per period, preview first, and reverse a mistaken run with POST /api/v1/journal-entries/{journal_entry_id}/reverse.
  • There is no automatic reversal on the first day of the next period.
  • The entry goes through normal journal validation, so a date in a closed accounting period is refused with 400 (see Close).
  • The FX line carries no class or location.
  • The unrealized_fx_gain_loss report in the Reports API is a separate read-only report that revalues foreign-currency balances using the same stored rates; it posts nothing.

Errors

Status When
400 Validation failure: bad body, equal FX currencies, non-positive rate, or a closed accounting period on POST /fx/revalue
401 Missing or invalid token, or missing x-business-id
403 Token scope, role or billing status does not allow the write

The error body is the standard envelope described in Error Handling. Send an Idempotency-Key on writes you may retry; it protects against a double-posted revaluation after a timeout.

Not covered

Realized FX at payment, per-document exchange rates, automatic rate feeds, and the observed-rate log that Xero, QuickBooks and Shopify imports populate (not exposed through a public route).