Statement sets

Catch-up check: verify a whole set of bank statements for one account together, the way a bookkeeper doing catch-up or cleanup work needs.

A statement set takes 2-24 already-processed bank_statement documents (uploaded and extracted via POST /v1/documents as usual, each in a terminal status) and checks them as one account's history: does every month have a statement, do balances carry forward correctly, and do any two statements duplicate the same transactions. Nothing is re-extracted and nothing is re-billed: the report is computed live from each document's already-stored result.

Create a set

The response is the new set's id together with its computed report in one call, so there is no separate fetch needed right after creating it.

http
POST /v1/statement-sets HTTP/1.1
Authorization: Bearer sk_live_...
Content-Type: application/json

{
  "document_ids": ["doc_january", "doc_march"]
}
json
{
  "id": "sts_9f2a1c",
  "name": null,
  "document_ids": ["doc_january", "doc_march"],
  "created_at": "2026-09-27T21:04:11.000Z",
  "updated_at": "2026-09-27T21:04:11.000Z",
  "statements": [
    { "document_id": "doc_january", "filename": "January.pdf", "period_start": "01/01/2026",
      "period_end": "01/31/2026", "opening_balance": 1000, "closing_balance": 1500,
      "account_number": "****1234", "currency": "USD", "failed_checks": [] },
    { "document_id": "doc_march", "filename": "March.pdf", "period_start": "03/01/2026",
      "period_end": "03/31/2026", "opening_balance": 1500, "closing_balance": 1800,
      "account_number": "****1234", "currency": "USD", "failed_checks": [] }
  ],
  "chain": [
    { "from_document_id": "doc_january", "to_document_id": "doc_march", "ok": false, "reasons": ["GAP"], "balance_verified": false }
  ],
  "findings": [
    { "type": "GAP", "message": "No statement covers February 1 to February 28, 2026.",
      "document_ids": ["doc_january", "doc_march"] }
  ]
}

