MICROMARKETING Book Free Consult
← Apps Development

Replaying Stripe Webhooks Without Double-Charging Customers

A Stripe webhook replay should feel boring. That sounds obvious until you are staring at 417 failed invoice.paid events after a campaign launch, knowing some of those customers came from yesterday’s LinkedIn ads, some from an affiliate drop, and a few are waiting on access to the thing they already bought. I have seen this […]

A Stripe webhook replay should feel boring.

That sounds obvious until you are staring at 417 failed invoice.paid events after a campaign launch, knowing some of those customers came from yesterday’s LinkedIn ads, some from an affiliate drop, and a few are waiting on access to the thing they already bought. I have seen this turn into a messy Slack thread fast. Growth wants the revenue numbers fixed. Support wants to know who paid. Engineering wants everyone to stop clicking random retry buttons in the Stripe Dashboard.

The risk is simple: Stripe can send the same event more than once, and your replay job can do the same damage twice if your app treats delivery as proof that work has never happened before.

For a marketing operator, this is not an edge-case engineering chore. Paid programs create bursts. A webinar funnel sends 900 people to checkout in 12 minutes. A Product Hunt launch wakes up a dormant coupon link. A newsletter sponsorship lands at 9:04 a.m. Eastern and half the checkouts happen before coffee. Those bursts expose every weak assumption in your billing automation.

Stripe’s own docs are clear on the shape of the problem. Webhook endpoints must return a 2xx quickly, failed deliveries are retried automatically for up to three days in live mode, and manual resends can be triggered from the Dashboard for 15 days or through the CLI for 30 days. Stripe also tells you to guard against duplicate events by logging processed event IDs. That last sentence is the whole game.

Still, event IDs alone are not enough in a real marketing stack.

The Failure Mode I See Most

The dangerous setup usually looks like this: Stripe sends checkout.session.completed, your webhook handler creates a user, grants access in the app, sends a welcome email through Customer.io, fires a conversion event into Segment, and maybe pushes a lead stage update into HubSpot. Fine on paper.

Then the handler times out after 11 seconds because HubSpot’s API is slow. Stripe marks the delivery as failed. Someone later hits “Resend” in the Stripe Dashboard. Your code runs the whole path again.

Now the customer has two welcome emails, two activation events, one confused lifecycle report, and maybe a second invoice-side effect if your code creates charges, credits, add-ons, or subscriptions from inside the webhook. That last part is where teams get burned. A webhook handler should almost never create money movement as a casual side effect.

In one B2B SaaS account I worked on in 2024, the damage was small but embarrassing: 63 duplicate onboarding emails and 17 duplicate HubSpot notes after a failed invoice.paid replay. No one was double-charged because billing creation lived outside the webhook path. That separation saved the team. Their attribution dashboard was noisy for a day, but nobody had to issue refunds.

Treat Stripe Events As Messages, Not Commands

A Stripe event is a message saying something happened inside Stripe. It is not a command to bill someone again.

That distinction matters. When invoice.paid arrives, Stripe has already collected payment for that invoice. Your job is to mark the matching account as paid, extend access, provision seats, send receipts, and update downstream systems. If your handler responds by calling PaymentIntent.create or creating another invoice item without a firm idempotency key, you have built a double-charge machine.

The cleaner design has three layers.

First, receive the webhook and verify the signature with Stripe’s signing secret. In Node, that means using the raw request body with stripe.webhooks.constructEvent, not a parsed JSON body that Express or Next.js has already mutated. Stripe’s official Node SDK has supported this flow for years, and the same pattern exists in Ruby, Python, Go, PHP, Java, and .NET.

Second, write the event to your own database before doing any business work. I usually use a stripe_events table with event_id, event_type, object_id, livemode, api_version, received_at, processed_at, status, and last_error. PostgreSQL 15 or 16 handles this nicely with a unique index on event_id.

Third, process the stored event through a worker. Sidekiq, BullMQ, Celery, Cloud Tasks, SQS, and Laravel queues all work. The tool is less important than the boundary. Your webhook endpoint should acknowledge receipt. Your worker should do the slower work.

