"Duplicate webhook, double charge: the idempotency bug I caught in OverAir before production"

Stripe and Meta deliver the same webhook more than once. Without dedup, ~0.5% of customers get charged twice. How I caught it in an 11pm stress test, and the pattern I use.

The short answer, before the story: Stripe and Meta both deliver the same webhook more than once, and if your handler doesn't dedupe, a slice of your customers will get charged twice. Stripe's docs say it plainly: "Occasionally, the webhook endpoints can receive the same event more than once. To protect against receiving duplicate events, record the IDs of events that you processed and do not process events already recorded" (Stripe Docs, 2026). The fix is a table keyed on event.id and a short-circuit before you mutate any state. I caught this in OverAir during an 11pm stress test, the night before the beta opened — before charging a single customer wrong. This post is the exact moment the bug surfaced, the naive handler that almost shipped, and the pattern I run today on both sides (Stripe and WhatsApp).

I'm Ulisses, founder of Hens. OverAir has zero paying customers today — I'll be honest about that throughout. But the billing handler was already written, tested in sandbox, "working." And it was exactly that "working" that almost took me down.

11pm, the stress test nobody asked for

It was the night before the beta. OverAir's checkout flow had been green for a week: the user pays in Stripe, the checkout.session.completed webhook lands, the handler creates the subscription, unlocks the plan, sends the WhatsApp confirmation. I'd clicked through that flow maybe thirty times in test mode. Worked every time.

But something was nagging me. In a sandbox you fire one event at a time. In production, with people paying at once and the network being the network, I had no idea what would happen. So at 11pm, instead of sleeping, I wrote a script that fired the same checkout event several times in parallel — simulating Meta and Stripe redelivering, which is exactly what they do when your endpoint is slow to return a 200.

Ran it. Looked at Firestore. Two subscriptions for the same user. Two billing records. I remember saying out loud, in the empty office: "this can't be happening." The handler was correct. The problem was that it ran twice, and nobody had told it that was possible.

It wasn't a bug in my code in the classic sense. It was a wrong assumption baked into it: that every webhook arrives once. That assumption is false on both platforms OverAir runs on.