GET /v1/statement-sets/{id} recomputes and returns the same shape later (e.g. to reload the set's page). Deleting a document removes it from every set it belonged to; the report simply reflects however many documents remain.

Findings

  • GAP: A missing period between two consecutive statements (no statement covers those dates). Computed over the UNION of every statement’s coverage, so a statement fully CONTAINED inside a broader one is correctly treated as already covered, never as a false gap.
  • BREAK: A genuine back-to-back statement boundary's closing balance does not match the next one's opening balance; shows both numbers and the difference. Never compared across a gap, or between a statement and one merely nested/overlapping inside it -- see StatementSetChainLink.balance_verified.
  • OVERLAP: Two statements whose account numbers don't CONTRADICT each other have periods that overlap, with a count of exact-match transactions (same date, amount, normalized description) that appear in both. The combined export keeps each once. Statements that overlap but show two different, confirmed account numbers are not compared here at all (see MIXED_ACCOUNTS). Whether a match actually counts as proof additionally requires BOTH the account number and the currency to be explicitly established on both statements and equal -- unresolved identity on either side (even when both sides are equally unresolved) doesn't skip the pair, but nothing in it can be merged; the outcome is recorded in POSSIBLE_DUPLICATE.
  • POSSIBLE_DUPLICATE: Within an OVERLAP, a transaction pair that looks like a duplicate but isn't proven to be one: a same-date/same-amount pair with a DIFFERENT description (a pending charge posting later, or OCR noise); an exact date/amount/description match whose running balances don't both confirm it (zero-tolerance exact equality required on BOTH sides; a balance on only one side, or on neither, is never treated as proof); or an exact match whose two statements were never BOTH confirmed to have the same account number AND the same currency. Currency: an explicit ISO code, never a bare symbol. Account: the two identifiers, normalized (spaces/dashes stripped, mask characters kept), must be STRING-IDENTICAL -- both full numbers equal, or both masked with the identical visible digits and mask shape; a value with no digits at all ("****", "N/A", "xxxx") counts as unknown; masked vs full ("****5678" vs "12345678") is never proof of the same account. Never auto-merged; kept as separate lines for a bookkeeper to check.
  • DUPLICATE_UPLOAD: Two documents in the set are byte-for-byte the same file AND their extractions agree on everything -- transactions (compared as a MULTISET at full extracted precision, never rounded to the cent -- 995.001 and 995.004 are a conflict, not a match -- ignoring row order), period (compared as real calendar dates, so "03/01/2026" and "2026-03-01" count as equal), opening/closing balance, account number, and currency (an accidental double upload). The duplicate contributes no lines to the combined export.
  • DUPLICATE_UPLOAD_CONFLICT: Two documents are the same uploaded file, but their extractions DISAGREE on any of transactions, period, opening/closing balance, account number, or currency, after normalizing dates and ignoring row order (e.g. a re-processed pass recovered an extra row, or read a different closing balance -- a reordered or differently-formatted-but-equal extraction is NOT a conflict). Never auto-resolved either way, in either document order; blocks the combined export (400 EXPORT_DUPLICATE_CONFLICT).
  • MIXED_ACCOUNTS: The statements don't all show the same account number, checked pairwise across every statement (not just against one reference, which a masked number could otherwise transitively bridge between two genuinely different accounts). A masked and an unmasked view of the same account are compared by their printed digits, never falsely flagged. Blocks the combined export (400 EXPORT_MIXED_ACCOUNTS); split the set by account.
  • CURRENCY_MISMATCH: The statements use different currencies, resolved ONLY from an explicit ISO 4217 code printed as text (USD, EUR, CAD, CNY, ...) -- never from a bare symbol alone ($, ¥, £, kr, ...), since each of those is shared by multiple real currencies: "CAD $" resolves to CAD, "CNY ¥" resolves to CNY, and a bare "$" or "¥" resolves to nothing rather than silently defaulting to USD/JPY (see UNIDENTIFIED_MEMBER). Blocks the combined export (400 EXPORT_MIXED_CURRENCY); split the set by currency.
  • UNIDENTIFIED_MEMBER: A statement has no USABLE account number (a placeholder with no digits at all -- "****", "N/A", "xxxx" -- counts the same as a genuinely missing field), or no explicitly-coded currency, while another statement in the set has one. Not treated as a mismatch (absence isn't evidence of one), but never treated as proof it belongs to the group's identity either. Blocks ofx specifically (400 EXPORT_UNIDENTIFIED_MEMBER, since only ofx declares one ACCTID/CURDEF for the whole file); csv/xlsx/qbo_csv/xero_csv have no such column to misattribute, so they still proceed with this noted.
  • ACCOUNT_UNPROVEN: Two statements' account numbers don't CONTRADICT each other (a masked and an unmasked view can plausibly be the same account -- see MIXED_ACCOUNTS), but nothing confirms they're the SAME account either: a full "12345678" alongside a masked "****5678" is a guess either way. Both statements DO have a usable account number (otherwise see UNIDENTIFIED_MEMBER instead), so this is a distinct, narrower gap. Blocks ofx specifically (400 EXPORT_UNIDENTIFIED_MEMBER, reused since the guarantee is the same: never let one member's account silently stand in for another's that was never actually confirmed); csv/xlsx/qbo_csv/xero_csv have no such column to misattribute, so they still proceed with this noted.
  • FAILED_CHECKS: One statement's own verification checks (e.g. opening + transactions = closing) did not all pass.
  • MISSING_PERIOD: A statement has no period_start/period_end, so it is included in the set but excluded from the chain and gap/overlap analysis.
  • DATE_UNRESOLVED: A statement's own transaction dates could not be resolved. Just like a single statement's own export, this blocks the combined export (400 EXPORT_DATE_UNRESOLVABLE) rather than silently exporting a truncated file.
  • RESULT_UNAVAILABLE: A document is still listed in the set, but its stored extraction result could not be loaded. It's excluded from this report, and blocks the combined export (400 RESULT_UNAVAILABLE) until removed or re-uploaded.

Combined export

GET /v1/statement-sets/{id}/export?format=csv|xlsx|qbo_csv|xero_csv|ofx produces ONE file covering every statement in the set: transactions merged and date-ordered, with an overlap's duplicate transactions included once (never dropping two genuinely distinct same-day, same-amount lines that both appear in a single statement). Every kept line stays traceable to its own source document. ofx FITIDs follow the exact same identity rules as a single statement's own OFX export, computed per source document, so two different lines can never collide (asserted before the file is ever returned). Its LEDGERBAL is the chronologically latest statement's own closing balance, but only among statements with a resolvable period_end to date that balance by -- with none, which of several undated balances is "the latest" can't be established from upload order alone, so the export refuses (400 EXPORT_MISSING_BALANCE) rather than let reversing the input order silently change the exported balance. DTSTART/DTEND always cover every emitted transaction's own date, not just the nominal statement periods.

This endpoint never silently drops or merges a real transaction: it refuses the whole export with a specific 400 error whenever that would otherwise happen -- EXPORT_MIXED_ACCOUNTS, EXPORT_MIXED_CURRENCY, EXPORT_DATE_UNRESOLVABLE, EXPORT_DUPLICATE_CONFLICT, EXPORT_UNIDENTIFIED_MEMBER (ofx only), RESULT_UNAVAILABLE, or (a defensive last resort)EXPORT_FITID_COLLISION. See the finding it corresponds to above for why.