Payment API Retries and Idempotency: Preventing Duplicate Payments
By OpenFinanceTools · Updated
A timeout means the outcome may be unknown
Your application sends a payment request. The bank accepts it, but the response disappears before reaching your server. A timeout is now visible to the caller; the accepted payment is not. If the retry creates a new business operation, the customer may pay twice.
The first design decision is to represent uncertainty honestly. “We did not receive a response” is different from “the provider rejected the request.” Keep an operation record before submission, preserve its identifiers, and reconcile uncertain outcomes using the provider’s documented lookup or status process.
HTTP semantics in RFC 9110 distinguishes idempotent methods and constrains automatic retries of non-idempotent requests. A payment POST does not become safe merely because it uses HTTPS. Its retry behavior depends on an explicit application contract.
This guide proposes an application design and a small in-memory exercise. It does not implement a bank payment endpoint, distributed transaction or durable payment ledger. Use the provider’s documented key scope, retention window and replay behavior when implementing an actual integration.
Give the business operation a stable identity
Create one operation identifier when the customer commits to a specific payment. Associate it with a normalized request and the provider-facing idempotency key. Reuse that key for an allowed retry of the same operation. Generate a new key for a genuinely new payment, not for every network attempt.
Scope the identity to the tenant or authenticated customer context. Two unrelated customers choosing the same client-generated string must not obtain each other’s results. Authenticate and authorize every request, including replays; possession of an idempotency key is not an access-control mechanism.
Bind the key to the business payload. If a retry changes the amount, currency or destination, reject the reuse or require a new explicit operation. Decide which fields belong to the fingerprint and how they are normalized. Comparing raw JSON strings can treat property order as a change; ignoring meaningful fields can treat a changed payment as a replay.
The example uses integer minor units and an ordered tuple of three known fields. This is suitable for demonstrating request identity, but a real system needs a currency-aware amount model, validation of every payment field and a stable normalization policy shared across deployment versions.
Run the lost-response exercise
The downloadable script creates a payment once, discards the conceptual first response, then submits the same key and payload again. The second attempt receives the original result without incrementing the operation count. Further checks cover a changed amount, tenant separation, a different operation key and an invalid fractional minor-unit value.
There is no network call or money movement. The Map is deliberately local and ephemeral. The example executes synchronously so its check-and-insert sequence has no interleaving inside a single call. Copying it into a multi-instance API would lose that property and lose all state on restart.
Download the runnable exercise
node payment-idempotency-lab.mjsView the complete exercise
import assert from 'node:assert/strict';
// Single-process model only. A production ledger needs durable, atomic storage.
const records = new Map();
let created = 0;
function submit(tenant, key, payment) {
assert.ok(tenant && key);
assert.ok(Number.isSafeInteger(payment.amountMinor) && payment.amountMinor > 0);
assert.match(payment.currency, /^[A-Z]{3}$/);
assert.ok(typeof payment.payee === 'string' && payment.payee);
const slot = JSON.stringify([tenant, key]);
const fingerprint = JSON.stringify([payment.amountMinor, payment.currency, payment.payee]);
const existing = records.get(slot);
if (existing) {
if (existing.fingerprint !== fingerprint) throw new Error('Key reused with changed payment');
return existing.result;
}
const result = Object.freeze({paymentId:`demo-${++created}`, status:'accepted'});
records.set(slot, {fingerprint, result});
return result;
}
const payment = {amountMinor:1250, currency:'GBP', payee:'synthetic-payee'};
const accepted = submit('tenant-a', 'operation-1', payment);
// Pretend the network lost the first response after the server accepted it.
assert.deepEqual(submit('tenant-a', 'operation-1', payment), accepted);
assert.equal(created, 1);
assert.throws(() => submit('tenant-a', 'operation-1', {...payment, amountMinor:1300}));
assert.notEqual(submit('tenant-b', 'operation-1', payment).paymentId, accepted.paymentId);
assert.notEqual(submit('tenant-a', 'operation-2', payment).paymentId, accepted.paymentId);
assert.throws(() => submit('tenant-a', 'bad', {...payment, amountMinor:12.5}));
console.log('6 idempotency checks passed');
Make deduplication durable and atomic
For a production design, put a uniqueness constraint on the tenant/operation identity in durable storage. Claim the operation atomically and store a request fingerprint, processing state, provider identifiers and enough response information for authorized replay. A second request must observe the existing operation rather than create another one.
The difficult gap is between your local transaction and the external provider call. Your database cannot atomically commit the bank’s side effect. A crash after provider acceptance but before saving the response leaves an unknown outcome. Persist the provider-facing key before sending, then recover by the documented replay or query mechanism.
An outbox can help coordinate local work, but does not by itself guarantee exactly-once external execution. A worker may deliver more than once. The provider’s deduplication contract and your reconciliation procedure remain essential. Write down what happens when the provider’s retention window expires before you resolve an uncertain operation.
Handle concurrent attempts explicitly. Depending on the API contract, a request may receive the stored result, a processing response, or a conflict. Avoid holding an unbounded request open while another worker waits on the bank. Include a bounded polling or status path that preserves authorization checks.
Choose retry behavior by outcome
| Observed outcome | Proposed handling |
|---|---|
| Definitive validation rejection | Fix the cause; do not loop on the identical invalid payload |
| Timeout after submission | Mark unknown; reconcile or use the documented same-key replay |
| 429 or temporary unavailability | Follow provider retry guidance with bounded delay; preserve operation identity |
| Same key, different amount | Reject key reuse; require a deliberate new operation |
| Accepted response | Persist the provider reference and track subsequent status changes |
Use a retry budget, an overall deadline and jittered delays to avoid synchronized traffic bursts. Respect documented server guidance, but never sleep indefinitely on an arbitrary response value. Retries should be observable: record attempt count, elapsed time, operation identity and a sanitized reason.
Authorization expiry is a separate concern. Refreshing credentials may allow an attempt to proceed, but must not reset the payment’s identity. If you rotate certificates during recovery, follow the mTLS rotation guide and maintain the correct token binding.
Test the failures before enabling automatic retries
Simulate a lost response after acceptance, concurrent attempts, process restart, a database timeout and a delayed status notification. Ensure the final ledger has one intended operation and an explainable history. Also test the unsafe cases: changed payload with the same key, an unauthorized replay and an expired provider deduplication window.
Do not equate a provider’s “accepted” status with final settlement. Model documented transitions and reconcile delayed or conflicting observations. A webhook and a polling response may arrive out of order; preserve timestamps and provider references rather than overwriting state blindly.
Use the UUID generator for synthetic examples and the JSON formatter to inspect test payloads. A random identifier reduces accidental collisions; it does not replace persistence, atomicity or authorization.
Before release, document the key scope, replay response, payload-mismatch behavior, retention period and recovery path. Those five answers are more useful than a generic “retry three times” rule. Keep ambiguous operations visible to support and reconciliation teams until their outcome is established.
Method and scope
Prepared 21 October 2026. This AI-assisted guide combines cited technical references with an illustrative engineering workflow. Its synthetic Node.js exercise is checked in the project test suite. It is not evidence of a live bank integration or certification.
Published by OpenFinanceTools. Testing, sources and corrections