Five payment APIs, five different maximum lengths for the same HTTP header. Stripe accepts 255 characters, Increase 200, Modern Treasury 180, Adyen 64, and PayPal tells you to stay inside 38. Write one key generator for your whole payments layer and at least one provider is already rejecting your requests.
The retention windows are worse. Stripe prunes keys after 24 hours. Adyen says "7 to 14 days" and declines to pick a number in that range. PayPal makes it per-endpoint. Increase documents no expiry at all. Idempotency-Key looks solved because every provider ships one, but there is no interoperable contract behind the name, and the IETF attempt to write one expired in April 2026 without reaching RFC.
What Is an Idempotency Key in a Payment API?
A client-generated string you attach to a POST so that retrying does not create a second payment. The server records the key on first use along with the response it produced, then replays that response for any later request carrying the same key.
The part people skip is why it stores failures too. Stripe saves "the resulting status code and body of the first request made for any given idempotency key, regardless of whether it succeeds or fails". Later requests with that key get the same result, including 500 errors. A cached 500 is not a bug. It is the mechanism refusing to guess.
Scope is narrower than most integrations assume. Stripe: "Don't send idempotency keys in GET and DELETE requests because it has no effect." POST only, on Stripe and Adyen both. Modern Treasury is the outlier that also accepts PATCH.
The failure this protects against is not an error you can read. It is the timeout where you never learn whether the request arrived. Stripe's guidance there is worth pinning above your retry code: "Treat requests that return 500 errors as indeterminate."
Stripe vs Adyen vs PayPal vs Increase: Idempotency Limits Compared
All of it from the vendors' own current documentation, checked 12 September 2026.
| Stripe | Adyen | PayPal | Increase | Modern Treasury | |
|---|---|---|---|---|---|
| Header | Idempotency-Key | idempotency-key | PayPal-Request-Id | Idempotency-Key | Idempotency-Key |
| Max length | 255 chars | 64 chars | 38 single-byte chars | 200 chars (ASCII) | 180 chars |
| Retention | 24 hours | "7 to 14 days" | Per-API; 45 days for refunds | Not documented | 24 hours |
| Replay signal | Idempotent-Replayed: true | None documented | None documented | Idempotent-Replayed: true | None documented |
| Replay returns | Frozen original response | Original response | Current status, not the original | Original object | Frozen status code and body |
| Duplicate in flight | idempotency_key_in_use | HTTP 422 or 409, error 704 | "might fail the second request" | HTTP 409 | HTTP 409 |
| Payload mismatch | idempotency_error | Not documented | Not documented | 409 idempotency_key_already_used_error | Not checked; key is route-independent |
| Methods | POST | POST | POST | POST | POST and PATCH |
Three rows in that table are load-bearing.
PayPal replays current state, not the original response. Its docs say it "provides the status of a request at the current time and not the status of the original request". Every other provider hands you a frozen artefact of the first call. Treat a PayPal replay as evidence of what happened at time T and your reconciliation will be right while your logs are wrong. Modern Treasury's keys are independent of the route. From their docs, verbatim: "if you use a key to create a payment order and then use the same key to create a counterparty within 24 hours, you will received the cached result of the first payment order request." A collision across two unrelated endpoints hands you a payment order when you asked for a counterparty. Stripe does the opposite and raises anidempotency_error when the endpoint does not match. Any helper deriving keys from a business event ID rather than a per-request UUID will hit this.
Increase persists the key on the object. idempotency_key is a nullable string field on the transfer, filterable on the List endpoints. That turns a lost response into a query rather than a retry. Best design decision in the table, and the one nobody else copies.
What Happens If You Reuse an Idempotency Key With Different Parameters
This is where providers diverge most, and where the IETF draft had an answer.
draft-ietf-httpapi-idempotency-key-header-07 defines three conditions. Missing key on an operation that requires one: HTTP 400. Retry arriving while the original is still being processed: HTTP 409. Key reused with a different request payload: HTTP 422, with a problem document of this shape:
{
"type": "https://developer.example.com/idempotency",
"title": "Idempotency-Key is already used"
}
It also names what most vendors implement without naming: an idempotency fingerprint, "generated from request payload data by the resource" — a checksum of the payload or of selected elements, field matching, or a request signature.
Nobody above follows all three rules. Stripe returns an idempotency_error for a mismatch but binds it to no status code anywhere in its published reference. Increase uses 409 where the draft says 422. Adyen documents no mismatch behaviour at all, only the in-flight collision as error 704, "request already processed or in progress". Do not assume Adyen fingerprints your payload; it is not written down.
The clearest fingerprint in the wild is Cross River's, which deduplicates on client ID, request URI, key and a hash of the body — the draft's concept, arrived at independently. At the other end, Orum ships a money-movement API with no idempotency documentation anywhere in its docs index. Neither fact appears on any feature matrix.
Why the IETF Idempotency-Key Standard Stalled in 2026
The draft entered the HTTP API working group in July 2021, having started as an individual submission in 2020. Revision 07 landed on 15 October 2025 and expired on 18 April 2026. Datatracker lists it expired and archived, intended status "(None)". There is no -08.
Here is my read, offered as a position rather than a fact: it was never going to ship, because the vendors already had. Standardising now would force Adyen to widen 64 characters, force Stripe to pick a status code it has deliberately left unstated, and force Modern Treasury to make keys route-scoped, breaking every integration that relies on current behaviour. The spec arrived after the market settled, and the only party with an incentive to adopt it is the integrator writing five adapters.
So treat "supports idempotency keys" on a vendor comparison page as meaningless. The header name tells you nothing about length, retention, scope, replay semantics or concurrency behaviour, and those five properties are what your retry logic is coupled to.
How to Store Idempotency Keys in Postgres
The reference implementation everyone converges on is still Brandur Leach's 2017 write-up of Stripe-style keys, and the schema has aged well:
CREATE TABLE idempotency_keys (
id BIGSERIAL PRIMARY KEY,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
idempotency_key TEXT NOT NULL CHECK (char_length(idempotency_key) <= 100),
last_run_at TIMESTAMPTZ NOT NULL DEFAULT now(),
locked_at TIMESTAMPTZ DEFAULT now(),
request_method TEXT NOT NULL,
request_params JSONB NOT NULL,
request_path TEXT NOT NULL,
response_code INT NULL,
response_body JSONB NULL,
recovery_point TEXT NOT NULL,
user_id BIGINT NOT NULL
);
Four things do the work. The key is claimed with a single atomic INSERT ... ON CONFLICT, so two concurrent requests cannot both win. locked_at marks in-flight work, which is what lets you return a 409 instead of running the operation twice. request_method, request_path and request_params are the fingerprint — exactly the choice Modern Treasury declined to make. response_code and response_body are the replay payload, written only once the request has finished unrecoverably.
recovery_point is the piece most home-grown implementations lack. The pattern splits a request into local state mutations inside a transaction, separated by foreign state mutations, the calls out to the card network or the bank. Commit the local phase and its recovery point before the foreign call, and a crash resumes at a known checkpoint instead of replaying a charge.
Which leads to the case that ruins a quiet Tuesday: the stored response says one thing and the rail says another. A cached 500 never changes, but Stripe reconciles behind it and "fires webhooks for objects created during reconciliation". Their advice generalises: handle webhook events you never see in normal API responses, and stamp your own identifier into metadata so a reconciliation-generated webhook can be matched back. The API response is not your source of truth. The webhook stream and your ledger are.
Do Idempotency Keys Prevent Duplicate ACH or SEPA Payments?
No. This is the boundary that matters most and it is almost never stated.
An idempotency key is an API-transport-layer mechanism with a retention window measured in hours or days. Underneath it, every clearing scheme relies on after-the-fact detection:
| Rail | Duplicate handling | Nature |
|---|---|---|
| ACH (US) | Return code R24 Duplicate Entry, returnable by the RDFI within 2 banking days | Receiver-side return |
| SEPA / SCT Inst | Reason code DUPL in the EPC R-transaction guidance | Reject or recall after the fact |
| Fedwire | None. "The Fedwire Funds Service checks the UETR for proper format but does not validate the uniqueness of it." | No dedupe at all |
| Cards | Adyen: "Duplicate payments can't always be prevented." | Chargeback or refund |
A portability trap sits underneath this. Increase maps its rejection enums straight onto the wire codes, and the same business failure carries a different code per rail: an amount over the receiving bank's ceiling is AM14 on RTP and E990 on FedNow. FedNow returns also carry AM05 and DUPL for duplication; RTP's documented set does not. Key your error taxonomy on the scheme code rather than a normalised internal enum and adding a second rail quietly breaks your alerting.
Adyen states the related trap plainly, for anyone using a business reference as a pseudo-key: "Adyen doesn't check the merchant reference for uniqueness. This means that a shopper can open multiple payment sessions during checkout and pay in all opened sessions." merchantReference is a reconciliation label, pspReference a per-attempt server identifier, and only idempotency-key carries dedupe semantics. Three identifiers, one of which does the job.
ISO 20022 Idempotency: EndToEndId, UETR and pacs.028
Down at the message layer the identifiers look like idempotency keys but do not behave like them.
InstrId(InstructionIdentification) is point-to-point between two agents and is not forwarded to the creditor's bank.EndToEndIdis "assigned by the initiating party to unambiguously identify the transaction" and is passed on unchanged through the whole chain. Maximum 35 characters, mandatory, withNOTPROVIDEDas the conventional escape hatch. No scheme enforces uniqueness on it. You can see the constraint surface in a modern API: Column's realtime transfer accepts an optionalend_to_end_idwithmaxLength: 35, sitting right next to a 255-characterIdempotency-Key. Two identifiers on the same request, one for your retry logic and one for the wire, and they are not interchangeable.TxIdis the interbank equivalent, assigned by the first instructing agent, absent from pain.001.UETRis the closest thing to a real correlation ID: 36 characters, a UUID v4 per RFC 4122, lower case, carried inPmtId/UETRand field 121 of an MT103. Mandatory for SWIFT cross-border since November 2018 and on Fedwire pacs.008, pacs.009 and pacs.004. Format-checked, not uniqueness-checked.
FIToFIPaymentStatusRequest), which asks for a pacs.002 status report on an instruction already sent, including the status of a pending cancellation. If money has moved and should not have, it is camt.056 (FIToFIPaymentCancellationRequest), answered by camt.029. Those are investigation workflows on human timelines, not a header you can retry.
FedNow inverts the model entirely. Its operating procedures enforce unique message IDs inbound, so a rejected instruction cannot be resubmitted as-is; it needs a new ID. Outbound, deduplication becomes your problem. The procedures warn that "there is a potential for Participants to receive a duplicate message for any message type from the FedNow Service", and that the duplicate "will be an identical copy" including the Business Application Header and the message ID. On a timeout the recommended sequence is not retry-with-same-key: send a pacs.028, wait past the 20-second payment timeout clock, then decide. Same shape as an idempotency key, split across two messages and a stopwatch, with the dedupe obligation on the receiver rather than the network. That asymmetry is the argument for getting the API layer right, and it is the point I made about FedNow's message surface and the ISO 20022 address deadline: the rail gives you fewer guarantees than the SDK implies.
What This Means for Payments Engineers
Concrete steps, in the order I would do them:
1. Generate a v4 UUID per request attempt, not per business event. Stripe suggests "V4 UUIDs, or another random string with enough entropy to avoid collisions". 36 characters clears every limit above, including PayPal's 38. Stripe also warns against personal identifiers in keys, which rules out the tempting customer@example.com:invoice-1234 shape.
2. Persist the key before sending the request, in the same transaction as the local state change. A key you generated but never stored is worthless after a crash.
3. Encode each provider's retention window in the adapter, not a wiki. A retry queue with a 48-hour backoff ceiling is safe against Adyen and unsafe against Stripe, whose keys are gone after 24 hours and whose docs are clear that a reused pruned key generates a brand new request.
4. Handle 409 as "unknown", never "failed". Increase and Modern Treasury both return 409 for a concurrent duplicate. Marking that transfer failed and letting someone re-key it is how you create the duplicate you were preventing.
5. On Modern Treasury, scope the key to the endpoint yourself. They will not do it for you.
6. Treat the webhook as the settlement record. Reconcile nightly, and expect objects that never appeared in an API response.
7. Know your return codes. R24 on ACH, DUPL on SEPA. If operations cannot see those on a dashboard, duplicate detection ends at your database boundary.
One more, for UK and EU firms: none of this substitutes for the safeguarding reconciliation your permissions require. A duplicate your idempotency layer missed shows up as a shortfall, and the clock on fixing it is not yours to set.
Key Takeaways
- Five providers, five key length limits between 38 and 255 characters, four incompatible retention policies. There is no shared contract.
- The IETF draft defines 400, 409 and 422 for the missing, in-flight and mismatched cases. It expired in April 2026 at revision 07 and no vendor implements all three.
- PayPal replays current state; everyone else replays a frozen response. Modern Treasury ignores the route; Stripe errors on it.
- Idempotency keys are API-layer only. ACH, SEPA, Fedwire and the card schemes all fall back to after-the-fact returns, and no ISO 20022 identifier is uniqueness-enforced.
- Your source of truth is the webhook stream and the ledger, not the cached response body.