Logo
JourneyBlogWorkContact

Engineered with purpose. Documented with depth.

© 2026 All rights reserved.

Stay updated

Loading subscription form...

GitHubLinkedInTwitter/XRSS
Back to Blog

Backend Engineering

Prevent Duplicate Payments: A Developer's Guide

idempotency
prevent duplicate payments
webhooks
race conditions,
payment processing
Jun 15, 2026
10 min read
3 views
Prevent Duplicate Payments: A Developer's Guide

Why duplicate payments are worse than they look

If your application takes payments, duplicate charges are one of the most damaging bugs you can ship. The customer pays once but gets charged twice, or a single purchase turns into two or three identical orders in your system. The support tickets arrive fast, the refunds eat your time, and the trust you spent months building takes a hit in minutes. The frustrating part is that the code usually looks correct, so the bug stays hidden until real traffic finds it.

This guide explains, in simple terms, why duplicate payments happen and how to prevent duplicate payments for good. It is written for developers who want working code, but the early sections are plain enough for a founder or business owner to follow, because the why matters as much as the how. As a real world note, this exact bug has taken down the checkout of busy stores during a flash sale, charging dozens of customers twice before anyone noticed, so it is well worth getting right.

The three reasons a payment runs twice

Most teams assume a double charge is a rare freak event. It is not. There are three everyday situations that each send the same action twice, and a basic checkout cannot tell them apart.

A retry from the customer's browser

On a slow connection, a customer taps Pay, sees nothing for a few seconds, and taps again. Sometimes the browser itself resends a request that looked like it failed but actually went through. Either way, your server receives two charge requests for one purchase.

A retry from the payment provider

Payment providers confirm a payment by sending your server a message called a webhook. To stay reliable, they resend it if they do not get a quick, clear reply. If your handler creates an order every time a webhook lands, those resends create duplicate orders.

Two requests at the exact same moment

Under load, the same action can hit your server twice within milliseconds. Both requests check whether the work is done, both see no, and both proceed. This is a race condition, and it is the kind of bug that only appears when traffic is high, which is why it slips past testing.

Why the usual quick fixes fail

Before the real fix, it helps to know what does not work, because these are the patches most teams reach for first.

Disabling the Pay button after one click feels like a solution, but it does nothing for browser resends, webhook retries, or requests already travelling to the server. Checking for a recent matching order before creating a new one seems safer, yet the check and the create are two separate steps, so two requests can still slip between them. The takeaway is simple. You cannot solve this in the browser or with a loose check. The server itself has to refuse to do the same work twice.

The core idea: make the work happen exactly once

Screenshot 2026 06 13 At 4.26.58 PM

Everything rests on one idea. Give every payment attempt a unique label, and make your server act on that label only once. If the same label arrives again, the server does not redo the work. It returns the result it produced the first time. That label is an idempotency key, and idempotency simply means that running an action many times has the same effect as running it once.

Here is the trap to avoid, so the fix makes sense.

// Naive checkout: no protection against repeats
app.post("/api/checkout", async (req, res) => {
  const { cartId, amount } = req.body;

  const charge = await payments.charge({ amount });          // money moves here
  const order = await db.orders.create({ cartId, chargeId: charge.id });

  res.json({ orderId: order.id });
});

This handler has no memory. Every call charges the card and creates an order, however many times the same purchase arrives. Now let us build it properly, one step at a time.

Step by step: idempotent payments

Prevent Duplicate Payments Guide Flow

Step 1: Create an idempotency key for each attempt

The browser creates one unique key when the customer begins a checkout, and sends that same key on every retry of that attempt. A fresh purchase gets a fresh key. A retry of the same purchase reuses the key.

// The browser creates one key per checkout attempt and reuses it on retry
const idempotencyKey = crypto.randomUUID(); // made once, before the first try

await fetch("/api/checkout", {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    "Idempotency-Key": idempotencyKey       // same key on every retry of THIS attempt
  },
  body: JSON.stringify({ cartId, amount })
});

Step 2: Store keys with a uniqueness guarantee

On the server, keep a small table that remembers every key you have seen, along with the result you produced. The key is the primary key of the table, so the database itself refuses to store the same key twice.

