[{"data":1,"prerenderedAt":16},["ShallowReactive",2],{"$fojFTUasmi5afRrTLLdBsM1D-Ag_ECTgIAVYwJZ94lhQ":3},{"slug":4,"title":5,"description":6,"date":7,"tags":8,"draft":13,"readingMinutes":14,"html":15},"stripe-webhooks","Stripe Webhooks: The Parts That Break in Production","Stripe webhooks in October 2026: raw bodies, signature checks, retries, duplicates and out-of-order events, and the redirect that silently fails every delivery.","2026-10-16",[9,10,11,12],"stripe","webhooks","payments","node",false,5,"\u003Cp>Stripe webhooks are where a payment integration either becomes boring or becomes a support queue. The checkout page is the easy part. Whether a paid customer actually gets access, whether a cancelled subscription actually ends, and whether a failed card actually locks anything, all of that happens in a POST request Stripe sends to your server, when Stripe feels like it, possibly twice, possibly in the wrong order.\u003C/p>\n\u003Cp>I&#39;ve wired payment providers into a lot of apps, and Stripe&#39;s webhook docs are honestly good. The trouble is that the important sentences are scattered across several pages. These are the ones I&#39;d tape to the monitor. Everything below is checked against \u003Ca href=\"https://docs.stripe.com/webhooks\">Stripe&#39;s webhook docs\u003C/a> on October 16, 2026.\u003C/p>\n\u003Ch2>The dashboard moved, and there&#39;s a new payload type\u003C/h2>\n\u003Cp>If you last set up a webhook a couple of years ago, two things look different. Endpoints are now created as &quot;event destinations&quot; in \u003Cstrong>Workbench\u003C/strong>, which replaced the old Developers Dashboard, and you can register up to 16 of them. And you pick a payload format: \u003Cstrong>snapshot\u003C/strong> (the full object, the classic \u003Ccode>event.data.object\u003C/code>) or \u003Cstrong>thin\u003C/strong> (a small notification you use to fetch the current object). Stripe recommends thin for new integrations, and it needs its own endpoint if you also listen to snapshot events.\u003C/p>\n\u003Cp>I like thin events for one reason: you always fetch the latest object, which quietly fixes half the ordering bugs below. If you&#39;re maintaining an old integration that reads \u003Ccode>data.object\u003C/code> everywhere, there&#39;s no rush to migrate. Snapshot events aren&#39;t going anywhere.\u003C/p>\n\u003Ch2>Verify the signature against the raw body\u003C/h2>\n\u003Cp>Every delivery carries a \u003Ccode>Stripe-Signature\u003C/code> header, an HMAC-SHA256 of the timestamp plus the exact bytes of the body, signed with your endpoint&#39;s \u003Ccode>whsec_\u003C/code> secret. The official libraries check it for you. The catch is the word &quot;exact&quot;. If anything parses the JSON before your handler sees it, the bytes change and verification fails with an error that tells you nothing useful.\u003C/p>\n\u003Cp>In Express, that means the webhook route gets a raw body parser and must sit before any global \u003Ccode>express.json()\u003C/code>:\u003C/p>\n\u003Cpre>\u003Ccode class=\"language-js\">app.post(&#39;/stripe/webhook&#39;, express.raw({ type: &#39;application/json&#39; }), (req, res) =&gt; {\n  let event;\n  try {\n    event = stripe.webhooks.constructEvent(req.body, req.headers[&#39;stripe-signature&#39;], process.env.STRIPE_WEBHOOK_SECRET);\n  } catch (err) {\n    return res.status(400).send(`Bad signature: ${err.message}`);\n  }\n  queue.add(&#39;stripe-event&#39;, { id: event.id, type: event.type });\n  res.sendStatus(200);\n});\n\u003C/code>\u003C/pre>\n\u003Cp>In a Next.js route handler, read \u003Ccode>await req.text()\u003C/code>, not \u003Ccode>req.json()\u003C/code>. In Laravel, take \u003Ccode>$request-&gt;getContent()\u003C/code> and make sure the route is outside the CSRF middleware; Stripe&#39;s docs warn about this for frameworks that check CSRF tokens on every POST.\u003C/p>\n\u003Cp>Two smaller things. The test-mode and live-mode secrets for the same URL are different, and so is the secret \u003Ccode>stripe listen\u003C/code> prints locally, so &quot;it works on my machine&quot; with the wrong secret in production is common. And the libraries reject a timestamp more than 5 minutes off by default. Keep the server clock on NTP, and don&#39;t &quot;fix&quot; a failing check by setting the tolerance to 0: Stripe&#39;s docs point out that 0 turns the replay protection off entirely.\u003C/p>\n\u003Cimg src=\"/blog/stripe-webhooks/stripe-webhooks-delivery-log.webp\" alt=\"A terminal running stripe listen, annotated: invoice.paid arrives before customer.subscription.created, one event is delivered twice with the same id, and a delivery fails because the endpoint answered with a 301 redirect\" width=\"1600\" height=\"900\" loading=\"lazy\">\u003Ch2>Answer fast, do the work later\u003C/h2>\n\u003Cp>Stripe wants a 2xx quickly, before your slow logic runs, and a timeout counts as a failure. So the handler above verifies, hands the event ID to a queue (BullMQ on Redis in a Node app, Laravel&#39;s queue in a PHP one) and returns. The worker does the database writes, the emails, the provisioning.\u003C/p>\n\u003Cp>It matters most on the 1st of the month. Every subscription renews at once, Stripe sends a wave of \u003Ccode>invoice.*\u003C/code> events, and a handler that sends a receipt email inline will start timing out right when the money is coming in.\u003C/p>\n\u003Ch2>Assume duplicates and assume the wrong order\u003C/h2>\n\u003Cp>Stripe says plainly that an endpoint &quot;might occasionally receive the same event more than once&quot; and that it &quot;doesn&#39;t guarantee the delivery of events in the order that they&#39;re generated&quot;. Creating a subscription can produce \u003Ccode>customer.subscription.created\u003C/code>, \u003Ccode>invoice.created\u003C/code>, \u003Ccode>invoice.paid\u003C/code> and \u003Ccode>charge.created\u003C/code>, in any order.\u003C/p>\n\u003Cp>So:\u003C/p>\n\u003Cul>\n\u003Cli>Keep a table of processed event IDs and skip anything already in it. A unique index on the ID and an insert at the start of the job is enough.\u003C/li>\n\u003Cli>Don&#39;t use the event&#39;s \u003Ccode>created\u003C/code> time to sort or dedupe. It&#39;s in whole seconds, and several events share the same second.\u003C/li>\n\u003Cli>Don&#39;t trust the payload to be the current state. When \u003Ccode>invoice.paid\u003C/code> arrives before you&#39;ve ever seen the subscription, fetch the subscription from the API and work from that. (Thin events do this by design.)\u003C/li>\n\u003C/ul>\n\u003Cp>A very common version of this bug is a handler that sets \u003Ccode>status = &#39;active&#39;\u003C/code> on \u003Ccode>invoice.paid\u003C/code> and \u003Ccode>status = &#39;past_due&#39;\u003C/code> on \u003Ccode>invoice.payment_failed\u003C/code>. Deliver those two in the wrong order after a retry and the customer is locked out of a subscription they paid for. Read the subscription&#39;s real status instead of inferring it from which event arrived last.\u003C/p>\n\u003Ch2>The redirect that fails every delivery\u003C/h2>\n\u003Cp>This one is quiet. If your endpoint URL answers with any 3xx, Stripe counts it as a failure. It doesn&#39;t follow the redirect. A URL registered as \u003Ccode>http://\u003C/code>, a missing trailing slash your framework &quot;helpfully&quot; redirects, or a bare domain that 301s to \u003Ccode>www\u003C/code>, and every single event fails while your logs show nothing, because your app never ran. Register the exact final URL, then check the \u003Cstrong>Event deliveries\u003C/strong> tab for the endpoint in Workbench. It shows the status code of each attempt.\u003C/p>\n\u003Ch2>When it was down anyway\u003C/h2>\n\u003Cp>In live mode Stripe retries a failed delivery for up to three days with exponential backoff. In a sandbox it&#39;s only three retries over a few hours, which is why a weekend outage in test mode looks so different from one in production. After that you can resend by hand: from the dashboard for up to 15 days after the event, or with \u003Ccode>stripe events resend &lt;event_id&gt; --webhook-endpoint=&lt;endpoint_id&gt;\u003C/code> for up to 30 days. Because your handler dedupes by event ID, resending everything from the outage window is safe.\u003C/p>\n\u003Cp>For local testing, \u003Ccode>stripe listen --forward-to localhost:4242/webhook\u003C/code> plus \u003Ccode>stripe trigger invoice.paid\u003C/code> covers most of it. Note that \u003Ccode>--forward-to\u003C/code> only works in the CLI in your own terminal, not in the Workbench Shell.\u003C/p>\n\u003Ch2>If you&#39;d rather not\u003C/h2>\n\u003Cp>None of this is hard, but it&#39;s exactly the part of a payments integration that gets skipped when a deadline is close, and it&#39;s the part your customers notice. If your Stripe setup is already live and you&#39;re not sure the webhooks are doing what you think, I&#39;m happy to look. The \u003Ca href=\"https://calendly.com/director-norsky/meeting\">first call is free\u003C/a>.\u003C/p>\n",1792110146251]