This is where replay becomes safe. If Stripe sends evt_1Pabc... five times, your insert hits the same unique key five times. Four deliveries become no-ops. The worker processes one stored record.

Idempotency Needs Two Keys

Stripe has its own idempotency keys for API requests. Use them whenever your server creates or changes Stripe objects. If you create a subscription from your backend after a checkout-like flow, the idempotency key should be tied to your internal purchase attempt, such as purchase_98213_create_subscription, not to a browser session or timestamp.

Webhook processing needs a separate idempotency layer in your database.

I like two guards because they catch different failures. The Stripe idempotency key protects calls you make to Stripe. The processed-event table protects work your app does after Stripe calls you. If you only use one, replay can still leak through the other side.

For invoice.paid, I usually key the business action on the invoice ID too: in_1Pxyz.... That gives you a second fence around account entitlement. The event ID prevents processing the same Stripe event twice. The invoice ID prevents granting the same paid period twice if Stripe emits related events that touch the same invoice.

Concrete example: invoice.paid and customer.subscription.updated can arrive close together during a renewal. Both may imply “customer is active.” If both handlers independently add 30 days to an account, the account gets 60 days. The fix is not clever code. Store the entitlement period as Stripe says it is, based on current_period_start and current_period_end, instead of incrementing your own date by 30 days.

Small detail. Big difference.

What I Put In The Database

Here is the table shape I reach for in Rails, Django, Laravel, or a TypeScript service backed by Postgres:

create table stripe_webhook_events (
  id bigserial primary key,
  stripe_event_id text not null,
  type text not null,
  object_id text,
  livemode boolean not null,
  api_version text,
  payload jsonb not null,
  status text not null default 'received',
  attempts integer not null default 0,
  last_error text,
  received_at timestamptz not null default now(),
  processed_at timestamptz,
  unique (stripe_event_id)
);

create index stripe_webhook_events_status_idx
  on stripe_webhook_events (status, received_at);

That unique constraint is not decoration. It is the replay lock.

When the webhook endpoint receives an event, it tries to insert. If the insert succeeds, enqueue a job using the internal row ID. If the insert conflicts, return 200 and stop. Do not enqueue another job. Do not parse the payload and try to be helpful. Duplicate delivery is normal, and your app should treat it as normal.

The worker then claims the row, checks whether processed_at is already set, and runs the specific handler for the event type. After success, it sets processed_at and status = 'processed'. After failure, it stores the error and leaves the row retryable.

In Postgres-heavy systems, I use select ... for update skip locked when multiple workers might pull from the same queue table. If the app already uses BullMQ with Redis 7 or Sidekiq with Redis, I let the queue handle job locking and keep Postgres as the source of truth for whether the event has been processed.

The Replay Runbook

The worst replay plan is “click resend on everything red.”

I want a short runbook that marketing, support, and engineering can all read under pressure. Mine usually has five checks.

Confirm the failed endpoint first. In Stripe Dashboard, go to Developers, then Webhooks, then the endpoint. Filter for failed deliveries and note the exact event types. If all failures are customer.subscription.updated, the risk profile is different from checkout.session.completed or invoice.paid.

Check your app logs around the first failure. Datadog, Honeycomb, Sentry, CloudWatch, or plain journalctl are fine. You are looking for boring causes: a deploy at 14:03 UTC, a 500 from a missing environment variable, a database connection cap, a timeout to HubSpot, or a bad signature after someone rotated the webhook secret.

Patch the cause before replaying. This sounds too basic to write down, but I have watched teams replay into the same broken endpoint and turn 40 failures into 400 failures.

Run the replay in slices. Start with 5 events in test mode if you can reproduce it. Then replay 10 live events, wait for the worker to drain, and check counts in your database. For a big backlog, I prefer batches of 100 to 250 events, depending on how heavy the downstream work is. A workflow that calls Customer.io, HubSpot, Slack, and your own database is not the same as one that flips a local flag.

