Here's a webhook payload Stripe actually sent, trimmed and redacted but structurally untouched — a checkout.session.completed event, the one that fires when someone finishes paying on a Checkout page. Most builders glance at this once, grab data.object.customer and data.object.amount_total, and move on. That's usually fine for a demo. It's the reason apps double-fulfill orders, miss subscription renewals, and get their first angry support email about a refund that "didn't go through." Let's take the whole thing apart.
{
"id": "evt_1P8xQ2K7z3n9lWqA00Ff2gLm",
"object": "event",
"api_version": "2024-06-20",
"created": 1725456000,
"type": "checkout.session.completed",
"livemode": true,
"pending_webhooks": 1,
"request": { "id": "req_9F2mA1", "idempotency_key": null },
"data": {
"object": {
"id": "cs_live_a1B2c3D4",
"object": "checkout.session",
"customer": "cus_Q3fZ8xLmN2",
"customer_details": {
"email": "[email protected]",
"tax_ids": []
},
"payment_status": "paid",
"payment_intent": "pi_3P8xQ2K7z3n9lWqA1gH4iJ5k",
"subscription": "sub_1P8xQaK7z3n9lWqA",
"amount_total": 2900,
"currency": "usd",
"metadata": {
"app_user_id": "usr_38821",
"plan": "pro_monthly"
}
}
}
}
id and the idempotency problem you inherit for free
evt_1P8xQ2K7z3n9lWqA00Ff2gLm looks like noise until you realize it's the only thing standing between you and a duplicate order. Stripe delivers webhooks at least once, not exactly once. If your server accepts the request but times out before it can send the 200 back, Stripe assumes failure and resends the same event, same id, sometimes minutes later, sometimes the next day. If your handler grants a subscription every time it sees checkout.session.completed without checking whether it's already processed this exact id, you will eventually grant it twice. The fix is one line in your database: a unique constraint on a processed_webhook_events table keyed on this field, checked before you do anything else. It's the least glamorous line of code in the whole integration and the one that actually matters.
the signature header that isn't in the body at all
The payload above is what Stripe sends as the request body. What it doesn't show is the Stripe-Signature header riding alongside it — a timestamp plus an HMAC-SHA256 signature computed with your webhook signing secret. Skip verifying it and your webhook endpoint is a public POST route that anyone on the internet can hit with a hand-crafted "payment succeeded" JSON blob to unlock your paid tier for free. This isn't a theoretical attack; endpoint URLs leak in client-side JS, in logs, in Slack pastes, and scanners look for exactly this shape of route.
I've seen this exact bug in production twice — both times the fix was five minutes, both times it had been live for months before anyone noticed the free "pro" accounts in the database.
Verifying costs you three lines with Stripe's SDK (stripe.webhooks.constructEvent(body, sig, secret)) and it has to run on the raw, unparsed request body — if a framework's JSON middleware already parsed it into an object before your handler sees it, the signature check fails on a byte-for-byte mismatch that has nothing to do with a real attack. That's the most common "why does my webhook always return 400" bug reported in Stripe's own forums.
data.object.customer vs. data.object.customer_details
These look redundant and aren't. customer is the Stripe customer ID — stable, reusable, the thing you store as a foreign key. customer_details is a snapshot of what the buyer typed into the checkout form at that moment — email, tax ID, sometimes a name — and it can be present even when customer is null, which happens on one-time Checkout sessions where you didn't ask Stripe to create a Customer object. If your onboarding logic reads customer assuming it's always populated, guest checkouts silently break it.
metadata: the two fields you actually put there yourself
app_user_id and plan aren't Stripe fields — they're whatever you attached when you created the Checkout session. This is the single most important design decision in the whole integration and it's easy to skip because Stripe's quickstart doesn't dwell on it. Without your own user ID in metadata, the only way to connect this payment back to a row in your database is by matching on email, and emails change, get typo'd, or belong to someone paying on behalf of a teammate. Every webhook handler I've written badly, in hindsight, was one that tried to reconstruct identity from customer_details.email instead of trusting metadata it had set itself three steps earlier.
payment_status: paid isn't the only value
It's tempting to treat this event's mere existence as proof of payment. It isn't, always — payment_status can also be unpaid (a session completed but a delayed payment method like a bank debit hasn't cleared) or no_payment_required (a fully-discounted checkout, a free trial with no card charge yet). Fulfilling on checkout.session.completed without checking this field means shipping the product before the money's actually confirmed. For anything above a few dollars, wait for payment_status: "paid" or better, key fulfillment off invoice.paid / payment_intent.succeeded instead of the session event.
| Event type | Fires when | What to do with it |
|---|---|---|
checkout.session.completed | Buyer finishes the Checkout form | Log it, but confirm payment_status before fulfilling |
payment_intent.succeeded | Money actually clears | Safe point to fulfill a one-time purchase |
invoice.paid | A subscription invoice is paid (initial or renewal) | Extend access, reset usage counters |
customer.subscription.updated | Plan change, quantity change, cancel-at-period-end toggled | Sync entitlements, don't assume it means cancellation |
charge.refunded | You or the buyer's bank reverses a charge | Revoke access, this one gets forgotten most often |
the field that isn't in this payload: what happens next
Nothing in this JSON tells you that Stripe will retry a failed delivery on a backoff schedule for up to three days, or that after enough consecutive failures it disables the endpoint and emails you about it. That behavior lives in your Dashboard settings, not the payload, and it's the part most builders discover only after their endpoint has been silently dead for a week because a deploy changed the route path.
Point a monitoring check at your webhook endpoint's success rate the same day you wire it up, not after the first missed renewal. The payload teaches you what happened. It doesn't warn you when your handler quietly stops listening.



