<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Vadym Kykalo</title>
    <description>The latest articles on DEV Community by Vadym Kykalo (@vadymkykalo).</description>
    <link>https://dev.to/vadymkykalo</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4132883%2F7f96add1-4a92-481b-8286-df5f8ffb94b9.png</url>
      <title>DEV Community: Vadym Kykalo</title>
      <link>https://dev.to/vadymkykalo</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/vadymkykalo"/>
    <language>en</language>
    <item>
      <title>What is a webhook? How webhooks work, and the six ways they break in production</title>
      <dc:creator>Vadym Kykalo</dc:creator>
      <pubDate>Sun, 20 Sep 2026 08:49:20 +0000</pubDate>
      <link>https://dev.to/vadymkykalo/what-is-a-webhook-how-webhooks-work-and-the-six-ways-they-break-in-production-2jfg</link>
      <guid>https://dev.to/vadymkykalo/what-is-a-webhook-how-webhooks-work-and-the-six-ways-they-break-in-production-2jfg</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;This is a cross-post. The &lt;a href="https://railhook.io/blog/what-is-a-webhook" rel="noopener noreferrer"&gt;original on the Railhook blog&lt;/a&gt; has a diagram for each part: the anatomy of a signed request, polling against push, the retry ladder, duplicates, out-of-order delivery and secret rotation.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A customer pays for order &lt;code&gt;ord_8412&lt;/code&gt;. The shop sends your backend a webhook, and your handler&lt;br&gt;
awards loyalty points, emails a receipt and calls the warehouse — which takes eleven seconds. The&lt;br&gt;
sender's timeout is ten, so it marks the delivery failed and sends it again. The customer gets two&lt;br&gt;
receipts and double points, and your logs show two perfectly healthy &lt;code&gt;200&lt;/code&gt;s.&lt;/p&gt;

&lt;p&gt;Nothing in that story is a bug in the usual sense. Every line of the handler did what it said. It&lt;br&gt;
is what happens when code written for a function call meets a protocol built on retries — and it&lt;br&gt;
is the first of six failures this guide walks through, after the basics every one of them depends&lt;br&gt;
on.&lt;/p&gt;
&lt;h2&gt;
  
  
  What is a webhook?
&lt;/h2&gt;

&lt;p&gt;A webhook is an HTTP &lt;code&gt;POST&lt;/code&gt; that one system sends to a URL another system registered, at the&lt;br&gt;
moment an event happens. The receiver registers once — "when an order is created, tell me at&lt;br&gt;
this URL" — and from then on the sender calls it every time. This is what one looks like, signed&lt;br&gt;
the way the open &lt;a href="https://github.com/standard-webhooks/standard-webhooks/blob/main/spec/standard-webhooks.md" rel="noopener noreferrer"&gt;Standard Webhooks&lt;/a&gt;&lt;br&gt;
specification describes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="nf"&gt;POST&lt;/span&gt; &lt;span class="nn"&gt;/webhooks/orders&lt;/span&gt; &lt;span class="k"&gt;HTTP&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="m"&gt;1.1&lt;/span&gt;
&lt;span class="na"&gt;Host&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;api.example-shop.com&lt;/span&gt;
&lt;span class="na"&gt;Content-Type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;application/json&lt;/span&gt;
&lt;span class="na"&gt;Content-Length&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;186&lt;/span&gt;
&lt;span class="na"&gt;webhook-id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;msg_2mQ8hZk3Xv9aR1cT&lt;/span&gt;
&lt;span class="na"&gt;webhook-timestamp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;1789983612&lt;/span&gt;
&lt;span class="na"&gt;webhook-signature&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;v1,pX/4CnjtyRIdKhtP4FEBLl0a8pgk0IErOOHRkgsduog=&lt;/span&gt;

