Idempotency
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, the messageId (your pain.001 MsgId) is the payment's duplicate identity, whether or not you also send an Idempotency-Key. A new Idempotency-Key does not turn a messageId the server 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 optional:
messageIdis the identity.
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, messageId). - A
messageIdis remembered for 90 days. 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. - After the 90 days the stored response is forgotten, but the
messageId's claim on the payment it created is permanent. Re-sending it later never creates a second payment: the submission is stopped at the point of recording. Documents are purged at 90 days too, so past that the answer can be an error instead of the original payment. Inside the window you get the original response body back verbatim. - A replay returns the stored body, so its
statusis the payment's status at submit time. Read the current state from the journal (see Receiving results). - A refused submit (a
422, a402, or no active connection for the bank) is not stored. Fix the cause and send it again under the samemessageId.
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 request_in_progress: retry the same request 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:
409 request_in_progress: your first attempt is still running. Retry the same request shortly.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" } ``
Each of the three has its own code: branch on code, never on the message (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?companyId=<companyId>&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. - 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 the bank, the environment, the payment body and the other submit options. 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 was recorded, takes back the stale claim and runs the payment normally. The one terminal error is a 500 when a stored response cannot be read back, and that one says to retry with a new 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, 409 request_in_progress),
// 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 409 conflict, 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, and look at what a resubmit does before you retry it. POST /api/journal/export is safe to repeat: see Receiving results.