Idempotency and Safe Retries: The Duplicate-Request Design Decision That Stops Double Charges Before They Happen

A customer taps "Pay" on a slow mobile connection. The spinner hangs, the app times out, and the customer taps again. Your payment provider processed both requests. Support now has a refund ticket, finance has a reconciliation mismatch, and engineering has a post-mortem to write. At AEGONTECH LLC, we see this pattern in almost every system we are asked to stabilize, and the root cause is rarely a bug in the happy path. It is a missing design decision about what happens when the same request arrives more than once.
That decision is called idempotency, and it quietly separates systems that survive real networks from systems that only work in demos.
Key Takeaways
- Retries are inevitable. Networks drop, clients time out, and queues redeliver. Any system that cannot safely handle a repeated request will eventually corrupt data.
- Idempotency means a repeated request produces the same result as the first one, with no extra side effects like a second charge or a duplicate order.
- Idempotency keys, unique constraints, and the outbox pattern cover the large majority of real-world cases without exotic infrastructure.
- Retries without backoff and jitter turn a small outage into a large one. Retry policy and idempotency must be designed together.
- Test the duplicate path on purpose. If you have never sent the same request twice in a test, your production traffic will do it for you.

What Does "Idempotent" Actually Mean in Software?
An operation is idempotent when performing it once or many times leaves the system in the same final state. Setting a user's email to a new value is idempotent; running it twice changes nothing further. Charging a card or appending a row to an order table is not idempotent by default, because every call adds another effect.
HTTP already encodes part of this idea. GET, PUT, and DELETE are defined as idempotent, while POST is not. The trouble is that the operations businesses care most about, such as creating payments, orders, and shipments, are almost always POST requests. That is exactly where duplicates hurt.
A useful rule from our engineering practice: a retry is only safe if the receiver can recognize it as a retry. Everything else in this article is a technique for making that recognition reliable.
Why Do Duplicate Requests Happen Even When Nobody Does Anything Wrong?
Duplicates happen because distributed systems cannot distinguish "the request failed" from "the request succeeded but the response was lost." A client that times out has no way to know which of the two occurred, so the only safe assumption is that it might have succeeded.
Common sources include:
- Client retries: mobile apps and browsers retry after timeouts, and impatient users double-tap buttons.
- Load balancer and proxy retries: many gateways automatically retry failed upstream calls.
- At-least-once message delivery: brokers such as Amazon SQS, RabbitMQ, and Kafka consumers can deliver the same message more than once by design. Exactly-once delivery across a network is, in practice, a myth; what teams build is at-least-once delivery plus idempotent processing.
- Webhook redelivery: payment providers like Stripe retry webhooks for days if your endpoint does not answer with a success code quickly enough.
- Operator actions: someone replays a dead-letter queue or re-runs a batch job after a partial failure.
Industry post-incident reviews regularly attribute a meaningful share of data-integrity incidents, often cited in the range of 10 to 20 percent, to retry and duplicate-handling flaws rather than to logic errors. Whatever the exact figure in your organization, the pattern is consistent: the failure is in the edges, not the center.
How Do Idempotency Keys Work?
An idempotency key is a unique identifier the client generates for each logical operation and sends with every attempt of that operation. The server stores the key together with the outcome of the first attempt, and if the same key arrives again, it returns the stored outcome instead of executing the work a second time.
A minimal, reliable implementation has four parts:
- The client generates the key once (a UUID works well) when the user initiates the action, and reuses it for every retry of that action. Generating a new key per attempt defeats the entire mechanism.
- The server records the key before doing the work, ideally in the same database transaction as the business change.
- A unique constraint on the key enforces the rule, so two concurrent requests with the same key cannot both succeed. Application-level "check then insert" logic has a race condition; a database constraint does not.
- The stored response is replayed for later requests with the same key, including the original status code and body.
Two details trip up even experienced teams. First, you must decide what happens when the same key arrives with a different payload; the safe answer is to reject it with a clear error rather than silently returning a mismatched result. Second, keys need a retention window. Stripe, for example, documents that its idempotency keys can be pruned after 24 hours, which is a sensible default for most payment flows.