&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"order.created"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"timestamp"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-09-21T09:40:12Z"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"data"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"order_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ord_8412"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"customer_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"cus_2291"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"total"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;42.50&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"currency"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"EUR"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The signature is real. It was made with the secret&lt;br&gt;
&lt;code&gt;whsec_DioEXswemwdu+ScHzpEImNzhtrASzXe0vxCXPBmwuTM=&lt;/code&gt;: paste the body, that secret and the three&lt;br&gt;
headers into the &lt;a href="https://railhook.io/tools/webhook-signature" rel="noopener noreferrer"&gt;signature verifier&lt;/a&gt; and it will say the signature is&lt;br&gt;
valid — and that the timestamp is far too old to accept. Both answers are correct, and the second&lt;br&gt;
one is a security feature.&lt;/p&gt;
&lt;h2&gt;
  
  
  Webhook vs API polling
&lt;/h2&gt;

&lt;p&gt;The alternative to being told is asking: calling the other system's API on a timer to see whether&lt;br&gt;
anything changed.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Polling an API&lt;/th&gt;
&lt;th&gt;Receiving a webhook&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Who starts the request&lt;/td&gt;
&lt;td&gt;You, on a timer&lt;/td&gt;
&lt;td&gt;The sender, when something happens&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Delay&lt;/td&gt;
&lt;td&gt;Up to one polling interval&lt;/td&gt;
&lt;td&gt;Usually seconds&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Requests when nothing happens&lt;/td&gt;
&lt;td&gt;One per interval, forever&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;What you must run&lt;/td&gt;
&lt;td&gt;A scheduled job&lt;/td&gt;
&lt;td&gt;A public HTTPS endpoint&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;If you are down&lt;/td&gt;
&lt;td&gt;You catch up on the next poll&lt;/td&gt;
&lt;td&gt;You depend on the sender's retries&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Shorten a polling interval and you pay in requests and rate limits; lengthen it and you pay in&lt;br&gt;
lag. A webhook costs one request per event. But read the last row twice: polling fails safe,&lt;br&gt;
because a missed poll is repeated by the next one, while a webhook fails however the &lt;em&gt;sender&lt;/em&gt;&lt;br&gt;
decides. That is why polling keeps a job in a good webhook integration — as the slow&lt;br&gt;
reconciliation that catches whatever the webhooks missed.&lt;/p&gt;
&lt;h2&gt;
  
  
  Sending webhooks: what the sender decides
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Event types.&lt;/strong&gt; Conventionally &lt;code&gt;resource.verb&lt;/code&gt; in the past tense: &lt;code&gt;order.created&lt;/code&gt;,&lt;br&gt;
&lt;code&gt;invoice.paid&lt;/code&gt;. The Standard Webhooks spec asks for a full-stop delimited &lt;code&gt;type&lt;/code&gt;, with a&lt;br&gt;
&lt;code&gt;timestamp&lt;/code&gt; and a &lt;code&gt;data&lt;/code&gt; object next to it. Treat types as a public API: renaming one breaks every&lt;br&gt;
receiver that routes on it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Payloads.&lt;/strong&gt; A full snapshot of the resource saves the receiver a round trip; a thin&lt;br&gt;
notification carrying only an id makes it fetch the current state, which is never stale.&lt;br&gt;
&lt;a href="https://docs.stripe.com/webhooks" rel="noopener noreferrer"&gt;Stripe&lt;/a&gt; offers both and calls them exactly that.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Subscriptions.&lt;/strong&gt; Which endpoint wants which event types. Sending everything to everyone just&lt;br&gt;
makes every receiver filter traffic it never asked for.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Signing.&lt;/strong&gt; Anyone can &lt;code&gt;POST&lt;/code&gt; to a public URL, so the receiver needs proof the request came from&lt;br&gt;
you, unaltered. That proof is an HMAC — a hash of the message keyed with a secret only the two of&lt;br&gt;
you hold. Standard Webhooks signs &lt;code&gt;webhook-id&lt;/code&gt;, &lt;code&gt;webhook-timestamp&lt;/code&gt; and the raw body joined by full&lt;br&gt;
stops, with HMAC-SHA256, and sends the base64 result after a &lt;code&gt;v1,&lt;/code&gt; prefix. The secret is 24 to 64&lt;br&gt;
random bytes, base64-encoded, with a &lt;code&gt;whsec_&lt;/code&gt; prefix.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A replay window.&lt;/strong&gt; The timestamp is signed so that a captured request cannot be sent again&lt;br&gt;
tomorrow: the receiver rejects anything older than a few minutes. The spec leaves the tolerance to&lt;br&gt;
you; Stripe's libraries default to five minutes, a sensible place to start.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Timeouts and status codes.&lt;/strong&gt; The spec recommends a request timeout "somewhere between 15 and&lt;br&gt;
30s". &lt;code&gt;2xx&lt;/code&gt; is success and anything else is retried — including redirects, which&lt;br&gt;
&lt;a href="https://docs.stripe.com/webhooks" rel="noopener noreferrer"&gt;Stripe&lt;/a&gt; also counts as failures. Two codes carry extra meaning:&lt;br&gt;
&lt;code&gt;410 Gone&lt;/code&gt; asks the sender to disable the endpoint, &lt;code&gt;429 Too Many Requests&lt;/code&gt; asks it to slow down&lt;br&gt;
(&lt;a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Status" rel="noopener noreferrer"&gt;MDN&lt;/a&gt; has the full list).&lt;/p&gt;
&lt;h2&gt;
  
  
  Receiving webhooks: four jobs, in order
&lt;/h2&gt;
&lt;h3&gt;
  
  
  Verify the signature on the raw bytes
&lt;/h3&gt;

&lt;p&gt;Verification means recomputing the HMAC and comparing. What breaks it most often is &lt;em&gt;what&lt;/em&gt; you&lt;br&gt;
compute it over: it must be the exact bytes that arrived.&lt;/p&gt;

&lt;p&gt;Look at the example body. It says &lt;code&gt;"total": 42.50&lt;/code&gt;. Parse it and serialise it back and you get&lt;br&gt;
&lt;code&gt;{"type":"order.created",…,"total":42.5,…}&lt;/code&gt; — no whitespace, no trailing zero. Same data, different&lt;br&gt;
bytes, a completely different HMAC. Many frameworks parse JSON before your handler runs, so the&lt;br&gt;
body you are handed is already the wrong one. Stripe puts it bluntly: "Any manipulation to the raw&lt;br&gt;
body of the request causes the verification to fail."&lt;/p&gt;

&lt;p&gt;In Node with Express, ask for the raw bytes on that one route:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;node:crypto&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;express&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;express&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;secret&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;Buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;WEBHOOK_SECRET&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/^whsec_/&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;base64&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;TOLERANCE_SECONDS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// rawBody is a Buffer: the bytes that arrived, before any JSON parser saw them.&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;verifyWebhook&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rawBody&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;webhook-id&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;timestamp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;webhook-timestamp&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;signatures&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;webhook-signature&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="sr"&gt;/^&lt;/span&gt;&lt;span class="se"&gt;\d&lt;/span&gt;&lt;span class="sr"&gt;+$/&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;timestamp&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;signatures&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="c1"&gt;// The replay window: a captured request is useless five minutes later.&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;TOLERANCE_SECONDS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;expected&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createHmac&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;sha256&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;secret&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rawBody&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="c1"&gt;// Space-separated: during a secret rotation there is one signature per secret.&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;signatures&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt; &lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;some&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;version&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;,&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;version&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;v1&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;received&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;Buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;base64&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;received&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;expected&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;timingSafeEqual&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;received&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;expected&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;app&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;express&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/webhooks/orders&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;express&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nf"&gt;verifyWebhook&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sendStatus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="c1"&gt;// …record the id, enqueue, answer (below)&lt;/span&gt;
  &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sendStatus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same in Python, where Flask's &lt;code&gt;request.get_data()&lt;/code&gt; returns the untouched bytes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;base64&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;hashlib&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;hmac&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;

&lt;span class="n"&gt;SECRET&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;base64&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;b64decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;WEBHOOK_SECRET&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;removeprefix&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;whsec_&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="n"&gt;TOLERANCE_SECONDS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;verify_webhook&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw_body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;msg_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;webhook-id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;timestamp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;webhook-timestamp&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;signatures&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;webhook-signature&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;msg_id&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;signatures&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isascii&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isdigit&lt;/span&gt;&lt;span class="p"&gt;()):&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;

    &lt;span class="c1"&gt;# The replay window: a captured request is useless five minutes later.
