The Complete Blueprint for Designing Idempotent APIs
Learn how to build fault-tolerant APIs that safely handle retries, duplicate requests, and distributed system failures without corrupting data.
Senior Developer

If you are building an API that handles money, orchestrates physical hardware, or manages critical state, you are one network timeout away from a disaster.
Consider a standard e-commerce flow: A user clicks "Pay $50". The client sends a POST /charge request to your server. Your server processes the payment successfully with Stripe, but just as it tries to send the 200 OK response back to the client, the user's cell connection drops.
The user sees an error screen: "Network Timeout. Please try again."
They click "Pay $50" again. Your server receives a brand new POST /charge request. You have just double-charged your customer.
The solution to this distributed systems nightmare is Idempotency.
What is Idempotency?
In mathematics, an idempotent operation is one that produces the same result whether it is applied once or multiple times (f(f(x)) = f(x)).
In API design, a POST request is idempotent if the client can safely retry the exact same request 100 times without changing the server state beyond the initial request. The server should process the first request, and for the subsequent 99 requests, simply return the cached response of the first successful run.
The Architecture of an Idempotent API
To achieve true idempotency, both the client and the server have strict responsibilities.
1. The Client's Responsibility
The client must generate a unique string (usually a UUIDv4) for every distinct user action, not every network request. This is the Idempotency Key.
If the network fails and the client retries the request, it must send the exact same Idempotency Key. If the user explicitly clears their cart and tries a new checkout, the client generates a new key.
This key is typically sent as an HTTP header:
POST /v1/payments
Idempotency-Key: 8f2d59-4b13-982c-1a2b3c
Content-Type: application/json
{ "amount": 5000, "currency": "usd" }2. The Server's Responsibility
The server must maintain an idempotency_keys table in its database. This table acts as the source of truth for what has been processed.
The Database Schema (PostgreSQL)
You need a robust schema to track the lifecycle of a request.
CREATE TABLE idempotency_keys (
id VARCHAR(255) PRIMARY KEY, -- The Idempotency-Key header
user_id UUID NOT NULL, -- To prevent User A from using User B's key
request_path VARCHAR(255), -- e.g., '/v1/payments'
request_hash VARCHAR(255), -- SHA-256 hash of the request body
status VARCHAR(50), -- 'STARTED', 'COMPLETED', 'FAILED'
response_body JSONB, -- The payload we sent back to the client
response_code INT, -- The HTTP status code
created_at TIMESTAMP DEFAULT NOW()
);
-- Crucial: Keys should expire so your DB doesn't grow infinitely
CREATE INDEX idx_idempotency_created ON idempotency_keys (created_at);The Step-by-Step Execution Flow
When a request arrives at POST /v1/payments, your server must execute a very specific sequence of operations.
Step 1: Validate the Input
Before touching the idempotency system, validate the request body. If the JSON is malformed, return a 400 Bad Request. Do not cache 4xx validation errors, because the client needs to be able to fix the payload and try again with the same key.
Step 2: Acquire the Lock (Concurrency Control)
What happens if a user impatiently double-clicks the "Pay" button? Two identical requests might hit your server at the exact same millisecond. If you aren't careful, both requests will see that the key doesn't exist, and both will process the payment.
You must rely on database constraints to solve this race condition.
// Using an imaginary query builder
try {
await db.query(`
INSERT INTO idempotency_keys (id, user_id, status, request_hash)
VALUES ($1, $2, 'STARTED', $3)
`, [key, userId, hashBody(req.body)]);
} catch (error) {
if (error.code === 'UNIQUE_VIOLATION') {
// The key already exists! We are dealing with a retry or a concurrent request.
return handleExistingKey(key, req, res);
}
throw error;
}By relying on the PRIMARY KEY constraint of the database, it is physically impossible for two concurrent requests to both succeed in the INSERT statement. One will succeed, and the other will be forced into the catch block.
Step 3: Handling Existing Keys
If the key already exists, we look at the status column.
If status is
COMPLETED: The original request finished. We simply fetch theresponse_bodyandresponse_codefrom the database and return it to the client immediately. Do not hit the payment gateway.If status is
STARTED: The original request is currently processing on another thread (the user double-clicked). You should return a409 Conflictor425 Too Earlytelling the client to back off and poll again in a few seconds. Do not attempt to process the request.Mismatched Payloads: Check the
request_hash. If a client sendsIdempotency-Key: 123to charge $50, and then sendsIdempotency-Key: 123to charge $500, this is a severe violation. Return a400 Bad Requestexplaining that idempotency keys cannot be reused across different payloads.
Step 4: Process the Business Logic
Now that we have successfully inserted the 'STARTED' row, we can actually process the payment.
try {
// Talk to Stripe, update user balances, dispatch emails, etc.
const paymentResult = await processPayment(req.body);
// Save the successful response
await db.query(`
UPDATE idempotency_keys
SET status = 'COMPLETED', response_body = $1, response_code = 200
WHERE id = $2
`, [paymentResult, key]);
return res.status(200).json(paymentResult);
} catch (businessError) {
// If our business logic fails (e.g., Insufficient Funds)
// We MUST mark the key as FAILED so the user can retry safely.
await db.query(`
UPDATE idempotency_keys
SET status = 'FAILED'
WHERE id = $1
`, [key]);
return res.status(500).json({ error: businessError.message });
}Edge Cases and Pitfalls
1. The Stripe Webhook Trap
Idempotency isn't just for your frontend. If you receive webhooks from Stripe or GitHub, they guarantee "At-Least-Once Delivery". This means they will occasionally send you the exact same webhook event twice.
If your webhook handler grants a user 100 credits, and you don't check for idempotency using the Stripe-Event-Id, you will accidentally grant them 200 credits when the webhook retries due to network latency.
2. Timeouts During the DB Update
What if the payment succeeds with Stripe, but your server crashes before it can run the UPDATE idempotency_keys SET status = 'COMPLETED' query?
The record stays stuck in 'STARTED' forever. To fix this, you need a background worker that scans for keys stuck in 'STARTED' for more than 5 minutes. The worker should query Stripe to see if the payment actually went through, and reconcile the database state automatically.
3. Key Expiration
If you process millions of requests, your idempotency_keys table will become enormous. Because clients usually only retry within a few minutes of a failure, you do not need to keep these records forever.
Implement a cron job or use Redis TTLs to delete idempotency records older than 24 or 48 hours.
Conclusion
Implementing idempotency is not trivial. It requires dedicated database tables, strict concurrency control, and careful state management. However, in distributed systems where network unreliability is guaranteed, it is the only way to build APIs that your users—and their wallets—can trust.
Comments (0)
Login to post a comment.