A customer paying is not the same as an order fulfilled. Browsers close, connections drop, and the success redirect your customer sees is a cosmetic detail — not proof of payment. The only signal you can trust is the webhook Stripe sends to your server.
Never trust the redirect
Treat success_url as decoration. The source of truth is the checkout.session.completed event, delivered server-to-server and signed. Build your fulfillment around that event and the user experience degrades gracefully instead of corrupting your orders.
@app.post("/webhooks/stripe")
async def stripe_webhook(request: Request):
payload = await request.body()
sig = request.headers.get("stripe-signature", "")
event = stripe.Webhook.construct_event(
payload, sig, WEBHOOK_SECRET
)
if event["type"] == "checkout.session.completed":
fulfill(event["data"]["object"])
return {"received": True}Verify the signature, always
Stripe signs every webhook with HMAC-SHA256 and a timestamp. Verifying the signature proves the payload came from Stripe, and the timestamp window (five minutes by default) blocks replay attacks. Skipping this check means anyone who finds your endpoint can mark orders as paid.
Idempotent fulfillment
Stripe retries webhooks, so your handler will see the same event more than once. Store the event id and skip work you have already done — send the delivery email once, increment the sales counter once, license the key once.
async def fulfill(session):
if await db.events.find_one({"_id": session["id"]}):
return # already processed
await db.events.insert_one({"_id": session["id"]})
await send_delivery_email(session["customer_email"])Get these three details right — server-side events, signature verification, idempotency — and your store survives the messy reality of production traffic. We sell exactly this wiring as a ready-made asset because every indie seller re-derives it the hard way.