Idempotency Keys vs Natural Unique Constraints: Which Should You Use?
Use a natural unique constraint when the operation has a business identity, and use an idempotency key when it does not. A natural constraint is simpler and more robust, because it lives in the data model rather than in request-handling code.
Consider the difference:
- Natural identity: "one invoice per order," "one subscription per customer per plan," "one enrollment per user per course." A unique index on those columns makes duplicates impossible regardless of how many times the code runs. Inserting with an "on conflict do nothing" clause in PostgreSQL turns a retry into a harmless no-op.
- No natural identity: "create a payment attempt," "send a message," "submit a support ticket." Two legitimate requests could look identical, so only a client-supplied key can distinguish a retry from a genuinely new action.
In our experience building and operating products such as Dolfy.ai and Mimicall.app, the strongest designs use both: a natural constraint as the last line of defense at the database, and an idempotency key at the API boundary so clients get a clean, replayable response.
How Do You Make Message Consumers and Webhooks Safe?
Treat every incoming message or webhook as if it will arrive twice, and record a processed-event identifier in the same transaction as the side effect. If the identifier already exists, skip the work and acknowledge the message.
For consumers, the standard recipe is an inbox table: a table with a unique column for the message or event ID. Processing becomes "insert the ID; if that succeeds, apply the change; commit together." If the consumer crashes after the commit but before acknowledging the broker, the redelivered message hits the unique constraint and is safely ignored.
For producers, the mirror-image problem is the dual write: updating your database and publishing an event are two separate operations, and a crash between them loses one. The transactional outbox pattern solves this by writing the event to an outbox table inside the same transaction as the business change, then having a separate process publish it. Delivery becomes at-least-once, which is exactly what idempotent consumers are built to handle.
For webhooks specifically, verify the signature, persist the event, return a 2xx response quickly, and process asynchronously. Slow handlers cause the provider to retry, which multiplies load at the worst possible moment.
How Should You Design Retries So They Help Instead of Hurt?
Retry with exponential backoff and random jitter, cap the number of attempts, and only retry operations that are safe to repeat. Naive immediate retries are one of the most reliable ways to turn a brief dependency slowdown into a full outage.
The arithmetic is unforgiving. If a service handles 1,000 requests per second and each failed call is retried three times immediately, a partial failure can push the load on the struggling dependency toward 4,000 requests per second exactly when it has the least capacity. This is the classic retry storm.
A sound policy has these elements:
- Exponential backoff: wait roughly 100 ms, then 200 ms, 400 ms, and so on.
- Jitter: randomize each delay so thousands of clients do not retry in lockstep. AWS engineering guidance popularized "full jitter" for precisely this reason.
- A retry budget: limit retries to a small fraction of total traffic, for example 10 percent, so a failing dependency is not buried under its own clients.
- Circuit breakers: stop calling a dependency that is clearly down, and let it recover.
- Retry only the safe cases: timeouts and 503 responses are candidates; a 400 validation error will fail identically every time.
Retries without idempotency are a bug generator, and idempotency without disciplined retries is an outage amplifier. Design them as one system.
What About Multi-Step Workflows Like Checkout?
For multi-step processes, make each step idempotent and track workflow state explicitly, so a replay resumes from where it stopped rather than starting over. A checkout that reserves inventory, charges a card, and creates a shipment touches three systems, and any of them can fail midway.
Two approaches work well. In orchestrated sagas, a coordinator stores the state of each step and issues compensating actions, such as releasing inventory or refunding a charge, if a later step fails. In choreographed flows, services react to events and each handles duplicates independently. Either way, each step carries the same operation identifier, so the payment provider, the inventory service, and the fulfillment service can all deduplicate on it.
The trade-off is real. Sagas add state to manage and more code paths to test. For a simple two-step flow, a single database transaction plus an idempotency key is often enough, and adding a workflow engine would be over-engineering. We advise clients to introduce orchestration only when a flow spans three or more independent systems or needs human-visible recovery.
How Do You Test That Your System Is Really Idempotent?
Test duplicates deliberately: send every critical write twice, send it concurrently, and kill the process between steps. Idempotency that is only reasoned about, never exercised, usually has a gap.
A practical checklist:
- Sequential duplicate test: call the endpoint twice with the same key; assert one side effect and identical responses.
- Concurrent duplicate test: fire ten simultaneous requests with the same key; assert exactly one succeeds in doing the work.
- Crash-in-the-middle test: inject a failure after the database commit but before the response is sent, then retry.
- Consumer replay test: redeliver the same queue message several times and compare final state.
- Reconciliation job: run a periodic check comparing your records with the payment provider's records. Even well-designed systems benefit from a safety net that catches what testing missed.
Fault-injection practices, such as those popularized by chaos engineering, make these tests part of routine delivery rather than a one-time exercise. Teams that bring failure testing into their CI/CD (continuous integration and continuous delivery) pipeline catch duplicate-handling flaws before customers do.
What Does This Look Like in Practice at AEGONTECH?
When AEGONTECH LLC designs a new API, the duplicate-request question is on the review checklist from day one, alongside authentication and logging. Every state-changing endpoint is classified: naturally idempotent, protected by a unique constraint, or requiring a client idempotency key. Message consumers get an inbox table by default, and integrations with payment and communication providers are built assuming webhooks arrive late, out of order, and more than once.
The payoff is practical. Support tickets about "I was charged twice" disappear, finance reconciliation becomes a confirmation step rather than an investigation, and engineers can safely replay a queue during an incident without fear. Reliability is not only about uptime; it is about being safe to retry.
Frequently Asked Questions
Is idempotency the same as exactly-once processing?
No. Exactly-once delivery across an unreliable network is not achievable in the general case. What teams actually build is at-least-once delivery combined with idempotent processing, which produces an exactly-once effect on your data.
Do all API endpoints need idempotency keys?
No. GET, PUT, and DELETE are idempotent by definition when implemented correctly, and operations with a natural business identity can use a unique constraint instead. Reserve idempotency keys for non-idempotent writes such as creating payments, orders, and messages.
How long should we store idempotency keys?
Long enough to cover your longest realistic retry window. For synchronous APIs, 24 hours is a common and defensible default; for asynchronous or batch flows with delayed replays, a week or more may be appropriate. Make the retention period a documented, deliberate choice.
Can we retrofit idempotency into an existing system?
Yes, and it is usually best done incrementally. Start with the highest-risk write paths, typically payments and order creation, add unique constraints and keys there, and expand outward. A short technical audit can identify which endpoints carry the most duplicate risk.
Conclusion
Networks fail in the middle of operations, and clients, brokers, and providers will retry. The only question is whether your system treats a repeated request as a surprise or as a normal event. Idempotency keys, unique constraints, inbox and outbox tables, and disciplined retry policies turn duplicates from a source of incidents into a non-event.
If you suspect your payment, ordering, or messaging flows are exposed to duplicate-request risk, the team at AEGONTECH LLC can run a focused reliability review and show you exactly where the gaps are. You can reach us through aegontech.dev to book a consultation.