DEV Community

Roshan singh
Roshan singh

Posted on

# πŸš€ Idempotency Keys in Payment APIs: Preventing Duplicate Transactions Explained

Have you ever wondered:

"Why don't payment apps charge me twice if I accidentally click the Pay button multiple times?"

Or

"What happens if my internet disconnects while making a payment?"

The answer is Idempotency.

If you're learning backend development, this is one of those concepts every backend engineer should understand.

Let's learn it from scratch.


πŸ€” The Problem

Imagine you're buying a laptop online.

You click the Pay button.

Client
   |
   |------ Pay β‚Ή50,000 ------>
   |
Server
Enter fullscreen mode Exit fullscreen mode

The server starts processing your payment.

But suddenly...

  • Your internet becomes slow.
  • The server successfully processes the payment.
  • The response never reaches your phone.

Now your app thinks:

"Maybe the payment failed."

So it retries automatically.

Client

Pay β‚Ή50,000
------------>

(No response)

Retry
------------>

Retry Again
------------>
Enter fullscreen mode Exit fullscreen mode

Now the server receives three identical requests.


❌ Without Idempotency

The backend processes every request independently.

Request 1
Charge β‚Ή50,000 βœ…

Request 2
Charge β‚Ή50,000 βœ…

Request 3
Charge β‚Ή50,000 βœ…
Enter fullscreen mode Exit fullscreen mode

Result:

Customer Paid β‚Ή150,000 ❌
Enter fullscreen mode Exit fullscreen mode

Even though the customer clicked only once.

This is one of the biggest problems backend systems need to solve.


Why Do Duplicate Requests Happen?

Duplicate requests happen all the time.

  • User double-clicks the button.
  • Internet connection becomes slow.
  • Mobile network reconnects.
  • Browser refreshes.
  • HTTP client automatically retries.
  • Load balancer retries.

Your backend must handle these situations safely.


βœ… The Solution: Idempotency

Idempotency simply means:

Sending the same request multiple times should have the same effect as sending it once.

The backend should process the request only once.

Every duplicate request should simply return the previous response.


How Does It Work?

Every request carries a unique value called an Idempotency Key.

Example:

POST /payments

Idempotency-Key: abc123
Enter fullscreen mode Exit fullscreen mode

Think of it as a unique tracking number for a request.


First Request

The frontend sends:

POST /payments

Idempotency-Key: abc123
Enter fullscreen mode Exit fullscreen mode

Backend receives it.

It asks:

Have I seen "abc123" before?
Enter fullscreen mode Exit fullscreen mode

Database:

No
Enter fullscreen mode Exit fullscreen mode

Since it's a new request, the backend:

  • Charges the customer
  • Creates the order
  • Stores the key
  • Stores the response

Database:

Key        Response
-----------------------------
abc123     Payment Successful
Enter fullscreen mode Exit fullscreen mode

Finally it returns:

{
    "status": "success",
    "orderId": 101
}
Enter fullscreen mode Exit fullscreen mode

Retry Happens

Because of a network timeout, the client retries.

It sends the same key.

POST /payments

Idempotency-Key: abc123
Enter fullscreen mode Exit fullscreen mode

Backend checks again.

Have I seen abc123?
Enter fullscreen mode Exit fullscreen mode

Database:

YES βœ…
Enter fullscreen mode Exit fullscreen mode

Instead of charging again...

The backend simply returns the stored response.

{
    "status": "success",
    "orderId": 101
}
Enter fullscreen mode Exit fullscreen mode

No duplicate payment.

No duplicate order.

No duplicate database entries.


Visual Flow

                First Request

Client
   |
   | Pay β‚Ή1000
   | Key = abc123
   |
   v

Server

Have I seen abc123?

        |
      No |
        |

Process Payment

Save

abc123 -> Payment Success

Return Response

----------------------------------------

              Retry

Client
   |
   | Pay β‚Ή1000
   | Key = abc123
   |
   v

Server

Have I seen abc123?

        |
      Yes
        |

Return Stored Response

(No Payment Again)
Enter fullscreen mode Exit fullscreen mode

Who Generates the Idempotency Key?

Usually, the frontend (React, Angular, iOS, Android, etc.) generates it.

Example:

const key = crypto.randomUUID();
Enter fullscreen mode Exit fullscreen mode

This key is attached to the request.


⚠️ A Common Beginner Mistake

Many people think:

"If the request fails, I'll generate another key."

That's wrong.

Suppose the first request uses:

abc123
Enter fullscreen mode Exit fullscreen mode

The retry uses:

xyz999
Enter fullscreen mode Exit fullscreen mode

The backend sees two completely different keys.

abc123 ❌

xyz999 ❌
Enter fullscreen mode Exit fullscreen mode

It thinks:

"These are two different payment requests."

So it charges twice.

Idempotency completely fails.


βœ… The Correct Way

