Stripe Idempotency Keys in Payment Retry Logic
Understanding idempotency keys prevents duplicate charges and webhook processing errors.
A server sends a charge request to Stripe. Stripe processes it, the card gets charged, and the confirmation response travels back toward the server, except it never arrives: a network timeout kills the connection somewhere in transit. The server, following its retry logic, sends the same request again. Without a specific mechanism to stop it, Stripe has no way of knowing this second request is a retry of the first rather than a second, independent charge, and the customer pays twice. The idempotency key exists to close that gap, and the common shorthand for how it works, attach a UUID so Stripe ignores the duplicate, undersells the mechanism and sets developers up to misuse it. Stripe's documentation describes something more exact: the key causes Stripe to save the resulting status code and body of the first request made under that key, successful or not, and every subsequent request carrying the same key gets back that same saved result, including a 500 error if that's what the first attempt produced. This is a contract to cache and replay a result, not a filter that quietly drops duplicates.
What Stripe enforces when a key is reused
Four rules define the edges of that contract, and all four have to hold at once for the protection to apply the way a developer expects. Keys are scoped to a specific endpoint and a specific Stripe account, and under API v2, to a specific API as well, so the same key value sent to paymentIntents.create and to customers.create are treated as entirely unrelated requests with no risk of collision between them. The parameter-matching rule is the one that trips up the most integrations: Stripe compares the incoming parameters against the parameters of the original request made under that key, and if they don't match, it returns a 400. A key is not a cache-busting tool and it can't be reused to push a modified version of a request through under the same identity. Keys only apply to POST requests, since GET and DELETE are idempotent by their nature under HTTP semantics, so attaching a key to either has no effect.
The other rule that gets misunderstood just as often is retention. Under the v1 API, Stripe can remove keys once they're at least 24 hours old, and if a key gets reused after it's been pruned, Stripe treats it as brand new, with no memory that it was ever used before. On top of these four rules, the rate limiter and the authentication layer both run ahead of the idempotency layer, so a 429 or a 401 response is never cached the way a 500 is. The same key can produce a different outcome on a later call depending on when the rate limiter happens to fire, and Stripe's own guidance for handling 4xx responses in general is to generate a fresh idempotency key rather than assume the old one still describes the state of the request.
How key-generation strategy determines whether the protection holds
Stripe enforces the contract correctly once a key reaches its servers. Whether the key itself is sound is entirely the developer's responsibility, and that decision gets made before the API call ever fires. The clearest version of a correct key ties it to the business operation it represents rather than to the act of making a request: combine an internal order ID with a version counter, producing something like order_12345_v1. Every retry of that same order reuses the same key, so Stripe keeps returning the cached result no matter how many times the request goes out, while a legitimate, intentional change to the order (a different amount, a different payment method) increments the counter to order_12345_v2 and is correctly treated as a new operation. A UUID generated once per checkout attempt and stored in session state works too, as long as it's persisted before the first API call and then reused on every retry of that same attempt.
The failure mode sits on the other side of that same logic: a key generated randomly at the moment of each retry provides none of the protection the contract promises. If the key generation call lives inside the retry loop itself, every attempt mints a value Stripe has never seen before, every attempt looks like a brand new request, and the idempotency layer never engages. A customer's email address fails for a related reason, since it isn't unique across the multiple payments that same customer might make. A raw timestamp fails because it isn't tied to any specific business operation and doesn't carry enough uniqueness on its own. Stripe's documentation recommends V4 UUIDs, or another random string with enough entropy to avoid collisions, up to 255 characters, and warns against using sensitive data like email addresses or personal identifiers as the key value. One operational detail decides whether all of this holds together: generate the key and persist it to durable storage before the API call goes out. A crash that happens between the call and the key being saved leaves nothing to recover from on the next attempt.
The three-state problem that breaks most retry implementations
Key generation can be done correctly and the system can still fail, because most homegrown retry logic only accounts for two states a key can be in: absent, never seen before, and complete, the result is known. A third state sits between those two that a lot of implementations never model: in-flight, a prior request under this same key still executing right now. Treating an in-flight request as though it were absent is what turns a retry into a duplicate charge, and it's a common race condition when two workers or two retry attempts happen to pick up the same job at nearly the same moment.
A correct implementation has to track all three states explicitly. No record means the request hasn't been seen, so the server processes it, acquires a lock, and persists the key. In-flight means a request under that key is already executing, and the correct response is a 409, not a second attempt at processing. Complete means execution finished and a result is sitting in cache, ready to be returned as-is. Building this correctly on the server side generally means a unique constraint on the key column to serialize concurrent retries, a lock field to distinguish in-flight from absent, and a cached response field to handle the case where the original response was lost in transit but the operation itself succeeded. Stripe's idempotency layer handles exactly this three-state problem for calls going from a server to Stripe. It does nothing for the reverse direction: if a webhook handler or an internal order-creation path receives concurrent calls, the three-state guard has to be built by hand, because Stripe has no visibility into that part of the system.
Webhook delivery is at-least-once, which creates a mirror idempotency problem on the inbound side
Everything covered so far governs outbound calls, the requests a server sends to Stripe. Stripe's webhook delivery operates under an at-least-once guarantee, so the same event can arrive at a handler more than once, and no amount of correctness in outbound idempotency key design does anything to protect against that. These are two separate problems solved by two separate mechanisms, and conflating them is the single most common point of confusion in a Stripe integration: idempotency keys protect the outbound side, while webhook deduplication protects inbound processing, and neither one covers for the other.
The deduplication mechanism on the inbound side is the event ID that ships with every webhook payload, evt_xxx, which the application has to store itself, in its own database, behind a unique constraint. Stripe does not deduplicate webhook deliveries on the receiving end. The handler that ships in most tutorials verifies the signature, parses the event, runs the side effect, and returns 200, and that handler works fine in staging, because no QA process in a staging environment typically replays the same event twice. The first time production traffic replays an event, which it eventually will under an at-least-once delivery guarantee, that same handler double-processes it. Delivery order is a related but distinct risk: Stripe does not guarantee events arrive in the order they were generated, so a payment_intent.succeeded event can land before the payment_intent.created event for the same resource. The correct guard against that is the event's own created timestamp, combined with a version field on the resource, so a handler can tell a stale payload from a canonical one regardless of which order they showed up in.
A reliable handler architecture separates acknowledgment from processing with a Verify-Enqueue-ACK pattern: verify the signature, write the raw event onto a durable queue, return 200 to Stripe immediately, and then process the event asynchronously, off that queue. This solves two problems in the same motion. A slow database write downstream no longer risks Stripe timing out and retrying a delivery that was actually received just fine, and the event ID sitting in the queue plus the unique constraint in the database gives the deduplication layer the inbound equivalent of what idempotency keys provide outbound.
The 24-hour key retention window, retry windows, and long-running operations
The 24-hour retention window on idempotency keys is a practical boundary on the contract, not a guarantee that holds indefinitely. Any retry strategy designed to run longer than that window needs its own application-layer handling, because Stripe's idempotency layer stops providing protection once the original key has been pruned. A scheduled job that retries a failed payment across several days is the clearest case where this matters: the key can't simply be reused across that whole span and expected to behave the same way on day three as it did on day one. The key has to be designed around the expiry boundary from the start, either with a new key minted per day, a new key per attempt batch, or a version counter that explicitly encodes which retry window the attempt belongs to.
Stripe's webhook retries can continue for up to three days, a second gap alongside the key's 24-hour window. If an outbound idempotency key has already expired and, in principle, a retried charge could go through as a new request, the webhook deduplication layer built on the event ID is the last line of defense protecting the application's own state from recording that event twice. That's precisely why the two layers, outbound idempotency keys and inbound webhook deduplication, need to be implemented as fully independent safeguards, each addressing its own problem. Neither one covers for a gap in the other.
Testing idempotency behavior requires stateful simulation, not stateless mocks
Verifying that any of this actually works in practice requires more than a mock that returns the right shape of response. A stateless mock can confirm that a POST to the payment intents endpoint returns a 200 with the expected fields. It cannot confirm that a second request carrying the same idempotency key returns the cached result of the first, because a stateless mock has no memory of the first call at all, it simply answers each request fresh. Stripe's own documentation acknowledges a role for mocking in CI test suites, alongside periodic live API requests in a testing environment to confirm that responses haven't drifted, but it also notes that those periodic live checks need to stay infrequent to avoid hitting rate limits, which works against the point of continuous integration. Stripe's own stripe-mock tool is explicit about its limits too: its responses are hardcoded, correct in type but not necessarily realistic given actual account state, and there's no plan to add statefulness or deeper testing sophistication to it. It's built for basic sanity checks, not for verifying a full request lifecycle.
That gap between shape-checking and lifecycle-checking appears in a specific, recurring way: a pipeline mocks POST /v1/payment_intents, asserts a 200 comes back, and merges the change. The first real checkout that runs against staging fails, because the handler never actually stored the PaymentIntent ID anywhere, and the webhook path that depends on reading that ID back finds it undefined. The mock proved the response had the right shape. It said nothing about whether the rest of the system could actually use that response. Three specific tests are what actually verify idempotency correctness, and none of them are reliably reproducible through manual QA, because reproducing concurrent delivery takes real infrastructure, not a developer running one request at a time from a terminal. A replay test confirms that the same event delivered twice produces exactly one side effect. A parameter-mismatch test confirms that retrying with changed parameters returns a 400 and writes nothing to the database. An out-of-order test confirms that a stale payload loses to the canonical state already on record. Making all three of these reproducible in CI, without burning through live rate limits or touching production credentials, requires a simulator that remembers state across calls, replays the cached response on a repeated key, and can be driven on demand into specific fault conditions.
Fault injection as the final verification layer for retry resilience
Passing replay tests under clean conditions confirms the idempotency contract works when nothing goes wrong. It says nothing about whether the retry logic wrapped around that contract behaves correctly when latency spikes, 429 rate-limit responses, or 503s from Stripe's own infrastructure, the conditions that actually trigger retries in production, show up. Those conditions have to be deliberately injected to be tested at all, since none of them occur on command in a normal test run. Integration tests need to isolate and reproduce each one individually: card declines, authentication failures, rate limits, and timeouts, each exercised against the retry-and-idempotency path on its own, not just once under ordinary network conditions.
The Stripe CLI's stripe trigger command covers part of this territory. It can fire predefined webhook events, with a limited set of parameter overrides, into a pipeline, including disputes and subscription changes, with chargebacks represented as dispute events. What it cannot do is inject network-layer faults on the outbound side, things like added latency or a dropped connection mid-request. Verifying outbound retry behavior under failure takes a different kind of tool: injecting a 429 partway through a retry sequence to confirm the retry loop actually waits on the Retry-After header before reusing the same idempotency key, or injecting a 503 to confirm the backoff schedule behaves the way it's supposed to. That kind of testing requires a simulator that can be configured to return specific error responses on demand, which is the only way to know, before production traffic finds out, whether the retry logic built around the idempotency key holds up under the exact conditions it was built to survive.