-- Each processed request is recorded once, by its key
CREATE TABLE idempotency_records (
  key         TEXT PRIMARY KEY,     -- the Idempotency-Key from the request
  status      TEXT NOT NULL,        -- "in_progress" or "done"
  response    JSONB,                -- the saved result to replay on a repeat
  created_at  TIMESTAMPTZ DEFAULT now()
);

Step 3: Claim the key, then replay the saved result

Now the core logic. When a request arrives, try to insert its key first. If the insert succeeds, this is the first time, so do the real work and save the result. If the insert hits a conflict, you have seen this key before, so skip the work and return the saved result. Wrapping it in a transaction keeps everything consistent.

app.post("/api/checkout", async (req, res) => {
  const key = req.header("Idempotency-Key");
  if (!key) return res.status(400).json({ error: "Missing Idempotency-Key" });

  const client = await pool.connect();
  try {
    await client.query("BEGIN");

    // 1. Try to claim this key. If it already exists, we have seen it before.
    const claim = await client.query(
      `INSERT INTO idempotency_records (key, status)
       VALUES ($1, 'in_progress')
       ON CONFLICT (key) DO NOTHING
       RETURNING key`,
      [key]
    );

    if (claim.rowCount === 0) {
      // Key already exists: return the saved result instead of charging again
      await client.query("ROLLBACK");
      const prev = await pool.query(
        `SELECT response FROM idempotency_records WHERE key = $1`,
        [key]
      );
      return res.json(prev.rows[0].response ?? { status: "processing" });
    }

    // 2. First time we have seen this key, so do the real work exactly once
    const { cartId, amount } = req.body;
    const charge = await payments.charge({ amount, idempotencyKey: key });
    const order = await client.query(
      `INSERT INTO orders (cart_id, charge_id) VALUES ($1, $2) RETURNING id`,
      [cartId, charge.id]
    );

    const response = { orderId: order.rows[0].id };

    // 3. Save the result so any later retry can replay it
    await client.query(
      `UPDATE idempotency_records SET status = 'done', response = $2 WHERE key = $1`,
      [key, response]
    );

    await client.query("COMMIT");
    res.json(response);
  } catch (err) {
    await client.query("ROLLBACK");
    res.status(500).json({ error: "Checkout failed, please retry" });
  } finally {
    client.release();
  }
});

Pass the same key to your payment provider's own charge call as well. Good providers support an idempotency key too, so even the charge itself cannot run twice. That gives you protection at both layers.

Step 4: Protect your webhooks the same way

The customer side is now safe, but the provider's webhooks can still arrive more than once. Protect them the same way, by recording each event's unique id and ignoring any event you have already handled.

// Payment providers retry webhooks, so dedupe on the event id
app.post("/api/webhooks", async (req, res) => {
  const event = verifySignature(req); // always confirm it truly came from the provider

  // Record the event id once. If it is already there, we have handled it.
  const result = await pool.query(
    `INSERT INTO processed_events (event_id) VALUES ($1)
     ON CONFLICT (event_id) DO NOTHING
     RETURNING event_id`,
    [event.id]
  );

  if (result.rowCount === 0) {
    return res.sendStatus(200); // already handled, acknowledge and stop
  }

  await handleEvent(event); // safe to run, because it runs only once per event
  res.sendStatus(200);
});

Always verify the webhook signature first, so you only act on events that truly came from your provider.

Step 5: Add a lock for the same instant race

The insert first pattern already handles most concurrency, because the database allows only one winner per key. For the rare case where two identical requests arrive at the exact same instant and you want the second one to wait and then return the real result, a short lock on the key does the job.

// For two identical requests at the same instant, lock on the key
// so the second one waits for the first to finish.
async function withKeyLock(client, key, fn) {
  // hashtext turns the key into a number for the advisory lock
  await client.query(`SELECT pg_advisory_xact_lock(hashtext($1))`, [key]);
  return fn(); // the lock releases automatically when the transaction ends
}

Wrap the claim and the work inside this lock. The second request pauses until the first finishes, then sees the saved result and returns it. The lock releases on its own when the transaction ends, so there is nothing to clean up.

Finishing touches that make it dependable

A few small details turn this from correct into reliable.

Always return the same response

Make sure a repeated request gets the exact same response as the original, including the same order id. That way the customer's app behaves consistently whether it is the first try or the fifth, and there is never a confusing second order id.

Clean up old keys

