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_revaluationand 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
currencyfrom the same list (see Invoices and Bills). Send it explicitly: the request schemas default it toUSD, not to the business currency. - Journal entries accept a top-level
currencyas 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
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.").
{
"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
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:
{
"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
currencydiffers from the base currency and whose status isopenorpartially_paid. Balance istotal - total_paid. - Payables: bills in a non-base currency with status
receivedorpartially_paid. Balance isamount - 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
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_lossreport 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).