Payments & Money APIs · Webhooks, Orchestration & Recurring Payments
Payment Webhooks Done Right
Verify, dedupe, handle out-of-order events and re-fetch.
The PSP tells you what happened via webhooks: payment succeeded, failed, refunded, disputed, payout paid. Handle them like a pro: verify signatures on the raw body, dedupe by event ID, ack fast (2xx) and process via a queue, expect at-least-once and out-of-order delivery (e.g. charge.refunded before payment_intent.succeeded is processed), re-fetch the object from the PSP API for current state when order matters, and run a backfill job that lists recent events/objects to catch anything missed during outages.
Webhook handling
| Concern | Solution |
|---|---|
| Authenticity | Signature verification over raw body + timestamp tolerance |
| Duplicates | Processed-events table keyed by event ID |
| Ordering | Re-fetch object state; state-machine transitions ignore stale events |
| Slow processing | Ack quickly, process asynchronously |
| Missed events (outage) | Periodic reconciliation via list endpoints |
| Endpoint rotation | Multiple secrets during rotation |
@router.post("/webhooks/stripe")
async def stripe_webhook(request: Request):
raw = await request.body()
try:
event = stripe.Webhook.construct_event(raw, request.headers["stripe-signature"], ENDPOINT_SECRET)
except (ValueError, stripe.error.SignatureVerificationError):
raise HTTPException(400)
inserted = await db.execute(
"INSERT INTO processed_events(id, type) VALUES ($1, $2) ON CONFLICT DO NOTHING", event.id, event.type)
if inserted == "INSERT 0 1":
await queue.enqueue("handle_stripe_event", event.id) # worker re-fetches the object
return {"received": True}
export async function handleStripeEvent(eventId: string) {
const event = await stripe.events.retrieve(eventId);
if (event.type.startsWith("payment_intent.")) {
const pi = await stripe.paymentIntents.retrieve((event.data.object as Stripe.PaymentIntent).id); // current state
const orderId = pi.metadata.order_id;
if (pi.status === "succeeded") await transition(orderId, "paid", { eventId });
else if (pi.status === "requires_payment_method") await transition(orderId, "payment_failed", { eventId });
}
}
Common pitfalls
- Doing heavy work before returning 2xx (timeouts → retries → duplicates).
- Assuming event order.
- No backfill after webhook outages.
Key points
- Verify, dedupe, ack, process async
- Re-fetch for current state
- Backfill/reconcile periodically
Further reading: Stripe: Webhooks
Practice questions
Why re-fetch the payment object when processing a webhook?
Answer
Events can arrive late or out of order; fetching current state from the PSP ensures you act on the latest truth.
How do you catch payment events missed during a webhook outage?
Answer
Run a periodic job that lists recent events or objects from the PSP API and applies any not yet processed.
Open in the app to mark lessons complete, take notes and track your progress.