Duolearn Logo

How Idempotency Keys Would Make Your API Fail-Proof

Author

Pavan Kumar

Date Published

Learn how Idempotency Keys Would Make Your API Fail-Proof

Have you ever tapped “Send ₹5” once, the screen froze, you tapped again, and later realised the money went twice? Mobile apps and payment networks often retry when they suspect a timeout or partial failure. If your API treats each retry as a fresh request, that one user intent can turn into multiple charges.

This post explains how to avoid that.

The solution is called an idempotency key. It is a small token the client attaches to a request to say this intent is the same as the previous one. With a simple state store that tracks a request as pending and then completed, you can make retries harmless and avoid double processing even when multiple identical requests hit your server at the same time.

We will build a tiny Node and Express demo that shows how an unsafe endpoint double charges, then we turn it into a safe idempotent endpoint. A short walkthrough video is embedded below. You can watch it while reading or follow along step by step.

💡 We’ve also set up a simple UI to simulate safe, unsafe and parallel safe in action, you can access the UI here or find the code for that in the repo

The Unsafe endpoint (POST /unsafe-pay)

The unsafe endpoint is vulnerable in nature, does not track previous attempts. It simply executes the transfer every time a request arrives.

Diagram of unsafe API

This flow treats every retry as a fresh payment, so the user can get charged multiple times.

For example:

Alice wants to send ₹5 to Bob, but tapped “send” 3 times, and the unsafe endpoint processed all 3 as separate payments.

Tabular comparison of the Alice and Bob balance with unsafe API

Now let’s build this endpoint in Express so we can see this problem clearly in code.

The unsafe endpoint accepts three simple parameters: the sender’s email, the recipient’s email, and the transfer amount.

1// Always validate input body.
2validateSendUnsafePaymentRequest(req.body);
3const { senderEmail, recipientEmail, amount } = req.body;

After the server validates the users and the balance from the database, deduct the amount from the sender, credits it to the recipient, and records a completed transfer.

1const transferId = randomUUID();
2let newBalance: number;
3
4await db.update(({ balances, transfers }) => {
5 const senderBalance = balances.find(({ email }) => email === senderEmail);
6 const recipientBalance = balances.find(
7 ({ email }) => email === recipientEmail,
8 );
9
10 if (!senderBalance || !recipientBalance) {
11 throw new Error("Balance update failed: sender or recipient not found");
12 }
13
14 senderBalance.balance -= amount;
15 recipientBalance.balance += amount;
16
17 transfers.push({
18 id: transferId,
19 senderEmail,
20 recipientEmail,
21 amount,
22 status: "COMPLETED",
23 createdAt: new Date(),
24 });
25
26 newBalance = senderBalance.balance;
27});
28

Now lets try to simulate a problem, we’ll build an hiccup implementation which tracks the amount of error thrown for every request. If the hiccup count is 3 or more we return success message, else we’ll return 503 retry error, to force the client to retry the request.

1// creating a hash of senderEmail, recipientEmail, amount, so that we can
2// detect if the another request comes in with the same payload.
3const hiccupKey = createHash("sha256")
4 .update([senderEmail, recipientEmail, amount].join("-"))
5 .digest("hex");
6
7const hiccupsCount = getHiccupsCount(hiccupKey);
8if (hiccupsCount >= 3) {
9 resetHiccupsCount(hiccupKey);
10 res.status(201).json({
11 status: "COMPLETED",
12 transferId,
13 newBalance: newBalance!,
14 });
15 return;
16}
17
18addHiccup(hiccupKey);
19
20res.set("Retry-After", "1").status(503).json({
21 message: "service unavailable",
22 status: "processing",
23});
24return;
25

If there is no protection against repetition here; every retry runs the same logic again, generating a new transaction and debiting the sender multiple times.

This is exactly how real-world systems accidentally double-charge customers under brief outages or timeout retries.

If you’re wondering how is this hiccup implemented?

It uses a small in-memory map that tracks how many times the hiccup has been called for the incoming request.

Here’s the exact implementation:

1const hiccupsCount = new Map<string, number>();
2
3export const addHiccup = (key: string) => {
4 if (!hiccupsCount.has(key)) hiccupsCount.set(key, 0);
5 hiccupsCount.set(key, hiccupsCount.get(key)! + 1);
6};
7
8export const getHiccupsCount = (key: string) => {
9 return hiccupsCount.get(key) || 0;
10};
11
12export const resetHiccupsCount = (key: string) => {
13 hiccupsCount.set(key, 0);
14};

Safe endpoint (POST /safe-pay)

