Four ways a webhook can mislead you
A webhook is a POST request to a public URL. Anyone can send one, and Stripe itself makes no promise about how many times or in what order. Its webhook docs say so plainly: an endpoint may receive the same event more than once, and events are not guaranteed to arrive in the order they were created. In live mode, Stripe keeps retrying a failed delivery for up to three days.
| Problem | What we do |
|---|---|
| Someone forges an event | Verify Stripe's signature on the raw body, with a five-minute window |
| The same event arrives twice | Store it under its event id, once; the second copy changes nothing |
| Events arrive out of order | Each record keeps the time of the last event applied; older ones lose |
| An event never arrives | A backfill lists recent events from Stripe's API and stores the missing ones |
Underneath all four sits one rule: money is received when Stripe says so, never because our own code assumed it.
Where this code runs today
This billing code is written and tested, and it is off. Clients of our services pay invoices sent from Mercury by bank transfer. The Stripe side waits for our products' subscriptions, and turns on only when its keys are set.
Check the signature on the raw body
Stripe signs every delivery with an HMAC (SHA-256) over a timestamp and the request body, and sends it in the Stripe-Signature header. Two details decide whether the check means anything.
- Verify the raw body. Stripe's docs warn that any change to the body, like a framework parsing and re-serializing the JSON, makes verification fail. We read the bytes first and parse them only after the check.
- Check the timestamp too. A valid signature on an old request is a replay. Stripe's libraries default to a five-minute tolerance, but the lower-level
verify_headerfunction in Python skips the timestamp check unless you pass one. We pass it explicitly.
payload = (await request.body()).decode()
try:
# Checks the HMAC over the raw body and rejects timestamps older than five minutes (replays).
stripe.WebhookSignature.verify_header(
payload,
stripe_signature or "",
settings.stripe_webhook_secret,
# verify_header skips the timestamp check unless given a tolerance.
tolerance=stripe.Webhook.DEFAULT_TOLERANCE,
)
except stripe.SignatureVerificationError:
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="invalid_signature") from NoneWithout a webhook secret configured, the route answers 503 stripe_not_configured instead of accepting anything. This is the one API route that the Next.js server doesn't call: Stripe calls it directly, so its signature is the only authentication it has.
Store each event once, then answer
Stripe's advice is to return a 2xx quickly, before any logic that might time out, and to process events from a queue. So the route does almost nothing: it stores the event under its Stripe id as the document's _id, queues a job to process it, and replies. Both writes happen in one MongoDB transaction, so there is never a stored event without its job, or a job without its event.
async def accept(db, event, *, now, source="webhook") -> bool:
"""Store the event and queue its projection, once. False if we already had it."""
async def work(session) -> bool:
if not await store.record_event(db, event, now=now, source=source, session=session):
return False
await queue.enqueue(db, JOB, {"event_id": str(event["id"])}, now=now, session=session)
return True
try:
return await in_transaction(db, work)
except DuplicateKeyError: # the same event arrived twice at once; the other one stored it
return False
A repeated delivery gets a 200 with duplicate: true, so Stripe stops retrying it. Two copies arriving at the same moment both try to insert the same _id; the database lets one win, and the other is treated as a duplicate. If storing fails for any other reason, the route returns a 500, and Stripe delivers the event again later. That's the retry we want.
Project state, and let older events lose
The worker turns events into three collections the admin reads: customers, subscriptions and invoices. Each document is keyed by its Stripe id with a unique index, so a replayed event overwrites the same record instead of adding one. Each also remembers the created time of the last event applied to it, and the update only matches when the incoming event is as new or newer.
newer_or_new = {
"$or": [{"lastEventCreated": {"$lte": event_created}}, {"lastEventCreated": {"$exists": False}}]
}
try:
await db[collection].update_one(
{key: stripe_id, **newer_or_new},
{"$set": {**fields, "lastEventCreated": event_created, "updatedAt": now}},
upsert=True,
)
except DuplicateKeyError:
# The document exists with a newer event, so the filter missed and the upsert collided: stale.
return FalseOne honest limit. Stripe's docs note that created is in whole seconds, so two events can share it, and they advise against using it to order events or to detect duplicates. We detect duplicates by event id only. We use created just to skip snapshots that are clearly older; on a tie, the event processed last writes. For invoices sent by email and paid days later, a tie between two states of the same invoice is unlikely, but it isn't impossible, which is one more reason the backfill exists.
The shapes change too. In API version 2025-03-31.basil, a subscription's current_period_end moved to its items, and an invoice's subscription moved under parent. Our readers accept both shapes, because an event keeps the API version it was created with.
Backfill what the webhook missed
Retries cover a server that was down for an hour. They don't cover an endpoint that was misconfigured for a week, or a delivery Stripe gave up on. So the worker also asks Stripe directly: on its maintenance cycle, every six hours by default, it lists recent events from Stripe's events API, which goes back up to 30 days.
- the oldest signature timestamp we accept
- 5 min
- how far back Stripe lists events
- 30 days
- of overlap each backfill reads again
- 1 hour
- Each run starts an hour before the newest event the last complete run saw, because Stripe can list an event late.
- It skips event types the webhook doesn't subscribe to, and sends the rest through the same
accept()as the webhook, markedsource: "backfill". What we already had is a no-op. - It reads at most 50 pages of 100 events. If it stops early or Stripe fails, the cursor doesn't move, so the next run reads the same window again instead of skipping it.
- A projection that fails is retried by the job queue with a growing wait (10 seconds, then 20, 40 and so on, capped at an hour), five attempts by default. After that it shows as failed in the admin's webhook log, with a button to retry it.
Never mark money as received from your side
Money is received when Stripe says so, never because our own code assumed it.
When the worker sends an invoice for a project milestone, it creates the customer, the invoice and its item through Stripe's API, each with an idempotency key built from the milestone and the send attempt. Stripe's idempotency docs explain why: a retried request with the same key returns the saved result of the first one, for at least 24 hours, instead of doing the work twice. A retried job never sends a second invoice. Stripe also advises against personal data in keys, so the customer's key carries a hash of the billing email, not the email.
Then the code stops. There is no "mark as paid" button. A milestone shows as paid only when the invoice record that the webhook projected says paid, and void only when Stripe says so. Today, with services invoiced from Mercury, the admin doesn't mark milestones paid by hand either: that waits for an integration with Mercury's API, which will report what was paid, the way Stripe's webhook would.
A checklist for your own webhook
- 01Verify the signature on the raw body, and make sure the timestamp is checked too.
- 02Store each event once, keyed by its event id, and queue the work in the same transaction.
- 03Answer fast; do the processing in a worker that retries.
- 04Keep the latest state per object, and don't let an older event overwrite a newer one.
- 05List recent events from the provider's API on a schedule, and replay what's missing through the same path.
- 06Let the provider tell you when money arrived. Your code sends invoices; it never decides they were paid.
This is how we build billing into the web platforms we make for clients. If you have one in mind, see what fits or send us a brief.


