Ledgerdoc Ledger API Console
Docs/Core concepts/Reconciliation windows
(01) — API reference · v2.4

Reconciliation windows.

A window is a bounded claim about the outside world: "between these two dates, this external statement says the balance moved by this much." Ledgerdoc's job is to tell you whether its own entries agree — and by exactly how much they do not.

Endpoint POST /v2/reconciliations Scope recon:write Since v2.4.0 Revised 16 Jul 2026

Reconciliation windows

How to open a window against a bank statement or payout file, match its lines against postings, read the residual, and close the window so the period can be signed off.

Balances in Ledgerdoc are derived from entries, never stored beside them, so the ledger is always internally consistent by construction. Reconciliation asks a different question: does the ledger agree with a document produced by somebody else? A window is how you ask it.

A window has a start, an end, one or more accounts under review, and a claimed movement taken from the external statement. Once open, you attach matches; once every statement line is either matched or explicitly written off, the residual should be zero and the window can be closed. A closed window is immutable, like an entry.

Prerequisite

Windows do not create or modify postings. If reconciliation reveals a genuinely missing transaction, you post it with POST /v2/entries and then re-read the residual. Nothing in this article writes to the ledger.

Window lifecycle

A window occupies exactly one of four states. Only closed and abandoned are terminal, and only those two are immutable.

open Created; accepting matches. Residual recalculates on every change.
balanced Residual is zero. Still writable — you may unmatch and revise.
closed Signed off. Immutable. Requires a zero residual or a recorded write-off.
abandoned Given up on. Immutable, and preserved so the attempt stays auditable.

A window moves from open to balanced automatically the moment its residual reaches zero; it moves back if you unmatch a line. Closing is always an explicit act, because closing is the assertion a human is making.

Opening a window

POST /v2/reconciliations recon:write

Supply the period, the accounts under review, and what the statement claims. The claimed_movement is the net change the external document asserts across those accounts, in minor units, using the same sign convention as entry lines.

POST /v2/reconciliations
curl -X POST https://api.ledgerdoc.dev/v2/reconciliations \
  -H "Authorization: Bearer $LEDGER_KEY" \
  -H "Idempotency-Key: recon-2026-06-cash-op" \
  -d '{
    "label": "June 2026 — operating account",
    "accounts": ["cash:operating"],
    "window": { "from": "2026-06-01", "to": "2026-06-30" },
    "claimed_movement": 1284400,
    "statement_ref": "stmt-06-2026-4471"
  }'

# 201 Created
{
  "id": "rec_3Kd9pX",
  "state": "open",
  "derived_movement": 1281900,
  "claimed_movement": 1284400,
  "residual": 2500,
  "matched_count": 0,
  "opened_at": "2026-07-02T08:41:07Z"
}

Note that the residual is populated immediately. derived_movement is computed from the ledger's own entries over the window — it is the same derivation GET /v2/accounts/:id/balance performs with an at timestamp, taken twice and subtracted. You therefore learn the size of the disagreement before you have matched a single line.

Rule

Derive the Idempotency-Key from the period and the account, as above. A retried open then returns the existing window rather than creating a second one for the same month — the same replay semantics as idempotent postings, including the Replayed: true header.

Request fields

labelstring · required
Human label shown in the console and in exports. Free text, capped at 200 characters.
accountsarray<string> · required
One or more account names in the chart, e.g. cash:operating. All must exist and be visible to the key. A window over several accounts sums their movements.
window.fromdate · required
Inclusive start, YYYY-MM-DD, interpreted in the workspace region's timezone.
window.todate · required
Inclusive end. Must be on or after from and not in the future.
claimed_movementinteger · required
Net movement the statement asserts, in minor units. Signed the same way entry lines are: positive is a debit to the listed accounts.
statement_refstring · optional
Your reference for the source document. Not validated; stored verbatim and returned on read so the window can be traced back to the paper.
toleranceinteger · optional
Minor units of residual you are willing to accept at close, default 0. Use it for known rounding on FX statements, never as a way to bury a break.

Matching lines

A match links one statement line to one or more postings inside the window. Matching does not change any posting; it records that a human or a rule has accounted for that line, and it reduces the residual.

Three match shapes are accepted, and the API does not prefer one over another:

  1. One to one — a statement line and a single posting agree on amount and date. The common case, and the one automation should handle.
  2. Many to one — several statement lines net to one posting, or vice versa. Typical for processor payouts, where a day of activity arrives as one settlement.
  3. Write-off — a line you accept will never match, recorded with a reason. It clears from the residual but stays visible on the window forever.
Attach a many-to-one match
curl -X POST https://api.ledgerdoc.dev/v2/reconciliations/rec_3Kd9pX/matches \
  -H "Authorization: Bearer $LEDGER_KEY" \
  -H "Idempotency-Key: match-stmt-06-2026-4471-l17" \
  -d '{
    "statement_lines": ["l17", "l18", "l19"],
    "entries": ["ent_8Fq2wZ"],
    "strategy": "amount_and_date"
  }'