&lt;/span&gt;    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;time&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;TOLERANCE_SECONDS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;

    &lt;span class="n"&gt;signed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;msg_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;.&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;timestamp&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;raw_body&lt;/span&gt;
    &lt;span class="n"&gt;expected&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;base64&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;b64encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hmac&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;SECRET&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;signed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;hashlib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sha256&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;

    &lt;span class="c1"&gt;# Space-separated: during a secret rotation there is one signature per secret.
&lt;/span&gt;    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;entry&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;signatures&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
        &lt;span class="n"&gt;version&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;partition&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;,&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;version&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;v1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;hmac&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;compare_digest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;expected&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three details are deliberate. The comparison is constant-time (&lt;code&gt;timingSafeEqual&lt;/code&gt;,&lt;br&gt;
&lt;code&gt;compare_digest&lt;/code&gt;), as the spec requires: a plain &lt;code&gt;==&lt;/code&gt; returns sooner the earlier two strings&lt;br&gt;
differ, and leaks the answer a byte at a time. The secret is base64-decoded after &lt;code&gt;whsec_&lt;/code&gt; is&lt;br&gt;
stripped — using the string as-is is a classic reason a correct implementation never verifies&lt;br&gt;
anything. And the header may carry several signatures, which matters the day you rotate. If you&lt;br&gt;
would rather not own this code, the Standard Webhooks project publishes libraries that do exactly&lt;br&gt;
this for most languages.&lt;/p&gt;
&lt;h3&gt;
  
  
  Answer in milliseconds, work later
