API versioning & compatibility
BankConnector's HTTP API is versioned by date, via the BankConnector-Version header, never a /v1 path prefix. Pin a version and the contract stays stable for you as the platform evolves underneath it.
BankConnector-Version: 2026-06-01
Every response echoes back the version it was served under (including error responses), so you can confirm what you're pinned to at a glance.
Omit the header and you get 2026-06-01, the oldest version we serve — not the newest. That is deliberate: an integration that never pinned anything is one we must not move underneath. Send the header. Any date you send resolves to the newest version published at or before it, and that resolved date is what comes back in the response.
Compatibility commitment
We treat the API as a long-lived contract:
- A pinned version keeps behaving the way it did when you integrated it. We do not make breaking changes to a version you've pinned.
- Additive changes (new optional request fields, new response fields, new endpoints) may appear within a version without notice. Build tolerant clients (ignore unknown fields, branch on
codenotmessage) so these never break you. - Breaking changes ship under a new dated version. The previous version keeps working, and you move on your own schedule after advance notice; you're never forced to migrate on our timeline.
This is why we recommend pinning BankConnector-Version explicitly in production rather than relying on the default.
Current version: 2026-08-25
- A payment we cannot process is
422, not400.400means we could not read your request at all (not JSON, a bad header);422means the request was fine and the payment inside it is not something we can accept — with the reasons inerror.details.issues. See the2026-08-25entry below, and Errors & Retries. - Canonical input schema
1.1(additive over1.0: structuredpostalAddress,regulatoryReporting, andpurposeCode/localInstrument/categoryPurposeoverrides; see Core Concepts). - Object error envelope:
{ error: { code, message, details? } }on every non-2xx response (see Errors & Retries). - Standard list envelope:
{ items, nextCursor?, total? }across all list endpoints (see Pagination). - One word for "you have pulled this document":
collected. On the journal read surfaces (GET /journal/listandGET /journal/documents) each item carriescollected/collectedAt, the/journal/listenvelope carries anuncollectedcount, and the filters are?uncollectedOnly=trueand?collected=true|false. See Receiving Results.
Contract changes
Integrator-facing contract changes (new fields, behaviour changes, deprecations) are recorded here as the API evolves, each under the dated version that introduced it. Internal work (refactors, security hardening, infrastructure) never appears here, because it doesn't change what you integrate against. If you pin a specific BankConnector-Version, watch this page.
This page is the published mirror of the engineering-maintained docs/API_CHANGELOG.md; a new dated version cannot ship without a section there (enforced by test/meta/api-version-changelog-pairing.test.ts), and the corresponding entry belongs here in the same change.
2026-09-01 — a past-due account degrades: a warning header, then 402 on new payments
Additive — nothing to pin. Unlike the 2026-08-25 entry below, this isn't gated behind a BankConnector-Version; it applies to your integration the moment your account's own billing state crosses into it, whichever version you have pinned.
An account with an unpaid invoice degrades in two stages, and it is never switched off:
- Invoice unpaid, any duration — every response (success or error) carries a
BankConnector-Billing-Warningheader, valued the moment the invoice went unpaid. Watch for it and show your own banner weeks before anything is refused; its absence means the account is current. - 30+ days past due —
POST /journal/payments(submitting a NEW payment) answers402 billing_past_duewith{ pastDueSince, graceDays: 30 }inerror.details, instead of accepting the payment. This is not retryable until the account is brought current. - Always unaffected: reads,
POST /journal/payments/preview, inbound bank-statement/status ingestion, and any payment you had already submitted (through approval, release and delivery) — none of that stops, at any stage, including past 30 days.
2026-08-25 — a payment we cannot process is 422, not 400
Pin BankConnector-Version: 2026-08-25 to get this. It is the first change we have published as a new dated version rather than correcting into the baseline.
A payment body that arrives as valid JSON but is not a valid canonical payment — a missing required field, a wrong type, a null where an object belongs — is now 422 validation_failed, with every problem listed in error.details.issues. It used to be 400 on some routes and 422 on others, for the identical defect.
| Request | 2026-06-01 | 2026-08-25 |
|---|---|---|
POST /journal/payments — a transaction with no creditor account | 400 | 422 |
POST /journal/payments/preview — same body | 400 | 422 |
POST /convert/{bank} — body null, 42, [] or {"payments":[null]} | 400 | 422 |
POST /convert-async/{bank} — same bodies | 400 | 422 |
POST /validate/{bank} — same bodies | 400 | 200, valid: false |
| a payment that parses but breaks a rule | 422 | 422 (unchanged) |
| a body that is not JSON at all | 400 | 400 (unchanged) |
So the boundary is now one sentence: 400 = we could not read your request; 422 = we read it and the payment is not one we can process. Neither is retryable as-is.
POST /validate/{bank} is worth calling out separately. It is a report endpoint, and it now reports a schema failure the same way it reports every other problem — inside its 200, as { "valid": false, "issues": [...] }. It used to answer 400 for a handful of malformed shapes, which made it unable to do the one job it exists for: tell you in advance what POST /convert/{bank} will say.
What to do:
- If you branch on the status, handle
422wherever you handle a rejected payment, and read400as "my request was malformed" only. - If you read
error.details.issues, nothing changes — it rides in the same place under both statuses and both versions. - If you treat a non-
200fromPOST /validate/{bank}as "could not validate", readvalidinstead. This is the only change here that could make a rejection look like a success, so it is the one worth grepping for. - Nothing forces you to move. Keep sending
2026-06-01and you keep the old statuses.
2026-08-12 — /bank-requests is now /bank-coverage-requests
If you call POST /bank-requests or GET /bank-requests, change the path to /bank-coverage-requests. The old path is removed rather than redirected, so a call to it returns 404. Request and response bodies are unchanged — this is the path and nothing else.
The old name read as "a request sent to a bank". It never was one: it is how you ask us to add support for a bank we do not cover yet, and the new path says so.
This was made to the existing 2026-06-01 version rather than published as a new dated one, because BankConnector has no customers yet and nobody is pinned to that version. That is the only reason it was possible — once anyone is integrated, a removed path means a new dated version and a migration window, not an edit to the current one.
Corrected into the 2026-06-01 baseline (not a new version)
BankConnector has no pinned integrations yet, so contract defects found before the first customer are corrected into the baseline rather than dual-served — the same way the error-code and list-envelope fixes were (ADR-0022). If you built against a pre-release snapshot, these are the names that moved. This list closes when the first customer is pinned; after that, a change like this ships as a new dated version instead.
- On
2026-06-01, a malformed payment is now400onPOST /convertandPOST /convert-asyncconsistently (2026-08-25). That version was not self-consistent: a few malformed shapes (null, a primitive, an array, anullinsidepayments[]) answered400while every other schema failure answered422, which was an accident of how the checks were written rather than a rule you could have found anywhere. On2026-06-01a structural failure is400on those routes uniformly now. In practice: a schema-invalid payment such as{"payments":[{}]}, or an amount written"1500"instead of"1500.00", answers400there where it used to answer422. Pin2026-08-25and it is422, which is the version this was fixed properly under.
- The whole-document responses now return a defined shape, and say
collectedtoo (2026-08-16).POST /journal/payments(201),POST /journal/ingest(200 and 201) andGET /journal/documents/{id}(200) used to hand back our stored record as-is. They now return a curated response shape:
| Where | Was | Now |
|---|---|---|
| all four bodies | retrieved, retrievedAt | collected, collectedAt |
| all four bodies | slaAlertSentAt | removed |
POST /journal/ingest 200 (dedupe hit) | sideEffectsCompleted | removed |
POST /journal/export — each exported[] row | wasAlreadyRetrieved | wasAlreadyCollected |
The two removed fields were internal engine state that was never documented: slaAlertSentAt is when we last alerted ourselves that a payment looked stuck, and sideEffectsCompleted is a marker our bank-polling loop uses to decide whether to replay a step. Nothing you could act on has been dropped — platformId, companyId, messageId, idempotencyKey, environment, expiresAt and the rest are all still there.
One fix goes the other way: the published schema for POST /journal/ingest 201 claimed a required payload. That route has never sent one — it answers the document summary — so the documentation was wrong, not the response. The schema no longer claims it. GET /journal/documents/{id} is the route that returns a payload, and still does.
- The journal "have you pulled this?" flag now says
collectedeverywhere it did not already. The concept had two names in one response — items saidcollectedwhile the same envelope's count saidunretrieved. It is nowcollectedthroughout:
| Where | Was | Now | ||
|---|---|---|---|---|
GET /journal/list envelope count | unretrieved | uncollected | ||
GET /journal/list filter | ?unretrievedOnly=true | ?uncollectedOnly=true | ||
GET /journal/documents item fields | retrieved, retrievedAt | collected, collectedAt | ||
GET /journal/documents filter | `?retrieved=true\ | false` | `?collected=true\ | false` |
GET /journal/list items already said collected / collectedAt and are unchanged. Why collected and not retrieved: "retrieve" is also what we do when we fetch a file from your bank, so retrieved: true was ambiguous about whose action it described. collected only ever means yours.
In the TypeScript SDK, journalList() / journalListAll() take uncollectedOnly instead of unretrievedOnly.
The full-document responses were the last holdouts. They were fixed on 2026-08-16 — see the entry above.
- One
messageIdis one payment, whateverIdempotency-Keyyou send (2026-08-13,POST /journal/payments, host-to-host). Submit a payment whosemessageIdwe have already accepted and you get the original payment back — the samejournalNo, the same body, withIdempotency-Replayed: true— instead of a second payment. Submit the samemessageIdwith a different body and you get409 conflictnaming themessageId.
That was already the behaviour when you sent no Idempotency-Key. Sending one used to switch it off, so retrying under a fresh key created a second live payment for one messageId. Three things follow:
- A new
Idempotency-Keyis no longer a way to resubmit amessageId. Give each payment its ownmessageId. The409message says this now; it used to suggest a fresh key, which was the wrong advice. - The deduplication window for a payment with a
messageIdis ~90 days, not 24 hours — it follows your bank's own MsgId-deduplication window. A payment with nomessageIdat all is still deduplicated on your key for 24 hours. - The reverse also changed. Sending one
Idempotency-Keywith two differentmessageIds used to be a409; both are now recorded, because twomessageIds are two payments. Same key, samemessageId, different content is still a409. If you relied on the key alone to catch an accidental resubmission, that check now sits on themessageId.
Your Idempotency-Key is otherwise unchanged: still validated, still echoed back on the response, and still what identifies a host-to-host payment that carries no messageId. Requests that only return a file are unaffected.
⚠️ If you re-use a messageId after a rejection, this affects you: within the window you will receive the rejected payment back rather than a new attempt. Give the corrected payment a new messageId — which is what your bank's own duplicate controls expect in any case.
- The API-key list item's type is now more precise (2026-08-22). An API-key list response has always returned two different shapes depending on the key's
source: an operator-provisioned key ("env") never carries a date; a real, database-backed key ("db") always carriescreatedAt, and sometimeslastUsedAt. The published type used to say both dates were optional on every key, which meant a TypeScript client could not tell "anenvkey with no dates" from "adbkey that is somehow missing one". The type now matches the data:createdAtis required whensourceis"db", and absent whensourceis"env".
The bytes on the wire have not changed — only the published TypeScript type got stricter. If your code reads item.createdAt without first checking item.source, regenerating the SDK types will surface a compile error; narrowing on source first (as the two shapes above already imply) resolves it. Non-TypeScript integrations are unaffected.