You click “Pay” once. But your backend charges you twice.

You did not write buggy code. The user did not click twice. What happened is simple: the network failed in the middle, the frontend retried, and your server processed the same request two times.

This happens all the time in real backends. Phones lose network. Load balancers timeout. Users double-click. Webhooks get redelivered. If your API is not ready for retries, you will create duplicate orders, duplicate payments, and duplicate emails.

The fix has a fancy name: idempotency. But the idea is very simple.


What Idempotency Means

An operation is idempotent if doing it many times has the same effect as doing it once.

Simple examples:

Most of the dangerous endpoints in your app are POST. Create order, make payment, send email, book ticket. All of them break if retried.

So we need a way to make POST safe to retry.


The Core Problem: You Can’t Tell Retry Apart

Look at this:

Client -> POST /payments { amount: 500 } -> Server creates payment #1
                                        <- response lost (timeout)

Client -> POST /payments { amount: 500 } -> Server creates payment #2
                                        <- success

From the server side, these look like two different requests. Same body, but no way to know it is a retry.

That is why we need the client to send an extra ID with every request.


The Fix: Idempotency Key

The client generates a random unique ID for each operation, and sends it in a header:

POST /payments
Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000

{
  "amount": 500,
  "order_id": "order_123"
}

Rule is simple:

So even if the client retries 5 times, the payment happens only once.

Frontend generates the key once per button click. Not per retry. That part is important. One user action = one key.

// frontend - generate key once when user clicks pay
const idempotencyKey = crypto.randomUUID();

async function payWithRetry() {
  for (let i = 0; i < 3; i++) {
    try {
      return await fetch("/payments", {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
          "Idempotency-Key": idempotencyKey, // same key on every retry
        },
        body: JSON.stringify({ amount: 500 }),
      });
    } catch (e) {
      // network failed, retry with SAME key
    }
  }
}

How To Store It In Backend

You need a table or cache to remember keys. I usually use a simple table in Postgres:

CREATE TABLE idempotency_keys (
  key TEXT PRIMARY KEY,
  status TEXT NOT NULL, -- 'processing', 'completed', 'failed'
  response_code INT,
  response_body JSONB,
  created_at TIMESTAMPTZ DEFAULT NOW()
);

Flow looks like this:

1. Request comes with Idempotency-Key: abc-123
2. Check table: have we seen abc-123 before?
   - No -> insert as 'processing', do the work, save result, return it
   - Yes and completed -> return saved result, skip work
   - Yes and processing -> return 409 Conflict, tell client "wait, still working"

Here is a simplified version in Go:

func CreatePayment(w http.ResponseWriter, r *http.Request) {
  key := r.Header.Get("Idempotency-Key")
  if key == "" {
    http.Error(w, "Idempotency-Key required", 400)
    return
  }

  // 1. Try to claim this key
  _, err := db.Exec(
    `INSERT INTO idempotency_keys(key, status) VALUES ($1, 'processing')`,
    key,
  )

  if err != nil {
    // Key already exists, fetch old result
    var code int
    var body []byte
    var status string
    db.QueryRow(`SELECT status, response_code, response_body FROM idempotency_keys WHERE key=$1`, key).
      Scan(&status, &code, &body)

    if status == "completed" {
      w.WriteHeader(code)
      w.Write(body)
      return
    }

    // Still processing, ask client to wait and retry later
    http.Error(w, "request in progress", 409)
    return
  }

  // 2. First time - do the real work
  payment := processPayment(r)

  respBody, _ := json.Marshal(payment)

  // 3. Save result so next retry returns same thing
  db.Exec(
    `UPDATE idempotency_keys SET status='completed', response_code=201, response_body=$2 WHERE key=$1`,
    key, respBody,
  )

  w.WriteHeader(201)
  w.Write(respBody)
}

The INSERT is the trick. Because key is PRIMARY KEY, only one request can win. If two retries hit at the same exact millisecond, Postgres will reject the second one. That solves race conditions for free.


Where I Use It

Same idea, different places. The key just changes.

1. Your own POST APIs - Idempotency-Key header. Frontend generates one UUID per click. Backend stores it in idempotency_keys table like above. Retry with same key returns saved result. This stops double orders when user double-clicks or network retries.

2. Razorpay webhooks - event_id as key. Razorpay retries until you return 200. So event_id becomes your idempotency key.

Flow:

1. User clicks Pay -> you create order with status='pending'
2. User pays -> Razorpay charges card
3. Razorpay calls POST /webhooks/payment
   { event_id: "evt_123", order_id: "order_123", status: "captured" }
4. You mark order paid

Same trick, smaller version:

func HandleRazorpayWebhook(w http.ResponseWriter, r *http.Request) {
  body, _ := io.ReadAll(r.Body)

  // 1. Verify signature first, reject fakes
  if !verifySignature(body, r.Header.Get("X-Razorpay-Signature"), webhookSecret) {
    http.Error(w, "bad signature", 400)
    return
  }

  var event RazorpayEvent
  json.Unmarshal(body, &event) // event.ID = "evt_123"

  // 2. event_id is PRIMARY KEY, only one wins
  _, err := db.Exec(
    `INSERT INTO webhook_events(event_id, status) VALUES ($1, 'processing')`,
    event.ID,
  )
  if err != nil {
    // duplicate delivery, just say OK so Razorpay stops retrying
    w.WriteHeader(200)
    return
  }

  // 3. first time - update order safely
  db.Exec(`UPDATE orders SET status='paid' WHERE id=$1 AND status='pending'`, event.OrderID)
  db.Exec(`UPDATE webhook_events SET status='completed' WHERE event_id=$1`, event.ID)

  w.WriteHeader(200)
}

Two rules: always return 200 for duplicates, and verify signature before inserting.

3. More places - signup, emails, bookings.

If the endpoint creates something, it needs a key. That is my rule.


Small Details That Matter

A few things I learned the hard way:

1. Keys should expire. Don’t keep keys forever. Add a TTL. 24 hours is enough for most APIs. For payments, maybe 7 days. Use a cron job or Postgres created_at check to clean up.

2. Same key + different body = error. If client sends key abc with amount 500, then sends key abc again with amount 900, that is a bug. Return 422 Unprocessable Entity. Don’t silently return the old result.

3. Only for POST, not for everything. You don’t need this for GET. Just add it to endpoints that create something: payments, orders, bookings, signups.

4. Return same status code too. Don’t just return same body. Return same HTTP code. If first call returned 201, retry should also return 201, not 200.


Key Takeaways

  1. Networks fail. Retries will happen whether you like it or not. Design for it.
  2. One action = one key. Frontend UUID per click, Razorpay event_id per webhook. Same idea.
  3. Database is your lock. Unique constraint on the key stops double processing, even under race conditions.
  4. Verify webhooks first. Check Razorpay signature before touching the DB.

You don’t need Kafka or fancy infra for this. Just one table, one header, and a little discipline. It takes an afternoon to add and saves you from the worst bug a backend can have - charging people twice.


References