If billing is your second-most-critical system after auth, then your billing webhook handler is the moment those two systems shake hands. We treat it the same way you would treat a payments-bus consumer: idempotent, replayable, and impossible to drop on the floor.
Here is what that looks like inside WRNexus.
Signature verification first, business logic second
The very first thing the handler does is verify the Stripe-Signature
header against the raw request body and our endpoint secret. This is
non-negotiable — without it, anyone who can guess your webhook URL can
forge events.
sig = request.headers.get("Stripe-Signature", "")
event = stripe.Webhook.construct_event(
payload=await request.body(),
sig_header=sig,
secret=settings.STRIPE_WEBHOOK_SECRET,
)
If verification fails we return 400 Bad Request immediately — and Stripe
will helpfully retry, which means a misconfigured secret turns into a noisy
alert in our dashboards instead of silent data loss.
Every event has an idempotency key
Stripe occasionally redelivers events, sometimes minutes later, sometimes
hours. We store every event.id in a stripe_event table with the raw
payload, the verified-at timestamp, and a processed_at column. The
handler is wrapped in a transaction that:
- Inserts the row with
ON CONFLICT (event_id) DO NOTHING. - If no row was inserted (i.e. we have seen this event before) — return
200 OKimmediately, do not re-run the business logic. - Otherwise, run the handler, set
processed_at = now(), commit.
The result: if Stripe retries the same event 17 times, we run the business logic exactly once and acknowledge each retry in under 5 ms.
Replays are a feature, not a bug
When a handler crashes mid-way through, we roll the transaction back,
processed_at stays NULL, and the row sticks around with the raw payload.
The admin console has a “replay unprocessed events” button that picks up
exactly where we left off, in original order. We use it about once a
quarter — usually after a deploy that introduced a bug in a downstream
handler.
The handlers themselves stay small
The hard work is in the plumbing. The actual handlers are tiny:
def handle_invoice_paid(event):
invoice = event.data.object
customer_id = invoice["customer"]
workspace = workspace_by_stripe_customer(customer_id)
workspace.mark_invoice_paid(invoice["id"], invoice["amount_paid"])
audit("billing.invoice.paid", workspace_id=workspace.id, metadata={"invoice": invoice["id"]})
Notice what is missing: no signature verification (already done), no idempotency check (already done), no error swallowing (we let exceptions bubble so the transaction rolls back). That’s the whole point. The boilerplate runs once, in one place, and every business handler stays focused on the domain.
Takeaways
If you are building your own webhook surface — billing or otherwise — the two things to get right are signature verification and idempotency. Get those right and the rest of your handler can stay refreshingly boring.