The safe endpoint builds on the same logic as the unsafe one, but adds one small change that makes it resilient. By adding one extra field in the request payload and using a simple state machine inside the API, the same user intent can be retried many times without creating duplicate effects.

Diagram of Safe API

This flow ties all retries to one idempotency key, so the user is charged only once.

For example:

Alice wants to send ₹5 to Bob, but tapped “send” thrice, but the safe endpoint recognised them as the same intent and processed only one payment.

Tabular comparison of the Alice and Bob balance with safe API


Now let’s move into how this is implemented in the safe endpoint.

The safe route takes the same parameters as before, but adds one more crucial field, called the idempotency key.

1validateSendSafePaymentRequest(req.body);
2const { senderEmail, recipientEmail, amount, idempotencyKey } = req.body;

This idempotencyKey is a unique string that represents the user intent. Whether the client retries the request once or a hundred times, all those requests share the same key, meaning they refer to the same logical operation.

The server creates a hash of senderEmail, recipientEmail, and amount so it can bind the idempotency key to the exact payload that the client sent. This makes sure the same key cannot be reused later with different data.

1const payloadHash = createHash("sha256")
2 .update([senderEmail, recipientEmail, amount].join("-"))
3 .digest("hex");

Next we check for snapshot, it is nothing but the pending details of an existing transaction.

1const { balances: balancesDB, snapshots: snapshotsDB } = db.data;
2
3const snapshot = snapshotsDB.find(({ id }) => id === idempotencyKey);

If the snapshot was not found then it’s same as Unsafe API, except there would only be debit from User A, but the credit to User B will still be pending.

But If the snapshot was found we then proceed to verify it’s status,

if it’s COMPLETED, we return success message

1if (snapshot) {
2 const { state, attemptCount, transferId } = snapshot;
3
4 if (state === "COMPLETED") {
5 const sender = db.data.balances.find(({ email }) => email === senderEmail);
6 res.status(201).json({
7 status: "COMPLETED",
8 transferId,
9 newBalance: sender?.balance!,
10 });
11 return;
12 }
13}

Else if it’s PENDING, we create a DB transaction as below

If the attemptCount(hiccups) are lesser than 3, we increment and exit from DB update.

If the attemptCount is greater than 3 then we mark the transaction as COMPLETED.

Then after we exit the transaction, we either throw retry error or success message, based on the condition above.

1if (state === "PENDING") {
2 await db.update(({ snapshots, transfers, balances }) => {
3 const sp = snapshots.find(({ id }) => id === idempotencyKey);
4 if (attemptCount < 3) {
5 sp!.attemptCount += 1;
6 }
7
8 if (attemptCount >= 3) {
9 sp!.state = "COMPLETED";
10
11 const tfs = transfers.find(({ id }) => id === transferId);
12 tfs!.status = "COMPLETED";
13
14 const recipient = balances.find(({ email }) => email === recipientEmail);
15 recipient!.balance += amount;
16 tfs!.finalizedAt = new Date();
17 }
18 });
19
20 if (attemptCount < 3) {
21 res.set("Retry-After", "1").status(503).json({
22 message: "service unavailable",
23 status: "processing",
24 idempotencyKey,
25 transferId,
26 });
27 return;
28 }
29
30 const sender = db.data.balances.find(({ email }) => email === senderEmail);
31
32 res.status(201).json({
33 status: "COMPLETED",
34 transferId,
35 newBalance: sender?.balance!,
36 });
37 return;
38}

You can check out the full implementation of the code, from our repo mentioned at the end.

Where this is used in the real world

This idea is not a theory. It solves very real problems:

1. Payments: protect users from getting charged more than once when apps retry.

2. Order creation: avoid duplicate orders when someone double-taps or refreshes.

3. Webhooks: many services retry the same event again and again, process it only once.

4. Any situation where something must happen exactly one time, such as ticketing or issuing gift vouchers, or reserving limited inventory.

For production systems and for interviews, this pattern of using an idempotency key with a tiny state store is considered a fundamental reliability skill. It is simple to implement, gives very strong benefit, and makes your APIs feel safe to trust.

Conclusion

Retries are normal. Duplicate effects are not. Idempotency keys with a small state machine make those retries safe, consistent, and predictable.

If you want to keep growing as an engineer who builds resilient production systems, follow us on our socials. We share practical content that strengthens real-world engineering judgment, not just theory.

You can find the Source Code here

https://github.com/pavankpdev/idempotency-implementation

Watch the complete walkthrough here

https://youtu.be/J80072N5EV8walkthrough

Thank you 🌱