&lt;/h3&gt;

&lt;p&gt;The sender is holding a connection open and counting.&lt;br&gt;
&lt;a href="https://docs.github.com/en/webhooks/using-webhooks/best-practices-for-using-webhooks" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;&lt;br&gt;
gives you ten seconds, then "considers the delivery a failure". Stripe publishes no number; it&lt;br&gt;
says to return &lt;code&gt;2xx&lt;/code&gt; "before any complex logic that could cause a timeout", and to process events&lt;br&gt;
from an asynchronous queue.&lt;/p&gt;

&lt;p&gt;So the handler does the minimum: verify, record the event id, enqueue, return &lt;code&gt;200&lt;/code&gt;. The email, the&lt;br&gt;
warehouse call, the loyalty points — the eleven seconds from the opening — happen in a worker,&lt;br&gt;
where nobody is waiting and a failure is yours to retry.&lt;/p&gt;
&lt;h3&gt;
  
  
  Deduplicate on the event id
&lt;/h3&gt;

&lt;p&gt;A sender that retries will eventually deliver something twice. The spec says &lt;code&gt;webhook-id&lt;/code&gt;&lt;br&gt;
"remains the same no matter how many times a webhook that has failed is retried", and tells&lt;br&gt;
receivers to use it as an idempotency key. Other providers have their own: &lt;code&gt;X-GitHub-Delivery&lt;/code&gt;,&lt;br&gt;
&lt;code&gt;X-Shopify-Webhook-Id&lt;/code&gt;, the event's &lt;code&gt;id&lt;/code&gt; at Stripe. The robust version is a unique constraint and&lt;br&gt;
an insert that is allowed to do nothing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;accept&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;msg_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;raw_body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Stores the event once. Returns False if this id was already accepted.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;cursor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;INSERT INTO received_webhooks (msg_id, body) VALUES (%s, %s) &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ON CONFLICT (msg_id) DO NOTHING&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;msg_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;raw_body&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;cursor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;rowcount&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A duplicate still gets a &lt;code&gt;200&lt;/code&gt;. You have it already, and an error would only make the sender try&lt;br&gt;
again.&lt;/p&gt;
&lt;h3&gt;
  
  
  Never assume order