Compare Stripe to your database after each batch. For invoice.paid, count distinct invoice IDs processed. For checkout.session.completed, count distinct session IDs and customer IDs. In a clean replay, unique business objects move once even if event deliveries move many times.

Marketing Systems Make This Trickier

Stripe is usually the reliable part. The shaky part is everything attached to it.

A paid acquisition stack might include Stripe Checkout, Segment, Google Ads enhanced conversions, Meta’s Conversions API, HubSpot, Customer.io, and a PostHog or Amplitude workspace. Organic might add Rewardful, FirstPromoter, PartnerStack, Webflow forms, Typeform, or a Zapier bridge someone set up during a launch week.

Each tool has its own duplicate behavior. Segment accepts a messageId for deduplication. Meta’s Conversions API uses event_id for deduping browser and server events. Customer.io can receive the same event twice unless you design your event naming and attributes carefully. HubSpot notes and timeline events are easy to duplicate if you use a naive “create” call on every replay.

So I carry Stripe’s event identity downstream. When I send a lifecycle event, I include stripe_event_id, stripe_invoice_id, stripe_checkout_session_id, and replay_source. If I have to clean up later, those fields save hours.

For attribution, I do not fire a fresh ad conversion on replay unless the original conversion never made it out. That is a deliberate check, not a side effect. If Google Ads already received the purchase on August 13 at 16:22 UTC, replaying the webhook on August 14 should not create a second purchase conversion with a new timestamp. Your ROAS report will lie, and the campaign manager will optimize against fake signal.

Events I Treat With Extra Care

checkout.session.completed deserves respect because it often starts provisioning. Create the user once. Attach the Stripe customer once. Store the Checkout Session ID. If the user already exists, update missing fields and move on.

invoice.paid is the renewal workhorse. Use the invoice ID as the business anchor. Mark the invoice paid in your system, sync the subscription period, and grant access to the exact period Stripe reports.

payment_intent.succeeded is useful for one-time payments, but it can overlap with Checkout and invoice events. Pick one event as the source of truth for fulfillment. In most subscription apps, I prefer checkout.session.completed for initial account setup and invoice.paid for paid access.

charge.refunded and customer.subscription.deleted need their own idempotency too. Replays can remove access twice, issue duplicate internal credits, or send repeated cancellation emails. The money already moved in Stripe. Your app should mirror the state once.

A Simple Test Before You Need It

You can test the whole thing with the Stripe CLI. Install a current Stripe CLI, run stripe listen --forward-to localhost:3000/api/stripe/webhook, complete a Checkout Session in test mode, then use stripe events resend <event_id>.

The expected result is dull: one row in stripe_webhook_events, one processed business action, one email, one analytics event, and multiple delivery attempts safely ignored. I also like a unit test that calls the webhook handler twice with the same signed payload. The second call should return 200 without enqueueing work.

For teams running Next.js 14 on Vercel, I pay close attention to raw body handling because it is easy to break signature verification when middleware parses the request too early. For Rails 7.1, the common footgun is doing too much work inline in the controller. For Laravel 11, I want the webhook route outside CSRF protection but still behind Stripe signature verification.

Different stack, same rule: receive, store, dedupe, process.

The Operator’s Version

If you own the revenue number but not the code, ask your developer three concrete questions before the next launch.

Do we store every Stripe event.id with a unique constraint? Do our fulfillment actions key off Stripe business objects like invoice ID or Checkout Session ID? Can we replay 100 failed webhooks without sending duplicate emails, duplicate ad conversions, or duplicate account credits?

Those questions cut through a lot of vague reassurance.

The best answer is a staging demo. Have someone trigger a test checkout, replay the same event three times, and show the database rows. You should see one customer entitlement, one invoice record, and one outbound lifecycle event. If the demo produces three welcome emails, fix it before buying another $5,000 newsletter placement.

Stripe webhook replay is not scary when the system is built for repetition. Stripe will retry. Humans will resend. Networks will flake during the exact 20-minute window when your launch traffic spikes. Build the handler as if every event can arrive twice, because eventually one will.