Idempotency
Audience: 🔌 Integration Client
Payments move money. If a request times out and you retry, you must not send twice. BankConnector makes this safe — but only if you use idempotency deliberately. Read this before you write any retry logic.
The one rule
Send a stable Idempotency-Key on every POST /journal/payments.
Idempotency-Key: 2f9a1c7e-order-10432
- It's accepted only on
POST /journal/payments(header, or bodyidempotencyKey; header wins). - For host-to-host payments it is mandatory: a request with neither an
Idempotency-Keynor amessageIdis rejected400 idempotency_key_required. - If you omit the key but supply a
messageId, the server derives one (msgid:<messageId>). So a stablemessageIdgives you idempotency for free — but an explicit key is clearer and is what we recommend.
Key format
8–255 characters, printable ASCII, no spaces (^[\x21-\x7e]+$). A UUID, or a stable business identifier like order-10432-attempt, both work. Make it deterministic for a given logical payment so a retry reuses the same key.
Scope and lifetime
- Uniqueness is per tenant:
(platformId, companyId, idempotencyKey). - Keys live for 24 hours, then are purged. Retries after that are treated as new — so retry promptly, don't sleep for a day.
The four outcomes
When you POST with a key, one of these happens:
| Outcome | When | What you get |
|---|---|---|
| new | first time this key is seen | the payment runs; 201 |
| replay | same key and identical body | the stored response, verbatim, plus header Idempotency-Replayed: true |
| conflict | same key, different body | 409 — "already used with a different request payload" |
| in-progress | same key, still processing | 409 — "already being processed. Retry shortly." |
The two 409s mean opposite things:
- in-progress
409→ your first attempt is still running. Retry the same request shortly with the same key. - different-body
409→ you reused a key for a changed payload. Never retry this as-is. Fix the key or the body. This is a bug in your code, not a transient condition.
Distinguish them by the message text (and see Errors for how to branch cleanly).
Why "same body" matters
A replay is only a replay if the body is byte-for-byte equivalent. The server fingerprints sha256 of {bankKey, payment, validate, maker}. If you change any of those under the same key, you get the different-body 409. So: freeze the payload and the key together. Generate both when you first decide to send, store them, and reuse both on every retry of that logical payment.
Crash safety (you get this for free)
If BankConnector crashes between starting and finishing a payment, the pending idempotency row self-heals: after 5 minutes, a retry either replays the committed document (as 201) or, if nothing committed, returns a terminal error telling you to retry with a new key — so a mid-flight crash can never double-create.
Recommended client pattern
// 1. When you decide to pay, mint the key + freeze the payload TOGETHER.
const idempotencyKey = `order-${orderId}-v1`; // deterministic, stable
const payload = buildPayment(order); // freeze this too
persist(orderId, { idempotencyKey, payload }); // store before sending
// 2. Send. On any transient failure (timeout, 429, 503, in-progress 409),
// retry with the SAME key and SAME 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 reused key.
Beyond payments
Idempotency-Key is not honoured on other endpoints. For non-payment writes, rely on natural keys where they exist. Note also: without a key and without a messageId on a non-host-to-host submission, there is no fingerprint dedup — resubmits create new documents. Always send a key.