A webhook looks like a function call and is not one. A function call happens once, in order, from a caller you trust. A webhook delivery gives you none of those three, and the bugs that follow are the expensive kind: a customer billed twice, an order fulfilled twice, a balance that drifts and nobody knows when it started.

The fix is not complicated, but it has to be designed in rather than added after the first incident.

The three things you do not get

Exactly-once delivery. Stripe’s own documentation is blunt about it: endpoints “might occasionally receive the same event more than once”. Retries run for up to three days with exponential backoff in live mode, and a handler that times out is a handler that gets retried.

Ordering. Creating a subscription generates customer.subscription.created, invoice.created, invoice.paid and charge.created, and they can arrive in any order. Worse, snapshot events record created only to the second, so two distinct events can carry the same timestamp.

Authenticity. The endpoint is a public URL that takes a POST. Without a signature check, anyone who guesses it can mark invoices paid.

1. Verify the signature, on the raw body

Signature verification has to happen before anything else, and it has to run against the exact bytes that arrived. The signature is an HMAC with SHA-256 over the timestamp, a full stop, and the raw request body. Re-serialising the parsed JSON changes the bytes and the check fails.

from fastapi import APIRouter, Header, HTTPException, Request
import stripe

router = APIRouter()

@router.post("/webhooks/stripe")
async def stripe_webhook(
    request: Request,
    stripe_signature: str = Header(alias="Stripe-Signature"),
):
    payload = await request.body()  # raw bytes, never the parsed body
    try:
        event = stripe.Webhook.construct_event(
            payload, stripe_signature, WEBHOOK_SECRET
        )
    except stripe.SignatureVerificationError:
        raise HTTPException(status_code=400, detail="Bad signature")

    await queue.enqueue(handle_event, event.id, event.type)
    return {"received": True}

Three details the libraries handle for you, worth knowing anyway. Comparison is constant-time, because a byte-by-byte early return leaks the expected signature. Schemes other than v1 are ignored, which is what stops a downgrade attack. And there is a timestamp tolerance, five minutes by default, which is what makes a captured payload stop working. Setting that tolerance to zero does not tighten it, it disables the recency check entirely.

If your framework checks a CSRF token on every POST, this route needs an exemption. It is a machine-to-machine call authenticated by signature, not a browser form.

2. Return 2xx before doing the work

Acknowledge first, then process. A handler that charges off and updates three tables before replying is a handler that eventually exceeds the delivery timeout, which counts as a failure and earns a retry, which is where your duplicate came from in the first place.

Queue the work and return. The queue also absorbs the spike on the first of the month when every subscription renews at once.

3. Deduplicate on the event id

The event id is the only thing you should key on. Not the timestamp, which is not unique, and not your own idea of what the payload means.

create table processed_event (
  event_id   text primary key,
  event_type text not null,
  handled_at timestamptz not null default now()
);
from sqlalchemy.dialects.postgresql import insert

def claim(event_id: str, event_type: str) -> bool:
    """True if we just claimed this event, False if it was already handled."""
    result = db.execute(
        insert(ProcessedEvent)
        .values(event_id=event_id, event_type=event_type)
        .on_conflict_do_nothing(index_elements=["event_id"])
    )
    return result.rowcount == 1

The primary key is doing the real work. Two workers racing on the same delivery both try to insert, one wins, the other gets rowcount == 0 and stops. That is your idempotency gate, enforced by the database rather than by a check that happens to run before a write.

One wrinkle: occasionally two separate event objects are generated for the same underlying change. Those have different event ids, so the id alone will not catch them. Key on the id of the object in data.object together with event.type when that matters.

4. Make handlers order-independent

The reliable pattern is to treat an event as a signal that something changed, not as the change itself. On receipt, fetch the current state of the object from the API and reconcile. That handler gives the same answer whether it runs once or three times, and whether invoice.paid turns up before or after invoice.created.

Accumulating deltas from events does the opposite: it needs every event, once, in order, which is exactly what you do not have.

The short version

  • Verify the signature on the raw bytes, before anything else.
  • Return 2xx immediately and do the work on a queue.
  • Deduplicate on the event id, with a unique constraint rather than a lookup.
  • Never order by the event timestamp. It is not unique and not sequential.
  • Fetch current state instead of replaying deltas, and the order stops mattering.

Related

Next step

Working on something this touches?

If this post lines up with a problem you have, I would rather hear about the problem than the post. Tell me what you are building.

  • Replies: Within two working days
  • Based in: the United States
  • Open to: full time, contract, remote or hybrid

Pages

Projects

Writing

Actions