# 200 OK — residual shrinks by the matched amount
{
  "id": "mat_7Vb2nQ",
  "reconciliation": "rec_3Kd9pX",
  "matched_amount": 2500,
  "residual": 0,
  "state": "balanced"
}

Because matches are themselves idempotent writes, a matching job that crashes halfway through can be re-run from the top without double-counting. Derive each match key from the statement line identity, as in the example.

Reading the residual

The residual is claimed_movement − derived_movement − matched_amount, and it is the only number that decides whether a window can close. A non-zero residual is not a failure; it is a question, and where it sits tells you which question.

Reading a non-zero residual on a window over cash:operating.
Shape of the residualUsual causeWhat to do
Clears when you extend window.to by a daySettlement timingNothing. Close the window; the entry lands in the next period
Small, consistent, proportional to volumeProcessor fees posted netPost the fee entry, then re-read. Do not raise tolerance
Matches one statement line exactlyA posting that was never writtenPost it with POST /v2/entries, then match
Equals twice a known amountA duplicate posting from an unsafe retryPost a reversing entry; audit the idempotency keys upstream
Unexplained after all lines are matchedA genuine breakLeave the window open. Escalate rather than write off
Anti-pattern

Raising tolerance until the window closes converts an unanswered question into a silent one. The residual is the most valuable output of the whole exercise — protect it.

Closing and abandoning

Closing is a PATCH that asserts the period is settled. It requires state: "balanced", or a residual within tolerance with a recorded write-off reason. A closed window is immutable — there is no reopen, by design.

PATCH /v2/reconciliations/:id recon:write
  • { "state": "closed" } — signs off the period. Records the key that closed it and the timestamp.
  • { "state": "abandoned", "reason": "…" } — gives up. The attempt and its residual are preserved for audit.
  • Any other transition returns 409 recon_state_invalid and changes nothing.

To correct a period after its window has closed, open a new window over the same dates. The two windows sit side by side in the audit trail, which is precisely what an auditor wants to see: not a revised answer, but the sequence of answers.

Errors

Every error body carries a machine-readable code and a human message. Nothing in this endpoint family writes partially — a rejected request leaves the window exactly as it was.

400 window_invalid to precedes from, or the window ends in the future.
404 account_unknown An entry in accounts does not exist in the chart, or is not visible to this key.
409 idempotency_conflict The key was reused with a different payload. Identical to the behaviour on POST /v2/entries.
409 recon_state_invalid The requested transition is not legal from the window's current state — closing while the residual is outside tolerance, or writing to a terminal window.
409 window_overlap An open window already covers these accounts over overlapping dates. Close or abandon it first.
422 match_amount_mismatch The matched statement lines and postings do not net to the same amount, and strategy was not manual.
429 rate_limited Write budget exhausted. Respect Retry-After; see rate limits below.

Rate limits on reconciliation

Windows and matches spend the same write budget as postings: 600 writes and 3,000 reads per minute per workspace, burstable to double for 30 seconds. A month-end matching job over ten thousand statement lines therefore needs to pace itself, or batch its matches.

  • Every response carries X-RateLimit-Remaining. A 429 adds Retry-After in seconds.
  • Replayed idempotent requests are not counted, so a resumed job pays only for the work it actually does.
  • Reading a window and its residual is a read, not a write — poll it as often as you like.

Relationship to balance derivation

It is worth being explicit about what a window does and does not add. GET /v2/accounts/:id/balance with an at timestamp already tells you what the ledger believes at any moment, and has since v2.4.0. A window adds three things on top of that derivation:

  1. An external claim, recorded and kept — claimed_movement and statement_ref.
  2. A record of who accounted for what, in the matches, including write-offs and their reasons.
  3. An assertion, by a named key, that a period is settled — which is the artefact a signed-off month actually consists of.

None of it changes a single posting. That is the point: the ledger remains a record of what happened, and the window is a record of somebody checking.

(02) — The whole surface

Eight endpoints, in context.

The two routes this article documents, shown against the rest. Every route is versioned under /v2; scopes are checked per key.

MethodPath DescriptionScope
POST/v2/entriesPost a balanced double-entry transactionentries:write
GET/v2/entries/:idRetrieve a single posting with its linesentries:read
GET/v2/entriesList postings, filtered by account or windowentries:read
POST/v2/accountsCreate an account in the chartaccounts:write
GET/v2/accounts/:id/balanceDerive a balance at an optional timestampaccounts:read
PATCH/v2/accounts/:idRename or re-tag an account, never its historyaccounts:write
POST/v2/reconciliationsOpen a reconciliation window against a statementrecon:write
DELETE/v2/webhooks/:idRemove a webhook subscriptionhooks:write

Matches and window transitions are sub-resources of /v2/reconciliations and share its recon:write scope. Postings remain immutable — there is deliberately no PATCH or DELETE on /v2/entries.

(03) — Next

Close a real month in the sandbox.

The reconciliation guides walk the same endpoints against seeded statement data, so you can watch a residual go to zero before you point this at production.

Perfect for: platform teams replacing a spreadsheet ledger with something auditable