Skip to main content
The ledger has one correctness rule and a handful of consequences that follow from it. Everything below is one of those consequences.

Double-entry, balanced per currency

Every transaction is a set of postings that move together. A posting names an account, an amount, a currency, and a direction (debit or credit). The ledger accepts a transaction only when its debits equal its credits in every currency it touches:
This is the entire correctness model. There is no other validation of whether a transaction “makes sense”, because the balance rule is what makes a double-entry ledger trustworthy. A transaction that does not balance is rejected with unbalanced (422). Accounts carry one of five accounting types: asset, liability, revenue, expense, or equity. The type is informational for reporting; the balance rule applies regardless of type.

Integer minor units

Amounts are integers in the currency’s smallest unit: cents for USD, satoshis for BTC, the base unit for a stablecoin. Floats are rejected at the schema. This removes rounding ambiguity from the wire format. Each response reports currency_decimals so you can format the value for display. The ledger does not police the currency symbol. Post in one it does not recognize and the write succeeds, but the response carries unknown_currency: true and a null currency_decimals, which means nothing can format it for you. Display the integer as it stands until you know the scale.
Money is a JSON string, not a number. Every monetary field (amounts, balances, and report totals) is serialized as a decimal string of integer minor units, for example "9700000" or "1000000000000000001", matching ^-?\d+$. This is deliberate: an 18-decimal token can exceed 2^53, where a JSON number silently loses precision. Parse every money field with a big-integer or arbitrary-precision decimal type, never with a JS number or parseInt. On write, amount still accepts an integer or a decimal string; reads always return a string.

Append-only and corrections

Postings are never edited. The economic columns of a posting (amount, direction, currency, account) are immutable once written. You do not “fix” a transaction in place; you post a new one that offsets it. There are two correction primitives:
  • Reversal undoes a transaction in full. POST /v1/transactions/{id}/reverse books an inverse transaction that references the original. The new transaction links back via reverses, and the original gains a reversed_by pointer.
  • Refund offsets a transaction partially or fully. POST /v1/transactions/{id}/refund books an inverse for the requested amount, proportionally scaling each leg so the result still balances per currency. Scaling uses banker’s rounding and puts any residual minor unit on the largest leg, so a refund of a third never leaks a cent.
A reversal also moves the original’s status to archived. It stays readable and still counts toward balances alongside its reversal; archived only tells you it has been undone. Transaction metadata is the one mutable surface, changed with PATCH /v1/transactions/{id}, which merges by default. Pass merge: false to replace the object. The postings themselves stay frozen. There is no delete: DELETE /v1/transactions/{id} answers 405. Because history is append-only, the events tail is your audit log. Nothing is silently rewritten, so you can always replay what happened.

Idempotency

Every write accepts an Idempotency-Key header. The key, scoped to your ledger, maps to exactly one transaction forever. Replaying a request with the same key returns the original transaction with a 200 and an Idempotent-Replayed: true header, instead of writing a duplicate. Keys match ^[A-Za-z0-9_:.-]+$ and are capped at 255 bytes.
Use a key that is unique per logical operation (an invoice id, a job id), not per HTTP attempt. Retries of the same logical write should reuse the key. A write without an idempotency key is rejected with missing_idempotency_key (422).
The key is matched on its own. Nothing compares the body you sent this time to the body that created the transaction, so reusing a key with different postings returns the first transaction and writes nothing, quietly. A different economic event needs a different key.
To validate a transaction without writing it, pass ?dry_run=true. A dry run needs no idempotency key, emits no events, and changes no balances; it returns a summary telling you whether the postings balance.

Multi-currency

Currency is a first-class primitive, not an afterthought. Account currency is chosen at creation and immutable. A transaction may touch several currencies at once; the balance rule applies independently per currency. When a transaction spans more than one currency (for example moving value through an FX leg), the ledger records the exchange-rate snapshot on the transaction so the rate is preserved for audit and replay. Downstream reports read that recorded rate rather than recomputing from current market data. Operations that need a per-currency contract take a currency argument explicitly. A refund of a multi-currency transaction requires currency to disambiguate which leg you are refunding; omitting it returns refund_currency_required (422). The platform never silently falls back to “the original’s currency”.

Value-date semantics

Each posting has a value_date: the instant the movement is economically effective. Point-in-time balance and statement queries use value_date, not the time the row was inserted. Read a historical balance by passing ?at=<ISO 8601 timestamp> on the balance endpoint:
This computes the balance as of that instant from each posting’s value_date. Account statements work the same way: GET /v1/accounts/{id}/statement?from=...&to=... returns the opening balance, every posting in the window ordered by value_date, and the closing balance.

Next steps

Pagination and expand

Cursor pagination and inlining related objects.

Webhooks

Event delivery, signatures, and replay.

Errors

Every error code, with hints.

API reference

Every endpoint, generated from the spec.