Writing a minimal retry policy that does not duplicate payments

2026-11-08 · 3 min read

Writing a minimal retry policy that does not duplicate payments

A customer taps Pay. The request times out. Your client retries. The card is charged twice. Now you owe a refund, the customer sees two pending charges, and support has to reconcile the order manually. The expensive part is not the retry itself; it is retrying after an ambiguous timeout without a way to recognize that the first attempt may already have succeeded.

Find the duplicate window first

Retries are safe only when the receiver can tell “new request” from “same request again.” A timeout is not proof of failure: the server may have committed the payment before the connection dropped [link needed]. The client cannot know which happened.

Reduce the window before adding retries:

Designing an idempotent retry policy

Use this minimal procedure for payment creation.

1. Generate the idempotency key once per payment intent. Create a UUID or opaque token before the first attempt. Store it with the order. Reuse the same key for every retry of that intent; do not regenerate it on each attempt.

2. Send the key on every attempt. Use the provider’s documented idempotency header or request field, if one exists [link needed]. Put it in your own API contract too, so internal callers can pass it through.

3. Persist the key before calling the provider. In one transaction with the payment record, insert (idempotency_key, request_hash, state), with a unique constraint on the key. request_hash is a hash of the amount, currency, customer, and other material fields.

4. Handle three lookup outcomes. On an incoming retry:

- Key seen and completed: return the stored response.

- Key seen and in progress: return 409 or 202 with a short Retry-After; do not start a second provider call.

- Key not seen: process normally.

5. Use the unique constraint as the race guard. Two workers can pass a lookup at the same time. Let the database unique constraint decide which insert wins; the loser reads the winner’s row.

6. Reconcile before retrying ambiguous states. If the provider call timed out and the local state is pending, query the provider by idempotency key or payment ID before sending another create call [link needed]. Mark terminal states explicitly: succeeded, failed_terminal, failed_retryable.

7. Back off with jitter and a cap. Use exponential backoff with random jitter; stop after a fixed attempt count or time window.

An idempotent retry policy is only as good as its storage. Keep keys for at least as long as the provider keeps them; document your TTL and cleanup job.

Handle key reuse and mismatches

If the same key arrives with a different request_hash, return a conflict instead of silently charging the new amount. This catches client bugs where an order is edited and retried under an old key. Store the response body and status code, not just “success,” so retries return the same result. Never let a retry create a new local payment row.

Test the idempotent retry policy with concurrent duplicate requests, a timeout after provider success, and a timeout before provider success. Assert one charge, one local record, and identical responses for repeated keys.

When NOT to do this

Skip the full policy when duplicates are cheap and reversible: internal analytics writes, non-billing notifications, or jobs safe to run twice. Also skip automatic retries when the provider has no idempotency support and no lookup endpoint; a local lock helps, but it cannot prove the remote call did not succeed. If a human already verifies every payment, automatic retries may add more state than value. Do not use retries to paper over a provider that is down for minutes; queue the work and reconcile later.

Pre-ship checklist

ad slot — paste the network tag here