Idempotency by Design: Making APIs Safe to Retry
Learn how API idempotency prevents duplicate operations on retries, with idempotency key patterns, database design, race-condition handling, and Node.js code.

Your mobile app loses its connection, your API gateway times out, or your client never receives a response that the server did send. In every one of these cases, retrying the request is the right move. The trouble starts when the request you retry has already succeeded.
If that request creates an order, charges a card, submits an investment, or registers a customer, the second attempt can create a duplicate. Idempotency is the API design that makes the retry harmless. You send the same logical request twice, and the business operation runs once.
This article shows why retries create duplicates, how idempotency keys prevent them, how to implement the pattern step by step with PostgreSQL and TypeScript, and what you give up when you add it.
Quick Answer
API idempotency means that repeating the same logical request does not repeat its side effects. You achieve it by sending an idempotency key with the request and storing that key together with the result of the first successful operation. When the client retries with the same key, the server recognizes the retry and returns the stored result instead of running the operation again. A unique database constraint, a request fingerprint, and clear handling of in-flight requests make the pattern safe under concurrency.
What Is API Idempotency?
An operation is idempotent when performing it several times has the same intended business effect as performing it once. A read such as GET /api/orders/123 already behaves this way. A write such as POST /api/orders does not, because HTTP defines POST as not idempotent, and each call can create a new resource.
Consider an order API. The client sends a create-order request, the server saves the order, and the response carrying the new order ID gets lost on the way back. The client cannot tell whether the order exists, so it sends the request again. Without idempotency, the server treats the second request as a new order, and the user ends up with two.
With idempotency, the server sees that the second request belongs to the same operation and returns the original order ID. You do not need this on every endpoint. You need it wherever running an operation twice causes damage: payments, orders, financial transactions, account creation, subscription changes, calls to external services, and webhook handlers that receive the same event more than once.
The Problem
A request can fail at many points between the client and the database: the network drops, a load balancer times out, a worker restarts, or the client stops waiting before the server finishes. A timeout does not tell you that the server did nothing. It only tells you that the client did not see a response.
Take a payment request. The client calls POST /api/payment, the API server charges the payment provider, and the charge succeeds. The response never reaches the client, so the client retries. The server has no way to know that this request repeats the last one, so it charges the customer again.

You cannot fix this on the client alone. A client can retry carefully, but only the server can tell whether it already handled the operation, so the server needs a way to recognize a repeated request.
The Solution
The fix is an idempotency key. The client generates a unique key for each logical operation, sends it in a header, and reuses the same key on every retry of that operation. The server stores the key when it starts processing, and stores the result when it finishes.
POST /api/orders
Idempotency-Key: 7f8c9d12-2f45-4d3b-a1c2-123456789abc
Content-Type: application/jsonThe key does not stop the client from sending the request twice. It gives the server enough information to see that both requests belong to the same operation. The first request runs the business logic. Every later request with the same key gets the stored result.

The steps below build this pattern up piece by piece. Steps 1 to 3 give you the basic lookup, and steps 4 to 8 make it hold up in production.
Step 1: Accept an idempotency key
Allow clients to send an Idempotency-Key header on operations that must not run twice. The client generates a new key when it starts a new operation and reuses that key when it retries. A random UUID works well because it is unique enough to identify one operation.
Decide what happens when the header is missing. For critical operations such as payments, rejecting the request is safer than processing it without protection.
Step 2: Store the key
The server needs persistent storage for idempotency state. In PostgreSQL, a table like this holds it:
CREATE TABLE idempotency_keys (
id UUID PRIMARY KEY,
key VARCHAR(255) UNIQUE NOT NULL,
request_hash VARCHAR(64) NOT NULL,
status VARCHAR(20) NOT NULL,
response JSONB,
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);The key column holds the client's value, and the UNIQUE constraint stops the database from storing the same key twice. The status column tracks whether the operation is still running. The response column holds the result you return on a retry, and request_hash becomes useful in step 8.
Step 3: Check for an existing request
When a request arrives, look the key up. If the operation already completed, return the stored response and skip the business logic.
const existingRequest = await db.idempotencyKey.findUnique({
where: { key: idempotencyKey },
});
if (existingRequest?.status === "COMPLETED") {
return existingRequest.response;
}This lookup is not enough on its own. Two requests can pass it at the same moment, which is the problem step 4 solves.
Step 4: Handle concurrent requests
Request A and request B arrive at almost the same time with the same key. Both look up the key, both find nothing, and both continue to process the order. You now have a duplicate, even though you added an idempotency check.

