Skip to main content
Reconciliation is the real problem most fintech teams are solving when they reach for a ledger. The questions are concrete and they come from finance, support, and engineering at once:
  • Why is this balance 1023.17?
  • Which transaction changed it?
  • What happened between date X and date Y?
  • Does it match an external system, such as an external processor or your bank statement?
Each one is answered with the same primitives you already use to write. Because balances are derived from postings and history is append-only, every answer traces back to a durable record you can fetch.

Why is this balance 1023.17?

A balance is not a stored number that someone could have edited. It is derived from the account’s postings, so you can always open it up and see the entries that produced it. List the postings on the account:
For a period view with opening and closing balances around the same postings, use the account statement:
The statement returns the opening balance at from, every posting in the window ordered by value_date, and the closing balance at to. The opening balance plus the postings always equals the closing balance, so the number is never a mystery. The statement does not paginate. It takes a limit, defaulting to 500 and capped at 5000, and reports truncated: true when it hit that limit, which is the only sign that entries are missing. Check the flag, and narrow the window rather than raising the limit.

Which transaction changed it?

Every posting belongs to exactly one transaction. The postings list and the account statement both carry the transaction reference for each entry, so you can go from “this line moved the balance” to “this transaction caused it” in one hop. Fetch the transaction to see all of its legs, its metadata, and any reversal links:
Because corrections are new transactions rather than edits, a reversed or refunded transaction still shows in history with its reverses and reversed_by links intact. Nothing disappears.

What happened between date X and Y?

The account statement is the per-account answer. It bounds the postings by value_date, so a point-in-time question gets a point-in-time answer: the result is computed from when each movement was economically effective, not from when the row was inserted.
For a tenant-wide, replayable view of everything that changed, read the durable events tail:
Every successful write emits an event, and the list is replayable, so a consumer that fell behind can catch up from where it stopped. Together the statement (per account) and the events tail (tenant-wide) cover both “what did this account do?” and “what did the whole ledger do?” over a window. Treat the tail as the operational record and the postings as the authority. Events are written just after the transaction they describe commits, not inside it, so a process that dies in that window leaves a transaction with no event. Recomputing from postings always agrees with itself; replaying events is how you catch up, not how you audit.

Does it match an external system?

This is reconciliation proper: comparing your ledger against an external source of truth, such as an external processor or your bank statement. You bring a snapshot of external items, and the ledger matches them against your unreconciled postings.
Matching is per currency and per amount within a time window. The response surfaces three sets: matched, unmatched_external (items with no posting to pair), and unmatched_internal (postings in the window that no external item claimed). The unmatched sets are your break list. Every monetary value here is a JSON string of integer minor units. Parse them with a big-integer type. On write, amount also accepts an integer. Runs are replayable. Items are stored as external transactions keyed by (source, external_id), so matched postings are marked once. A re-run of the same snapshot reports already-matched items in matched with their original attribution; nothing is double-marked. Fetch a past run with its full snapshot:

Durable feeds and breaks

Inline snapshots work for one-off checks. For a recurring feed, create a source and ingest rows once; they persist with a status (open, matched, ignored) and GET /v1/external_transactions?status=open is your break list across runs.
Ingestion is idempotent on (source, external_id); re-pushed rows report replayed. Rows carrying a reference_rail/reference_kind/reference_value triple match their transaction’s posting first (strategy external_ref), before amount heuristics run. Work the break list with POST /v1/external_transactions/:id/ignore (a row that should never match, with a reason), /match (manual attribution to postings that sum exactly to the row’s amount), and /unmatch (undo; the match row is kept as audit). Each emits an event, so webhooks can drive a breaks workflow. See Feeds for the full workflow, the hosted inbound push endpoint, and a Stripe recipe. For the comparison basis itself, the reports endpoints give you ledger-side totals to check against the external figures. Use the trial balance for per-currency debit and credit totals across every account:
The trial balance is computed from value_date, so an “as of” figure is reproducible. It reports totals_by_currency with a residual per currency and a top-level healthy boolean, which is true only when every residual is zero. Alert on healthy, not on your own subtraction.
External systems named here, such as a processor or a bank, are your own integrations. The ledger is your system of record for what the money means; reconciliation is how you prove it agrees with the systems that moved it.

Next steps

Feeds

Durable external feeds, the break queue, and the inbound push endpoint.

Invariants

Why balances are always derivable from postings.

Webhooks

Drive a breaks workflow off reconciliation.run_completed.

API reference

Every endpoint, generated from the spec.