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.