Keys do not need to live forever. A small scheduled job that removes records older than a day or two keeps the table fast without losing any protection that matters.

What you gain, in engineering and in revenue

Once this is in place, duplicate charges and duplicate orders stop completely, even if you deliberately hammer the checkout with repeated and simultaneous requests in testing. The system now follows one clear rule: one key, one outcome. Debugging gets easier too, because every payment attempt leaves a clean record of what happened and when. Better still, the same pattern works for any sensitive action, so you can reuse it for refunds, confirmation emails, and account changes, closing off a whole class of "it happened twice" bugs at once.

The business payoff is just as real. Fewer duplicate charges means fewer refunds, fewer angry tickets, and far less damage to customer trust. For a store running a big sale, that is the difference between a record day and a public mess.

The one rule to remember

Assume that any request can arrive more than once, because under real load it will. Design every sensitive action so it runs exactly once, give each attempt an idempotency key, and let your database enforce the uniqueness for you. Do that, and you prevent duplicate payments long before they ever reach a customer's statement.


Suggested Articles

  • If you're planning to build intelligent applications, How I Shipped Production-Ready AI Agents for a Client shares real-world lessons, architectural decisions, and deployment strategies that helped move AI from prototype to production.

  • Interested in using AI for operational excellence? How I Built an AI-Assisted Log Analysis System to Catch Production Issues Before Users Did breaks down a practical approach to monitoring, anomaly detection, and proactive issue resolution.

  • Before investing heavily in automation, read Why Most AI Automation Pipelines Break in Production - The AI Workflows with n8n and OpenAI Architecture That Actually Works to understand the common failure points and the architecture patterns that improve reliability.

  • Performance problems often hide in plain sight. How a Hidden N+1 Query Slowed API by 6x and the Exact Steps I Used to Fix It walks through a real debugging journey, showing how small database inefficiencies can create major scalability issues.


External Links

  • Stripe Docs, Idempotent requests

  • AWS Builder's Library, Making retries safe with idempotent APIs

  • PostgreSQL, advisory locks

  • Stripe Docs, Best practices for using webhooks

  • MDN, crypto.randomUUID()

Table of Contents

  • Why duplicate payments are worse than they look
  • The three reasons a payment runs twice
  • A retry from the customer's browser
  • A retry from the payment provider
  • Two requests at the exact same moment
  • Why the usual quick fixes fail
  • The core idea: make the work happen exactly once
  • Step by step: idempotent payments
  • Step 1: Create an idempotency key for each attempt
  • Step 2: Store keys with a uniqueness guarantee
  • Step 3: Claim the key, then replay the saved result
  • Step 4: Protect your webhooks the same way
  • Step 5: Add a lock for the same instant race
  • Finishing touches that make it dependable
  • Always return the same response
  • Clean up old keys
  • What you gain, in engineering and in revenue
  • The one rule to remember
  • Suggested Articles
  • External Links

Frequently Asked Questions

If you're building something complex and want a second brain before things get expensive — let's talk.

Continue Reading

How a Hidden N+1 Query Slowed API by 6x and the Exact Steps I Used to Fix It
Backend Engineering17 min read

How a Hidden N+1 Query Slowed API by 6x and the Exact Steps I Used to Fix It

The API wasn’t crashing. Nothing looked broken. But production response times quietly became six times slower. This is a real-world breakdown of how a hidden N+1 query slipped through reviews, how I proved it in Laravel, and the exact steps that fixed it permanently.

Mar 12, 2026131 views
How I Built an AI-Assisted Log Analysis System to Catch Production Issues Before Users Did
Backend Engineering9 min read

How I Built an AI-Assisted Log Analysis System to Catch Production Issues Before Users Did

Logs were there. Alerts were there. Incidents still slipped through. This guide explains how I combined traditional logging with AI-driven pattern analysis to proactively detect production issues and reduce firefighting.

Mar 12, 20267 views
Why OFFSET Pagination Broke Our API at Scale (And How Cursor Pagination Fixed It)
Backend Engineering14 min read

Why OFFSET Pagination Broke Our API at Scale (And How Cursor Pagination Fixed It)

OFFSET pagination broke our API at scale, causing slow queries and latency spikes. Learn how cursor pagination fixed performance without breaking clients.

Jan 16, 20265 views