What Stripe actually promises (and it isn't once)

Here's the mental error almost every vibe-coded handler carries. The tools generate code that assumes exactly-once delivery — each event, one time. The platforms guarantee the opposite.

Stripe delivers at-least-once and retries failures with exponential backoff for up to 72 hours, so the same evt_… can arrive several times — the ID doesn't change across retries (Stripe Docs, 2026). Stripe's whole idempotent-requests doc exists for this: idempotency keys "guarantee the safe retrying of requests... and prevent the accidental creation of duplicate objects" (Stripe API Reference, 2026).

The number people throw around is ~0.5% duplicate deliveries. Being honest: Stripe doesn't publish that percentage — it's an operational estimate, not an official figure. What Stripe does publish is the part that matters more: duplicates aren't an edge case, they're a normal operating condition. In test you never see it. In production it shows up every week.

Meta does the same on the WhatsApp Cloud API. Their docs are explicit: notifications are delivered at-least-once, and "if a request to your endpoint returns a status other than 200, retries continue with decreasing frequency until they succeed, for up to 7 days" (Meta for Developers, 2026). Seven days of redelivery. Add the message lifecycle, which fires one event per status change, and the duplicate count only climbs.

Treating this as an exception is designing for the wrong world. Duplicates are the normal case. Exactly-once is the illusion.

The naive handler that almost shipped

This was the shape of my handler before the stress test. I trimmed the details, but the form is this:

export const stripeWebhook = onRequest(async (req, res) => {
  const event = stripe.webhooks.constructEvent(
    req.rawBody,
    req.headers["stripe-signature"],
    process.env.STRIPE_WEBHOOK_SECRET,
  );

  switch (event.type) {
    case "checkout.session.completed":
      await handlePaymentSuccess(event.data.object); // creates subscription + unlocks plan
      break;
  }

  return res.json({ received: true });
});

Read it again. The signature is verified — good, nobody forges an event. But there's nothing between receiving the event and handlePaymentSuccess. Stripe redelivers the same checkout.session.completed, this code runs handlePaymentSuccess again, and the user gets a second subscription. The handler has no memory. It doesn't know it already saw this event.

And here's where it actually hurts, because the production symptom isn't a 500 screaming in your logs. It's a customer getting two billing emails and opening a dispute.

The cost of skipping dedup

Each Stripe chargeback costs a USD 15 dispute fee, pulled from your balance immediately, regardless of fault. Since June 2025 there's also a dispute countered fee — another USD 15 if you choose to fight it (refundable only if you win) (Stripe Support, 2026). Lost the dispute after contesting? USD 30 total, plus the refunded amount, plus the customer who never comes back.

Run the math with the 0.5% estimate:

Item Value
Transactions/month 1,000
Duplicate rate (estimated) 0.5%
Duplicate charges/month 5
Dispute fee (USD 15 each) USD 75/month
If contested and lost (USD 30 each) USD 150/month
Customers churned per month 5

USD 75 to USD 150 a month evaporating into dispute fees, on a product that might bill USD 2,000. For a Gulf-based merchant the sting is the same in AED — roughly AED 55 per dispute, plus the churned customer who tells two friends. And every row in that table is a human who trusted your checkout and got billed double. To me this is the kind of bug you don't negotiate — you don't ship a billing flow without dedup, period. It's not "we'll fix it later." Later is the chargeback.

The fix: event.id as a primary key

The fix is boring precisely because it's simple, and that's exactly why the tools skip it. Before processing anything, you ask: have I seen this event? This is OverAir's handler today:

// ── Verify the Stripe signature ──
const event = stripe.webhooks.constructEvent(
  req.rawBody,
  req.headers["stripe-signature"],
  process.env.STRIPE_WEBHOOK_SECRET,
);

// ── Idempotency: reject duplicate events ──
const eventRef = db().collection("stripe_events").doc(event.id);
const existing = await eventRef.get();
if (existing.exists) {
  console.log(`[OverAir] Stripe: duplicate event ${event.id}, skipping`);
  return res.json({ received: true, duplicate: true });
}

switch (event.type) {
  case "checkout.session.completed":
    await handlePaymentSuccess(event.data.object);
    break;
  // ...
}

// ── Record the event for idempotency ──
await eventRef.set({ type: event.type, processedAt: new Date() });
return res.json({ received: true });

The stripe_events collection uses event.id as the document ID. Stripe guarantees that ID is stable across redeliveries, so the second arrival hits the existing.exists guard and leaves without touching handlePaymentSuccess. It works for checkout.session.completed and for every other type — subscription deleted, invoice failed, async payment confirmed later. One guard, every event.

But if you stopped reading here thinking it's solved, there's still a hole. And that hole is what sent me back to the code a second time.

The race condition get-then-set still leaves open

Look at the pattern again: get(), check existing.exists, then set(). Between the get and the set there's a window. If two webhooks for the same event.id arrive at the same time — which is precisely what parallel redelivery does — both run get() before either runs set(). Both see "doesn't exist." Both pass the guard. Both process.

The read-then-write guard isn't atomic. It cuts duplicate probability by orders of magnitude, but it doesn't zero it. For a billing flow, "almost never" is still money leaking.

The right fix is to let the database resolve the race, because a single atomic write is something every database knows how to do. In Firestore, swap set() for create(), which fails if the document already exists — so you catch the error and treat it as a duplicate:

try {
  await eventRef.create({ type: event.type, processedAt: new Date() });
} catch (err) {
  if (err.code === 6 /* ALREADY_EXISTS */) {
    return res.json({ received: true, duplicate: true });
  }
  throw err;
}
// only the invocation that won the create reaches here — process safely

In SQL it's the same principle under another name: event.id as a UNIQUE constraint and an INSERT that catches the duplicate-key error (or INSERT ... ON CONFLICT DO NOTHING and check the affected row count). Whoever loses the INSERT race knows they lost, and skips. The atomicity comes from the database, not from your if.

I left the get-then-set in OverAir for a while because volume was low and the probability was tiny. But I logged the debt and closed it before any real charge. Honest take: if the webhook touches money, use the atomic write from day one — the if in front of the set is the version that gives you a false sense of safety.

The same bug lives in WhatsApp

The nice part of having both problems in the same product is seeing that the shape is identical. OverAir's WhatsApp webhook doesn't keep a separate stripe_events table — it uses the message.id itself as the document ID in the processing queue:

const docRef = db().collection("pending_messages").doc(message.id);
await docRef.set({
  message,
  senderPhone: message.from,
  status: "pending",
  retryCount: 0,
  createdAt: new Date(),
  expireAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
});

When Meta redelivers the same message, the second set() writes to the same document — pending_messages/{message.id} — and just overwrites, instead of creating a second work item. The trigger that processes the queue fires once per document, not per delivery. Deduplication is a natural consequence of choosing message.id as the key, not an extra step. It's the exact advice Meta gives: use messages[].id (inbound) or statuses[].id (status) as a deduplication key (Meta for Developers, 2026).

Notice the background pattern on both sides: the webhook handler only enqueues and returns 200 immediately; the real processing runs later, isolated. That's not elegance — it's defense. If your handler processes synchronously and takes longer than the platform's timeout window (5 to 10 seconds on WhatsApp), the platform assumes it failed and redelivers, and now you're manufacturing the very duplicates you're trying to avoid. Return 200 fast, process in a queue. Both pieces of advice — dedup by ID and immediate ACK — are two sides of one coin.

The checklist I run before any webhook ships

Every time a payment or message webhook lands in a system I deliver, I walk through this:

  1. Verify the signature against rawBody, never the already-parsed req.body — Stripe and Meta sign the raw bytes, and the parser changes those bytes.
  2. Dedupe by IDevent.id on Stripe, message.id/statuses[].id on Meta — with an atomic write (create/UNIQUE), not get-then-set.
  3. Return 200 immediately and enqueue — heavy processing out of the request, so you don't blow the timeout and trigger redelivery.
  4. Handle each event type explicitly and log the ones you ignore — a silent default hides a new event the platform started sending.
  5. Run a parallel-redelivery stress test before the beta. Fire the same event N times at once and confirm the final state matches a single delivery. That's the test that saved me at 11pm.

None of those five show up in an AI-generated handler by default. I tested this in the post on hardening vibe-coded WhatsApp bots — I asked three tools to write the handler and all three assumed exactly-once delivery. It's not the tool being dumb. It's that Meta's and Stripe's docs aren't in its context when it generates your code. They're in mine, because I already paid to learn them.

The lesson

A webhook isn't a function you call — it's a loose promise that a message will arrive, maybe more than once, maybe out of order. Every handler that assumes exactly-once delivery is a chargeback waiting for a date. Dedup by ID with an atomic write costs ten lines and spares you the conversation nobody wants: explaining to a customer why they were charged twice.

If you've got a billing flow in production and you've never run a parallel-redelivery stress test, open the handler now. The odds it assumes exactly-once delivery are high, and Stripe warned you in the first line of the docs. At Hens, this is the kind of thing we find before it ships — and the 11pm stress test is cheaper than the first dispute.

Sources

Want an app like this for your business?

Hens builds Flutter apps, AI-powered WhatsApp bots and custom backoffice systems — with the same rigor we apply to our own products. From MVP to production. First call is direct, no form.

Talk on WhatsApp →