Generate the key once.

Reuse it for every retry.

const key = crypto.randomUUID();

try {

    await sendPayment(key);

} catch {

    // Retry with SAME key

    await sendPayment(key);

}
Enter fullscreen mode Exit fullscreen mode

Notice something?

const key = crypto.randomUUID();
Enter fullscreen mode Exit fullscreen mode

is executed only once.


Frontend Example

async function pay() {

    // Generate only ONCE

    const key = crypto.randomUUID();

    try {

        await sendPayment(key);

    } catch {

        console.log("Retrying...");

        // Retry using SAME key

        await sendPayment(key);

    }

}

async function sendPayment(key) {

    return fetch("/api/payment", {

        method: "POST",

        headers: {

            "Content-Type": "application/json",

            "Idempotency-Key": key

        },

        body: JSON.stringify({

            amount: 1000

        })

    });

}
Enter fullscreen mode Exit fullscreen mode

Backend Pseudo Code

@app.post("/payment")

def payment(request):

    key = request.headers["Idempotency-Key"]

    # Have we already processed this request?

    if database.contains(key):

        return database.get_response(key)

    # First time seeing this request

    payment = charge_customer()

    database.save(
        key=key,
        response=payment
    )

    return payment
Enter fullscreen mode Exit fullscreen mode

That's the entire idea behind idempotency.


Where Is the Key Stored?

Usually in a database.

Example:

+----------------------------------------+
| Idempotency Table                      |
+----------------------------------------+

Key           Response

------------------------------------------

abc123        Order #101

xyz456        Order #102

mno888        Payment Failed

------------------------------------------
Enter fullscreen mode Exit fullscreen mode

Many production systems use Redis because it's much faster than querying a database.


Which HTTP Methods Need Idempotency?

GET

GET /users/1
Enter fullscreen mode Exit fullscreen mode

Already idempotent.

Calling it 100 times doesn't change anything.


PUT

PUT /users/1
Enter fullscreen mode Exit fullscreen mode

Updating the same resource multiple times results in the same final state.

Already idempotent.


DELETE

DELETE /users/1
Enter fullscreen mode Exit fullscreen mode

Deleting an already deleted resource still leaves the system in the same state.

Also idempotent.


POST

POST /payments
Enter fullscreen mode Exit fullscreen mode

POST usually creates a new resource.

Calling it multiple times creates multiple records.

That's why POST endpoints commonly use Idempotency Keys.


Where Is Idempotency Used?

Almost every payment platform.

  • Stripe
  • Razorpay
  • PayPal
  • PhonePe
  • Google Pay
  • Amazon Pay

It's also widely used in:

  • Order Creation
  • Flight Booking
  • Hotel Booking
  • Wallet Recharge
  • Subscription Creation
  • Invoice Generation

Basically...

Anywhere duplicate processing would cause serious problems.


Real-Life Analogy

Imagine you're ordering pizza.

You call the restaurant.

"One Large Pizza."

The phone disconnects.

You don't know whether they heard you.

You call again.

Without idempotency:

Pizza
Pizza
Pizza
Enter fullscreen mode Exit fullscreen mode

Three pizzas arrive.

With idempotency:

The restaurant checks your order number.

"We've already received Order #123."

No new pizza is prepared.


Key Takeaways

βœ… One business operation = One Idempotency Key

βœ… Every retry uses the same key

βœ… The backend stores the key after processing

βœ… Duplicate requests return the previous response

βœ… No duplicate payments

βœ… No duplicate orders


Final Thoughts

Idempotency is one of those backend concepts users never noticeβ€”but they benefit from it every day.

The next time your payment succeeds after a network timeout, or you don't get charged twice after accidentally clicking Pay multiple times, you'll know there's an idempotency mechanism working behind the scenes.

Good backend systems aren't just about making things work.

They're about making sure they can't accidentally work twice. πŸš€

Top comments (1)

Collapse
 
johnfrandsen profile image
John Frandsen

Great breakdown β€” the payment side (PIS) gets most of the idempotency attention, but the same pattern matters just as much on the read side.

When you poll a bank's transaction API to sync balances into a budget or accounting tool, you hit a subtle deduplication problem: the same transaction can show up across multiple sync cycles (a retry fires after a timeout, or the API paginates differently). Request-level idempotency keys protect the HTTP call, but the real dedup work happens at the data layer β€” matching on the bank's stable transaction ID plus booking date, not just "did this request succeed."

That's why bank-data aggregators (Plaid, Tink, GoCardless, Enable Banking) return transaction IDs alongside amount, payee, and date. Your sync logic should key off those, not off request success.

One edge case that catches teams off guard: some European banks return different (or no) transaction IDs across calls for the same transaction, so you end up building fuzzy matching on amount + date + description as a fallback. Worth designing for early.

Disclosure: I'm affiliated with open-banking.io, a cert-free EU bank-data API.