sandbox.bankconnector.com API Reference

Idempotency

Audience: 🔌 Integration Client

A payment sent twice can be paid twice. BankConnector makes retries safe, but only if you reuse the same identity on every retry. Read this before you write retry logic.

The one rule

One messageId is one payment. Give every logical payment its own messageId, and send that same messageId on every retry of it.

On POST /api/journal/payments for host-to-host payments, the messageId (your pain.001 MsgId) is the payment's duplicate identity, whether or not you also send an Idempotency-Key. The server keys the submission on msgid:<messageId>, so a new Idempotency-Key does not turn a messageId it has already seen into a second payment.

Send a stable Idempotency-Key as well:

Idempotency-Key: 2f9a1c7e-order-10432
  • It's accepted only on POST /api/journal/payments (header, or body idempotencyKey; header wins).
  • It is validated, and echoed back in the Idempotency-Key response header when your payment runs.
  • It is the identity only where there is no messageId to key on. For host-to-host, a request with neither an Idempotency-Key nor a messageId is rejected 400 idempotency_key_required.
  • Keys beginning msgid: are reserved for the server-derived key and are rejected 400 validation_failed.

Key format

8–255 characters, printable ASCII, no spaces (^[\x21-\x7e]+$). A UUID or a stable business identifier like order-10432-attempt works. Make it deterministic for a given logical payment so a retry reuses the same key.

Scope and lifetime

  • Uniqueness is per company: (companyId, identity), where the identity is msgid:<messageId> for a host-to-host payment and your Idempotency-Key where there is no messageId. An ERP add-on that names the company it pairs for (erp_company_id) has its messageId checked across that company's installs, so two companies of one organisation may number their batches independently.
  • A messageId is remembered for 90 days, matching the bank's own MsgId de-duplication window. Within that window, re-sending it returns the original result instead of paying again (see the outcomes) or, when the original payment was rejected, cancelled or rejected-by-approver, refuses the re-send with 409 message_id_already_used.
  • An Idempotency-Key that is the only identity (no messageId) is remembered for 24 hours.
  • After either window the stored response is forgotten, but the identity's claim on the payment it created is permanent. Re-sending it later does not pay twice: the submission is stopped at the point of recording and answered with the payment that already exists (a 201 carrying the original journalNo, with Idempotency-Replayed: true), whatever that payment's status. A messageId therefore identifies one payment for good, not for 90 days. Inside the window you get the original response body back verbatim.

The outcomes

When you POST, one of these happens:

OutcomeWhenWhat you get
newfirst time this identity is seenthe payment runs; 201
replaysame identity and identical bodythe stored response, verbatim, plus header Idempotency-Replayed: true
conflictsame identity, different body409 conflict: a different payload under an identity already used
in-progresssame identity, still processing409 conflict: "…already being processed. Retry shortly."
messageId already usedsame messageId, and its original payment is rejected, cancelled or rejected-by-approver (within 90 days)409 message_id_already_used; the re-send is not attempted

While the original payment is in flight or executed, a re-send is a replay (the ordinary timeout retry). Only a rejected, cancelled or rejected-by-approver original is refused. A rejected-by-approver payment never reached the bank, but its messageId is spent all the same.

The three 409s mean different things:

  • in-progress 409 conflict: your first attempt is still running. Retry the same request shortly.
  • different-body 409 conflict: you sent a changed payload under a messageId (or key) that was already used. Never retry this as-is. If it is a different payment, it needs its own messageId; a new Idempotency-Key does not help, because the messageId governs.
  • 409 message_id_already_used: the payment you first sent under this messageId was rejected or cancelled, and this re-send was not attempted. Correct the payment and submit it with a new messageId. The error's details names the original:

``json { "messageId": "ORDER-2026-10432", "originalJournalNo": "OUT-000123", "originalStatus": "rejected", "originalIsTerminal": true, "remedy": "new_message_id" } ``

message_id_already_used is its own code. The two conflicts share a code; tell them apart by the message text: only the in-progress one says "being processed" (see Errors).

When the answer never arrived: look the batch up

If a send ends without an answer (a timeout, a dropped connection), the first choice is to retry the same request: the same messageId and payload replay the original result, or run it once if it never arrived. When you instead need to know whether the payments may be sent again as a new payment, ask:

GET /api/journal/payments/batch?messageId=ORDER-2026-10432
outcomeWhat it meansMay you send again under a new messageId?
foundBankConnector recorded the batch. payments lists each endToEndId with its reconciliation state.No
purgedIt recorded the batch; the document has since passed retention.No
processingA send of this batch is running, or was interrupted; your next same-batch send finishes or releases it.No: retry the original request
not-foundNothing holds this batch, and no send of it is running.Yes
  • Look up by the messageId, not by your Idempotency-Key: the messageId is the identity BankConnector stores.
  • Every outcome is a 200. An unknown batch is never a 404, so a 404 (a proxy, a wrong URL) must never be read as "not found".
  • not-found describes the moment of the read. Ask only once your own send has ended, so a request still on its way is not missed.
  • Treat an outcome you do not recognise as "BankConnector holds it".
  • For found, each payment's recon.state says where its money is, and which states are final: see Which reconciliation states are final.

Why "same body" matters

A replay needs an equivalent body. The server fingerprints sha256 of {bankKey, environment, payment, validate, maker, actingAs}. If you change any of those under the same identity, you get the different-body 409. Freeze the payload, its messageId and the key together: generate them when you first decide to send, store them, and reuse them on every retry.

Crash safety

If BankConnector crashes mid-payment, a retry after 5 minutes either replays the committed document (as 201) or, if nothing committed, returns a terminal error telling you to retry with a new messageId (or a new key, where there is no messageId).

// 1. When you decide to pay, mint the messageId + key + payload TOGETHER.
const messageId = `ORDER-${orderId}`;           // one payment, one messageId
const idempotencyKey = `order-${orderId}-v1`;   // deterministic, stable
const payload = buildPayment(order, messageId); // freeze this too
persist(orderId, { messageId, idempotencyKey, payload }); // store before sending

// 2. Send. On any transient failure (timeout, 429, 503, in-progress 409),
//    retry with the SAME messageId, key and payload.
// 3. On a 201 with Idempotency-Replayed: true, treat it as success:
//    it already ran; you're just seeing the stored result.
// 4. On a different-body 409, alert; do not retry. It means the payload
//    changed under a messageId or key that was already used.
// 5. On 409 message_id_already_used, the original payment was rejected or
//    cancelled: correct it and submit it as a new payment with a NEW messageId.

Beyond payments

Idempotency-Key is not honoured on other endpoints. For non-payment writes, rely on natural keys where they exist. On a non-host-to-host submission the messageId is not used for de-duplication, so without a key resubmits create new documents. Always send a key.

POST /journal/export is idempotent on journalNos. It marks each returned document collected, but the read is never filtered by collected state, so a repeated call with the same journalNos always returns the same documents again, with wasAlreadyCollected telling you which ones this call already saw before. So if you never receive the response (a timeout, a dropped connection), retry with the same journalNos: no data is lost. What you must not do is derive your retry list purely from GET /journal/list?uncollectedOnly=true: an export whose response you never saw has still marked its documents collected, so they have already dropped off that list. Keep the journalNos you sent until you've confirmed you received the export response.