A SELECT followed by an INSERT cannot close this gap. Let the database decide who wins by claiming the key with a single atomic statement. The UNIQUE constraint from step 2 already provides the index, so you only need to insert:
INSERT INTO idempotency_keys (id, key, request_hash, status)
VALUES (gen_random_uuid(), '7f8c9d12-2f45-4d3b-a1c2-123456789abc', 'hash-of-request', 'PROCESSING')
ON CONFLICT (key) DO NOTHING
RETURNING id;If the statement returns a row, you own the key and can process the request. If it returns no rows, another request already claimed the key, so you treat yours as a retry. The PostgreSQL documentation on unique indexes explains how the constraint is enforced.
Step 5: Store the original result
After the business operation succeeds, save the response next to the key:
{
"status": 201,
"body": {
"orderId": "order_456",
"status": "created"
}
}A retry with the same key now returns this payload. This matters most when the response contains a generated ID. The first request creates order_456, and every retry returns order_456 instead of creating another order.
Step 6: Use transactions carefully
The idempotency record and the business operation need to commit together. For order creation, you start a transaction, claim the key, create the order, store the result against the key, and commit. If the application fails before the commit, the database rolls everything back.
This prevents the worst inconsistency: an idempotency record that says an operation completed when the order was never saved. Define clearly where the transaction begins and ends, because the right boundaries depend on your application.
Step 7: Track the operation state
Give each record a state: PROCESSING, COMPLETED, or FAILED. The first request moves to PROCESSING when it claims the key and to COMPLETED when the operation succeeds.
The state matters when a second request arrives while the first is still running. You choose what request B receives: a conflict response, a wait until request A finishes, or another strategy that fits your system. No single answer fits every API, so pick one on purpose and document it.
The following function puts steps 3 to 7 together. It claims the key first and handles the duplicate through the unique violation, instead of checking beforehand. It assumes a Prisma client named db and an order model.
import { randomUUID } from "node:crypto";
async function createOrder(idempotencyKey: string, orderData: CreateOrderInput) {
let record;
try {
record = await db.idempotencyKey.create({
data: { id: randomUUID(), key: idempotencyKey, status: "PROCESSING" },
});
} catch (error: any) {
if (error.code !== "P2002") throw error; // not a unique violation
const existing = await db.idempotencyKey.findUnique({
where: { key: idempotencyKey },
});
if (existing?.status === "COMPLETED") return existing.response;
throw new Error("Request with this key is already processing");
}
try {
const order = await db.order.create({ data: orderData });
const response = { status: 201, body: { orderId: order.id, status: "created" } };
await db.idempotencyKey.update({
where: { id: record.id },
data: { status: "COMPLETED", response },
});
return response;
} catch (error) {
await db.idempotencyKey.update({
where: { id: record.id },
data: { status: "FAILED" },
});
throw error;
}
}This version keeps the code short, so the order creation and the key update are two separate writes. In production, wrap them in one transaction as step 6 describes.
Step 8: Reject a reused key with different data
A client sends Idempotency-Key: abc-123 with a request for product-1 and quantity 2. Later it sends the same key with product-2 and quantity 5. Both requests carry the same key but describe different operations, and the server must not treat them as one.
Store a hash of the request body in the request_hash column. When a key comes back, compare the incoming hash with the stored one. If they differ, reject the request instead of returning the result of an unrelated operation. A key identifies one logical operation, and it should never become a general-purpose identifier.
Trade-offs
Idempotency adds state that you have to look up, store, and clean up. Every protected request now costs a database lookup and a write, so index the key column and measure the overhead on your own workload. If you cannot justify that cost for an endpoint, you probably do not need the pattern there.
Retention is a real decision. If you delete records too early, a late retry looks like a brand-new operation. If you keep them forever, the table grows without limit. Keep each record at least as long as clients and infrastructure can retry, and delete it after that.
A request can also get stuck in PROCESSING when the server crashes after claiming the key. You need a way to find stale records and decide whether to retry, fail, or resume them. Track retries, duplicate requests, failed operations, and stale PROCESSING records in production, because normal testing rarely shows these problems.
You also have to choose where to store the state. PostgreSQL fits when the business operation already lives there, because the key and the operation can share one transaction. Redis gives you fast lookups and built-in expiry through key expiration, but it does not remove the consistency problem. If Redis says an operation completed and PostgreSQL disagrees, you are back to duplicates or lost work, so you still design the boundary between them.
Protect the stored data as well. Make keys hard to guess, and scope them to the authenticated user so one account cannot read another account's stored response. If a response contains sensitive data, store a reference to the created resource instead of the full payload.
Finally, idempotency is the wrong tool for operations that are already safe to repeat. Reads, and writes such as setting a field to a fixed value, do not need a key table. The question to ask is what happens if the operation runs twice, not which HTTP method it uses. For a production-grade reference on the header and its semantics, see the Stripe documentation on idempotent requests.
Conclusion
On Monday, pick one write endpoint where a duplicate would hurt, such as order creation or payments, and add an Idempotency-Key header to it. Create the idempotency_keys table with the unique constraint, claim the key atomically, and store the result in the same transaction as the business operation. Then decide and document what a retry receives while the first request is still processing, how long you keep records, and how you recover stuck ones. Once that endpoint is safe to retry, apply the same pattern to the next one.
Need help building reliable APIs for payments, financial systems, third-party integrations, or background workers? Our development team can help you design and implement production-ready backend architecture. Contact us

Aarav Sharma
Lead Software Engineer
Aarav leads product engineering at Matlab Infotech, where he has shipped mobile and web platforms across healthcare, fintech, and SaaS. He writes about pragmatic engineering and shipping fast without cutting corners.