&lt;/h3&gt;

&lt;p&gt;Unless a sender explicitly promises ordering, assume there is none. Stripe says outright that it&lt;br&gt;
"doesn't guarantee the delivery of events in the order that they're generated". Failure four below&lt;br&gt;
is what to do about it.&lt;/p&gt;
&lt;h2&gt;
  
  
  Six ways webhooks break in production
&lt;/h2&gt;
&lt;h3&gt;
  
  
  1. Your endpoint is down: who retries, and for how long?
&lt;/h3&gt;

&lt;p&gt;A deploy, a crashed pod, an expired certificate. The sender gets a connection error or a &lt;code&gt;5xx&lt;/code&gt;,&lt;br&gt;
and what happens next is entirely its policy. Good senders retry with exponential back-off — each&lt;br&gt;
wait longer than the last, with random jitter so a backlog of retries does not land in the same&lt;br&gt;
second. The Standard Webhooks spec's example schedule starts immediately and runs for more than&lt;br&gt;
three days.&lt;/p&gt;

&lt;p&gt;Not every sender retries. GitHub "does not automatically redeliver failed webhook deliveries"&lt;br&gt;
(&lt;a href="https://docs.github.com/en/webhooks/using-webhooks/handling-failed-webhook-deliveries" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;);&lt;br&gt;
you redeliver by hand or through its API. Our&lt;br&gt;
&lt;a href="https://railhook.io/blog/stripe-github-shopify-when-your-endpoint-is-down" rel="noopener noreferrer"&gt;comparison of Stripe, GitHub and Shopify&lt;/a&gt;&lt;br&gt;
puts three real policies side by side.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway:&lt;/strong&gt; know each sender's retry schedule before the outage, and never return &lt;code&gt;2xx&lt;/code&gt; for an&lt;br&gt;
event you have not stored — a &lt;code&gt;200&lt;/code&gt; is a promise that you have it.&lt;/p&gt;
&lt;h3&gt;
  
  
  2. Your endpoint is slow: a timeout is a retry you asked for
&lt;/h3&gt;

&lt;p&gt;A timeout looks like downtime to the sender, with one nasty difference: your handler may have&lt;br&gt;
finished the work. That is the opening story. A slow endpoint converts directly into duplicate&lt;br&gt;
processing, and it gets slower under load — exactly when a burst of retries arrives.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway:&lt;/strong&gt; acknowledge inside the budget, whatever the work costs. If you do not know your&lt;br&gt;
handler's 99th-percentile latency, measure it before anything else.&lt;/p&gt;
&lt;h3&gt;
  
  
  3. The same event arrives twice
&lt;/h3&gt;

&lt;p&gt;Duplicates come from retries after a lost response, from redeliveries someone triggered by hand,&lt;br&gt;
and from the sender itself. Stripe's docs say so in one sentence: "Webhook endpoints might&lt;br&gt;
occasionally receive the same event more than once."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway:&lt;/strong&gt; check the id before any side effect, in the same transaction that records the event.&lt;br&gt;
A check in memory, or one after the email has gone, is not a check.&lt;/p&gt;
&lt;h3&gt;
  
  
  4. Events arrive out of order
&lt;/h3&gt;

&lt;p&gt;An &lt;code&gt;order.created&lt;/code&gt; whose first attempt failed can arrive after the &lt;code&gt;order.updated&lt;/code&gt; and&lt;br&gt;
&lt;code&gt;order.cancelled&lt;/code&gt; that followed it. Apply them in arrival order and a cancelled order comes back&lt;br&gt;
to life.&lt;/p&gt;

