One key cannot make three workflows idempotent
A key that expires before delayed delivery or manual replay turns an old command into new work. Retention must follow each workflow's real replay window.
7 min read
One idempotency key can identify one intended operation. It cannot decide whether a second message is a retry of a charge, a second legitimate refund, or another delivery of an event. Those are three different questions. Give them one namespace—an order ID, for example—and the system will eventually suppress work that should happen or repeat work that should not.
The contracts must be separate. A payment-write key protects one payment attempt from an ambiguous retry. A refund key protects one refund instruction while allowing other refunds against the same payment. A webhook-consumption key records that one event, or one resource transition, has already produced its downstream effects. The scopes, fingerprints, retention periods and replay responses differ in each case.
A key names intent, not merely bytes
HTTP defines an operation as idempotent when multiple identical requests have the same intended effect as one. It also warns clients not to retry a non-idempotent method automatically unless they know the request semantics are idempotent or know that the original request was not applied.1 A POST does not acquire those semantics merely because it carries a random string.
The string works only inside a server-side contract. That contract needs at least five decisions: the namespace in which the key is unique; the request fields that must match on replay; how concurrent arrivals are serialized; what response a replay receives; and how long the record survives.
Public payment APIs make those choices differently. PayPal requires its request ID to be unique for each request and API call type, and returns the latest status of the earlier request.2 Stripe stores the first request's status code and body—including a 500—compares later parameters with the original, and permits pruning keys after they are at least 24 hours old.3 Adyen scopes keys to a company account for at least seven days and notes that duplicate detection does not cross regional endpoints.4
These are not cosmetic differences. A client that assumes “same key means same behaviour everywhere” has left part of its correctness model inside undocumented scope boundaries.
Payment writes: retry the same attempt
Consider a checkout service sending a payment request. The provider accepts it, but the response disappears after a network timeout. The client cannot infer failure from silence. Repeating the request with a new key risks creating another payment; retrying the same request with the same key lets the provider recognise the original intent. PayPal documents exactly this capture scenario, while Stripe recommends reusing the key and identical parameters after a network error.23
A useful identity is therefore closer to pay:{merchant}:{order}:{attempt} than simply {order}. The fingerprint should cover the fields that define the financial effect: amount, currency, beneficiary, payment instrument reference and capture mode. A repeat with the same key and a different fingerprint is not a retry. It is a conflict and should fail closed.
The scope must also exist above any single provider. A key stored by provider A cannot prevent provider B from charging when an orchestrator reroutes an uncertain attempt. Adyen's documented regional boundary is a narrower version of the same problem.4 Before a new route or key is issued, the payment service needs to resolve the earlier attempt through its own operation record and the provider's status interface.
Concurrency belongs in the contract too. The first request should atomically claim (tenant, operation_type, key). A simultaneous request sees in_progress, not an invitation to execute the write again. Square's payment API makes an idempotency key mandatory and requires it to be unique for every CreatePayment request.5 That is the right granularity: one payment command, not the entire commercial order.
Refunds: retry one return of funds
A refund is not the inverse replay of a payment. It is a new financial instruction linked to an earlier payment. One captured payment can legitimately receive several partial refunds: Stripe, Adyen and PayPal all document multiple refunds up to the captured total.789 Reusing the payment key for the first refund confuses two intents. Reusing that same key for a later refund can incorrectly return the first refund's result instead of returning more money.
The refund namespace should identify the refund business intent: refund:{payment_id}:{return_or_case_id}. Its fingerprint needs the refund amount and currency plus any fields that change allocation or destination semantics. Retrying that instruction keeps the key. A second return, price adjustment or goodwill credit gets another key—even if the amount happens to match.
This separation also exposes an invariant that a key cannot enforce: the sum of accepted refunds must not exceed the refundable balance. Adyen says the sum of multiple partial refunds cannot exceed the captured amount; Square defines a refund's maximum as the payment total less previously completed refunds.86 The refund service must protect that aggregate under concurrency, then correlate the asynchronous outcome to the particular refund record. The payment record is the parent. It is not the refund's deduplication record.
Webhooks: deduplicate delivery, then apply state
A webhook is neither a payment command nor a refund command. It is a provider's report that something happened. Its retry policy is controlled by the sender, and acknowledgement usually means “received”, not “all business consequences completed”. PayPal retries a webhook when it does not receive a 2xx response, up to 25 times over three days.12 Adyen recommends verifying the message, storing it, returning success, and only then applying business logic; it places unacknowledged deliveries in a retry queue.11
The first idempotency boundary is therefore an inbox with a uniqueness constraint such as (provider, account, event_id). Stripe advises logging processed event IDs, but also warns that separate Event objects can represent the same underlying object transition; for that case it recommends the object ID together with the event type.10 Adyen describes duplicates that share eventCode and pspReference even though eventDate and other fields can differ.11 No payment request key can represent either rule.
Receipt deduplication is only the first gate. The consumer must also make the resulting state transition idempotent. Insert the inbox row and enqueue—or transactionally record—the intended downstream work before acknowledging. On a duplicate, return success without issuing another shipment, ledger posting or notification. When distinct events concern the same resource, compare the provider's version, sequence or event time and apply an explicit state machine. Stripe does not guarantee that events arrive in generation order.10
Retention follows delivery reality, not API-request reality. A command key can expire after the maximum supported client-retry window, provided unresolved operations are retained elsewhere. A webhook receipt must remain deduplicable across automatic retries, delayed deliveries and any supported manual resend period. Those clocks need not match; Stripe, Adyen and PayPal already document different windows and behaviours for API keys and webhook delivery.341012
Three records, three replay answers
The practical design is three stores—or one store with three explicit record types and uniqueness constraints:
| Workflow | Unique identity | Same identity, changed payload | Replay result | |---|---|---|---| | Payment write | Tenant + payment attempt key | Reject as a conflict | Original result, latest status, or an explicit in_progress, according to the API contract | | Refund write | Tenant + refund intent key | Reject as a conflict | The result or current status of that refund only | | Webhook consume | Provider + account + event identity | Quarantine as inconsistent | Acknowledge receipt; do not repeat downstream effects |
For command records, store the scope, key, canonical request hash, lifecycle state, provider reference, replayable response and expiry. For webhook records, retain the verified payload hash, receipt time, processing state and the resource transition it caused. A UUID may supply uniqueness, but it cannot supply any of those semantics.
This model also makes failures diagnosable. “Duplicate key” becomes a precise statement: duplicate within which operation, tenant and retention window? “Replay” means a known response for a command, but a harmless acknowledgement for an event delivery. “Same payment” permits several refunds without permitting the same refund twice.
Idempotency is not a property sprinkled over a workflow by a header. It is an agreement about identity and effect. Payments, refunds and webhooks do not share either one. They should not share a key contract.
Sources
- RFC 9110: HTTP Semantics (opens in a new tab) — Internet Engineering Task Force.
- Idempotency (opens in a new tab) — PayPal Developer.
- Idempotent requests (opens in a new tab) — Stripe Documentation.
- API idempotency (opens in a new tab) — Adyen Docs.
- Create payment (opens in a new tab) — Square Developer.
- Refund payment (opens in a new tab) — Square Developer.
- Create a refund (opens in a new tab) — Stripe API Reference.
- Refund (opens in a new tab) — Adyen Docs.
- Issue a Refund for Captured Payments (opens in a new tab) — PayPal Developer.
- Receive Stripe events in your webhook endpoint (opens in a new tab) — Stripe Documentation.
- Handle webhook events (opens in a new tab) — Adyen Docs.
- Integrate webhooks (opens in a new tab) — PayPal Developer.