WRNexus
Back to the blog
engineering 6 min read

Stripe webhooks, the boring way

How WRNexus processes Stripe webhooks idempotently, replays them safely, and never silently drops an invoice.paid event.

Rohan Verma

#engineering · #billing · #stripe

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:

  1. Inserts the row with ON CONFLICT (event_id) DO NOTHING.
  2. If no row was inserted (i.e. we have seen this event before) — return 200 OK immediately, do not re-run the business logic.
  3. 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.