&lt;p&gt;Compare a version or an &lt;code&gt;updated_at&lt;/code&gt; on the resource and ignore anything older than what you&lt;br&gt;
hold — or treat the webhook as a nudge and fetch the current state from the sender's API. Be wary&lt;br&gt;
of the event timestamp as a tiebreaker: Stripe notes its events record &lt;code&gt;created&lt;/code&gt; in seconds, so&lt;br&gt;
distinct events can share one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway:&lt;/strong&gt; every handler must produce the right state whatever order its events arrive in.&lt;/p&gt;
&lt;h3&gt;
  
  
  5. Signatures suddenly stop verifying
&lt;/h3&gt;

&lt;p&gt;This arrives as a wall of &lt;code&gt;400&lt;/code&gt;s, and the cause is nearly always one of five:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The body was parsed and re-serialised before verification.&lt;/li&gt;
&lt;li&gt;The wrong secret: test instead of live, or another endpoint's.&lt;/li&gt;
&lt;li&gt;A &lt;code&gt;whsec_&lt;/code&gt; secret used as a literal string instead of decoded.&lt;/li&gt;
&lt;li&gt;A drifting server clock, so every timestamp looks stale.&lt;/li&gt;
&lt;li&gt;A secret rotated with no overlap.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The last one is an outage you schedule yourself. If the old secret dies the instant a new one is&lt;br&gt;
issued, every request between that moment and your deploy fails. Senders avoid it by signing with&lt;br&gt;
both secrets for a while: the spec describes exactly this, and Stripe keeps the previous secret&lt;br&gt;
valid for up to 24 hours, with one signature per active secret.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway:&lt;/strong&gt; accept any valid &lt;code&gt;v1&lt;/code&gt; in the header, deploy the new secret inside the overlap, and log&lt;br&gt;
&lt;em&gt;why&lt;/em&gt; verification failed — stale timestamp, no matching signature, missing header — never a bare&lt;br&gt;
&lt;code&gt;400&lt;/code&gt;.&lt;/p&gt;
&lt;h3&gt;
  
  
  6. Events stop, and nobody notices
&lt;/h3&gt;

&lt;p&gt;The quietest failure is the worst. The sender exhausts its retries and gives up. A subscription is&lt;br&gt;
disabled after repeated failures — Shopify removes it&lt;br&gt;
(&lt;a href="https://shopify.dev/docs/apps/build/webhooks/troubleshooting-webhooks" rel="noopener noreferrer"&gt;Shopify&lt;/a&gt;). Or your own&lt;br&gt;
handler catches an exception, logs it at &lt;code&gt;debug&lt;/code&gt; and returns &lt;code&gt;200&lt;/code&gt;. From then on nothing errors;&lt;br&gt;
events just stop, and the first symptom is a customer asking where their order went.&lt;/p&gt;

