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 bodyidempotencyKey; header wins). - It is validated, and echoed back in the
Idempotency-Keyresponse header when your payment runs. - It is the identity only where there is no
messageIdto key on. For host-to-host, a request with neither anIdempotency-Keynor amessageIdis rejected400 idempotency_key_required. - Keys beginning
msgid:are reserved for the server-derived key and are rejected400 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 ismsgid:<messageId>for a host-to-host payment and yourIdempotency-Keywhere there is nomessageId. An ERP add-on that names the company it pairs for (erp_company_id) has itsmessageIdchecked across that company's installs, so two companies of one organisation may number their batches independently. - A
messageIdis 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 wasrejected,cancelledorrejected-by-approver, refuses the re-send with409 message_id_already_used. - An
Idempotency-Keythat is the only identity (nomessageId) 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
201carrying the originaljournalNo, withIdempotency-Replayed: true), whatever that payment's status. AmessageIdtherefore 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:
| Outcome | When | What you get |
|---|---|---|
| new | first time this identity is seen | the payment runs; 201 |
| replay | same identity and identical body | the stored response, verbatim, plus header Idempotency-Replayed: true |
| conflict | same identity, different body | 409 conflict: a different payload under an identity already used |
| in-progress | same identity, still processing | 409 conflict: "…already being processed. Retry shortly." |
| messageId already used | same 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 amessageId(or key) that was already used. Never retry this as-is. If it is a different payment, it needs its ownmessageId; a newIdempotency-Keydoes not help, because themessageIdgoverns. 409 message_id_already_used: the payment you first sent under thismessageIdwas rejected or cancelled, and this re-send was not attempted. Correct the payment and submit it with a newmessageId. The error'sdetailsnames 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
outcome | What it means | May you send again under a new messageId? |
|---|---|---|
found | BankConnector recorded the batch. payments lists each endToEndId with its reconciliation state. | No |
purged | It recorded the batch; the document has since passed retention. | No |
processing | A send of this batch is running, or was interrupted; your next same-batch send finishes or releases it. | No: retry the original request |
not-found | Nothing holds this batch, and no send of it is running. | Yes |
- Look up by the
messageId, not by yourIdempotency-Key: themessageIdis the identity BankConnector stores. - Every outcome is a
200. An unknown batch is never a404, so a404(a proxy, a wrong URL) must never be read as "not found". not-founddescribes 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
outcomeyou do not recognise as "BankConnector holds it". - For
found, each payment'srecon.statesays 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).
Recommended client pattern
// 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.