&lt;p&gt;The defence is a delivery log on whichever side you control:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Record&lt;/th&gt;
&lt;th&gt;Why you will want it&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Event id and type&lt;/td&gt;
&lt;td&gt;To find one event, and to prove it was a duplicate&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Every attempt: time, status, latency&lt;/td&gt;
&lt;td&gt;To tell "down" from "slow" from "rejected"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The response body, truncated&lt;/td&gt;
&lt;td&gt;Most failures explain themselves there&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Final outcome&lt;/td&gt;
&lt;td&gt;To count what was abandoned, not just what failed once&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Takeaway:&lt;/strong&gt; alert on the failure rate &lt;em&gt;and&lt;/em&gt; on silence. An endpoint that received a thousand&lt;br&gt;
events an hour yesterday and none today is not healthy just because nothing is failing.&lt;/p&gt;
&lt;h2&gt;
  
  
  Testing webhooks locally
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;See what arrives.&lt;/strong&gt; A request bin is a throwaway URL that records every request sent to it. Our&lt;br&gt;
free &lt;a href="https://railhook.io/tester" rel="noopener noreferrer"&gt;webhook tester&lt;/a&gt; is one, keeping the latest hundred requests for 24 hours: point a&lt;br&gt;
provider at it and read the real headers and body before you write a handler.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reach your laptop.&lt;/strong&gt; A tunnel gives &lt;code&gt;localhost&lt;/code&gt; a public HTTPS URL. Stripe's docs suggest ngrok,&lt;br&gt;
and its CLI forwards events with &lt;code&gt;stripe listen --forward-to localhost:4242/webhook&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Replay real payloads.&lt;/strong&gt; A captured body and its headers are a test fixture that is exactly what&lt;br&gt;
production sends:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST http://localhost:3000/webhooks/orders &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s1"&gt;'Content-Type: application/json'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s1"&gt;'webhook-id: msg_2mQ8hZk3Xv9aR1cT'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"webhook-timestamp: &lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%s&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"webhook-signature: v1,&lt;/span&gt;&lt;span class="nv"&gt;$SIGNATURE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--data-binary&lt;/span&gt; @order-created.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;--data-binary&lt;/code&gt; sends the file byte for byte; plain &lt;code&gt;-d&lt;/code&gt; strips newlines and changes the&lt;br&gt;
signature. A replay needs a fresh timestamp and so a fresh signature — the replay window doing its&lt;br&gt;
job. When a signature will not verify, the &lt;a href="https://railhook.io/tools/webhook-signature" rel="noopener noreferrer"&gt;signature verifier&lt;/a&gt; checks&lt;br&gt;
body, secret and header in your browser without sending them anywhere.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Break it on purpose.&lt;/strong&gt; Return &lt;code&gt;500&lt;/code&gt; and watch the retries arrive. Sleep past the timeout. Send&lt;br&gt;
the same payload twice. Send &lt;code&gt;order.updated&lt;/code&gt; before &lt;code&gt;order.created&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Build it yourself, or put a gateway in front?
&lt;/h2&gt;

&lt;p&gt;For one sender and one receiver, build it: verification, a table of seen ids and a queue are a&lt;br&gt;
day's work, and the code above is most of it.&lt;/p&gt;

&lt;p&gt;It gets harder as the edges multiply. Sending webhooks to your own customers means storing every&lt;br&gt;
event before the first attempt, a retry scheduler that survives restarts, isolation so one slow&lt;br&gt;
customer does not delay the rest, a delivery log they can read, replay and secret rotation.&lt;br&gt;
Receiving from several providers means several signing schemes and retry policies. None of it is&lt;br&gt;
exotic, but it is a system. A gateway is that system already built — at the price of one more&lt;br&gt;
component that must stay up and that holds your signing secrets.&lt;/p&gt;

&lt;h3&gt;
  
  
  How Railhook handles it
&lt;/h3&gt;

&lt;p&gt;Railhook is an open-source webhook gateway, MIT-licensed; these are its defaults. Outgoing&lt;br&gt;
webhooks are stored before the first attempt and attempted up to seven times, waiting one minute,&lt;br&gt;
five minutes, fifteen minutes, one hour, six hours and twenty-four hours — about thirty-one hours&lt;br&gt;
in all, each wait jittered between half and one and a half times its tier. Webhooks it relays&lt;br&gt;
onward for you get a shorter ladder of five attempts. By default every delivery carries the&lt;br&gt;
Standard Webhooks headers shown above, and after a secret rotation both secrets sign for a 24-hour&lt;br&gt;
grace window. Every attempt is logged with its status code and response, and any stored event can&lt;br&gt;
be replayed. &lt;a href="https://railhook.io/docs/" rel="noopener noreferrer"&gt;The docs&lt;/a&gt; have the details.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Provider behaviour quoted above was read from each provider's own documentation on the date at the&lt;br&gt;
top of this article. These policies change; follow the links before you build on a number.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;Originally published on the &lt;a href="https://railhook.io/blog/what-is-a-webhook" rel="noopener noreferrer"&gt;Railhook blog&lt;/a&gt;. Railhook is an open-source webhook gateway — send and receive, MIT, self-host with one command: &lt;a href="https://github.com/vadymkykalo/railhook" rel="noopener noreferrer"&gt;github.com/vadymkykalo/railhook&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>webhooks</category>
      <category>api</category>
      <category>webdev</category>
      <category>beginners</category>
    </item>
  </channel>
</rss>
