<?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: InstaWebhook</title>
    <description>The latest articles on DEV Community by InstaWebhook (@instawebhook).</description>
    <link>https://dev.to/instawebhook</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%2F4015117%2F816ab357-711b-419d-94c7-13745e03c38c.png</url>
      <title>DEV Community: InstaWebhook</title>
      <link>https://dev.to/instawebhook</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/instawebhook"/>
    <language>en</language>
    <item>
      <title>Routing Webhooks Through Cloudflare Workers KV and Durable Objects: Building a Globally Distributed Ingress Buffer</title>
      <dc:creator>InstaWebhook</dc:creator>
      <pubDate>Sat, 17 Oct 2026 02:07:00 +0000</pubDate>
      <link>https://dev.to/instawebhook/routing-webhooks-through-cloudflare-workers-kv-and-durable-objects-building-a-globally-distributed-1gje</link>
      <guid>https://dev.to/instawebhook/routing-webhooks-through-cloudflare-workers-kv-and-durable-objects-building-a-globally-distributed-1gje</guid>
      <description>&lt;p&gt;Inbound webhooks are deceptively hard to handle at scale. Providers such as Stripe, GitHub, and Shopify don’t know about your database connection pool, your autoscaling lag, or a brief microservice outage. They send events when things happen, and a busy period for one of your customers can turn into a burst of requests against your origin.&lt;/p&gt;

&lt;p&gt;If the origin sits directly behind a load balancer, one of those bursts can exhaust database connections and memory and cause timeouts. Some senders then retry, which adds more load while you are already struggling.&lt;/p&gt;

&lt;p&gt;One way to protect the origin is an &lt;strong&gt;ingress buffer&lt;/strong&gt; at the edge. With &lt;strong&gt;Cloudflare Workers&lt;/strong&gt;, &lt;strong&gt;Workers KV&lt;/strong&gt;, and &lt;strong&gt;Durable Objects&lt;/strong&gt;, you can verify signatures, deduplicate, rate-limit, and durably queue incoming webhooks before a single request reaches your infrastructure. Cloudflare runs Workers in more than 330 cities and says that puts code within roughly 50 ms of about 95% of the world’s Internet-connected population.&lt;/p&gt;

&lt;p&gt;One caveat up front: only the entrypoint Worker is “everywhere”. Each tenant’s Durable Object is a single globally unique instance that coordinates all requests sent to it. The buffer is therefore distributed across tenants, not replicated for any one tenant.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Webhook Ingestion Fails
&lt;/h2&gt;

&lt;p&gt;Each of these failure modes shapes the design below.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Retry behavior differs by provider.&lt;/strong&gt; Stripe retries failed live-mode deliveries with exponential backoff for up to three days and disables endpoints that keep failing. Shopify retries 8 times over the following 4 hours. GitHub does not automatically redeliver failed deliveries at all. A short outage can therefore produce a burst of retries from some senders and permanently lost events from others. You can’t assume one retry contract.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Senders give up quickly.&lt;/strong&gt; Shopify allows a one-second connection timeout and a five-second timeout for the whole request. If your origin does real work before responding, slow responses are treated as failures and trigger the retries you were trying to avoid.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Duplicates are normal.&lt;/strong&gt; Providers deliver at least once. Shopify says its app “might receive the same webhook more than once”, for example after a network timeout or a retry, and recommends deduplicating on the &lt;code&gt;X-Shopify-Webhook-Id&lt;/code&gt; header. Stripe events carry a stable event ID that stays the same across retries.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One tenant can starve the rest.&lt;/strong&gt; A misconfigured integration that fires thousands of events can consume shared processing capacity unless you isolate and limit it per tenant.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Acknowledging quickly after a durable write handles the first two, deduplication handles the third, and a per-tenant limiter handles the fourth.&lt;/p&gt;

&lt;h2&gt;
  
  
  Architecture
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[Webhook sender]
       │
       ▼
[Worker: validate tenant, verify HMAC]
       │            │
       │            └──► [Workers KV: cached tenant config, secrets, limits]
       ▼
[Durable Object (one per tenant, SQLite-backed)]
   ├─ dedupe by event ID
   ├─ per-tenant rate limit
   ├─ durable event queue
   └─ alarm → batched delivery to origin (or to Cloudflare Queues)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Worker (entrypoint):&lt;/strong&gt; Parses the path, loads tenant config, verifies the signature, and forwards verified events to the tenant’s Durable Object. Anycast routing and Cloudflare’s scheduling typically place it close to the sender.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Workers KV:&lt;/strong&gt; A read-optimized, eventually consistent cache for data that changes rarely, such as tenant config and rate limits. Cloudflare cites sub-5 ms hot read latency. Writes can take 60 seconds or more to be visible in other locations.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Durable Objects:&lt;/strong&gt; Provide per-tenant coordination with a private, transactional SQLite database. Because each object processes one tenant’s events, dedupe checks and rate-limit counters don’t need distributed locks.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Alarms:&lt;/strong&gt; Each object can schedule a single alarm, which lets it drain its buffer in batches without waiting for another incoming request.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Step 1: The Entrypoint Worker
&lt;/h2&gt;

&lt;p&gt;The Worker reads the raw bytes, verifies the HMAC over exactly those bytes, and only then touches the Durable Object. Several details differ from naive implementations:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Fail closed.&lt;/strong&gt; If there is no secret for the tenant, the request is rejected. A shared fallback secret would let anyone who knows it forge events for every tenant.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Constant-time comparison.&lt;/strong&gt; Workers provide &lt;code&gt;crypto.subtle.timingSafeEqual&lt;/code&gt;. Cloudflare’s documentation notes it is not constant-time with respect to length, which is harmless here because an HMAC-SHA256 digest is always 32 bytes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Meaningful dedupe keys.&lt;/strong&gt; If the sender provides no event ID, the code falls back to a hash of the body. A random UUID would defeat deduplication, because every retry would look new.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Minimal forwarding.&lt;/strong&gt; Only what the origin needs is passed on, not every header. Forwarding all headers would persist signatures and other sensitive values.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/index.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;TenantWebhookBuffer&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;./tenant-webhook-buffer&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;Env&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;TENANT_DO&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;DurableObjectNamespace&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;TenantWebhookBuffer&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;WEBHOOK_KV&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;KVNamespace&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;ORIGIN_BASE_URL&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// e.g. https://api.example.com&lt;/span&gt;
  &lt;span class="nl"&gt;ORIGIN_TOKEN&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;    &lt;span class="c1"&gt;// set with `wrangler secret put ORIGIN_TOKEN`&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;TenantConfig&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;active&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;rateLimit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// max accepted events per 10-second window&lt;/span&gt;
  &lt;span class="nl"&gt;secrets&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;[];&lt;/span&gt;   &lt;span class="c1"&gt;// current + previous secret during rotation&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;TENANT_ID&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sr"&gt;/^&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;A-Za-z0-9_-&lt;/span&gt;&lt;span class="se"&gt;]{1,64}&lt;/span&gt;&lt;span class="sr"&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;encoder&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;TextEncoder&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="na"&gt;request&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;env&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="na"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ExecutionContext&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Response&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="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;method&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;POST&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="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Method Not Allowed&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="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;405&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;Allow&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;POST&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="p"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;// Expected path: /webhook/&amp;lt;tenantId&amp;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;prefix&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;tenantId&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;pathname&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="s2"&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;prefix&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;webhook&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;tenantId&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;TENANT_ID&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;tenantId&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="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Not Found&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="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;404&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;config&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;getTenantConfig&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;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;tenantId&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;config&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;active&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="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Unknown tenant&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="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;404&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;// Verify against the exact bytes the sender signed.&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;bodyBytes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Uint8Array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;arrayBuffer&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;signature&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;request&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="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;X-Signature&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="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;valid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;for &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="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;secrets&lt;/span&gt;&lt;span class="p"&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="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;verifyHmacHex&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bodyBytes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;signature&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="nx"&gt;valid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&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;valid&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="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Invalid signature&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="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;401&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;eventId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;request&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="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;X-Event-ID&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="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;sha256Hex&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bodyBytes&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

    &lt;span class="k"&gt;try&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;stub&lt;/span&gt; &lt;span class="o"&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;TENANT_DO&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getByName&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tenantId&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;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;stub&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ingestWebhook&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="nx"&gt;eventId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="na"&gt;receivedAt&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="na"&gt;contentType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;request&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="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Content-Type&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;application/octet-stream&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="na"&gt;rawBody&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;TextDecoder&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bodyBytes&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="c1"&gt;// assumes UTF-8 payloads (JSON)&lt;/span&gt;
        &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rateLimit&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="nx"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;result&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="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;retryAfter&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Retry-After&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;retryAfter&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="kc"&gt;undefined&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="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="c1"&gt;// The event was NOT durably stored, so ask the sender to retry.&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Temporarily unavailable&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="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;503&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Retry-After&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="s2"&gt;30&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="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;verifyHmacHex&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="nb"&gt;Uint8Array&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;headerSig&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&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="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;boolean&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="nx"&gt;provided&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;hexToBytes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;headerSig&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;provided&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;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;subtle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;importKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;raw&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;encoder&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="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="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;HMAC&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;hash&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;SHA-256&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;sign&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="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="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Uint8Array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;subtle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sign&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;HMAC&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;key&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="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;provided&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="nx"&gt;subtle&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;provided&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="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;hexToBytes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;hex&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Uint8Array&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&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;hex&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="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="mi"&gt;0&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;[&lt;/span&gt;&lt;span class="sr"&gt;0-9a-fA-F&lt;/span&gt;&lt;span class="se"&gt;]&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;hex&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;null&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;out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Uint8Array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;hex&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="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;parseInt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;hex&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;16&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;out&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;sha256Hex&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Uint8Array&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&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="nx"&gt;digest&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;subtle&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;SHA-256&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;Array&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="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Uint8Array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;b&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;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;padStart&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;0&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;join&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This example assumes a generic sender that sends a hex HMAC-SHA256 of the body in &lt;code&gt;X-Signature&lt;/code&gt;. Real providers differ, so adapt the verification step:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;GitHub sends &lt;code&gt;sha256=&amp;lt;hex&amp;gt;&lt;/code&gt; in &lt;code&gt;X-Hub-Signature-256&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Shopify sends a base64-encoded digest in &lt;code&gt;X-Shopify-Hmac-SHA256&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Stripe signs a timestamp plus the payload, which also gives you replay protection.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For providers with a stable delivery ID, use it as the dedupe key: &lt;code&gt;X-Shopify-Webhook-Id&lt;/code&gt; for Shopify, &lt;code&gt;X-GitHub-Delivery&lt;/code&gt; for GitHub, and the event &lt;code&gt;id&lt;/code&gt; in the body for Stripe.&lt;/p&gt;

&lt;p&gt;The Worker calls the Durable Object through Workers RPC. Public methods on a class that extends &lt;code&gt;DurableObject&lt;/code&gt; are callable through a stub. Arguments and return values must be serializable, so this example returns a plain object and builds the &lt;code&gt;Response&lt;/code&gt; in the Worker.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Per-Tenant State in a Durable Object
&lt;/h2&gt;

&lt;p&gt;Each tenant’s Durable Object holds four things in its SQLite database:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;processed event IDs, for deduplication&lt;/li&gt;
&lt;li&gt;a short rate-limit window&lt;/li&gt;
&lt;li&gt;the pending event queue&lt;/li&gt;
&lt;li&gt;a small counter for delivery backoff&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;SQLite-backed Durable Objects are Cloudflare’s recommended storage backend for new classes, and the SQL API is synchronous. Writes made without an intervening &lt;code&gt;await&lt;/code&gt; are committed together, and an output gate holds the response until the writes are confirmed. The 202 therefore means the event has been durably stored, not just accepted into memory.&lt;/p&gt;

&lt;p&gt;Keeping deduplication and rate limiting in separate tables matters. Pruning old rate-limit rows from the same table that records processed IDs would erase your dedupe history after seconds.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/tenant-webhook-buffer.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;DurableObject&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;cloudflare:workers&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="kd"&gt;type&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="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;./index&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;WebhookEvent&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;eventId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;receivedAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;contentType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;rawBody&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;IngestResult&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="mi"&gt;202&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="mi"&gt;429&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;retryAfter&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;number&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;RATE_WINDOW_MS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="nx"&gt;_000&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;DEDUPE_RETENTION_MS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;24&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// longer than Stripe's 3-day retry window&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;BATCH_SIZE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;50&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;FLUSH_DELAY_MS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="nx"&gt;_000&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;MAX_BACKOFF_MS&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="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;TenantWebhookBuffer&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;DurableObject&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Env&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;constructor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="na"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;DurableObjectState&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;env&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="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;super&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ctx&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;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;blockConcurrencyWhile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &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="nx"&gt;sql&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;storage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sql&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="nx"&gt;sql&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`CREATE TABLE IF NOT EXISTS processed_events (
        event_id TEXT PRIMARY KEY, received_at INTEGER NOT NULL)`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="nx"&gt;sql&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`CREATE TABLE IF NOT EXISTS rate_window (received_at INTEGER NOT NULL)`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="nx"&gt;sql&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`CREATE INDEX IF NOT EXISTS idx_rate_window ON rate_window (received_at)`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="nx"&gt;sql&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`CREATE TABLE IF NOT EXISTS event_queue (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        event_id TEXT NOT NULL, payload TEXT NOT NULL, received_at INTEGER NOT NULL)`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="nx"&gt;sql&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`CREATE TABLE IF NOT EXISTS flush_state (
        id INTEGER PRIMARY KEY CHECK (id = 1), failures INTEGER NOT NULL DEFAULT 0)`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="nx"&gt;sql&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`INSERT OR IGNORE INTO flush_state (id, failures) VALUES (1, 0)`&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="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;ingestWebhook&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="na"&gt;event&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;WebhookEvent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;limit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;IngestResult&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="nx"&gt;sql&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;storage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sql&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;now&lt;/span&gt; &lt;span class="o"&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="c1"&gt;// 1. Deduplicate. Duplicates are acknowledged with 200 and cost almost nothing.&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;seen&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;sql&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;SELECT 1 FROM processed_events WHERE event_id = ?&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;eventId&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toArray&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;seen&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;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&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="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&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="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;deduplicated&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;eventId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;eventId&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="c1"&gt;// 2. Sliding-window rate limit over accepted events.&lt;/span&gt;
    &lt;span class="nx"&gt;sql&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;DELETE FROM rate_window WHERE received_at &amp;lt; ?&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;now&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;RATE_WINDOW_MS&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;count&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;sql&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;exec&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;count&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;SELECT COUNT(*) AS count FROM rate_window&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;one&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;count&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nx"&gt;limit&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="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;429&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;rate_limited&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="na"&gt;retryAfter&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;// 3. Record and enqueue atomically.&lt;/span&gt;
    &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;storage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;transactionSync&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="nx"&gt;sql&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;INSERT INTO processed_events (event_id, received_at) VALUES (?, ?)&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;eventId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;now&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="nx"&gt;sql&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;INSERT INTO rate_window (received_at) VALUES (?)&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;now&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="nx"&gt;sql&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;INSERT INTO event_queue (event_id, payload, received_at) VALUES (?, ?, ?)&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;eventId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="nx"&gt;now&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="c1"&gt;// Schedule a flush only if one isn't pending. Calling setAlarm() again would&lt;/span&gt;
    &lt;span class="c1"&gt;// overwrite the pending alarm and keep pushing it back under steady traffic.&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;storage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getAlarm&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;storage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setAlarm&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;now&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;FLUSH_DELAY_MS&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="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;202&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;buffered&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;eventId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;eventId&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="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;alarm&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;void&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="nx"&gt;sql&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;storage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sql&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="c1"&gt;// Housekeeping: expire old dedupe records.&lt;/span&gt;
    &lt;span class="nx"&gt;sql&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;DELETE FROM processed_events WHERE received_at &amp;lt; ?&lt;/span&gt;&lt;span class="dl"&gt;"&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="nx"&gt;DEDUPE_RETENTION_MS&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;rows&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;sql&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;exec&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;SELECT id, payload FROM event_queue ORDER BY id LIMIT ?&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;BATCH_SIZE&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toArray&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;rows&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="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;delivered&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;try&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;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&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="k"&gt;this&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;ORIGIN_BASE_URL&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/internal/webhooks/batch`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Content-Type&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="s2"&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="na"&gt;Authorization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Bearer &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="k"&gt;this&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;ORIGIN_TOKEN&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="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;events&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;r&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;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
        &lt;span class="na"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;AbortSignal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
      &lt;span class="p"&gt;});&lt;/span&gt;
      &lt;span class="nx"&gt;delivered&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;delivered&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&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;delivered&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;sql&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;DELETE FROM event_queue WHERE id &amp;lt;= ?&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;rows&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="mi"&gt;1&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="nx"&gt;sql&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;UPDATE flush_state SET failures = 0 WHERE id = 1&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="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;sql&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;exec&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;n&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;SELECT COUNT(*) AS n FROM event_queue&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;one&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;n&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;storage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setAlarm&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="nx"&gt;FLUSH_DELAY_MS&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// keep draining&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&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;failures&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;sql&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;exec&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;failures&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;SELECT failures FROM flush_state WHERE id = 1&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;one&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
      &lt;span class="nx"&gt;sql&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;UPDATE flush_state SET failures = ? WHERE id = 1&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;failures&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&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;delay&lt;/span&gt; &lt;span class="o"&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;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;FLUSH_DELAY_MS&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt; &lt;span class="nx"&gt;failures&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;MAX_BACKOFF_MS&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;storage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setAlarm&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="nx"&gt;delay&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few design points are worth explaining:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Alarm retries are limited.&lt;/strong&gt; Cloudflare retries a throwing &lt;code&gt;alarm()&lt;/code&gt; handler with exponential backoff starting at 2 seconds, up to 6 retries. After that the alarm is not retried. That is not enough to ride out a long origin outage, so the handler above catches errors and manages its own backoff, capped at five minutes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Delivery to the origin is at-least-once.&lt;/strong&gt; If the Durable Object is interrupted after the origin accepted a batch but before the rows are deleted, the batch is sent again. Each event carries its &lt;code&gt;eventId&lt;/code&gt;, so the origin should deduplicate on it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A permanently failing batch blocks the head of the queue.&lt;/strong&gt; In production, cap per-event attempts and move poison events to a dead-letter table.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The 429 is a safety valve, not a guarantee.&lt;/strong&gt; Providers that retry, such as Stripe and Shopify, will treat a non-2xx as a failure and try again later. Don’t rely on every sender honoring &lt;code&gt;Retry-After&lt;/code&gt;, and GitHub won’t redeliver automatically.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Step 3: Workers KV for Tenant Config
&lt;/h2&gt;

&lt;p&gt;KV suits data that is read often and changed rarely, such as tenant status, rate limits, and signing secrets. It is eventually consistent. A write is visible immediately in the location that made it, but can take 60 seconds or more to propagate elsewhere, and a missing key can be cached for up to that long too. KV also enforces a minimum TTL of 60 seconds and a maximum of one write per second to the same key.&lt;/p&gt;

&lt;p&gt;That has practical consequences:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Use short TTLs.&lt;/strong&gt; A 300-second &lt;code&gt;expirationTtl&lt;/code&gt; is a reasonable default for rules that change occasionally.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Accept old and new secrets during rotation.&lt;/strong&gt; The &lt;code&gt;secrets&lt;/code&gt; array in the config above does this, because a rotated secret can lag at some edge locations.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don’t write on every cache miss.&lt;/strong&gt; Concurrent misses for one key would hit the write limit, so the write happens in &lt;code&gt;ctx.waitUntil&lt;/code&gt; and failures are ignored.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don’t cache unknown tenants.&lt;/strong&gt; Otherwise anyone can fill your namespace with junk keys by inventing tenant IDs.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getTenantConfig&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;Env&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ExecutionContext&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;tenantId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;TenantConfig&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&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="nx"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`config:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;tenantId&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cached&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&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_KV&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kd"&gt;get&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;TenantConfig&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&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="s2"&gt;json&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;cacheTtl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;60&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;cached&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;cached&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="c1"&gt;// Cache miss: load from the system of record. Only known tenants are cached.&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&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;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ORIGIN_BASE_URL&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/internal/tenants/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;tenantId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/webhook-config`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;Authorization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Bearer &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;ORIGIN_TOKEN&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="na"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;AbortSignal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&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;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&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;null&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;fresh&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;TenantConfig&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;waitUntil&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_KV&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;put&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fresh&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;expirationTtl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;300&lt;/span&gt; &lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="k"&gt;catch&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="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;fresh&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;Treat KV as a cache of data you can reload from your system of record. Never hardcode secrets in source.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrangler Configuration
&lt;/h2&gt;

&lt;p&gt;Cloudflare now documents a declarative &lt;code&gt;exports&lt;/code&gt; field for Durable Object class lifecycle in new Workers. Older projects use the &lt;code&gt;migrations&lt;/code&gt; array with &lt;code&gt;new_sqlite_classes&lt;/code&gt;, and a Worker can use only one of the two. Cloudflare recommends the SQLite backend for all new namespaces.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;wrangler.jsonc&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;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"webhook-ingress"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"main"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"src/index.ts"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"compatibility_date"&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-10-01"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"vars"&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;"ORIGIN_BASE_URL"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://api.example.com"&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;"kv_namespaces"&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;"binding"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"WEBHOOK_KV"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"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;"&amp;lt;KV_NAMESPACE_ID&amp;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;"durable_objects"&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;"bindings"&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;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"TENANT_DO"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"class_name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"TenantWebhookBuffer"&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;span class="nl"&gt;"exports"&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;"TenantWebhookBuffer"&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;"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;"durable-object"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"storage"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"sqlite"&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;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;If you are on the legacy flow, replace &lt;code&gt;exports&lt;/code&gt; with &lt;code&gt;"migrations": [{ "tag": "v1", "new_sqlite_classes": ["TenantWebhookBuffer"] }]&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Failure Modes and Mitigations
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Component&lt;/th&gt;
&lt;th&gt;Consistency model&lt;/th&gt;
&lt;th&gt;Primary failure mode&lt;/th&gt;
&lt;th&gt;Mitigation&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Worker&lt;/td&gt;
&lt;td&gt;Stateless&lt;/td&gt;
&lt;td&gt;Uncaught exception or timeout&lt;/td&gt;
&lt;td&gt;Wrap the Durable Object call in &lt;code&gt;try/catch&lt;/code&gt; and return a deliberate 503 so retrying senders try again&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Workers KV&lt;/td&gt;
&lt;td&gt;Eventually consistent&lt;/td&gt;
&lt;td&gt;Stale config or secrets for 60 seconds or more&lt;/td&gt;
&lt;td&gt;Short TTLs, accept old and new secrets during rotation, don’t cache unknown tenants&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Durable Object&lt;/td&gt;
&lt;td&gt;Strongly consistent per object&lt;/td&gt;
&lt;td&gt;Per-object limits: 10 GB storage on Workers Paid (1 GB on Free), soft limit of about 1,000 requests per second&lt;/td&gt;
&lt;td&gt;Alert on backlog depth, split very large tenants across several objects, and shard dedupe by event ID&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Alarm&lt;/td&gt;
&lt;td&gt;At-least-once&lt;/td&gt;
&lt;td&gt;Handler keeps failing after the built-in retries&lt;/td&gt;
&lt;td&gt;Custom backoff with a cap; origin deduplicates by &lt;code&gt;eventId&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Origin&lt;/td&gt;
&lt;td&gt;Yours&lt;/td&gt;
&lt;td&gt;Outage or slow responses&lt;/td&gt;
&lt;td&gt;The buffer absorbs it; drain in controlled batches&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Backpressure and Origin Outages
&lt;/h3&gt;

&lt;p&gt;If the origin goes down, each tenant’s Durable Object keeps accepting and storing events up to its storage limit, and the sender sees fast 202 responses. When the origin recovers, the alarm drains the backlog in batches of 50 with a pause between them, which avoids a thundering herd against your database.&lt;/p&gt;

&lt;p&gt;Buffered events are only safe while there is space. When a SQLite-backed Durable Object hits its storage cap, writes fail. In this design that surfaces as a 503, and providers that retry will redeliver later. Providers that don’t retry, GitHub among them, will have lost the event. Monitor queue depth and age so you can act well before the cap.&lt;/p&gt;

&lt;h2&gt;
  
  
  Alternative: Cloudflare Queues
&lt;/h2&gt;

&lt;p&gt;If you prefer to hand batches to a managed queue, Cloudflare Queues offers at-least-once delivery, consumer batches of up to 100 messages, and dead-letter queues. A single message is limited to 128 KB, so large webhook bodies need to go to object storage with only a reference queued. A Durable Object buffer, as built above, gives you per-tenant ordering, deduplication, and rate limiting in one place, and Queues can still sit behind it as the delivery mechanism.&lt;/p&gt;

&lt;h2&gt;
  
  
  Production Checklist
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Fail closed on authentication.&lt;/strong&gt; No fallback secrets, constant-time comparison, and provider-specific verification. Check timestamps where the provider signs them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deduplicate on the provider’s ID and retain it long enough.&lt;/strong&gt; Keep dedupe records for longer than the longest provider retry window. Stripe’s is three days, hence four above.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Make the origin idempotent.&lt;/strong&gt; Delivery from the buffer is at-least-once, so the origin must tolerate repeats.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Set limits deliberately.&lt;/strong&gt; Choose per-tenant rate limits from real traffic, and plan for tenants that approach a single object’s throughput ceiling.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Add a dead-letter path&lt;/strong&gt; for events the origin permanently rejects, so they don’t block a tenant’s queue.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Alert on backlog depth and oldest-event age&lt;/strong&gt; per tenant.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Test the failure paths&lt;/strong&gt;: origin down, KV stale, duplicate floods, and a rotated secret.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Further Reading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://developers.cloudflare.com/durable-objects/api/alarms/" rel="noopener noreferrer"&gt;Durable Objects alarms&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developers.cloudflare.com/durable-objects/platform/limits" rel="noopener noreferrer"&gt;Durable Objects limits&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developers.cloudflare.com/durable-objects/reference/durable-objects-migrations/" rel="noopener noreferrer"&gt;Durable Object class exports&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developers.cloudflare.com/workers/runtime-apis/rpc" rel="noopener noreferrer"&gt;Workers RPC&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developers.cloudflare.com/kv/api/write-key-value-pairs/" rel="noopener noreferrer"&gt;Workers KV: writing key-value pairs&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developers.cloudflare.com/workers/examples/protect-against-timing-attacks" rel="noopener noreferrer"&gt;Protecting against timing attacks with &lt;code&gt;timingSafeEqual&lt;/code&gt;&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://shopify.dev/docs/apps/build/webhooks/verify-deliveries" rel="noopener noreferrer"&gt;Shopify: verifying webhook deliveries&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.github.com/en/enterprise-server@3.17/webhooks/using-webhooks/handling-failed-webhook-deliveries" rel="noopener noreferrer"&gt;GitHub: handling failed webhook deliveries&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://hookdeck.com/webhooks/platforms/guide-to-stripe-webhooks-features-and-best-practices" rel="noopener noreferrer"&gt;Hookdeck: guide to Stripe webhooks&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://instawebhook.com/blog/routing-webhooks-through-cloudflare-workers-kv-and-durable-objects-building-a-gl" rel="noopener noreferrer"&gt;https://instawebhook.com/blog/routing-webhooks-through-cloudflare-workers-kv-and-durable-objects-building-a-gl&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>devops</category>
    </item>
    <item>
      <title>Zero-Downtime Webhook Schema Evolution: Managing Breaking Payloads Without Dropping Events</title>
      <dc:creator>InstaWebhook</dc:creator>
      <pubDate>Fri, 16 Oct 2026 02:04:12 +0000</pubDate>
      <link>https://dev.to/instawebhook/zero-downtime-webhook-schema-evolution-managing-breaking-payloads-without-dropping-events-5fce</link>
      <guid>https://dev.to/instawebhook/zero-downtime-webhook-schema-evolution-managing-breaking-payloads-without-dropping-events-5fce</guid>
      <description>&lt;p&gt;Webhooks carry a lot of B2B integration traffic: payment notifications, inventory syncs, CRM updates. If you receive them from third parties, you have probably seen the failure mode. A producer silently renames a JSON key, turns an integer ID into a prefixed string, or drops a field you depended on. Your parser throws, your endpoint starts returning 4xx or 5xx responses, and events pile up in a dead letter queue.&lt;/p&gt;

&lt;p&gt;Webhooks have what we can call the &lt;strong&gt;distributed publisher problem&lt;/strong&gt;. In an internal event stream you can often coordinate producer and consumer releases through shared CI, contract tests, or a schema registry you both control. With a third-party webhook you do not control the producer’s release cadence. Your ingestion pipeline is the part that has to absorb the change.&lt;/p&gt;

&lt;p&gt;This guide covers how to make that absorption safe: durable capture before parsing, tolerant readers, schema validation at the edge, and versioned mapping layers backed by contract tests. One caveat first. “Zero downtime” here means &lt;em&gt;you never lose an event because of a schema change&lt;/em&gt;. It does not mean the change costs nothing, and it does not remove the need to talk to your partners.&lt;/p&gt;




&lt;h2&gt;
  
  
  1. The Schema Drift Dilemma in Multi-Producer Integrations
&lt;/h2&gt;

&lt;p&gt;Schema drift is the gap between the data a producer sends and the structure your consumer expects. Internally you can close that gap with contract tests and coordinated rollouts. Across dozens of external producers you mostly cannot, so you have to design for it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Common Breaking Changes
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Type mutation.&lt;/strong&gt; A resource &lt;code&gt;id&lt;/code&gt; changes from a number (&lt;code&gt;123456&lt;/code&gt;) to a prefixed string (&lt;code&gt;"usr_9f83j2"&lt;/code&gt;), or the reverse. A strictly typed model throws on the first payload that doesn’t match. Providers do consider this kind of change safe. Stripe’s API upgrade documentation, for example, lists adding or removing fixed prefixes on object IDs among its backward-compatible changes. Treat IDs as opaque strings unless the provider promises otherwise.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Key renaming and restructuring.&lt;/strong&gt; A team refactors its domain model and renames &lt;code&gt;customer_email&lt;/code&gt; to &lt;code&gt;email_address&lt;/code&gt;, or promotes &lt;code&gt;billing.address.zip&lt;/code&gt; to a top-level &lt;code&gt;zip_code&lt;/code&gt;. Stripe’s own upgrade notes list this kind of change, such as renaming a &lt;code&gt;user_id&lt;/code&gt; property on Connect events to &lt;code&gt;account&lt;/code&gt;, as breaking enough to require a new API version.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Field removal.&lt;/strong&gt; A producer deletes a field it assumed nobody used, and your fraud check or reconciliation job depended on it.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Not every change is breaking. Stripe treats adding new API resources, new properties on existing responses, and &lt;strong&gt;new event types&lt;/strong&gt; as backward-compatible, and tells consumers to ignore unfamiliar fields and event types. A consumer that rejects unknown fields or event types will break on changes the producer considers routine.&lt;/p&gt;

&lt;h3&gt;
  
  
  Large Integers Are a Hidden Type Problem
&lt;/h3&gt;

&lt;p&gt;Numeric IDs also have a precision problem. JavaScript’s &lt;code&gt;Number&lt;/code&gt; type is an IEEE 754 double, so integers above 2^53 − 1 can’t be represented exactly. A consumer that parses a 64-bit ID with a naive JavaScript &lt;code&gt;JSON.parse&lt;/code&gt; can silently corrupt it. For this reason many APIs send IDs as strings, and you should too if you produce webhooks.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Cost of Rigid Ingestion
&lt;/h3&gt;

&lt;p&gt;Fail-fast validation protects your database, but in an event-driven system it has real costs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Dead letter queue overflow.&lt;/strong&gt; Valid business events pile up behind a minor schema change and need manual replay.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stalled workflows.&lt;/strong&gt; Payment fulfillment, inventory locking, and similar flows wait on events that were rejected rather than processed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Limited provider retries.&lt;/strong&gt; Providers retry for a bounded time. Stripe, for instance, retries live-mode deliveries for up to three days with exponential backoff, then disables an endpoint that keeps failing. If your fix takes longer than the provider’s retry window, you can lose events permanently.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last point shapes the rest of the design. The goal is to &lt;strong&gt;accept and persist first, interpret later&lt;/strong&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  2. Capture First: Acknowledge Fast, Parse Later
&lt;/h2&gt;

&lt;p&gt;Before any schema handling, decide what your HTTP handler does. The pattern most providers recommend is:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Read the &lt;strong&gt;raw request body&lt;/strong&gt; and verify the signature against it.&lt;/li&gt;
&lt;li&gt;Write the raw payload and headers to durable storage or a queue.&lt;/li&gt;
&lt;li&gt;Return a &lt;code&gt;2xx&lt;/code&gt; immediately.&lt;/li&gt;
&lt;li&gt;Parse, validate, and map asynchronously.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Stripe’s documentation tells receivers to defer complex logic and return a successful response quickly. If you parse synchronously and fail, you depend on the provider’s retry policy. If you persist first and fail later, you can fix the parser and replay from your own store at any time.&lt;/p&gt;

&lt;p&gt;Verify signatures against the raw bytes. Frameworks that auto-parse JSON and re-serialize it can change whitespace or key order and break the signature check. The open &lt;a href="https://github.com/standard-webhooks/standard-webhooks" rel="noopener noreferrer"&gt;Standard Webhooks&lt;/a&gt; specification, used by providers such as Svix, Resend, and Clerk, defines &lt;code&gt;webhook-id&lt;/code&gt;, &lt;code&gt;webhook-timestamp&lt;/code&gt;, and &lt;code&gt;webhook-signature&lt;/code&gt; headers. The signature is an HMAC over &lt;code&gt;&amp;lt;id&amp;gt;.&amp;lt;timestamp&amp;gt;.&amp;lt;body&amp;gt;&lt;/code&gt;, and receivers are advised to reject messages whose timestamp falls outside a tolerance of about five minutes. The &lt;code&gt;webhook-id&lt;/code&gt; stays the same across retries of a delivery, so it works as a deduplication key. Because providers retry, your handlers must tolerate duplicates regardless of which spec the provider follows.&lt;/p&gt;




&lt;h2&gt;
  
  
  3. Writing Resilient Parsers: The Tolerant Reader
&lt;/h2&gt;

&lt;p&gt;Martin Fowler’s 2011 bliki entry on the &lt;strong&gt;Tolerant Reader&lt;/strong&gt; builds on Postel’s Law, the robustness principle from early TCP specifications: &lt;em&gt;be conservative in what you do, be liberal in what you accept from others.&lt;/em&gt; His recommendation is to be as tolerant as possible when reading data from a service. In practice that means extracting only the fields you need, ignoring the rest, and not making assumptions about structure you don’t depend on. He also suggests that the consumer can send its reader and tests to the provider to run in their build, so they can catch breakages early.&lt;/p&gt;

&lt;p&gt;A caveat is worth keeping in mind. Postel’s Law has critics, including a 2023 argument by Martin Thomson and David Schinazi that unconditional liberal acceptance can undermine robustness and security over time. Tolerance without feedback hides problems. The approach in this article is therefore &lt;strong&gt;tolerant to keep processing, loud to keep producers honest&lt;/strong&gt;. Accept what you can, but log and count every deviation.&lt;/p&gt;

&lt;h3&gt;
  
  
  Tolerant Parsing in Go
&lt;/h3&gt;

&lt;p&gt;Go’s &lt;code&gt;encoding/json&lt;/code&gt; ignores unknown object keys by default. &lt;code&gt;Decoder.DisallowUnknownFields()&lt;/code&gt; is an opt-in method that makes the decoder strict. It takes no arguments, so tolerance is the default and you just don’t call it.&lt;/p&gt;

&lt;p&gt;Type mismatches are the problem. Decoding a JSON string into an integer field produces an &lt;code&gt;UnmarshalTypeError&lt;/code&gt;. The simplest tolerant approach for IDs is to store them as strings and accept either JSON form:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;package&lt;/span&gt; &lt;span class="n"&gt;main&lt;/span&gt;

&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s"&gt;"encoding/json"&lt;/span&gt;
    &lt;span class="s"&gt;"fmt"&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c"&gt;// FlexibleID accepts a JSON string or number and stores it as a string,&lt;/span&gt;
&lt;span class="c"&gt;// so integer IDs, prefixed IDs and UUIDs all survive without loss.&lt;/span&gt;
&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;FlexibleID&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;FlexibleID&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;UnmarshalJSON&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="kt"&gt;byte&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Unmarshal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;FlexibleID&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Number&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Unmarshal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;FlexibleID&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;String&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;nil&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;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Errorf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"flexible id: expected string or number, got %s"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;WebhookPayload&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;EventID&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;     &lt;span class="s"&gt;`json:"event_id"`&lt;/span&gt;
    &lt;span class="n"&gt;UserID&lt;/span&gt;  &lt;span class="n"&gt;FlexibleID&lt;/span&gt; &lt;span class="s"&gt;`json:"user_id"`&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;main&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;payloads&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="s"&gt;`{"event_id": "evt_123", "user_id": "usr_9f83j2"}`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c"&gt;// new prefixed string ID&lt;/span&gt;
        &lt;span class="s"&gt;`{"event_id": "evt_124", "user_id": 123456}`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;       &lt;span class="c"&gt;// original integer ID&lt;/span&gt;
        &lt;span class="s"&gt;`{"event_id": "evt_125", "user_id": 5, "plan": "pro"}`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c"&gt;// unknown field is ignored&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="k"&gt;range&lt;/span&gt; &lt;span class="n"&gt;payloads&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;wp&lt;/span&gt; &lt;span class="n"&gt;WebhookPayload&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Unmarshal&lt;/span&gt;&lt;span class="p"&gt;([]&lt;/span&gt;&lt;span class="kt"&gt;byte&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;wp&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Println&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"parse error:"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Printf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"event=%s user=%s&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;wp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;EventID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;wp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;UserID&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The earlier common approach of coercing a non-numeric string to &lt;code&gt;0&lt;/code&gt; is dangerous. Many IDs would collapse into the same value and you would corrupt data silently. Keeping the original string avoids that. Using &lt;code&gt;json.Number&lt;/code&gt; also avoids float64 precision loss for large integers.&lt;/p&gt;

&lt;h3&gt;
  
  
  Key Strategies
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Ignore unknown fields and unknown event types.&lt;/strong&gt; Route unrecognized event types to a “seen but unhandled” counter rather than an error path.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use union types for fields prone to change&lt;/strong&gt;: IDs, timestamps (epoch seconds versus ISO 8601), and booleans sent as &lt;code&gt;"true"&lt;/code&gt; versus &lt;code&gt;true&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Separate required from nice-to-have.&lt;/strong&gt; Fail only when a field your logic cannot work without is missing. For everything else, use a default and emit a metric.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep a thin boundary.&lt;/strong&gt; Parse the payload into a small internal model at the edge, as Fowler suggests with a Data Transfer Object, so the rest of your system never touches raw partner JSON.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  4. Schema Validation at the Ingress Gateway
&lt;/h2&gt;

&lt;p&gt;Tolerant parsing keeps you running, but it does not tell you that a producer changed. For that you need validation and telemetry at the edge.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[Webhook producer]
       │  HTTP POST
       ▼
[Ingress: verify signature → persist raw → return 2xx]
       │
       ▼
[Validator] ──(fetch schema by partner + event type + version)──► [Schema store]
       │
       ├─► conforms   ──► [Mapper] ──► [Event bus / queue]
       └─► deviates   ──► metric + alert, still mapped if tolerable, else quarantine
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Choosing a Schema Format and Registry
&lt;/h3&gt;

&lt;p&gt;JSON Schema is the most widely used way to describe JSON documents. The current version is &lt;strong&gt;2020-12&lt;/strong&gt;, which the project’s site lists as the latest specification. You can store versioned schemas per partner and event type in a registry or in a simple database table.&lt;/p&gt;

&lt;p&gt;Check what your tooling supports before standardizing on a draft. AWS Glue Schema Registry’s JSON support covers Draft-04, Draft-06, and Draft-07, with validation done by the Everit library, so it does not accept 2020-12 schemas. Glue also defines eight compatibility modes: NONE, DISABLED, BACKWARD, BACKWARD_ALL, FORWARD, FORWARD_ALL, FULL, and FULL_ALL. Other registries such as Confluent Schema Registry and Apicurio Registry also support JSON Schema, with their own draft coverage, so verify the draft against the version you run.&lt;/p&gt;

&lt;h3&gt;
  
  
  Tiered Validation Modes
&lt;/h3&gt;

&lt;p&gt;Strict edge validation defeats the point if every producer change causes rejections. Use tiers:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Shadow mode.&lt;/strong&gt; Validate and record deviations, but never block. This is where you discover drift.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Enforcement for critical fields.&lt;/strong&gt; Once telemetry shows a schema is stable, reject or quarantine payloads that break the few invariants your business logic requires.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Compatibility checks on your own schema versions.&lt;/strong&gt; Registries enforce compatibility modes such as BACKWARD or FULL between versions of a schema &lt;em&gt;you register&lt;/em&gt;. This governs your view of a partner’s payload. You cannot enforce it on the partner. If a partner publishes a changelog or an OpenAPI/JSON Schema description, diff against it in CI.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Record the deviation rate per partner and event type. A sudden rise after a partner’s release date is often the first sign of drift, usually before any customer complains.&lt;/p&gt;




&lt;h2&gt;
  
  
  5. Dual-Schema Mapping Layers
&lt;/h2&gt;

&lt;p&gt;Sometimes a breaking change is unavoidable, such as a restructured order object, and you will see old and new shapes at the same time. A mapping layer based on the Adapter pattern translates each variant into one &lt;strong&gt;canonical internal event&lt;/strong&gt;, so only the adapter knows about partner formats.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Version detection.&lt;/strong&gt; Identify the variant from whatever the producer gives you. Stripe, for example, ties the payload shape to the API version configured on each webhook endpoint, and its documentation recommends updating webhook code to handle both the old and new version of each object before upgrading. If a provider offers no version marker, detect by shape (presence of keys) as a fallback.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Transformation.&lt;/strong&gt; Use declarative mappings where they fit, such as jq or JSONata, or plain functions for logic that needs types and tests.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Canonical event.&lt;/strong&gt; Emit one internal structure that downstream services consume. Version the canonical model too, so internal changes follow the same discipline.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Keep the raw payload alongside the canonical event so you can re-map historical events when a mapping bug is found.&lt;/p&gt;




&lt;h2&gt;
  
  
  6. Contract Tests for Producers You Don’t Control
&lt;/h2&gt;

&lt;p&gt;Consumer-driven contract testing tools such as Pact were built around services where both sides can run the tests. External webhook producers won’t run your suite, so treat recorded payloads as the contract instead:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Keep a &lt;strong&gt;fixture library&lt;/strong&gt; of real payloads per partner, event type, and version, with secrets and personal data removed.&lt;/li&gt;
&lt;li&gt;Run every fixture through the parser and mapper in CI.&lt;/li&gt;
&lt;li&gt;When a partner announces or ships a change, add the new payload shape as a fixture &lt;em&gt;before&lt;/em&gt; it reaches production.&lt;/li&gt;
&lt;li&gt;Where a partner is willing, share your fixtures or schemas with them, as Fowler suggests for the Tolerant Reader.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Here is a pytest example. Parsing monetary values from floats is a classic trap: &lt;code&gt;int(19.99 * 100)&lt;/code&gt; evaluates to &lt;code&gt;1998&lt;/code&gt; in Python because &lt;code&gt;19.99 * 100&lt;/code&gt; is slightly under 1999. The example converts through &lt;code&gt;Decimal&lt;/code&gt; instead.&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;from&lt;/span&gt; &lt;span class="n"&gt;dataclasses&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;dataclass&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;decimal&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Decimal&lt;/span&gt;

&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;pytest&lt;/span&gt;


&lt;span class="nd"&gt;@dataclass&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;frozen&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;NormalizedOrder&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
    &lt;span class="n"&gt;total_cents&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;
    &lt;span class="n"&gt;customer_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;parse_incoming_webhook&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;schema_version&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="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;NormalizedOrder&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;schema_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_legacy&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="c1"&gt;# Legacy: integer id, amount in decimal dollars, "client_id"
&lt;/span&gt;        &lt;span class="n"&gt;cents&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="nc"&gt;Decimal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;amount&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]))&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;NormalizedOrder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;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;total_cents&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;cents&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;customer_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;client_id&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="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;schema_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;v2_current&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="c1"&gt;# Current: string order id, integer cents, "customer_id"
&lt;/span&gt;        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;NormalizedOrder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;order_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;total_cents&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;payload&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;total_amount_cents&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]),&lt;/span&gt;
            &lt;span class="n"&gt;customer_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;customer_id&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="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Unsupported schema version: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;schema_version&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;


&lt;span class="nd"&gt;@pytest.mark.parametrize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;payload,version,expected&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="p"&gt;(&lt;/span&gt;
            &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;9988&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;amount&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;49.99&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;client_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;42&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;v1_legacy&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="nc"&gt;NormalizedOrder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;9988&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;4999&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;42&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="p"&gt;(&lt;/span&gt;
            &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;9989&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;amount&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;19.99&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;client_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;42&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;v1_legacy&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="nc"&gt;NormalizedOrder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;9989&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1999&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;42&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="p"&gt;(&lt;/span&gt;
            &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;order_id&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="s"&gt;ord_abc123&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="s"&gt;total_amount_cents&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;5000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;customer_id&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="s"&gt;usr_789&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="s"&gt;v2_current&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="nc"&gt;NormalizedOrder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ord_abc123&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;5000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;usr_789&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="p"&gt;(&lt;/span&gt;
            &lt;span class="c1"&gt;# Unknown extra field must not break parsing
&lt;/span&gt;            &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;order_id&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="s"&gt;ord_abc124&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="s"&gt;total_amount_cents&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;customer_id&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="s"&gt;usr_1&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="s"&gt;new_flag&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;v2_current&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="nc"&gt;NormalizedOrder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ord_abc124&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;usr_1&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="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_webhook_schema_compatibility&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&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;expected&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="nf"&gt;parse_incoming_webhook&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&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="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;expected&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_unknown_version_is_rejected_explicitly&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;pytest&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;raises&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="nf"&gt;parse_incoming_webhook&lt;/span&gt;&lt;span class="p"&gt;({},&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;v9_future&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Operational Checklist
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Persist before you interpret.&lt;/strong&gt; Verify the signature on the raw body, store the payload, and return &lt;code&gt;2xx&lt;/code&gt; quickly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Be tolerant in parsing.&lt;/strong&gt; Ignore unknown fields and event types, accept multiple representations of IDs and timestamps, and avoid lossy coercions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Be loud in monitoring.&lt;/strong&gt; Validate against versioned schemas in shadow mode, track deviation rates per partner, and alert on spikes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Isolate with a canonical model.&lt;/strong&gt; Map every partner variant through versioned adapters and keep raw payloads for replay.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Test against recorded reality.&lt;/strong&gt; Maintain a fixture matrix of past, current, and announced payloads in CI.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Make handlers idempotent.&lt;/strong&gt; Deduplicate on the provider’s event or message ID, because retries and replays will deliver the same event more than once.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Talk to your partners.&lt;/strong&gt; Subscribe to their changelogs, pin API versions where they offer it, and upgrade deliberately. Stripe, for example, ships monthly releases with no breaking changes and issues major releases with breaking changes twice a year.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Treat webhook schemas as contracts that evolve and you can keep processing events through producer changes. The unavoidable work is noticing a change quickly and mapping it correctly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Further Reading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Stripe: &lt;a href="https://docs.stripe.com/webhooks" rel="noopener noreferrer"&gt;Receive events in your webhook endpoint&lt;/a&gt;, &lt;a href="https://docs.stripe.com/api-versions" rel="noopener noreferrer"&gt;API versioning&lt;/a&gt;, &lt;a href="https://docs.stripe.com/upgrades" rel="noopener noreferrer"&gt;API upgrades&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Martin Fowler: &lt;a href="https://martinfowler.com/bliki/TolerantReader.html" rel="noopener noreferrer"&gt;Tolerant Reader&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/standard-webhooks/standard-webhooks" rel="noopener noreferrer"&gt;Standard Webhooks specification&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://json-schema.org/specification" rel="noopener noreferrer"&gt;JSON Schema specification&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.aws.amazon.com/glue/latest/dg/schema-registry.html" rel="noopener noreferrer"&gt;AWS Glue Schema Registry&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Go: &lt;a href="https://pkg.go.dev/encoding/json" rel="noopener noreferrer"&gt;&lt;code&gt;encoding/json&lt;/code&gt; package documentation&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://instawebhook.com/blog/zero-downtime-webhook-schema-evolution-managing-breaking-payloads-without-droppi" rel="noopener noreferrer"&gt;https://instawebhook.com/blog/zero-downtime-webhook-schema-evolution-managing-breaking-payloads-without-droppi&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>security</category>
    </item>
    <item>
      <title>Edge-Native Webhook Ingress: Running Webhook Gateways on Cloudflare Workers and WebAssembly (Wasm)</title>
      <dc:creator>InstaWebhook</dc:creator>
      <pubDate>Thu, 15 Oct 2026 03:51:58 +0000</pubDate>
      <link>https://dev.to/instawebhook/edge-native-webhook-ingress-running-webhook-gateways-on-cloudflare-workers-and-webassembly-wasm-1lk6</link>
      <guid>https://dev.to/instawebhook/edge-native-webhook-ingress-running-webhook-gateways-on-cloudflare-workers-and-webassembly-wasm-1lk6</guid>
      <description>&lt;p&gt;Modern distributed systems lean on webhooks to drive event-driven workflows, sync data between SaaS platforms, trigger deployments, and process billing events. The traditional design routes webhook traffic from providers such as Stripe, GitHub, or Shopify straight into a centralized Kubernetes cluster or cloud region. That design has real security and capacity drawbacks.&lt;/p&gt;

&lt;p&gt;A public webhook endpoint accepts requests from anyone, so it is only as safe as its signature verification. If every request, valid or not, has to travel to your origin to be rejected, bursts and floods consume ingress, API gateway, and worker capacity. That can end in connection starvation, resource exhaustion, and cascading failures.&lt;/p&gt;

&lt;p&gt;Moving signature verification, payload checks, and tenant routing to the edge with &lt;strong&gt;Cloudflare Workers&lt;/strong&gt; and, where useful, &lt;strong&gt;WebAssembly (Wasm)&lt;/strong&gt; changes this. Cloudflare reports a network of more than 330 cities, and your gateway code runs inside it. Bad requests are dropped before they reach your origin, and valid events are acknowledged quickly and handed off asynchronously.&lt;/p&gt;

&lt;p&gt;This article walks through the architecture, the code, and the limits you need to design around. Where a claim depends on platform limits or provider behavior, it follows the current documentation. Links are at the end.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. The Architectural Shift: Moving Ingress to the Edge
&lt;/h2&gt;

&lt;p&gt;In a legacy webhook architecture, an HTTP POST hits your load balancer and passes through an API gateway. It then consumes application memory to parse a body, runs signature verification in your application runtime, and finally writes state to a database.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[Legacy Flow]
Webhook Provider ──&amp;gt; Cloud LB ──&amp;gt; API Gateway ──&amp;gt; App Cluster (CPU/memory spent on invalid requests)

[Edge-Native Flow]
Webhook Provider ──&amp;gt; Cloudflare Worker (limits, verify, drop) ──&amp;gt; Queue ──&amp;gt; Origin consumers
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The legacy model has several weaknesses:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Wasted compute on invalid requests.&lt;/strong&gt; Misconfigured clients, scanners, and attackers can send garbage or replayed payloads. Your origin still spends CPU reading the body and running signature checks before it can reject them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Shared failure domains.&lt;/strong&gt; A webhook flood competes with your user-facing APIs for the same pods, connection pools, and database connections.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tight coupling to provider timeouts.&lt;/strong&gt; Providers expect a fast response. GitHub treats a delivery as failed if your server does not return a 2xx within 10 seconds, and Stripe’s guidance is to return a 2xx quickly, before running slow logic. If your origin is slow or down, retries pile on top of the load you already have.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;An edge gateway acts as a stateless front door. It checks size, content type, rate, and signature, then writes accepted events to a durable queue and returns a 2xx.&lt;/p&gt;

&lt;p&gt;One caveat: geographic latency is a weaker argument than it first appears. Webhook senders are usually cloud services calling from a handful of regions, not end users spread around the world. The main benefits are shielding your origin and acknowledging deliveries fast, not shaving round-trip time for every sender.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Choosing the Right Verification Primitive: WebCrypto First, Wasm When Needed
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Know what your providers actually use
&lt;/h3&gt;

&lt;p&gt;Signature schemes differ by provider, so check the documentation of each sender you support:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Provider&lt;/th&gt;
&lt;th&gt;Scheme&lt;/th&gt;
&lt;th&gt;Notes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Stripe&lt;/td&gt;
&lt;td&gt;HMAC-SHA256 over &lt;code&gt;timestamp.payload&lt;/code&gt;, in the &lt;code&gt;Stripe-Signature&lt;/code&gt; header (&lt;code&gt;t=…,v1=…&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;Default timestamp tolerance is 5 minutes. The header can carry multiple &lt;code&gt;v1&lt;/code&gt; signatures during secret rotation.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GitHub&lt;/td&gt;
&lt;td&gt;HMAC-SHA256 over the raw body, in &lt;code&gt;X-Hub-Signature-256&lt;/code&gt; (&lt;code&gt;sha256=&amp;lt;hex&amp;gt;&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;Payloads are capped at 25 MB. GitHub does not automatically redeliver failed deliveries.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Discord&lt;/td&gt;
&lt;td&gt;Ed25519, via &lt;code&gt;X-Signature-Ed25519&lt;/code&gt; and &lt;code&gt;X-Signature-Timestamp&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Asymmetric. You verify with Discord’s public key.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Telnyx (Webhook API v2)&lt;/td&gt;
&lt;td&gt;Ed25519, via &lt;code&gt;telnyx-signature-ed25519&lt;/code&gt; and &lt;code&gt;telnyx-timestamp&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Signs the timestamp and raw body together.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Standard Webhooks&lt;/td&gt;
&lt;td&gt;HMAC (&lt;code&gt;v1&lt;/code&gt;) or Ed25519 (&lt;code&gt;v1a&lt;/code&gt;), with &lt;code&gt;webhook-id&lt;/code&gt;, &lt;code&gt;webhook-timestamp&lt;/code&gt;, and &lt;code&gt;webhook-signature&lt;/code&gt; headers&lt;/td&gt;
&lt;td&gt;An open specification with an asymmetric variant.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two things follow. Most big providers use HMAC-SHA256, not Ed25519. And in every scheme above, the timestamp is part of the signed data or checked alongside it. That matters for the replay protection discussed below.&lt;/p&gt;

&lt;h3&gt;
  
  
  Workers’ WebCrypto already covers the common cases
&lt;/h3&gt;

&lt;p&gt;The Workers runtime implements the Web Crypto API, and Cloudflare notes that WebCrypto operations are significantly faster than doing the same work in pure JavaScript. HMAC is supported, and so is Ed25519 (as the standard &lt;code&gt;Ed25519&lt;/code&gt; algorithm, plus a legacy &lt;code&gt;NODE-ED25519&lt;/code&gt; variant). For many gateways, &lt;code&gt;crypto.subtle.verify&lt;/code&gt; is therefore the right first choice. It needs no extra build step and no extra bundle size.&lt;/p&gt;

&lt;p&gt;Here is a Stripe-style HMAC verifier using only WebCrypto:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;enc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;TextEncoder&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;hexToBytes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;hex&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Uint8Array&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&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;hex&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="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="mi"&gt;0&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;[&lt;/span&gt;&lt;span class="sr"&gt;0-9a-fA-F&lt;/span&gt;&lt;span class="se"&gt;]&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;hex&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;null&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;out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Uint8Array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;hex&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="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&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;out&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;parseInt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;hex&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;16&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="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;concat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Uint8Array&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Uint8Array&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Uint8Array&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;out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Uint8Array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&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;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&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;out&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;verifyStripeStyle&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="nb"&gt;Uint8Array&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;header&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&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="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;toleranceSec&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;300&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;boolean&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="nx"&gt;pairs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;header&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;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;p&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;p&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;as&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kr"&gt;string&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;t&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;pairs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;(([&lt;/span&gt;&lt;span class="nx"&gt;k&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;k&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;t&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)?.[&lt;/span&gt;&lt;span class="mi"&gt;1&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;sigs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;pairs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(([&lt;/span&gt;&lt;span class="nx"&gt;k&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;k&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="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(([,&lt;/span&gt; &lt;span class="nx"&gt;v&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;v&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;t&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;sigs&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="mi"&gt;0&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;ts&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;t&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="nb"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isFinite&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ts&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&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="nx"&gt;ts&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;toleranceSec&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="kc"&gt;false&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;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;subtle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;importKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;raw&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;enc&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="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="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;HMAC&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;hash&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;SHA-256&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&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;verify&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;signedPayload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;concat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;enc&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="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;t&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;rawBody&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;for &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;sig&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;sigs&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;sigBytes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;hexToBytes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sig&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="c1"&gt;// crypto.subtle.verify performs the comparison for you&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;sigBytes&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;subtle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;verify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;HMAC&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;sigBytes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;signedPayload&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="kc"&gt;true&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="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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note the &lt;code&gt;Number.isFinite&lt;/code&gt; guard. Without it, a non-numeric timestamp becomes &lt;code&gt;NaN&lt;/code&gt;, and &lt;code&gt;Math.abs(NaN) &amp;gt; 300&lt;/code&gt; is &lt;code&gt;false&lt;/code&gt;, so the stale-timestamp check would silently pass.&lt;/p&gt;

&lt;h3&gt;
  
  
  Where Wasm fits
&lt;/h3&gt;

&lt;p&gt;Wasm earns its place when you need something WebCrypto does not offer. Examples are a provider-specific or non-standard scheme, a Rust library you already trust and want to reuse, or heavier parsing and canonicalization logic. Compiling Rust to Wasm gives you near-native execution speed and a sandboxed linear memory, and you can reuse audited code.&lt;/p&gt;

&lt;p&gt;Some claims about Wasm need correcting. The sandbox isolates a module from the host, but it does not prevent leaks or guarantee predictable performance. That comes from how you write the module. Rust’s ownership model helps, but Wasm does not make memory management magically safe. Treat any speed claim as something to benchmark in your own Worker.&lt;/p&gt;

&lt;p&gt;CPU limits are also not a blocker. Workers on the Free plan allow 10 ms of CPU time per request, while Paid plans default to 30 seconds and can be raised to 5 minutes. Time spent waiting on I/O does not count as CPU time.&lt;/p&gt;

&lt;h3&gt;
  
  
  An Ed25519 verification module in Rust
&lt;/h3&gt;

&lt;p&gt;This module uses the &lt;code&gt;ed25519-dalek&lt;/code&gt; 2.x API. It uses &lt;code&gt;verify_strict&lt;/code&gt;, which also rejects some malleable or weak-key edge cases that plain &lt;code&gt;verify&lt;/code&gt; accepts. For webhooks, strict is the safer default.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="nn"&gt;ed25519_dalek&lt;/span&gt;&lt;span class="p"&gt;::{&lt;/span&gt;&lt;span class="n"&gt;Signature&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;VerifyingKey&lt;/span&gt;&lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="nn"&gt;wasm_bindgen&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;prelude&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nd"&gt;#[wasm_bindgen]&lt;/span&gt;
&lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;verify_webhook_signature&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;public_key_bytes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;u8&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;u8&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="n"&gt;signature_bytes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;u8&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Result&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&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;JsValue&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;let&lt;/span&gt; &lt;span class="n"&gt;key_array&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;u8&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="mi"&gt;32&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;public_key_bytes&lt;/span&gt;
        &lt;span class="nf"&gt;.try_into&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="nf"&gt;.map_err&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="nn"&gt;JsValue&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;from_str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Invalid public key length"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;verifying_key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;VerifyingKey&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;from_bytes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key_array&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="nf"&gt;.map_err&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="nn"&gt;JsValue&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;from_str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Malformed public key"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;signature&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;Signature&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;from_slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;signature_bytes&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="nf"&gt;.map_err&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="nn"&gt;JsValue&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;from_str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Invalid signature format"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="nf"&gt;Ok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;verifying_key&lt;/span&gt;&lt;span class="nf"&gt;.verify_strict&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;signature&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="nf"&gt;.is_ok&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;Build it with the web target, for example &lt;code&gt;wasm-pack build --target web&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Loading the module in a Worker
&lt;/h3&gt;

&lt;p&gt;Cloudflare’s runtime does not let you compile Wasm from bytes at request time. Instead, Wrangler bundles any file ending in &lt;code&gt;.wasm&lt;/code&gt; and your code imports it as a precompiled &lt;code&gt;WebAssembly.Module&lt;/code&gt;. Cloudflare’s guidance is to instantiate it once at the top level, not on every request. With wasm-bindgen’s web target, that means calling &lt;code&gt;initSync&lt;/code&gt; with the imported module:&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="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;initSync&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;verify_webhook_signature&lt;/span&gt; &lt;span class="p"&gt;}&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;./pkg/webhook_crypto.js&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;wasmModule&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;./pkg/webhook_crypto_bg.wasm&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// Instantiate once per isolate, at module scope.&lt;/span&gt;
&lt;span class="nf"&gt;initSync&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;module&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;wasmModule&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Recent wasm-bindgen versions take an object (&lt;code&gt;{ module }&lt;/code&gt;). Older versions took the module directly, so check the signature in your generated &lt;code&gt;.js&lt;/code&gt; file.&lt;/p&gt;

&lt;h3&gt;
  
  
  The full gateway handler
&lt;/h3&gt;

&lt;p&gt;The handler below puts the layers in order and verifies a generic Ed25519 scheme. The signed message is the timestamp plus the raw body, as in Telnyx’s scheme (which joins them with &lt;code&gt;|&lt;/code&gt;) and Stripe’s (&lt;code&gt;.&lt;/code&gt;). &lt;strong&gt;Adapt the signed-message format to your provider.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Binding the timestamp into the signature is what makes replay protection real. If you only check a timestamp header that is not covered by the signature, an attacker can replay an old valid body with a fresh header.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;initSync&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;verify_webhook_signature&lt;/span&gt; &lt;span class="p"&gt;}&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;./pkg/webhook_crypto.js&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;wasmModule&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;./pkg/webhook_crypto_bg.wasm&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nf"&gt;initSync&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;module&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;wasmModule&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;Env&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;TENANT_KV&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;KVNamespace&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;WEBHOOK_QUEUE&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Queue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;WEBHOOK_LIMITER&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;RateLimit&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// Workers Rate Limiting binding&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;TenantConfig&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;publicKeysHex&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;[];&lt;/span&gt; &lt;span class="c1"&gt;// multiple entries during key rotation&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Cloudflare Queues messages are limited to 128 KB, so keep the accepted size well below that.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;MAX_BODY_BYTES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;64&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1024&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;MAX_SKEW_SECONDS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;300&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;enc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;TextEncoder&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;dec&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;TextDecoder&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="na"&gt;request&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;env&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="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Response&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="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;method&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;POST&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="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Method Not Allowed&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="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;405&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;// 1. Route: /webhook/:tenantId/events&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;match&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;pathname&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/^&lt;/span&gt;&lt;span class="se"&gt;\/&lt;/span&gt;&lt;span class="sr"&gt;webhook&lt;/span&gt;&lt;span class="se"&gt;\/([\w&lt;/span&gt;&lt;span class="sr"&gt;-&lt;/span&gt;&lt;span class="se"&gt;]{1,64})\/&lt;/span&gt;&lt;span class="sr"&gt;events$/&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;match&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Not Found&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="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;404&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;tenantId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;match&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;

    &lt;span class="c1"&gt;// 2. Cheap per-location throttle (permissive and eventually consistent; see section 3)&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ip&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;request&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="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;cf-connecting-ip&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;unknown&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="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;success&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&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_LIMITER&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;key&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;tenantId&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;ip&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="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;success&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Too Many Requests&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="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;429&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

    &lt;span class="c1"&gt;// 3. Size and content-type guards before touching the body&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;declared&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;request&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="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;content-length&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;0&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;declared&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;MAX_BODY_BYTES&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="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Payload Too Large&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="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;413&lt;/span&gt; &lt;span class="p"&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;request&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="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;content-type&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)?.&lt;/span&gt;&lt;span class="nf"&gt;startsWith&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="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Unsupported Media Type&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="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;415&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;// 4. Tenant config (cached at the edge)&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;config&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;getTenantConfig&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;tenantId&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;config&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Unauthorized&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="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;401&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

    &lt;span class="c1"&gt;// 5. Required headers and timestamp window&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;signatureHex&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;request&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="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;X-Webhook-Signature&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;request&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="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;X-Webhook-Timestamp&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;signatureHex&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;timestamp&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="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Missing security headers&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="na"&gt;status&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="p"&gt;}&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ts&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="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="nb"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isFinite&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ts&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&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="nx"&gt;ts&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;MAX_SKEW_SECONDS&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="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Stale or invalid timestamp&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="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;401&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;// 6. Raw bytes. Never parse JSON before verification.&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;rawBody&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Uint8Array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;arrayBuffer&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;rawBody&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;byteLength&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;MAX_BODY_BYTES&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="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Payload Too Large&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="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;413&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;// 7. Verify against every active key (supports rotation)&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;signature&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;hexToBytes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;signatureHex&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;signature&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Invalid Signature&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="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;401&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;message&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;concat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;enc&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="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="nx"&gt;rawBody&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;valid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;publicKeysHex&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;keyHex&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="nx"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;hexToBytes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;keyHex&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;key&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nf"&gt;verify_webhook_signature&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;signature&lt;/span&gt;&lt;span class="p"&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;valid&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Invalid Signature&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="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;401&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

    &lt;span class="c1"&gt;// 8. Durably enqueue BEFORE acknowledging&lt;/span&gt;
    &lt;span class="k"&gt;await&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_QUEUE&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="nx"&gt;tenantId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;eventId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;request&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="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;X-Webhook-Id&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="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;randomUUID&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
      &lt;span class="na"&gt;receivedAt&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="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;dec&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;decode&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="c1"&gt;// JSON is UTF-8 text&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Accepted&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="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;202&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;function&lt;/span&gt; &lt;span class="nf"&gt;hexToBytes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;hex&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Uint8Array&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&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;hex&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="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="mi"&gt;0&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;[&lt;/span&gt;&lt;span class="sr"&gt;0-9a-fA-F&lt;/span&gt;&lt;span class="se"&gt;]&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;hex&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;null&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;out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Uint8Array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;hex&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="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&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;out&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;parseInt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;hex&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;16&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="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;concat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Uint8Array&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Uint8Array&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Uint8Array&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;out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Uint8Array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&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;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&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;out&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;Because the Worker awaits &lt;code&gt;send()&lt;/code&gt; before returning, a &lt;code&gt;202&lt;/code&gt; means the event is in the queue. Do not use &lt;code&gt;waitUntil&lt;/code&gt; for this step, or you could acknowledge events you later fail to enqueue.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. CDN Boundary Protection: Layered Early Rejection
&lt;/h2&gt;

&lt;p&gt;Application resilience depends on &lt;strong&gt;early rejection&lt;/strong&gt;. The cheapest request to handle is one that never invokes your code, and the next cheapest is one rejected before the body is read.&lt;/p&gt;

&lt;p&gt;A sensible order of defenses looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[Edge Security Pipeline]
1. Cloudflare network-level protections and WAF / rate limiting rules (before the Worker runs)
2. Optional provider IP allowlisting (defense in depth)
3. Worker: method, path, content-type, size guards
4. Worker: per-key rate limiting
5. Worker: tenant lookup, timestamp window, signature verification
6. Valid   -&amp;gt; enqueue, return 2xx
   Invalid -&amp;gt; 4xx at the edge, zero origin compute
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Protective measures in practice
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Platform and WAF rules first.&lt;/strong&gt; Cloudflare processes traffic across its network and mitigates DDoS attacks there. A rejected request still invokes your Worker, so for the cheapest possible drop, put WAF custom rules or rate limiting rules in front of it rather than relying only on Worker code.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;IP allowlisting as defense in depth.&lt;/strong&gt; Some providers publish the IP ranges they send from. GitHub documents an allowlist approach and Stripe publishes its addresses. Signatures remain the real control, because IP ranges change and allowlists drift.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Size enforcement.&lt;/strong&gt; Check &lt;code&gt;Content-Length&lt;/code&gt; early, but also check the actual byte length after reading, because the header can be absent or wrong. Request body limits depend on your Cloudflare plan, and Cloudflare returns a 413 for requests that exceed them. Pick your own, much lower limit based on what your providers send. GitHub, for example, caps payloads at 25 MB, but most events are tiny.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Content-type validation.&lt;/strong&gt; Accept only the types each provider documents.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rate limiting with realistic expectations.&lt;/strong&gt; The Workers Rate Limiting binding takes a &lt;code&gt;limit&lt;/code&gt; and a &lt;code&gt;period&lt;/code&gt; of either 10 or 60 seconds. Counters are tracked per key per Cloudflare location, and Cloudflare describes the API as permissive and eventually consistent, not an accurate accounting system. It is a good throttle against abuse, not a billing-grade quota. If you need exact, globally consistent counts, use a Durable Object, which provides strongly consistent state at the cost of routing those requests to a single instance.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Do not use KV for counters.&lt;/strong&gt; KV is eventually consistent, and concurrent writes to the same key can overwrite each other. Writes to a single key are also rate limited.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  4. Real-Time Tenant Routing with Edge KV
&lt;/h2&gt;

&lt;p&gt;In a multi-tenant SaaS product, one gateway endpoint receives events for many customers, each with its own signing keys and routing rules. Querying a central database on every request adds latency and creates a new origin dependency.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Workers KV&lt;/strong&gt; suits this read-heavy, rarely-changing data. It is a globally distributed, eventually consistent key-value store.&lt;/p&gt;

&lt;h3&gt;
  
  
  Know KV’s consistency model
&lt;/h3&gt;

&lt;p&gt;KV writes are visible immediately in the location where they were made. In other locations they can take up to 60 seconds, or the &lt;code&gt;cacheTtl&lt;/code&gt; you set on reads, to appear. Two other limits apply:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The minimum &lt;code&gt;expirationTtl&lt;/code&gt; on writes is 60 seconds.&lt;/li&gt;
&lt;li&gt;The minimum &lt;code&gt;cacheTtl&lt;/code&gt; on reads is 60 seconds, which is also the default.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For webhooks this has a practical consequence. &lt;strong&gt;Key and secret changes are not instantaneous.&lt;/strong&gt; After a rotation, some locations will keep serving the old config for a while, so your gateway must accept both old and new keys during an overlap window.&lt;/p&gt;

&lt;p&gt;Durable Objects are not a replicated edge cache. Each object has a single active instance, which gives strong consistency but means requests may travel to wherever that instance lives. Use them for coordination such as exact counters or per-tenant serialization, not as a global config cache.&lt;/p&gt;

&lt;h3&gt;
  
  
  Tenant routing flow
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Identify the tenant&lt;/strong&gt; from the path (for example &lt;code&gt;/webhook/:tenant_id/events&lt;/code&gt;) or a custom header. Prefer the path, because it is covered by your routing and WAF rules.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Look up the config&lt;/strong&gt; in KV, including the active public keys or secrets and the destination.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Verify the signature&lt;/strong&gt; with the tenant’s key, then enqueue with the tenant ID attached.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Implementation example
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;NEGATIVE_MARKER&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;__none__&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getTenantConfig&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;Env&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;tenantId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;TenantConfig&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&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="nx"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`tenant_config:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;tenantId&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="c1"&gt;// cacheTtl keeps hot keys in the local cache (minimum 60 seconds)&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;raw&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&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;TENANT_KV&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="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;cacheTtl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;60&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;raw&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Fall back to your control plane&lt;/span&gt;
    &lt;span class="nx"&gt;raw&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetchTenantFromOrigin&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;tenantId&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;raw&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="c1"&gt;// Cache the miss briefly so unknown tenant IDs can't hammer your origin&lt;/span&gt;
      &lt;span class="k"&gt;await&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;TENANT_KV&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;put&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;NEGATIVE_MARKER&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;expirationTtl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;60&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;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;await&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;TENANT_KV&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;put&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;expirationTtl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;300&lt;/span&gt; &lt;span class="p"&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;raw&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;NEGATIVE_MARKER&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;null&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;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;TenantConfig&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 negative cache is a real production concern. Without it, anyone can send requests for random tenant IDs and force a control-plane call every time, which recreates the origin-exhaustion problem at the edge.&lt;/p&gt;

&lt;h3&gt;
  
  
  Protect secrets stored at the edge
&lt;/h3&gt;

&lt;p&gt;Anything you place in KV is data your Worker can read, so treat tenant signing secrets accordingly. Prefer storing &lt;strong&gt;public keys&lt;/strong&gt; for asymmetric schemes, since a leaked public key cannot forge signatures. For HMAC secrets, consider encrypting values and keeping the data-encryption key in a Worker secret, or using a dedicated secrets service.&lt;/p&gt;

&lt;p&gt;Measure your gateway’s latency in your own deployment with Workers observability instead of assuming a fixed number. Cache hits are fast, but cold reads, cache misses, and control-plane fallbacks all take longer.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Production Blueprint and Best Practices
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Never parse JSON before verification.&lt;/strong&gt; Read the raw request body as bytes (&lt;code&gt;ArrayBuffer&lt;/code&gt;) or text. HMAC and Ed25519 sign bytes, not objects, and re-serializing parsed JSON changes whitespace and key order, which breaks the signature. Parse only after the check passes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bind the timestamp into verification.&lt;/strong&gt; Check that the timestamp is a finite number inside your tolerance window, and make sure it is part of what is signed. Stripe’s default tolerance is 5 minutes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Acknowledge fast, process asynchronously.&lt;/strong&gt; Return a &lt;code&gt;2xx&lt;/code&gt; once the event is durably enqueued. Never run slow database writes or third-party API calls inline.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use Cloudflare Queues as the buffer, within its limits.&lt;/strong&gt; Delivery is at-least-once, so duplicates are possible. Messages are limited to 128 KB (including a small amount of metadata), consumer batches go up to 100 messages, and default retention is 4 days (configurable up to 14). For larger payloads, write the body to R2 and enqueue a reference. Configure a dead-letter queue so poison messages don’t retry forever.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Make consumers idempotent.&lt;/strong&gt; Duplicates can come from the provider and from the queue itself. Stripe retries failed deliveries for up to three days in live mode, and its guidance is to expect repeat events. GitHub does not automatically redeliver failed deliveries, but it sends an &lt;code&gt;X-GitHub-Delivery&lt;/code&gt; GUID you can use for deduplication, and you can redeliver manually or via the API. Track event IDs in a uniqueness table or similar store.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Plan for provider differences in retries.&lt;/strong&gt; GitHub will not retry for you, so if your gateway or queue is unavailable you may need to reconcile with its delivery API afterwards. Stripe retries for days, so a long outage creates a backlog to absorb when you recover.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Handle key rotation gracefully.&lt;/strong&gt; Support multiple active keys per tenant. Stripe may include more than one &lt;code&gt;v1&lt;/code&gt; signature while a secret is being rolled, so accept any valid one. Remember KV propagation delays when you roll your own keys.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Test with provider tooling.&lt;/strong&gt; Use the provider’s test events or CLI against a staging Worker before going live. Test mode and live mode typically use different signing secrets.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;An edge-native webhook gateway moves the cheapest and most abuse-prone work, rejecting bad requests, to Cloudflare’s network. Your origin then sees only verified, queued events. For HMAC and Ed25519, Workers’ built-in WebCrypto is often enough. Wasm is the right tool when you need custom schemes or reusable Rust code, and it loads as a precompiled module rather than being compiled at request time. KV gives you fast tenant lookups, as long as you plan for its eventual consistency and encrypt or avoid sensitive secrets. Cloudflare Queues decouples ingestion from processing, as long as your consumers are idempotent.&lt;/p&gt;

&lt;p&gt;Next steps for taking this further:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Add per-tenant metrics and alerts for rejection reasons (bad signature, stale timestamp, rate limited).&lt;/li&gt;
&lt;li&gt;Add a dead-letter queue consumer and a replay tool for failed events.&lt;/li&gt;
&lt;li&gt;Add R2 offloading for payloads that exceed the Queues message limit.&lt;/li&gt;
&lt;li&gt;Run load tests that include invalid-signature floods to confirm origin load stays flat.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Cloudflare Workers limits: &lt;a href="https://developers.cloudflare.com/workers/platform/limits" rel="noopener noreferrer"&gt;https://developers.cloudflare.com/workers/platform/limits&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Cloudflare Workers: Wasm in JavaScript: &lt;a href="https://developers.cloudflare.com/workers/runtime-apis/webassembly/javascript" rel="noopener noreferrer"&gt;https://developers.cloudflare.com/workers/runtime-apis/webassembly/javascript&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Cloudflare Workers Web Crypto: &lt;a href="https://developers.cloudflare.com/workers/runtime-apis/web-crypto/" rel="noopener noreferrer"&gt;https://developers.cloudflare.com/workers/runtime-apis/web-crypto/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Cloudflare Workers KV, writing key-value pairs: &lt;a href="https://developers.cloudflare.com/kv/api/write-key-value-pairs" rel="noopener noreferrer"&gt;https://developers.cloudflare.com/kv/api/write-key-value-pairs&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Cloudflare Workers Rate Limiting binding: &lt;a href="https://developers.cloudflare.com/workers/runtime-apis/bindings/rate-limit/" rel="noopener noreferrer"&gt;https://developers.cloudflare.com/workers/runtime-apis/bindings/rate-limit/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Cloudflare Queues documentation: &lt;a href="https://developers.cloudflare.com/queues/" rel="noopener noreferrer"&gt;https://developers.cloudflare.com/queues/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Stripe webhooks: &lt;a href="https://stripe.com/docs/webhooks" rel="noopener noreferrer"&gt;https://stripe.com/docs/webhooks&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;GitHub webhooks best practices: &lt;a href="https://docs.github.com/webhooks/using-webhooks/best-practices-for-using-webhooks" rel="noopener noreferrer"&gt;https://docs.github.com/webhooks/using-webhooks/best-practices-for-using-webhooks&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;GitHub handling failed webhook deliveries: &lt;a href="https://docs.github.com/webhooks/using-webhooks/handling-failed-webhook-deliveries" rel="noopener noreferrer"&gt;https://docs.github.com/webhooks/using-webhooks/handling-failed-webhook-deliveries&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;GitHub webhook events and payloads: &lt;a href="https://docs.github.com/en/webhooks/webhook-events-and-payloads" rel="noopener noreferrer"&gt;https://docs.github.com/en/webhooks/webhook-events-and-payloads&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://instawebhook.com/blog/edge-native-webhook-ingress-running-webhook-gateways-on-cloudflare-workers-and-w" rel="noopener noreferrer"&gt;https://instawebhook.com/blog/edge-native-webhook-ingress-running-webhook-gateways-on-cloudflare-workers-and-w&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>devops</category>
    </item>
    <item>
      <title>HTTP/3 and QUIC for Webhook Delivery: What It Fixes, What It Doesn’t, and How to Adopt It</title>
      <dc:creator>InstaWebhook</dc:creator>
      <pubDate>Wed, 14 Oct 2026 02:36:11 +0000</pubDate>
      <link>https://dev.to/instawebhook/http3-and-quic-for-webhook-delivery-what-it-fixes-what-it-doesnt-and-how-to-adopt-it-4mdc</link>
      <guid>https://dev.to/instawebhook/http3-and-quic-for-webhook-delivery-what-it-fixes-what-it-doesnt-and-how-to-adopt-it-4mdc</guid>
      <description>&lt;p&gt;Webhooks are the standard way services push events to each other: payment notifications, repository events, order updates. A webhook sender has an unusual job. It makes outbound HTTPS requests to thousands of independent endpoints it doesn’t control. Many are slow, some are on lossy links, and most receive only occasional bursts of traffic.&lt;/p&gt;

&lt;p&gt;HTTP/3, built on the QUIC transport, changes several things that matter for this workload. It is not a universal speed-up, though. This article covers where HTTP/3 helps webhook delivery, where it doesn’t, and what it takes to adopt it safely.&lt;/p&gt;




&lt;h2&gt;
  
  
  1. Where Webhook Delivery Hits TCP’s Limits
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Handshake cost for sporadic endpoints
&lt;/h3&gt;

&lt;p&gt;A new HTTPS connection over TCP with TLS 1.3 needs one round trip for the TCP handshake and one for the TLS handshake. The first request byte therefore goes out roughly two round trips after the dial begins. A QUIC connection combines the transport and TLS 1.3 handshakes (RFC 9001) and needs one round trip before the client can send application data.&lt;/p&gt;

&lt;p&gt;For a sender that keeps long-lived, busy connections to a few endpoints, this saving is amortized away. For a sender that delivers one event every few minutes to each of thousands of subscribers, many deliveries pay the full handshake cost, so the saving is real.&lt;/p&gt;

&lt;h3&gt;
  
  
  Ephemeral port and &lt;code&gt;TIME_WAIT&lt;/code&gt; pressure
&lt;/h3&gt;

&lt;p&gt;Each outbound TCP connection is identified by its 4-tuple (source IP, source port, destination IP, destination port). A sender with a fixed set of egress IPs and a busy destination can run short of source ports. On Linux, the default ephemeral range (&lt;code&gt;net.ipv4.ip_local_port_range&lt;/code&gt;) is 32768–60999, which gives about 28,000 ports per source IP and destination tuple. Connections that close actively also linger in &lt;code&gt;TIME_WAIT&lt;/code&gt;, which is 60 seconds on Linux, so the usable number is lower in practice.&lt;/p&gt;

&lt;p&gt;QUIC runs over UDP, and a single UDP socket can carry many QUIC connections, which are distinguished by connection IDs rather than by the 4-tuple alone. A QUIC client therefore doesn’t need a separate local port per connection. There is also no &lt;code&gt;TIME_WAIT&lt;/code&gt; state in the TCP sense. This relieves port pressure, though it does not remove other limits such as file descriptors, CPU, or NAT table capacity.&lt;/p&gt;

&lt;h3&gt;
  
  
  Head-of-line blocking
&lt;/h3&gt;

&lt;p&gt;Head-of-line (HoL) blocking has several distinct forms:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;HTTP/1.1.&lt;/strong&gt; One request occupies a connection at a time. Pipelining exists in the spec but is rarely used in practice. A slow response ties up the connection, so senders open many parallel connections.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;HTTP/2 over TCP.&lt;/strong&gt; Many streams share one TCP connection, which removes request-level blocking. TCP, however, delivers a single ordered byte stream. When one packet is lost, the kernel withholds all later data from the application until the retransmission arrives, so every stream on that connection stalls, including streams whose packets all arrived.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;HTTP/3 over QUIC.&lt;/strong&gt; Streams are independent at the transport layer. Loss on one stream delays only that stream, and other streams can keep delivering data.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;QUIC does not eliminate HoL blocking entirely. Data within a single stream is still delivered in order, and an overloaded receiving application can still queue work.&lt;/p&gt;




&lt;h2&gt;
  
  
  2. How QUIC Works
&lt;/h2&gt;

&lt;p&gt;QUIC is specified in RFC 9000 (transport), RFC 9001 (TLS integration) and RFC 9002 (loss detection and congestion control). HTTP/3 is specified in RFC 9114 and its header compression, QPACK, in RFC 9204. All were published in 2021–2022. The protocol grew out of work Google began around 2012, with Jim Roskind as its original designer, and was then standardized by the IETF with significant changes.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;+-------------------------------------------------------+
| HTTP/3 (Semantics: Headers, Methods, QPACK)           |
+-------------------------------------------------------+
| QUIC (Streams, Loss Recovery, Congestion Control,     |
|       TLS 1.3 handshake and encryption)               |
+-------------------------------------------------------+
| UDP (Datagrams)                                       |
+-------------------------------------------------------+
| IP                                                    |
+-------------------------------------------------------+
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;QUIC implementations live in user space, which makes them easier to update than kernel TCP stacks. They also have to build features such as pacing, batching, and socket buffer management themselves.&lt;/p&gt;

&lt;h3&gt;
  
  
  0-RTT: powerful, but dangerous for webhooks
&lt;/h3&gt;

&lt;p&gt;QUIC supports 0-RTT resumption: a client that has previously connected to a server can send application data in its first flight. That sounds ideal for webhooks, but &lt;strong&gt;0-RTT data is not protected against replay&lt;/strong&gt;. An attacker who captures the first flight can resend it, and the server may process it twice.&lt;/p&gt;

&lt;p&gt;Because of this, HTTP/3 clients and libraries restrict 0-RTT to idempotent requests. The Go library &lt;code&gt;quic-go&lt;/code&gt;, for example, exposes 0-RTT only for GET and HEAD requests and documents that 0-RTT offers no replay protection. A webhook is a POST that triggers side effects, so a dispatcher should not send it in 0-RTT unless the receiver has made its handler idempotent and explicitly accepts early data. Treat the 1-RTT handshake as the realistic baseline.&lt;/p&gt;

&lt;h3&gt;
  
  
  Connection migration: narrower than it sounds
&lt;/h3&gt;

&lt;p&gt;QUIC connections are identified by connection IDs rather than by the IP and port 4-tuple. A client can therefore continue a connection after its address changes, for example after a NAT rebinding or a switch between network interfaces. Two limits apply:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Migration is initiated by the &lt;strong&gt;client&lt;/strong&gt;. A server cannot migrate an established connection to a new address on its own.&lt;/li&gt;
&lt;li&gt;A dispatcher pod that is evicted and rescheduled with a new IP does not carry its connections with it. The process is gone, and so is its connection state. New connections have to be established, as with TCP.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For webhook dispatchers, migration mainly helps with NAT rebinding and path changes. It does not help with pod churn, so don’t plan around it for that.&lt;/p&gt;




&lt;h2&gt;
  
  
  3. Designing a Dispatcher for HTTP/3
&lt;/h2&gt;

&lt;h3&gt;
  
  
  From connection pools to multiplexed sessions
&lt;/h3&gt;

&lt;p&gt;HTTP/2 and HTTP/3 both let one connection carry many concurrent requests. A dispatcher can therefore keep one or a few long-lived connections per subscriber host rather than a deep pool. The peer limits the number of concurrent streams through the &lt;code&gt;SETTINGS&lt;/code&gt;/transport parameters it advertises, and dispatchers should respect that limit.&lt;/p&gt;

&lt;h3&gt;
  
  
  Prioritization
&lt;/h3&gt;

&lt;p&gt;Prioritization in HTTP/3 uses the Extensible Prioritization scheme from RFC 9218, which signals urgency and incrementality hints through the &lt;code&gt;Priority&lt;/code&gt; header field. Servers treat these signals as hints and are not required to honor them. Don’t rely on them to guarantee that a critical event overtakes a routine one. Handle that ordering in your own queues before events reach the transport.&lt;/p&gt;

&lt;h3&gt;
  
  
  Loss recovery and congestion control
&lt;/h3&gt;

&lt;p&gt;QUIC’s loss recovery works per packet and retransmits the affected stream data while other streams continue. RFC 9002 describes a NewReno-style baseline, and implementations commonly offer Cubic and, in some cases, BBR variants. The available algorithms depend on the library, so check the one you use.&lt;/p&gt;

&lt;h3&gt;
  
  
  Discovery and fallback
&lt;/h3&gt;

&lt;p&gt;A dispatcher needs to know whether an endpoint supports HTTP/3. There are two standard mechanisms:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;Alt-Svc&lt;/code&gt; response header&lt;/strong&gt; (RFC 7838): an HTTP/1.1 or HTTP/2 response advertises an HTTP/3 alternative on a UDP port, usually 443.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;DNS &lt;code&gt;HTTPS&lt;/code&gt; records&lt;/strong&gt; (RFC 9460), which can advertise &lt;code&gt;h3&lt;/code&gt; support before the first connection.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Whichever you use, UDP can be blocked or rate-limited by a firewall or network path, so you need a fallback to TCP. A practical design:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Race HTTP/3 and TCP when no preference is cached, or try HTTP/3 with a short timeout.&lt;/li&gt;
&lt;li&gt;Cache the outcome per endpoint with a TTL, and retry HTTP/3 after the TTL expires.&lt;/li&gt;
&lt;li&gt;Treat the fallback as a normal path, not an error.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  4. Reference Architecture
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[Event Source / Queue] ---&amp;gt; [Dispatcher Workers]
                                      |
                              [Protocol Selector + Cache]
                                      |
       +------------------------------+------------------------------+
       | HTTP/3 (QUIC/UDP)            | HTTP/3 (QUIC/UDP)            | HTTP/2 or 1.1 (TCP)
       v                              v                              v
[Subscriber A]                [Subscriber B]                [Subscriber C]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Durable queue.&lt;/strong&gt; Events are buffered in a system such as Kafka, SQS, or RabbitMQ.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Workers.&lt;/strong&gt; They serialize the payload and sign it. Providers differ here: GitHub sends an HMAC-SHA256 signature in &lt;code&gt;X-Hub-Signature-256&lt;/code&gt;, Stripe uses a &lt;code&gt;Stripe-Signature&lt;/code&gt; header, and Shopify uses &lt;code&gt;X-Shopify-Hmac-Sha256&lt;/code&gt;. Subscribers need to know which scheme you use.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Protocol selector.&lt;/strong&gt; It holds the per-endpoint cache described above.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Retries.&lt;/strong&gt; Use exponential backoff with jitter, and give every event a stable ID so receivers can deduplicate.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Go sketch
&lt;/h3&gt;

&lt;p&gt;This example uses &lt;code&gt;quic-go&lt;/code&gt;’s HTTP/3 transport and sends a real request body. The transport is created once and reused, because creating one per request throws away connection reuse. (In recent &lt;code&gt;quic-go&lt;/code&gt; releases &lt;code&gt;http3.RoundTripper&lt;/code&gt; was renamed to &lt;code&gt;http3.Transport&lt;/code&gt;; check the version you depend on.)&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;package&lt;/span&gt; &lt;span class="n"&gt;main&lt;/span&gt;

&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s"&gt;"bytes"&lt;/span&gt;
    &lt;span class="s"&gt;"context"&lt;/span&gt;
    &lt;span class="s"&gt;"errors"&lt;/span&gt;
    &lt;span class="s"&gt;"fmt"&lt;/span&gt;
    &lt;span class="s"&gt;"io"&lt;/span&gt;
    &lt;span class="s"&gt;"net/http"&lt;/span&gt;
    &lt;span class="s"&gt;"time"&lt;/span&gt;

    &lt;span class="s"&gt;"github.com/quic-go/quic-go/http3"&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;h3Transport&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;http3&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Transport&lt;/span&gt;&lt;span class="p"&gt;{}&lt;/span&gt;

&lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;h3Client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Client&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;Transport&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;h3Transport&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;Timeout&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;   &lt;span class="m"&gt;10&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Second&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c"&gt;// Standard TCP client (HTTP/2 or HTTP/1.1) used as a fallback.&lt;/span&gt;
&lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;tcpClient&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Client&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;Timeout&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="m"&gt;10&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Second&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;newRequest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;url&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="kt"&gt;byte&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sig&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NewRequestWithContext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;MethodPost&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;bytes&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NewReader&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&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;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Header&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&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="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Header&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"X-Webhook-Signature"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sig&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Header&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"User-Agent"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"ExampleWebhookDispatcher/1.0"&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;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Client&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;req&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Do&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;req&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;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&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;err&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;defer&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Body&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Close&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;_&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;io&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Copy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;io&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Discard&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Body&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;resp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StatusCode&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="m"&gt;200&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StatusCode&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="m"&gt;300&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;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Errorf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"subscriber returned status %d"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StatusCode&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="no"&gt;nil&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c"&gt;// dispatch tries HTTP/3 first (if the endpoint is known to support it)&lt;/span&gt;
&lt;span class="c"&gt;// and falls back to TCP on transport-level failure.&lt;/span&gt;
&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;dispatch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;url&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="kt"&gt;byte&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sig&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;supportsH3&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kt"&gt;error&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;supportsH3&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;h3Ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cancel&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WithTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;3&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Second&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;defer&lt;/span&gt; &lt;span class="n"&gt;cancel&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;newRequest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;h3Ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sig&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;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&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;err&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;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;h3Client&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;isTransportError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;err&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;err&lt;/span&gt; &lt;span class="c"&gt;// e.g. a non-2xx response: do not resend over TCP blindly&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="c"&gt;// transport failure: record it in the protocol cache, then fall through&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;newRequest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sig&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;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&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;err&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;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tcpClient&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;isTransportError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;ne&lt;/span&gt; &lt;span class="k"&gt;interface&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;Timeout&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="kt"&gt;bool&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;errors&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;As&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;ne&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;errors&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Is&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DeadlineExceeded&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;Note the retry caveat: a timeout can mean the receiver processed the request but the response was lost. Falling back to another transport can therefore deliver the event twice. Include an event ID in the payload and require receivers to be idempotent.&lt;/p&gt;




&lt;h2&gt;
  
  
  5. Operational Challenges
&lt;/h2&gt;

&lt;h3&gt;
  
  
  UDP blocking and throttling
&lt;/h3&gt;

&lt;p&gt;Some enterprise firewalls and cloud security groups block or rate-limit UDP port 443. Make sure your egress rules allow it, and make the TCP fallback automatic and cached.&lt;/p&gt;

&lt;h3&gt;
  
  
  CPU cost and tuning
&lt;/h3&gt;

&lt;p&gt;QUIC does packet processing and encryption in user space, and implementations are generally more CPU-intensive per byte than kernel TCP with offloads, though the gap varies by library and hardware. Practical steps:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Load-test your actual library and payload sizes rather than assuming parity with TCP.&lt;/li&gt;
&lt;li&gt;Raise the UDP socket buffer limits (&lt;code&gt;net.core.rmem_max&lt;/code&gt; and &lt;code&gt;net.core.wmem_max&lt;/code&gt; on Linux). The &lt;code&gt;quic-go&lt;/code&gt; documentation recommends this and logs a warning when the buffers are small.&lt;/li&gt;
&lt;li&gt;Watch for kernel and NIC features, such as UDP segmentation offload, that your library can exploit.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Amplification and abuse
&lt;/h3&gt;

&lt;p&gt;QUIC’s address validation and its anti-amplification limit (a server may send at most three times the data it has received from an unvalidated address) mainly protect servers. A dispatcher acting as a client is less exposed, but the usual outbound controls still apply: egress rate limits, SSRF protections on subscriber-supplied URLs, and sensible timeouts.&lt;/p&gt;

&lt;h3&gt;
  
  
  Observability
&lt;/h3&gt;

&lt;p&gt;QUIC encrypts most transport metadata that TCP exposes, so passive tools such as &lt;code&gt;tcpdump&lt;/code&gt; with TCP flag analysis show much less. Instrument the application instead:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;handshake duration and failure reasons&lt;/li&gt;
&lt;li&gt;fraction of deliveries by protocol (HTTP/3 vs. fallback)&lt;/li&gt;
&lt;li&gt;per-endpoint latency and error rates&lt;/li&gt;
&lt;li&gt;stream resets and connection closures&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Many QUIC libraries can emit &lt;code&gt;qlog&lt;/code&gt; traces for deep debugging.&lt;/p&gt;




&lt;h2&gt;
  
  
  6. What the Evidence Says
&lt;/h2&gt;

&lt;p&gt;Public measurements show the general pattern rather than webhook-specific numbers, so treat any exact figure with caution, including the ones below.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Adoption on the receiving side is substantial but uneven.&lt;/strong&gt; W3Techs reported HTTP/3 in use on about 39.7% of websites in July 2026. Much of that is concentrated behind CDNs and reverse proxies; for sites using Cloudflare, W3Techs reported 88.8% in March 2026.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Actual request share is lower.&lt;/strong&gt; One analysis of Q1 2026 Cloudflare Radar data put HTTP/3 at about 21% of requests, compared with about 51% for HTTP/2 and about 28% for HTTP/1.x, with bot and server-to-server traffic a large contributor to the gap.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Benefits are largest on high-latency and lossy paths.&lt;/strong&gt; Field measurements consistently find larger gains over long distances and on mobile networks than on clean, low-latency datacenter links.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For a dispatcher, this means two things. First, only some of your subscribers can receive HTTP/3 today, so fallback is a day-one requirement. Second, the gain for any individual endpoint depends on its network path, so measure your own traffic: compare latency percentiles, failure rates, and CPU use between the two protocols for the same endpoints before committing.&lt;/p&gt;




&lt;h2&gt;
  
  
  7. Should You Adopt It?
&lt;/h2&gt;

&lt;p&gt;HTTP/3 is a good fit for a webhook dispatcher when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;many endpoints are contacted infrequently, so handshake cost matters,&lt;/li&gt;
&lt;li&gt;you deliver across long or lossy internet paths,&lt;/li&gt;
&lt;li&gt;you are hitting ephemeral port limits or connection churn on TCP, and&lt;/li&gt;
&lt;li&gt;your subscribers sit behind CDNs or proxies that already speak HTTP/3.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It offers little when your traffic goes to a handful of well-connected endpoints over warm, long-lived HTTP/2 connections.&lt;/p&gt;

&lt;p&gt;A sensible rollout:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Add HTTP/3 as an opt-in transport behind a feature flag, with cached fallback to TCP.&lt;/li&gt;
&lt;li&gt;Start with endpoints that advertise &lt;code&gt;Alt-Svc&lt;/code&gt; or DNS &lt;code&gt;h3&lt;/code&gt; support.&lt;/li&gt;
&lt;li&gt;Don’t use 0-RTT for POST deliveries.&lt;/li&gt;
&lt;li&gt;Compare latency, success rate, and CPU against the TCP baseline per endpoint.&lt;/li&gt;
&lt;li&gt;Expand gradually as the data supports it.&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  References and Further Reading
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;RFC 9000: &lt;em&gt;QUIC: A UDP-Based Multiplexed and Secure Transport&lt;/em&gt;, IETF.&lt;/li&gt;
&lt;li&gt;RFC 9001: &lt;em&gt;Using TLS to Secure QUIC&lt;/em&gt;, IETF.&lt;/li&gt;
&lt;li&gt;RFC 9002: &lt;em&gt;QUIC Loss Detection and Congestion Control&lt;/em&gt;, IETF.&lt;/li&gt;
&lt;li&gt;RFC 9114: &lt;em&gt;HTTP/3&lt;/em&gt;, IETF.&lt;/li&gt;
&lt;li&gt;RFC 9204: &lt;em&gt;QPACK: Field Compression for HTTP/3&lt;/em&gt;, IETF.&lt;/li&gt;
&lt;li&gt;RFC 9218: &lt;em&gt;Extensible Prioritization Scheme for HTTP&lt;/em&gt;, IETF.&lt;/li&gt;
&lt;li&gt;RFC 7838: &lt;em&gt;HTTP Alternative Services&lt;/em&gt;, IETF.&lt;/li&gt;
&lt;li&gt;RFC 9460: &lt;em&gt;Service Binding and Parameter Specification via the DNS (SVCB and HTTPS Resource Records)&lt;/em&gt;, IETF.&lt;/li&gt;
&lt;li&gt;W3Techs, &lt;em&gt;Usage statistics of HTTP/3&lt;/em&gt; (w3techs.com).&lt;/li&gt;
&lt;li&gt;Cloudflare Radar, &lt;em&gt;HTTP protocol version trends&lt;/em&gt; (radar.cloudflare.com).&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;quic-go&lt;/code&gt; documentation (quic-go.net), including its notes on 0-RTT and UDP buffer sizes.&lt;/li&gt;
&lt;li&gt;Ilya Grigorik, &lt;em&gt;High Performance Browser Networking&lt;/em&gt; (O’Reilly, 2013). Pre-dates HTTP/3, but remains a good foundation for TCP and TLS performance.&lt;/li&gt;
&lt;/ol&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://instawebhook.com/blog/http-3-and-quic-for-webhook-delivery-what-it-fixes-what-it-doesn-t-and-how-to-ad" rel="noopener noreferrer"&gt;https://instawebhook.com/blog/http-3-and-quic-for-webhook-delivery-what-it-fixes-what-it-doesn-t-and-how-to-ad&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>tutorial</category>
    </item>
    <item>
      <title>The Multi-Tenant Neighbor Problem: Why Traditional API Rate Limiters Fail Under Webhook-Heavy Bursts</title>
      <dc:creator>InstaWebhook</dc:creator>
      <pubDate>Tue, 13 Oct 2026 02:20:58 +0000</pubDate>
      <link>https://dev.to/instawebhook/the-multi-tenant-neighbor-problem-why-traditional-api-rate-limiters-fail-under-webhook-heavy-bursts-2dja</link>
      <guid>https://dev.to/instawebhook/the-multi-tenant-neighbor-problem-why-traditional-api-rate-limiters-fail-under-webhook-heavy-bursts-2dja</guid>
      <description>&lt;p&gt;Modern software architectures rely heavily on webhooks. Whether your application processes payments via Stripe, synchronizes inventory with Shopify, or reacts to repository events from GitHub, webhooks are how many real-time, event-driven systems learn that something happened. As applications scale, engineering teams run into an architectural problem known as the &lt;strong&gt;multi-tenant neighbor problem&lt;/strong&gt;, often called the noisy neighbor problem.&lt;/p&gt;

&lt;p&gt;Consider a hypothetical scenario. Your SaaS platform serves thousands of businesses, each a tenant with its own data and API credentials. One of your largest customers launches a flash sale on Shopify, and your ingestion endpoint suddenly receives tens of thousands of webhook requests per second. For illustration, this article uses 50,000 requests per second (RPS). That figure is an assumption, not a published Shopify number. It is a plausible order of magnitude for a platform that aggregates events from many merchants, and a single merchant would rarely produce it alone.&lt;/p&gt;

&lt;p&gt;If your system uses rate-limiting strategies designed for ordinary user traffic, it may crash, bottleneck, or starve unrelated customers.&lt;/p&gt;

&lt;p&gt;This guide explains why traditional rate limiters struggle with webhook spikes, how the neighbor problem plays out in a real ingestion pipeline, and which patterns help you build a resilient one. Provider behavior described here (timeouts, retries) comes from the providers’ own documentation, linked at the end.&lt;/p&gt;




&lt;h2&gt;
  
  
  1. Understanding the Multi-Tenant Neighbor Problem in Webhooks
&lt;/h2&gt;

&lt;p&gt;In a shared multi-tenant architecture, CPU, memory, database connection pools, and network capacity are pooled across customers. This improves hardware efficiency and lowers cost, but it exposes every tenant to the behavior of the loudest one.&lt;/p&gt;

&lt;h3&gt;
  
  
  How Webhook Traffic Differs from User Traffic
&lt;/h3&gt;

&lt;p&gt;Ordinary user traffic is spread across many endpoints (login, dashboard, search) and paced by human interaction. Webhook traffic behaves differently:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Event-driven bursts.&lt;/strong&gt; Providers push data when events occur. Bulk operations, such as a flash sale, a mass product update, or a batch import, can generate thousands of events in a very short window. For scale, Shopify reported that during Black Friday–Cyber Monday 2025 its API requests peaked at &lt;a href="https://www.shopify.com/news/bfcm-data-2025" rel="noopener noreferrer"&gt;31.8 million per minute&lt;/a&gt; and merchant sales peaked at $5.1 million per minute. Those are platform-wide figures rather than webhook counts, but they show how sharply commerce traffic can spike.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tight response deadlines.&lt;/strong&gt; Providers expect a fast acknowledgment. Shopify fails a delivery if your app doesn’t respond within &lt;a href="https://shopify.dev/docs/apps/build/webhooks/troubleshoot" rel="noopener noreferrer"&gt;five seconds&lt;/a&gt;, and GitHub gives your server &lt;a href="https://docs.github.com/webhooks/using-webhooks/best-practices-for-using-webhooks" rel="noopener noreferrer"&gt;10 seconds&lt;/a&gt; before it terminates the connection and records a failure. Stripe’s documentation asks endpoints to return a 2xx status code quickly, before doing complex work.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Provider-side retries.&lt;/strong&gt; Retry behavior varies by provider, and it can amplify load on a struggling receiver:

&lt;ul&gt;
&lt;li&gt;Stripe, in live mode, &lt;a href="https://stripe.com/docs/webhooks" rel="noopener noreferrer"&gt;attempts delivery for up to three days with exponential backoff&lt;/a&gt;. In sandbox mode it retries three times over a few hours.&lt;/li&gt;
&lt;li&gt;Shopify &lt;a href="https://shopify.dev/docs/apps/build/webhooks/troubleshoot" rel="noopener noreferrer"&gt;retries failed deliveries up to eight times over four hours&lt;/a&gt;. If failures persist, it removes the webhook subscription.&lt;/li&gt;
&lt;li&gt;GitHub &lt;a href="https://docs.github.com/webhooks/using-webhooks/handling-failed-webhook-deliveries" rel="noopener noreferrer"&gt;does not automatically redeliver failed deliveries&lt;/a&gt;. You have to redeliver them manually or with a script.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Per-request work.&lt;/strong&gt; Webhook handlers typically verify a cryptographic signature, resolve which tenant the event belongs to, and persist or enqueue the payload.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;When one tenant generates a traffic surge, it competes with every other tenant for the same shared resources. The others see higher latency, timeouts, or dropped events even though their own traffic hasn’t changed.&lt;/p&gt;




&lt;h2&gt;
  
  
  2. Why Traditional API Rate Limiters Fail
&lt;/h2&gt;

&lt;p&gt;Common algorithms such as token bucket, leaky bucket, and fixed or sliding window counters, often backed by Redis, work well for protecting CRUD APIs from scrapers and runaway client scripts. They break down for webhook ingestion for several reasons.&lt;/p&gt;

&lt;h3&gt;
  
  
  A. IP-Based Limiting Doesn’t Identify Tenants
&lt;/h3&gt;

&lt;p&gt;Webhooks from a given provider come from that provider’s infrastructure, not from your tenant’s. Every tenant’s events arrive from the same pool of source addresses. Limiting by IP therefore either throttles the provider as a whole, which punishes every tenant using that integration, or fails to single out the one tenant causing the burst. IP allowlists are still useful as an additional security layer where providers publish ranges (GitHub, for example, exposes its addresses through its &lt;a href="https://docs.github.com/webhooks/using-webhooks/best-practices-for-using-webhooks" rel="noopener noreferrer"&gt;&lt;code&gt;GET /meta&lt;/code&gt; endpoint&lt;/a&gt;), but IP is the wrong key for fairness between tenants.&lt;/p&gt;

&lt;h3&gt;
  
  
  B. Global or Per-Route Limits Cause Cross-Tenant Starvation
&lt;/h3&gt;

&lt;p&gt;Suppose you cap &lt;code&gt;/api/webhooks/shopify&lt;/code&gt; at 10,000 RPS. If Tenant A generates 12,000 RPS, the limiter has no concept of who is responsible. Tenant B’s legitimate events are throttled or rejected alongside A’s, which causes missed order updates and out-of-sync data.&lt;/p&gt;

&lt;h3&gt;
  
  
  C. A Centralized Counter Becomes a Hot Spot
&lt;/h3&gt;

&lt;p&gt;Distributed rate limiters commonly rely on Redis atomic operations (&lt;code&gt;INCR&lt;/code&gt;, &lt;code&gt;EXPIRE&lt;/code&gt;, or Lua scripts). Redis executes commands on a single main thread, so the problem is not lock contention. It is that one hot key lives on one shard and every limiter check is a network round trip to it. At tens of thousands of checks per second, the limiter’s own latency, network bandwidth, and availability become part of your request path. If the limiter is unreachable, you have to decide whether to fail open (and risk overload) or fail closed (and reject legitimate traffic).&lt;/p&gt;

&lt;h3&gt;
  
  
  D. Rejecting a Webhook Doesn’t Make It Go Away
&lt;/h3&gt;

&lt;p&gt;Traditional limiters are binary: allow the request or reject it with &lt;code&gt;429 Too Many Requests&lt;/code&gt;. Webhook senders aren’t ordinary API clients. Shopify counts any non-2xx response as a failed delivery and retries it, and it may eventually remove the subscription. Stripe retries for days. A rejected event is therefore either redelivered later, adding to future load, or lost if the provider gives up or never retries (as with GitHub). Neither outcome is what you want from a load-protection mechanism.&lt;/p&gt;

&lt;p&gt;Providers’ documentation does not state that they honor a &lt;code&gt;Retry-After&lt;/code&gt; header on webhook deliveries, so don’t design your protection strategy around it. The more reliable approach is to acknowledge quickly and absorb the burst in a durable buffer.&lt;/p&gt;




&lt;h2&gt;
  
  
  3. Anatomy of a Webhook Ingestion Failure
&lt;/h2&gt;

&lt;p&gt;Here is how an unprotected, synchronous pipeline typically fails under a sudden burst from one tenant:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Edge saturation.&lt;/strong&gt; The load balancer or gateway (NGINX, Envoy, a cloud API gateway) handles a flood of new connections. TLS handshakes and connection handling consume CPU.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Per-request overhead.&lt;/strong&gt; Each request needs signature verification and a lookup of the tenant’s signing secret. The HMAC computation itself is cheap relative to TLS and JSON parsing, but if the secret comes from a database on every request, that lookup becomes the bottleneck.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Connection pool exhaustion.&lt;/strong&gt; Each handler tries to read tenant metadata or write the event to a relational database. Connection pools (for example HikariCP, or PgBouncer in front of PostgreSQL) fill up, threads block waiting for connections, and timeouts cascade.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Memory pressure.&lt;/strong&gt; Requests pile up in application memory while waiting for workers. Eventually the process or container exceeds its memory limit and the kernel’s OOM killer, or the orchestrator, restarts it. A restarted instance that immediately faces the same backlog can fall into a crash loop.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Provider retries amplify the damage.&lt;/strong&gt; Once responses exceed the provider’s timeout (five seconds for Shopify, ten for GitHub), the provider records failures. Where it retries, as Stripe and Shopify do, it adds more load while you are already down.&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  4. Modern Architectural Patterns to Solve the Neighbor Problem
&lt;/h2&gt;

&lt;p&gt;The core shift is from synchronous processing and flat rate limits to &lt;strong&gt;asynchronous ingestion, per-tenant isolation, and tenant-keyed quotas&lt;/strong&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Pattern 1: Tenant-Aware Rate Limiting with Dynamic Quotas
&lt;/h3&gt;

&lt;p&gt;Key limits by tenant rather than by route or IP. Depending on the provider, the tenant can be identified from the URL path (for example &lt;code&gt;/webhooks/shopify/{tenant_id}&lt;/code&gt;), a subdomain, or a header. Shopify, for instance, includes the originating shop’s domain in a request header.&lt;/p&gt;

&lt;p&gt;One caution: the tenant identifier is unauthenticated until the signature has been verified. Anyone who can reach your endpoint can forge a header or URL. Use the identifier to look up the signing secret and verify the signature before charging the request to that tenant’s quota or trusting the payload. Otherwise an attacker can exhaust a victim’s quota with forged requests.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Hierarchical limiting.&lt;/strong&gt; Use two tiers:

&lt;ol&gt;
&lt;li&gt;A &lt;em&gt;global safety cap&lt;/em&gt; that protects the infrastructure as a whole.&lt;/li&gt;
&lt;li&gt;A &lt;em&gt;per-tenant quota&lt;/em&gt;, set by plan tier or historical volume, so Tenant A cannot consume capacity reserved for Tenant B.&lt;/li&gt;
&lt;/ol&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Shed after acknowledging, not before.&lt;/strong&gt; Because rejected webhooks come back as retries (or are lost), apply per-tenant limits primarily to how fast you &lt;em&gt;process&lt;/em&gt; events from the buffer, not to whether you &lt;em&gt;accept&lt;/em&gt; them.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Pattern 2: Decouple Ingestion from Processing
&lt;/h3&gt;

&lt;p&gt;Don’t process webhook payloads synchronously. Both &lt;a href="https://docs.github.com/webhooks/using-webhooks/best-practices-for-using-webhooks" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; and &lt;a href="https://shopify.dev/docs/apps/build/webhooks/troubleshoot" rel="noopener noreferrer"&gt;Shopify&lt;/a&gt; advise responding quickly and handling the work in the background, and GitHub specifically suggests a queue for this. The ingestion endpoint should do the minimum:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Read the raw request body.&lt;/li&gt;
&lt;li&gt;Verify the signature against the raw bytes.&lt;/li&gt;
&lt;li&gt;Write the event to a durable buffer.&lt;/li&gt;
&lt;li&gt;Return a 2xx response.&lt;/li&gt;
&lt;/ol&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Durable buffers.&lt;/strong&gt; Apache Kafka, Redpanda, Amazon Kinesis, or a managed queue such as Amazon SQS all work. Choose based on throughput, ordering, and operational needs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Partitioning by tenant.&lt;/strong&gt; In Kafka-style logs, keying messages by tenant ID sends all of a tenant’s events to the same partition, which preserves per-tenant ordering. Ordering is guaranteed only within a partition, and several tenants can share one. A very large tenant can also create a hot partition, so consider sub-keying (for example by entity ID) for heavy tenants if you don’t need strict tenant-wide ordering.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Idempotency.&lt;/strong&gt; Delivery is at-least-once, so consumers must tolerate duplicates. Use the provider’s delivery identifier, such as &lt;code&gt;X-Shopify-Webhook-Id&lt;/code&gt; for Shopify or &lt;code&gt;X-GitHub-Delivery&lt;/code&gt; for GitHub, as an idempotency key. GitHub keeps the same &lt;code&gt;X-GitHub-Delivery&lt;/code&gt; value on manual redeliveries.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[Webhook Provider]
       │  burst of requests
       ▼
[Edge / API Gateway]
       │  verify signature, enqueue, return 2xx
       ▼
[Durable Log / Queue]  (partitioned by tenant)
       │
       ▼
[Per-Tenant Rate-Limited Worker Pools]
       │
       ▼
[Tenant Data Stores]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Pattern 3: Prioritization and Graceful Degradation
&lt;/h3&gt;

&lt;p&gt;When queues or worker pools approach saturation, degrade deliberately instead of failing everywhere.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Priority lanes.&lt;/strong&gt; Route business-critical events (for example, payment confirmations) to a high-priority queue and lower-value events (analytics, profile updates) to a lower-priority one that can lag during congestion.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Backlog-aware processing.&lt;/strong&gt; Slow or pause consumption for the offending tenant while others continue, and alert on queue depth and consumer lag rather than only on request rates.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Treat non-2xx as a last resort.&lt;/strong&gt; If you must reject traffic, such as when the buffer itself is unavailable, remember that Shopify caps retries at eight and may remove the subscription, and that GitHub won’t retry at all. Build a reconciliation path: GitHub lets you list and redeliver failed deliveries through its API, and Shopify recommends re-importing data from the outage window after downtime.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Pattern 4: Resource Isolation (Bulkheading)
&lt;/h3&gt;

&lt;p&gt;Bulkheads, borrowed from ship design and resilience engineering, partition resources so that failure or overload in one compartment doesn’t sink everything.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Dedicated worker pools or queues&lt;/strong&gt; for high-volume enterprise tenants, with smaller shared pools for everyone else.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Per-tenant concurrency caps&lt;/strong&gt; so one tenant cannot occupy all database connections.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Data partitioning.&lt;/strong&gt; Shard or partition tenant data so heavy write load from one tenant is confined to its own partitions or databases where practical.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  5. A Token Bucket Rate Limiter in Redis (Lua)
&lt;/h2&gt;

&lt;p&gt;To avoid race conditions and extra round trips when enforcing per-tenant limits, run the token bucket logic as an atomic Lua script. Redis executes a script as a single unit, so concurrent callers can’t interleave between the read and the write.&lt;/p&gt;

&lt;p&gt;This version reads the clock from Redis itself (&lt;code&gt;TIME&lt;/code&gt;) so that clock skew between application servers cannot corrupt the bucket. It requires Redis 5.0 or later, where scripts that call non-deterministic commands are replicated by their effects. It also returns a suggested wait time you can use to pace your own consumers.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight lua"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- Tenant token bucket&lt;/span&gt;
&lt;span class="c1"&gt;-- KEYS[1]: bucket key, e.g. ratelimit:tenant:12345&lt;/span&gt;
&lt;span class="c1"&gt;-- ARGV[1]: bucket capacity (maximum burst)&lt;/span&gt;
&lt;span class="c1"&gt;-- ARGV[2]: refill rate (tokens per second)&lt;/span&gt;
&lt;span class="c1"&gt;-- ARGV[3]: tokens requested (usually 1)&lt;/span&gt;

&lt;span class="kd"&gt;local&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'TIME'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;local&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;tonumber&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nb"&gt;tonumber&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1000000&lt;/span&gt;

&lt;span class="kd"&gt;local&lt;/span&gt; &lt;span class="n"&gt;capacity&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;tonumber&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ARGV&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;span class="kd"&gt;local&lt;/span&gt; &lt;span class="n"&gt;refill_rate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;tonumber&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ARGV&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;span class="kd"&gt;local&lt;/span&gt; &lt;span class="n"&gt;requested&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;tonumber&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ARGV&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;

&lt;span class="kd"&gt;local&lt;/span&gt; &lt;span class="n"&gt;bucket&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'HMGET'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;KEYS&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="s1"&gt;'tokens'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'ts'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;local&lt;/span&gt; &lt;span class="n"&gt;tokens&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;tonumber&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bucket&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;span class="kd"&gt;local&lt;/span&gt; &lt;span class="n"&gt;ts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;tonumber&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bucket&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2&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;tokens&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="kc"&gt;nil&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;ts&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="kc"&gt;nil&lt;/span&gt; &lt;span class="k"&gt;then&lt;/span&gt;
  &lt;span class="n"&gt;tokens&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;capacity&lt;/span&gt;
  &lt;span class="n"&gt;ts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;

&lt;span class="c1"&gt;-- Refill based on elapsed time&lt;/span&gt;
&lt;span class="n"&gt;tokens&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;math.min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;capacity&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tokens&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nb"&gt;math.max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;ts&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;refill_rate&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="kd"&gt;local&lt;/span&gt; &lt;span class="n"&gt;allowed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
&lt;span class="kd"&gt;local&lt;/span&gt; &lt;span class="n"&gt;retry_after&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;tokens&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;requested&lt;/span&gt; &lt;span class="k"&gt;then&lt;/span&gt;
  &lt;span class="n"&gt;tokens&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;tokens&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;requested&lt;/span&gt;
  &lt;span class="n"&gt;allowed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
&lt;span class="k"&gt;else&lt;/span&gt;
  &lt;span class="n"&gt;retry_after&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;requested&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;tokens&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;refill_rate&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;

&lt;span class="n"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'HSET'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;KEYS&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="s1"&gt;'tokens'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tokens&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'ts'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c1"&gt;-- Let idle buckets expire (twice the time needed to fully refill)&lt;/span&gt;
&lt;span class="n"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'EXPIRE'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;KEYS&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nb"&gt;math.ceil&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;capacity&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;refill_rate&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;allowed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;tostring&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;retry_after&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two practical notes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Where to apply it.&lt;/strong&gt; Use this limiter to meter &lt;em&gt;processing&lt;/em&gt; per tenant, such as consumers pulling from the queue, rather than as a gate that rejects webhook deliveries at the edge (see Pattern 3).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Limiter failure mode.&lt;/strong&gt; Decide up front what happens if Redis is slow or unavailable. For ingestion, failing open (accept and buffer) is usually safer than failing closed, because the queue provides the real protection. Keep local, in-process fallback limits as a backstop.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  6. Summary and Best Practices Checklist
&lt;/h2&gt;

&lt;p&gt;Protecting your ingestion pipeline from the multi-tenant neighbor problem means moving from reactive scaling to proactive isolation:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Acknowledge fast, process later.&lt;/strong&gt; Verify the signature, write to a durable buffer, and return 2xx well inside the provider’s deadline (five seconds for Shopify, ten for GitHub).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Key quotas by tenant, not by IP or route.&lt;/strong&gt; Authenticate the request before charging a tenant’s quota.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Verify signatures on the raw body&lt;/strong&gt;, and cache signing secrets so verification doesn’t hit your database on every request.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Meter consumption, not acceptance.&lt;/strong&gt; Apply per-tenant rate limits and concurrency caps to workers reading from the queue.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Design for duplicates.&lt;/strong&gt; Use delivery IDs for idempotency, since providers retry and consumers may reprocess.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Isolate with bulkheads.&lt;/strong&gt; Give heavy tenants dedicated pools, and cap per-tenant database concurrency.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Plan for reconciliation.&lt;/strong&gt; Know how to recover missed events: GitHub supports redelivery through its API, and Shopify recommends re-importing data after an outage.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Monitor the right signals.&lt;/strong&gt; Watch queue depth, consumer lag, and per-tenant processing time, along with request rate.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A system built this way absorbs bursts in the buffer, throttles the noisy tenant at the processing stage, and keeps its neighbors online.&lt;/p&gt;




&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Shopify, &lt;a href="https://shopify.dev/docs/apps/build/webhooks/troubleshoot" rel="noopener noreferrer"&gt;Troubleshoot webhooks&lt;/a&gt;: five-second response limit, retry behavior, and subscription removal.&lt;/li&gt;
&lt;li&gt;Stripe, &lt;a href="https://stripe.com/docs/webhooks" rel="noopener noreferrer"&gt;Receive Stripe events in your webhook endpoint&lt;/a&gt;: retry behavior in live and sandbox modes.&lt;/li&gt;
&lt;li&gt;GitHub Docs, &lt;a href="https://docs.github.com/webhooks/using-webhooks/best-practices-for-using-webhooks" rel="noopener noreferrer"&gt;Best practices for using webhooks&lt;/a&gt;: 10-second response window, queue recommendation, delivery header.&lt;/li&gt;
&lt;li&gt;GitHub Docs, &lt;a href="https://docs.github.com/webhooks/using-webhooks/handling-failed-webhook-deliveries" rel="noopener noreferrer"&gt;Handling failed webhook deliveries&lt;/a&gt;: no automatic redelivery.&lt;/li&gt;
&lt;li&gt;Shopify, &lt;a href="https://www.shopify.com/news/bfcm-data-2025" rel="noopener noreferrer"&gt;BFCM 2025 data&lt;/a&gt;: peak sales and API request volumes.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://instawebhook.com/blog/the-multi-tenant-neighbor-problem-why-traditional-api-rate-limiters-fail-under-w" rel="noopener noreferrer"&gt;https://instawebhook.com/blog/the-multi-tenant-neighbor-problem-why-traditional-api-rate-limiters-fail-under-w&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>security</category>
    </item>
    <item>
      <title>Webhook Payload Encryption at Rest: Application-Layer Cryptography for Sensitive Webhook Pipelines</title>
      <dc:creator>InstaWebhook</dc:creator>
      <pubDate>Mon, 12 Oct 2026 01:58:16 +0000</pubDate>
      <link>https://dev.to/instawebhook/webhook-payload-encryption-at-rest-application-layer-cryptography-for-sensitive-webhook-pipelines-33hk</link>
      <guid>https://dev.to/instawebhook/webhook-payload-encryption-at-rest-application-layer-cryptography-for-sensitive-webhook-pipelines-33hk</guid>
      <description>&lt;p&gt;Webhooks synchronize data between microservices, trigger automated workflows, and deliver events from third-party SaaS providers. More and more of them carry regulated data: payment card numbers, health records, and personal data covered by GDPR and CCPA. That makes every system a webhook touches after your ingress point part of your security perimeter.&lt;/p&gt;

&lt;p&gt;A typical pipeline terminates HTTPS at a gateway, pushes the JSON body into a queue such as Redis or Amazon SQS, and archives it in a relational database like PostgreSQL. Every hop after TLS termination usually holds plaintext. An attacker who reads your queue, restores a database backup, or finds an unredacted log line gets the full payloads.&lt;/p&gt;

&lt;p&gt;This guide describes a practical blueprint for &lt;strong&gt;application-layer (envelope) encryption of webhook payloads&lt;/strong&gt;, so that plaintext exists only at the ingress service and at the workers that actually need it. It covers envelope encryption, a per-tenant key hierarchy, and how to keep events searchable in PostgreSQL. It also covers retries and idempotency, and what “cryptographic erasure” can and cannot promise.&lt;/p&gt;

&lt;p&gt;A note on terminology: the sender’s TLS session ends at your edge, so this is not end-to-end encryption between you and the webhook provider. It is end-to-end &lt;em&gt;inside your own infrastructure&lt;/em&gt;, from the ingress service to the worker that processes the event.&lt;/p&gt;




&lt;h2&gt;
  
  
  1. The Threat Model of Traditional Webhook Pipelines
&lt;/h2&gt;

&lt;p&gt;A standard webhook carrying sensitive data goes through these stages:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Ingress:&lt;/strong&gt; A provider (Stripe, Salesforce, a healthcare partner) sends an HTTP POST with a JSON body to your public endpoint.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Termination:&lt;/strong&gt; Your load balancer or API gateway terminates TLS, and the body exists as plaintext bytes in memory. Your receiver also needs those raw bytes to verify the provider’s signature (see Section 6).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Queueing:&lt;/strong&gt; The receiver pushes the body to a broker (Redis Streams, RabbitMQ, SQS) for asynchronous processing. In many setups, the queue holds raw JSON.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Worker processing:&lt;/strong&gt; A worker consumes the message, runs business logic, and writes the event to an audit table or event store, often in a PostgreSQL &lt;code&gt;JSONB&lt;/code&gt; column. Without payload-level encryption, those rows hold the sensitive values indefinitely, and they propagate into replicas and backups.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Logging:&lt;/strong&gt; Debug statements and exception handlers print request bodies into Datadog, Elasticsearch, CloudWatch, or similar systems.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Platform-level encryption helps but does not close these gaps. SQS supports server-side encryption, and managed databases encrypt volumes. That protects against stolen disks and storage-layer access. It does not protect against any principal that is &lt;em&gt;authorized&lt;/em&gt; to read the queue or query the database, such as an over-privileged service role, a compromised application credential, or a developer with production read access. Application-layer encryption narrows that exposure to principals that are also allowed to use the key.&lt;/p&gt;

&lt;h3&gt;
  
  
  Compliance Implications
&lt;/h3&gt;

&lt;p&gt;Encryption is not the only control these frameworks require, but each one points toward protecting stored data in this way.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;PCI DSS (v4.0.1):&lt;/strong&gt; Requirement 3.5.1 says the primary account number (PAN) must be rendered unreadable anywhere it is stored. Acceptable methods are one-way hashes, truncation, index tokens, or strong cryptography with key-management processes. The requirement’s applicability notes cover primary storage and also backups, audit logs, and exception or troubleshooting logs. In PCI DSS v3.2.1 this was Requirement 3.4. Requirement 3.5.1.2 also limits disk-level or partition-level encryption as a way to render PAN unreadable. It is acceptable only on removable media, otherwise another mechanism meeting 3.5.1 is needed. This is a strong argument for field-level or payload-level encryption of non-removable storage. Separately, Requirement 3.3.1 prohibits storing sensitive authentication data after authorization, even if encrypted. The best design is to keep PANs out of webhook payloads entirely by using tokens.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;HIPAA Security Rule:&lt;/strong&gt; 45 CFR § 164.312(a)(2)(iv) covers encryption and decryption of ePHI. Today it is an “addressable” implementation specification: you must implement it or document an equivalent alternative. HHS published a proposed rule on January 6, 2025 that would make encryption of ePHI at rest and in transit mandatory with limited exceptions. As of mid-2026 reporting, it had not been finalized, and sources differ on when it might be, with some pointing to a 2027 target. Encryption also matters for breach response. Under HHS guidance, ePHI encrypted consistent with NIST guidance, with the keys not compromised, is generally not considered “unsecured PHI” for breach notification purposes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GDPR Article 32:&lt;/strong&gt; It lists pseudonymisation and encryption of personal data as examples of appropriate technical measures, to be chosen according to risk, cost, and context. It does not mandate encryption in every case. Article 17 provides the right to erasure.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CCPA/CPRA:&lt;/strong&gt; California’s consumer right is a right to &lt;em&gt;delete&lt;/em&gt; personal information, not a literal “right to be forgotten.” The statute’s breach-related private right of action (Civil Code § 1798.150) applies to &lt;em&gt;nonencrypted and nonredacted&lt;/em&gt; personal information, which gives encryption direct legal value.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  2. Core Pattern: Envelope Encryption
&lt;/h2&gt;

&lt;p&gt;To protect payloads across queues, caches, and general-purpose databases, you encrypt before the data touches any of them. The standard technique for this is &lt;strong&gt;envelope encryption&lt;/strong&gt;, which uses two tiers of keys:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Data Encryption Key (DEK):&lt;/strong&gt; A random symmetric key generated for each webhook payload. It encrypts the payload with an authenticated mode such as AES-GCM or ChaCha20-Poly1305.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Key Encryption Key (KEK):&lt;/strong&gt; A longer-lived key that never encrypts payloads directly. It only wraps (encrypts) DEKs. In production the root KEK lives in a KMS or HSM, such as AWS KMS, Google Cloud KMS, Azure Key Vault, or HashiCorp Vault’s transit engine.
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Incoming webhook body (plaintext bytes)
        │
        ▼
[ Generate random DEK ] ──encrypts──&amp;gt; [ Ciphertext + nonce + auth tag ]
        │                                          │
        ▼                                          │
[ Wrap DEK with KEK ]                              │
        │                                          │
        ▼                                          ▼
   [ Wrapped DEK ]  ───────── stored together ─────────&amp;gt;  queue / database
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Why an AEAD mode?
&lt;/h3&gt;

&lt;p&gt;Use an Authenticated Encryption with Associated Data (AEAD) cipher. &lt;strong&gt;AES-256-GCM&lt;/strong&gt; (specified in NIST SP 800-38D) and &lt;strong&gt;ChaCha20-Poly1305&lt;/strong&gt; (RFC 8439) provide confidentiality and integrity. If anyone modifies the ciphertext, nonce, or associated data, decryption fails with an authentication error instead of returning altered plaintext. Alert on those failures.&lt;/p&gt;

&lt;p&gt;Two details are often missed:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Bind context with associated data.&lt;/strong&gt; Pass values such as the tenant ID, event ID, and envelope version as AAD. They are not encrypted but are authenticated, so an attacker cannot move a valid ciphertext into another tenant’s record or swap it between events.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Respect nonce limits.&lt;/strong&gt; GCM is catastrophically weak if a nonce repeats under the same key. NIST SP 800-38D requires that the probability of reusing an IV under one key stay at or below 2^-32. With random 96-bit IVs this is commonly interpreted as a cap of about 2^32 (roughly 4.3 billion) invocations per key. A fresh DEK per message makes the payload encryption safe by construction. But if you wrap every DEK under one long-lived tenant key with random nonces, you are accumulating invocations under that key. Version your keys and rotate them well before the limit, or use a KMS call for wrapping.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  3. Multi-Tenant Key Hierarchy
&lt;/h2&gt;

&lt;p&gt;In a multi-tenant system, encrypting everyone’s data under one global key creates a large blast radius. It also makes per-tenant deletion impossible without re-encrypting everything. You want each tenant’s data to be separable cryptographically.&lt;/p&gt;

&lt;p&gt;One KMS key per tenant is the simplest model and works well for tens or hundreds of tenants. It gets expensive and operationally heavy at thousands. AWS KMS cryptographic operations also share a per-account, per-region request quota that varies by region (the AWS Security Blog cites a 10,000 requests-per-second default in several major regions). The quota is adjustable, and AWS recommends reducing KMS calls with data key caching, for example in the AWS Encryption SDK.&lt;/p&gt;

&lt;p&gt;A scalable alternative is a &lt;strong&gt;three-level hierarchy&lt;/strong&gt;:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Root key (in KMS/HSM):&lt;/strong&gt; Never leaves the KMS boundary. It is used only to wrap and unwrap tenant keys.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tenant key (random, 256-bit):&lt;/strong&gt; Generated per tenant, for example with KMS &lt;code&gt;GenerateDataKey&lt;/code&gt;. You store only its KMS-wrapped form in a &lt;code&gt;tenant_keys&lt;/code&gt; table, and workers unwrap it once and cache it in memory with a TTL.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Message DEK (random, 256-bit):&lt;/strong&gt; Generated per webhook and wrapped with the tenant key.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;HKDF (RFC 5869) fits in this hierarchy for &lt;strong&gt;domain separation&lt;/strong&gt;: deriving independent subkeys (payload wrapping, blind indexes, and so on) from one tenant key so the same key material is never reused across purposes.&lt;/p&gt;

&lt;p&gt;A common design mistake is to derive tenant keys with HKDF directly from a root key held in KMS. KMS does not expose raw key material or an HKDF operation, so your code would have to hold the root key in application memory, defeating the point of a KMS boundary. If you want deterministic derivation, derive from a root secret you manage yourself, and understand that it then becomes your most sensitive asset. Also note that a purely derived key cannot be deleted. It can always be recomputed from the root and the tenant ID. That matters for erasure (Section 7), and it is why the hierarchy above stores a &lt;em&gt;random&lt;/em&gt; tenant key you can destroy.&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;boto3&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;cryptography.hazmat.primitives&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;hashes&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;cryptography.hazmat.primitives.kdf.hkdf&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;HKDF&lt;/span&gt;

&lt;span class="n"&gt;kms&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;boto3&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;client&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;kms&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;ROOT_KEY_ID&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;alias/webhook-root&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;  &lt;span class="c1"&gt;# your KMS key
&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;create_tenant_key&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tenant_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="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;tuple&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="nb"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Create a random tenant key. Persist only the wrapped copy.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;resp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;kms&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;generate_data_key&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;KeyId&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;ROOT_KEY_ID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;KeySpec&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;AES_256&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;EncryptionContext&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tenant_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;tenant_id&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;resp&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Plaintext&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CiphertextBlob&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;  &lt;span class="c1"&gt;# store CiphertextBlob
&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;load_tenant_key&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tenant_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;wrapped_key&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;bytes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Unwrap once, then cache in memory with a short TTL.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;resp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;kms&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;decrypt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;CiphertextBlob&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;wrapped_key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;EncryptionContext&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tenant_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;tenant_id&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;resp&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Plaintext&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;derive_subkey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tenant_key&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;purpose&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="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Domain-separated 256-bit subkey (e.g. &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;dek-wrap&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="s"&gt;blind-index&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="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;HKDF&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;algorithm&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;hashes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;SHA256&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
        &lt;span class="n"&gt;length&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;32&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;salt&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;info&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="s"&gt;webhook-pipeline/v1/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;purpose&lt;/span&gt;&lt;span class="si"&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;utf-8&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="nf"&gt;derive&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tenant_key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The KMS encryption context is itself authenticated additional data. The same tenant ID must be presented at decryption, and it shows up in CloudTrail logs. Scope IAM and key policies so the ingress service can only generate and wrap keys, while only workers (and, if necessary, an admin service) may decrypt.&lt;/p&gt;

&lt;p&gt;Caching unwrapped tenant keys in an in-memory LRU cache with a TTL means workers call KMS once per tenant per interval instead of once per message. The trade-off is that plaintext keys then sit in process memory. Keep TTLs short and avoid logging or dumping them.&lt;/p&gt;




&lt;h2&gt;
  
  
  4. Securing the Pipeline: Redis, SQS, and PostgreSQL
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Step 1: Encrypt immediately at ingestion
&lt;/h3&gt;

&lt;p&gt;Verify the provider’s signature first, since it needs the original bytes, then encrypt the &lt;strong&gt;raw body bytes&lt;/strong&gt; before they go anywhere else. Do not re-serialize the JSON, because that changes the bytes and breaks later signature checks or forensic comparisons.&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;os&lt;/span&gt;

&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;cryptography.hazmat.primitives.ciphers.aead&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;AESGCM&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;b64&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&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;str&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;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;data&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ascii&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;encrypt_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;tenant_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;event_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;tenant_key&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;key_version&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&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;dict&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;wrap_key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;derive_subkey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tenant_key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;dek-wrap&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;aad&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="s"&gt;v1|&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;tenant_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;event_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;key_version&lt;/span&gt;&lt;span class="si"&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;utf-8&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c1"&gt;# Per-message DEK encrypts the payload
&lt;/span&gt;    &lt;span class="n"&gt;dek&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;AESGCM&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;generate_key&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bit_length&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;256&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;nonce&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;urandom&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;ciphertext&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;AESGCM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dek&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;encrypt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;nonce&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="n"&gt;aad&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# tag appended
&lt;/span&gt;
    &lt;span class="c1"&gt;# Tenant-derived key wraps the DEK
&lt;/span&gt;    &lt;span class="n"&gt;wrap_nonce&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;urandom&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;wrapped_dek&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;AESGCM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;wrap_key&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;encrypt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;wrap_nonce&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dek&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;aad&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;v&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;kid&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;key_version&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;nonce&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;b64&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;nonce&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ct&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;b64&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ciphertext&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;wrap_nonce&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;b64&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;wrap_nonce&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;wrapped_dek&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;b64&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;wrapped_dek&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;decrypt_webhook&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;env&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tenant_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;event_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;tenant_key&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;bytes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;wrap_key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;derive_subkey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tenant_key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;dek-wrap&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;aad&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="s"&gt;v1|&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;tenant_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;event_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;env&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;kid&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;utf-8&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;dek&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;AESGCM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;wrap_key&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;decrypt&lt;/span&gt;&lt;span class="p"&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;env&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;wrap_nonce&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&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;env&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;wrapped_dek&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]),&lt;/span&gt; &lt;span class="n"&gt;aad&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;AESGCM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dek&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;decrypt&lt;/span&gt;&lt;span class="p"&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;env&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;nonce&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&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;env&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ct&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]),&lt;/span&gt; &lt;span class="n"&gt;aad&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# raises InvalidTag if tampered
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;kid&lt;/code&gt; field records which tenant-key version was used, which makes rotation possible: new messages use the new version while old ones stay decryptable.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2: Queue only envelopes
&lt;/h3&gt;

&lt;p&gt;Serialize the envelope and publish it to Redis Streams, RabbitMQ, or SQS. Someone who dumps the Redis keyspace, reads persistence files (RDB/AOF), or intercepts SQS messages sees only opaque base64 strings. Platform encryption such as SQS server-side encryption is still worth enabling as defense in depth, since it covers different threats.&lt;/p&gt;

&lt;p&gt;Remember that encoding costs space. Hex doubles the size of the ciphertext, and base64 adds about 33%. If message size matters, store raw bytes (for example, &lt;code&gt;BYTEA&lt;/code&gt; in PostgreSQL) instead of text encodings.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3: Store and query in PostgreSQL
&lt;/h3&gt;

&lt;p&gt;The common objection is: “If we encrypt the payload, how do we search or audit events?” The answer is to &lt;strong&gt;leave routing metadata unencrypted and encrypt only the sensitive body&lt;/strong&gt;. Make sure that metadata is itself non-sensitive. An &lt;code&gt;event_type&lt;/code&gt; like &lt;code&gt;invoice.paid&lt;/code&gt; is fine, while a patient identifier is not.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;webhook_events&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;id&lt;/span&gt;                &lt;span class="n"&gt;UUID&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;gen_random_uuid&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="n"&gt;tenant_id&lt;/span&gt;         &lt;span class="nb"&gt;TEXT&lt;/span&gt;        &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;event_type&lt;/span&gt;        &lt;span class="nb"&gt;TEXT&lt;/span&gt;        &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;-- non-sensitive, indexed&lt;/span&gt;
    &lt;span class="n"&gt;external_event_id&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt;        &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;-- provider's event id, for dedupe&lt;/span&gt;
    &lt;span class="n"&gt;received_at&lt;/span&gt;       &lt;span class="n"&gt;TIMESTAMPTZ&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="n"&gt;key_version&lt;/span&gt;       &lt;span class="nb"&gt;INTEGER&lt;/span&gt;     &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;envelope&lt;/span&gt;          &lt;span class="n"&gt;JSONB&lt;/span&gt;       &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;-- nonce, ciphertext, wrapped DEK&lt;/span&gt;
    &lt;span class="k"&gt;UNIQUE&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tenant_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;external_event_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;idx_webhook_events_lookup&lt;/span&gt;
    &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;webhook_events&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tenant_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;event_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;received_at&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(&lt;code&gt;gen_random_uuid()&lt;/code&gt; is built into PostgreSQL 13 and later.)&lt;/p&gt;

&lt;p&gt;Queries on &lt;code&gt;tenant_id&lt;/code&gt;, &lt;code&gt;event_type&lt;/code&gt;, and &lt;code&gt;received_at&lt;/code&gt; run on plain indexed columns. When an authorized user opens one event in an admin dashboard, the application fetches that single row, decrypts it in memory, and returns the plaintext. There is no table scan and no bulk decryption.&lt;/p&gt;

&lt;p&gt;For equality search on a sensitive field, such as finding events for a given email address, use a &lt;strong&gt;blind index&lt;/strong&gt;: store an HMAC-SHA256 of the normalized value, keyed with a separate HKDF-derived subkey (&lt;code&gt;derive_subkey(tenant_key, "blind-index")&lt;/code&gt;), in its own indexed column. The database can match on the HMAC without seeing the value. The trade-off is that blind indexes reveal when two rows share the same value, and they support only exact matches, so use them sparingly and only for fields that truly need lookup.&lt;/p&gt;

&lt;p&gt;PostgreSQL’s &lt;code&gt;pgcrypto&lt;/code&gt; extension can encrypt in SQL, but then plaintext and keys pass through the database server, its logs, and statement history. For this threat model, encrypt in the application so the database never sees them.&lt;/p&gt;




&lt;h2&gt;
  
  
  5. Performance and Overhead
&lt;/h2&gt;

&lt;p&gt;Cryptography adds cost, but for typical webhook sizes it is rarely the bottleneck.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;CPU:&lt;/strong&gt; With AES-NI hardware acceleration, AES-GCM generally runs at gigabytes per second per core on modern x86 servers, so encrypting a payload of a few kilobytes takes microseconds. JSON handling, base64 encoding, network I/O, and any remote key call will outweigh it. Don’t trust a generic figure, including this one. Measure on your own hardware with &lt;code&gt;openssl speed -evp aes-256-gcm&lt;/code&gt; and benchmark your actual encrypt path.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Size:&lt;/strong&gt; With the scheme above, fixed binary overhead is about 88 bytes: a 12-byte payload nonce, a 16-byte payload tag, a 12-byte wrap nonce, and a 60-byte wrapped DEK (32-byte key plus 16-byte tag, plus its 12-byte nonce). Text encoding is the bigger factor. Base64 inflates everything by roughly a third, and hex would double it, so a 15 KB payload becomes about 20 KB as base64 and 30 KB as hex. Field names and JSON syntax add a little more.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Network and KMS:&lt;/strong&gt; The expensive operation is a remote KMS call. Unwrap tenant keys once and cache them, and monitor your account’s KMS request quota. With caching, per-message overhead stays local and throughput stays close to a plaintext pipeline, though you should confirm this under load.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  6. Retries, Idempotency, and Failure States
&lt;/h2&gt;

&lt;p&gt;Webhook delivery is at-least-once. Providers retry on failures and timeouts, and the same event can arrive more than once. Stripe, for example, retries failed deliveries for up to 72 hours. Your encryption layer has to work with that.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Verify before you encrypt.&lt;/strong&gt; Providers sign the raw request body. Stripe sends an HMAC-SHA256 signature in the &lt;code&gt;Stripe-Signature&lt;/code&gt; header, computed over the timestamp and the exact raw body, and verification fails if you parse or re-serialize the JSON first. Verify the signature, then encrypt the same raw bytes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deduplicate on the event ID, not the signature.&lt;/strong&gt; The signature header is a signature, not an idempotency key. Use the provider’s stable event identifier, such as the Stripe event &lt;code&gt;id&lt;/code&gt; (&lt;code&gt;evt_...&lt;/code&gt;), stored in the unencrypted &lt;code&gt;external_event_id&lt;/code&gt; column with a unique constraint, as in the schema above. An &lt;code&gt;INSERT ... ON CONFLICT DO NOTHING&lt;/code&gt; lets you discard replays before spending any decryption or queue work on them. (Stripe’s &lt;code&gt;Idempotency-Key&lt;/code&gt; header is a different mechanism, used on your &lt;em&gt;outbound&lt;/em&gt; API requests.)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Acknowledge fast, process asynchronously.&lt;/strong&gt; Return a 2xx once the event is verified, encrypted, and durably enqueued. Do the slow work in workers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dead-letter queues stay encrypted.&lt;/strong&gt; When a worker exhausts its retries, the message moves to a DLQ still in envelope form. Operators can inspect metadata, count failures, and purge or redrive messages without ever seeing PII or PHI. Decrypting for debugging should require an explicit, audited action.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Log defensively.&lt;/strong&gt; Never log request bodies or decrypted payloads. Log the event ID, tenant ID, key version, and outcome, and scrub exception messages that may embed request data.&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  7. Cryptographic Erasure and Its Limits
&lt;/h2&gt;

&lt;p&gt;Deleting one tenant’s records from backups, archives, and queues is slow and error-prone. &lt;strong&gt;Cryptographic erasure&lt;/strong&gt; offers a shortcut: if every record for a tenant is encrypted under keys that only exist in one place, destroying those keys renders the data unreadable even where ciphertext copies persist. NIST SP 800-88 recognizes cryptographic erase as a media sanitization technique.&lt;/p&gt;

&lt;p&gt;With the hierarchy from Section 3, the procedure is:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Delete the tenant’s wrapped tenant-key records (all versions) from your key store.&lt;/li&gt;
&lt;li&gt;Purge cached plaintext copies from worker memory, for example by restarting workers or invalidating the cache.&lt;/li&gt;
&lt;li&gt;Make sure backups of the &lt;em&gt;key store&lt;/em&gt; do not retain the keys longer than your retention policy allows. Erasure is only as strong as the oldest surviving copy of the key.&lt;/li&gt;
&lt;li&gt;If you use per-tenant KMS keys, schedule key deletion. AWS KMS requires a waiting period of 7 to 30 days (30 by default) before deletion completes. During that period the key is in a “pending deletion” state and cannot be used for cryptographic operations. The key can be recovered by cancelling deletion until the period ends, so plan for that delay in your deletion SLA.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Be careful with the claims you make about this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;It is not “instantaneous” in practice, because of caches, key-store backups, and KMS waiting periods.&lt;/li&gt;
&lt;li&gt;“Mathematically impossible” overstates it. The accurate statement is that decryption becomes computationally infeasible, assuming the keys are truly gone and the cipher remains secure.&lt;/li&gt;
&lt;li&gt;Whether crypto-shredding satisfies a particular erasure request is a legal question. Regulators and auditors may expect that other copies, such as plaintext in logs, analytics stores, or unencrypted metadata, are removed too. Treat cryptographic erasure as one tool within your erasure process and confirm the approach with your data protection officer or counsel.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Production Checklist
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Use envelope encryption.&lt;/strong&gt; Generate a fresh random 256-bit DEK per webhook, and never encrypt payloads directly with a long-lived key.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use AEAD with bound context.&lt;/strong&gt; Use AES-256-GCM or ChaCha20-Poly1305, include tenant ID, event ID, and version as associated data, and alert on authentication failures.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep the root key in KMS/HSM.&lt;/strong&gt; Use random per-tenant keys wrapped by the root key, and use HKDF for domain-separated subkeys rather than for reconstructing deletable keys.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cache and version keys.&lt;/strong&gt; Cache unwrapped tenant keys briefly, track a key version in every envelope, and rotate before approaching GCM invocation limits.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Verify, then encrypt raw bytes.&lt;/strong&gt; Check provider signatures on the raw body, and encrypt those exact bytes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep non-sensitive metadata queryable.&lt;/strong&gt; Leave routing fields such as tenant, event type, timestamp, and provider event ID unencrypted and indexed, and use blind indexes only where a sensitive field needs equality search.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deduplicate on provider event IDs&lt;/strong&gt; with a unique constraint, and keep DLQs encrypted.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Plan erasure honestly.&lt;/strong&gt; Design key deletion, cache invalidation, and key-store backup retention together, and document what your erasure guarantees do and don’t cover.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scrub logs.&lt;/strong&gt; No payloads in logs, traces, or error reports.&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;PCI DSS v4.0.1, Requirements 3.3.1, 3.5.1, 3.5.1.2: &lt;a href="https://www.pcisecuritystandards.org/document_library/" rel="noopener noreferrer"&gt;https://www.pcisecuritystandards.org/document_library/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;HIPAA Security Rule NPRM status (mid-2026): &lt;a href="https://luxsci.com/hipaa-security-rule-final-rule-missed-may-deadline/" rel="noopener noreferrer"&gt;https://luxsci.com/hipaa-security-rule-final-rule-missed-may-deadline/&lt;/a&gt; and &lt;a href="https://www.myworkdrive.com/blog/hipaa-security-rule-update" rel="noopener noreferrer"&gt;https://www.myworkdrive.com/blog/hipaa-security-rule-update&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;GDPR Article 32: &lt;a href="https://gdpr-info.eu/art-32-gdpr/" rel="noopener noreferrer"&gt;https://gdpr-info.eu/art-32-gdpr/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;RFC 5869, HKDF: &lt;a href="https://www.rfc-editor.org/rfc/rfc5869" rel="noopener noreferrer"&gt;https://www.rfc-editor.org/rfc/rfc5869&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;RFC 8439, ChaCha20-Poly1305: &lt;a href="https://www.rfc-editor.org/rfc/rfc8439" rel="noopener noreferrer"&gt;https://www.rfc-editor.org/rfc/rfc8439&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;NIST SP 800-38D, GCM: &lt;a href="https://csrc.nist.gov/publications/detail/sp/800-38d/final" rel="noopener noreferrer"&gt;https://csrc.nist.gov/publications/detail/sp/800-38d/final&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;NIST SP 800-88, Media Sanitization: &lt;a href="https://csrc.nist.gov/publications/detail/sp/800-88/rev-1/final" rel="noopener noreferrer"&gt;https://csrc.nist.gov/publications/detail/sp/800-88/rev-1/final&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;AWS KMS request quotas: &lt;a href="https://docs.aws.amazon.com/kms/latest/developerguide/requests-per-second.html" rel="noopener noreferrer"&gt;https://docs.aws.amazon.com/kms/latest/developerguide/requests-per-second.html&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;AWS KMS key deletion: &lt;a href="https://docs.aws.amazon.com/kms/latest/developerguide/deleting-keys.html" rel="noopener noreferrer"&gt;https://docs.aws.amazon.com/kms/latest/developerguide/deleting-keys.html&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://instawebhook.com/blog/webhook-payload-encryption-at-rest-application-layer-cryptography-for-sensitive" rel="noopener noreferrer"&gt;https://instawebhook.com/blog/webhook-payload-encryption-at-rest-application-layer-cryptography-for-sensitive&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>devops</category>
    </item>
    <item>
      <title>Web-Scale Webhook Ingress &amp; Traffic Management: Architecting Resilient, High-Throughput Gateways</title>
      <dc:creator>InstaWebhook</dc:creator>
      <pubDate>Sun, 11 Oct 2026 02:09:26 +0000</pubDate>
      <link>https://dev.to/instawebhook/web-scale-webhook-ingress-traffic-management-architecting-resilient-high-throughput-gateways-1c6j</link>
      <guid>https://dev.to/instawebhook/web-scale-webhook-ingress-traffic-management-architecting-resilient-high-throughput-gateways-1c6j</guid>
      <description>&lt;p&gt;Webhooks connect SaaS platforms, payment processors, CI/CD pipelines, and cloud-native microservices. Unlike internal RPCs or authenticated API clients, a webhook ingress tier faces an untrusted external ecosystem. Senders include well-behaved platforms with documented retry policies, misconfigured integrations that replay events in tight loops, and attackers who find your public endpoint and flood it.&lt;/p&gt;

&lt;p&gt;The cost of failure is concrete. Providers cap how long they wait for your response, and they give up after a finite retry window:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Provider&lt;/th&gt;
&lt;th&gt;Response timeout&lt;/th&gt;
&lt;th&gt;Retry behavior&lt;/th&gt;
&lt;th&gt;Other limits&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://developer.github.com/changes/2017-09-12-changes-to-maximum-webhook-timeout-period/" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;10 seconds&lt;/td&gt;
&lt;td&gt;Not covered here&lt;/td&gt;
&lt;td&gt;
&lt;a href="https://docs.github.com/en/enterprise-server@3.13/webhooks/webhook-events-and-payloads" rel="noopener noreferrer"&gt;Payloads capped at 25 MB&lt;/a&gt;; larger events are not delivered&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://shopify.dev/docs/apps/build/webhooks/troubleshooting" rel="noopener noreferrer"&gt;Shopify&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;5 seconds (&lt;a href="https://hookdeck.com/webhooks/platforms/definitive-guide-shopify-webhooks-https-hookdeck" rel="noopener noreferrer"&gt;1-second connection timeout&lt;/a&gt;)&lt;/td&gt;
&lt;td&gt;Up to 8 retries over 4 hours&lt;/td&gt;
&lt;td&gt;Subscriptions are removed if failures persist&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://stripe.com/docs/webhooks" rel="noopener noreferrer"&gt;Stripe&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Not published in the sources reviewed&lt;/td&gt;
&lt;td&gt;Live mode: exponential backoff for up to 3 days&lt;/td&gt;
&lt;td&gt;Test mode retries 3 times over a few hours&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;If your gateway is down for longer than a provider’s retry window, events are lost permanently. Surviving that takes more than a reverse proxy. It takes layered defenses: distributed rate limiting, a geo-distributed edge, regional failover with a realistic idempotency model, and streaming request handling that never buffers a whole body in RAM.&lt;/p&gt;




&lt;h2&gt;
  
  
  1. Rate Limiting for Webhook Ingress
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The Webhook Traffic Problem
&lt;/h3&gt;

&lt;p&gt;Conventional API rate limiting keys off API keys, OAuth tokens, or user accounts, and clients are expected to honor &lt;code&gt;429 Too Many Requests&lt;/code&gt; and &lt;code&gt;Retry-After&lt;/code&gt;. Webhooks invert this. The publisher is an external system pushing data into yours, and you have no control over its retry logic.&lt;/p&gt;

&lt;p&gt;Well-run platforms treat non-2xx responses as failures and retry them with backoff. Rejecting traffic with a 429 therefore usually delays delivery rather than dropping it, but only while you are inside the provider’s retry window. Misconfigured senders, retry storms, and buggy emitters may ignore backpressure entirely. Retry behavior also varies widely between providers (see the table above), so a rate-limit policy tuned for one provider can silently drop events from another.&lt;/p&gt;

&lt;p&gt;Without fine-grained limits, one runaway publisher can exhaust your connection pool and worker threads. That starves every other tenant on the same tier.&lt;/p&gt;

&lt;h3&gt;
  
  
  Token Bucket vs. Leaky Bucket
&lt;/h3&gt;

&lt;p&gt;Two algorithms dominate traffic shaping:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Token bucket&lt;/strong&gt; allows bursts up to a configured capacity while enforcing an average refill rate. It suits legitimate webhook spikes, such as a merchant platform emitting a batch of order events during a sale.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Leaky bucket&lt;/strong&gt; (as a queue or meter) drains at a constant rate regardless of arrival burstiness. It smooths traffic ahead of fragile downstream systems. NGINX’s &lt;code&gt;limit_req&lt;/code&gt; module is a well-known implementation of this model.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Layer Local and Global Limits
&lt;/h3&gt;

&lt;p&gt;A single gateway instance cannot enforce a per-publisher limit across a horizontally scaled fleet, so you need shared state. Envoy documents this as two stages. A &lt;a href="https://www.envoyproxy.io/docs/envoy/latest/configuration/listeners/listener_filters/local_rate_limit_filter" rel="noopener noreferrer"&gt;local token-bucket filter&lt;/a&gt; absorbs large bursts in each proxy, and a &lt;a href="https://envoyproxy.io/docs/envoy/v1.24.5/intro/arch_overview/other_features/global_rate_limiting.html" rel="noopener noreferrer"&gt;global gRPC rate limit service&lt;/a&gt; enforces fine-grained limits. Envoy’s reference rate limit service is written in Go and uses a Redis backend. The local stage reduces load on the global service, so a burst does not turn your rate limiter into your bottleneck.&lt;/p&gt;

&lt;p&gt;Two design points matter for webhooks:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Do the cheap checks first.&lt;/strong&gt; HMAC verification requires reading the whole body, so limiting by “verified signature” means attackers can make you spend CPU and bandwidth before you can reject them. Key the first-stage limits on things you know before reading the body: the source IP or network, and a per-tenant endpoint identifier in the URL path. Apply per-publisher limits after authentication.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fail open or closed deliberately.&lt;/strong&gt; If Redis is unreachable, decide in advance whether to admit traffic (risking overload) or shed it (risking lost events inside the retry window). A local limiter is a sensible fallback.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  A Distributed Token Bucket in Redis and Lua
&lt;/h3&gt;

&lt;p&gt;Redis executes a Lua script atomically, so the read-modify-write cycle cannot interleave with another client’s. The version below fixes problems that commonly appear in examples of this pattern:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;It reads time from Redis (&lt;code&gt;TIME&lt;/code&gt;) rather than trusting a caller-supplied timestamp, which avoids clock skew between gateway nodes.&lt;/li&gt;
&lt;li&gt;It uses &lt;code&gt;HSET&lt;/code&gt; with multiple fields, because &lt;code&gt;HMSET&lt;/code&gt; has been deprecated since Redis 4.0.&lt;/li&gt;
&lt;li&gt;It uses sub-second precision so fractional refill rates work.&lt;/li&gt;
&lt;li&gt;It returns the token count as a string, because Redis truncates Lua floats to integers in replies.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight lua"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- KEYS[1]: rate limit key, e.g. "rl:tenant:acme"&lt;/span&gt;
&lt;span class="c1"&gt;-- ARGV[1]: capacity (max tokens)&lt;/span&gt;
&lt;span class="c1"&gt;-- ARGV[2]: refill rate (tokens per second)&lt;/span&gt;
&lt;span class="c1"&gt;-- ARGV[3]: cost of this request (usually 1)&lt;/span&gt;

&lt;span class="kd"&gt;local&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;         &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;KEYS&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="kd"&gt;local&lt;/span&gt; &lt;span class="n"&gt;capacity&lt;/span&gt;    &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;tonumber&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ARGV&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;span class="kd"&gt;local&lt;/span&gt; &lt;span class="n"&gt;refill_rate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;tonumber&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ARGV&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;span class="kd"&gt;local&lt;/span&gt; &lt;span class="n"&gt;requested&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;tonumber&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ARGV&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;

&lt;span class="c1"&gt;-- Use the Redis server clock so all gateway nodes agree on "now"&lt;/span&gt;
&lt;span class="kd"&gt;local&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"TIME"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;local&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;tonumber&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nb"&gt;tonumber&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1000000&lt;/span&gt;

&lt;span class="kd"&gt;local&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"HMGET"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"tokens"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"ts"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;local&lt;/span&gt; &lt;span class="n"&gt;tokens&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;tonumber&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;span class="kd"&gt;local&lt;/span&gt; &lt;span class="n"&gt;ts&lt;/span&gt;     &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;tonumber&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2&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;tokens&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="kc"&gt;nil&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;ts&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="kc"&gt;nil&lt;/span&gt; &lt;span class="k"&gt;then&lt;/span&gt;
  &lt;span class="n"&gt;tokens&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;capacity&lt;/span&gt;
&lt;span class="k"&gt;else&lt;/span&gt;
  &lt;span class="kd"&gt;local&lt;/span&gt; &lt;span class="n"&gt;elapsed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;math.max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;ts&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="n"&gt;tokens&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;math.min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;capacity&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tokens&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;elapsed&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;refill_rate&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;

&lt;span class="kd"&gt;local&lt;/span&gt; &lt;span class="n"&gt;allowed&lt;/span&gt;     &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
&lt;span class="kd"&gt;local&lt;/span&gt; &lt;span class="n"&gt;retry_after&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;tokens&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;requested&lt;/span&gt; &lt;span class="k"&gt;then&lt;/span&gt;
  &lt;span class="n"&gt;tokens&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;tokens&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;requested&lt;/span&gt;
  &lt;span class="n"&gt;allowed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
&lt;span class="k"&gt;else&lt;/span&gt;
  &lt;span class="n"&gt;retry_after&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;math.ceil&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;requested&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;tokens&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;refill_rate&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;

&lt;span class="n"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"HSET"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"tokens"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tokens&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"ts"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c1"&gt;-- Expire idle buckets after twice the time needed to refill from empty&lt;/span&gt;
&lt;span class="n"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"PEXPIRE"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;math.ceil&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;capacity&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;refill_rate&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;allowed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;tostring&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tokens&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;retry_after&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notes for production use:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;In Redis Cluster, each bucket lives under a single key, so the script stays single-slot. Always pass the key via &lt;code&gt;KEYS&lt;/code&gt;, never build it inside the script.&lt;/li&gt;
&lt;li&gt;Return &lt;code&gt;429&lt;/code&gt; with a &lt;code&gt;Retry-After&lt;/code&gt; header built from &lt;code&gt;retry_after&lt;/code&gt;. Compliant senders will use it, and others will at least be easy to identify in logs.&lt;/li&gt;
&lt;li&gt;Cross-region active-active Redis deployments typically replicate asynchronously and reconcile conflicts. Do not assume a token bucket stays exact across regions. Give each region its own share of the budget, or accept approximate global limits.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Where this logic runs depends on your stack. Envoy supports both the rate limit service above and Wasm extensions. OpenResty (NGINX plus Lua) and custom Go gateways can call Redis directly.&lt;/p&gt;




&lt;h2&gt;
  
  
  2. Geo-Distributed Ingress: Anycast, Edge Routing, and Regional Failover
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Why a Single Region Is Not Enough
&lt;/h3&gt;

&lt;p&gt;Pointing every publisher at one cloud region adds latency for distant senders and makes that region a single point of failure. Because providers only retry for a bounded time, a regional outage that outlasts the window means permanent event loss, not just delay.&lt;/p&gt;

&lt;h3&gt;
  
  
  What Anycast Actually Does
&lt;/h3&gt;

&lt;p&gt;Anycast means multiple locations announce the same IP prefix via BGP, and Internet routing delivers each client’s packets to a topologically nearby announcer. This is how large CDNs and DDoS-mitigation networks absorb attack traffic across many sites. Managed products such as AWS Global Accelerator also expose static anycast IPs.&lt;/p&gt;

&lt;p&gt;Be precise about terminology. “Anycast DNS” refers to authoritative DNS servers sharing anycast addresses. That is a different thing from fronting your HTTPS endpoint with an anycast IP. For webhook ingress you usually want the latter, optionally combined with DNS-based steering and health checks to move traffic between regions.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[Webhook publishers worldwide]
          │
          ▼  (anycast IP)
┌──────────────────────────────┐
│ Edge network (CDN / WAF)     │
│  - TLS termination           │
│  - DDoS mitigation           │
│  - Coarse rate limits        │
│  - Body size caps            │
└───────┬──────────────┬───────┘
        │              │
        ▼              ▼
┌──────────────┐ ┌──────────────┐
│ Region A     │ │ Region B     │
│ ingress tier │ │ ingress tier │
└──────┬───────┘ └──────┬───────┘
       ▼                ▼
  durable queue    durable queue
  (per region)     (per region)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  What to Do at the Edge, and What Not To
&lt;/h3&gt;

&lt;p&gt;Edge nodes are a good place for cheap, stateless work: TLS termination, IP reputation and allowlisting, coarse rate limits, and rejecting oversized or malformed requests. Some providers publish their source IP ranges. GitHub does, through its meta API, so allowlisting can be an extra layer, though not a substitute for signature verification.&lt;/p&gt;

&lt;p&gt;Be careful about moving HMAC verification into edge functions. Edge platforms impose body-size limits that can make verification of large payloads impossible or incorrect:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;With CloudFront, Lambda@Edge only exposes the request body if you enable the include-body option, and &lt;a href="https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/lambda-at-edge-function-restrictions.html" rel="noopener noreferrer"&gt;it truncates the body at 1 MB&lt;/a&gt; for origin request events. A signature computed over a truncated body will not match.&lt;/li&gt;
&lt;li&gt;Cloudflare enforces a &lt;a href="https://developers.cloudflare.com/workers/about/limits" rel="noopener noreferrer"&gt;request body limit by plan&lt;/a&gt;: 100 MB on Free and Pro, 200 MB on Business, and 500 MB by default on Enterprise. Larger requests get a 413 at the edge.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The practical rule is to verify signatures at your own ingress tier, where you control buffering and can stream the entire body. Use the edge for filtering that does not depend on the full payload.&lt;/p&gt;

&lt;h3&gt;
  
  
  Idempotency Across Regions: Be Honest About the Consistency Model
&lt;/h3&gt;

&lt;p&gt;Cross-region deduplication is where many designs go wrong. Webhook delivery is at-least-once, so duplicates are normal. They come from provider retries after a timeout, and from your own failover when two regions both receive the same event.&lt;/p&gt;

&lt;p&gt;Consider a multi-region key-value store used for “have I seen this event ID?” checks. In DynamoDB global tables, the default &lt;a href="https://docs.aws.amazon.com/prescriptive-guidance/latest/dynamodb-global-tables/overview.html" rel="noopener noreferrer"&gt;multi-Region eventual consistency (MREC)&lt;/a&gt; mode replicates asynchronously and resolves conflicting writes with last-writer-wins. A conditional “insert if not exists” is evaluated against the local replica. If the same event lands in two regions before replication completes, both writes can succeed, and you process the event twice.&lt;/p&gt;

&lt;p&gt;AWS also offers &lt;a href="https://docs.aws.amazon.com/prescriptive-guidance/latest/dynamodb-global-tables/overview.html" rel="noopener noreferrer"&gt;multi-Region strong consistency (MRSC)&lt;/a&gt;, introduced in June 2025. A successful write is immediately readable from any replica, but MRSC tables are limited to certain Region sets and the consistency mode cannot be changed after creation. Strong cross-region coordination also costs latency.&lt;/p&gt;

&lt;p&gt;A robust design combines several measures rather than relying on any one:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Partition by publisher or tenant.&lt;/strong&gt; Route each publisher to a home region, for example with consistent hashing at the edge. Fail over to another region only when the home region is unhealthy. This keeps almost all deduplication local and strongly consistent.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use a strongly consistent unique constraint in the home region.&lt;/strong&gt; For a relational store (PostgreSQL syntax shown):
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;   &lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;webhook_events&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;received_at&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
   &lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="s1"&gt;'RECEIVED'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
   &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;CONFLICT&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;DO&lt;/span&gt; &lt;span class="k"&gt;NOTHING&lt;/span&gt;
   &lt;span class="n"&gt;RETURNING&lt;/span&gt; &lt;span class="n"&gt;event_id&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If no row is returned, the event is a duplicate. Acknowledge it with a &lt;code&gt;2xx&lt;/code&gt; and skip further processing.  &lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Make downstream handlers idempotent anyway.&lt;/strong&gt; During failover, a small window of cross-region duplicates is unavoidable with asynchronous replication. Side effects such as charging a card, sending an email, or provisioning a resource should carry their own idempotency keys.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use provider-supplied identifiers.&lt;/strong&gt; Don’t invent a header name. GitHub sends a unique &lt;a href="https://docs.github.com/en/enterprise-server@3.13/webhooks/webhook-events-and-payloads" rel="noopener noreferrer"&gt;&lt;code&gt;X-GitHub-Delivery&lt;/code&gt;&lt;/a&gt; header per delivery. Providers following the &lt;a href="https://www.svix.com/resources/glossary/webhook-signature/" rel="noopener noreferrer"&gt;Standard Webhooks&lt;/a&gt; convention send &lt;code&gt;webhook-id&lt;/code&gt;, which stays constant across retries of the same message, and Stripe puts an event &lt;code&gt;id&lt;/code&gt; in the body. There is no universal &lt;code&gt;X-Webhook-ID&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;CRDTs can model a replicated “seen set,” but they converge eventually. They cannot give you the first-writer-wins exclusivity that prevents two regions from both acting on one event, so don’t present them as a substitute for the measures above.&lt;/p&gt;




&lt;h2&gt;
  
  
  3. Streaming Large Payloads Without Running Out of Memory
&lt;/h2&gt;

&lt;h3&gt;
  
  
  How Big Are Webhooks, Really?
&lt;/h3&gt;

&lt;p&gt;Most webhook bodies are small JSON documents. Large ones exist, but provider limits cap them. GitHub, for example, &lt;a href="https://docs.github.com/en/enterprise-server@3.13/webhooks/webhook-events-and-payloads" rel="noopener noreferrer"&gt;does not deliver payloads over 25 MB&lt;/a&gt;. Your own internal publishers, partner integrations, and bulk-export callbacks may be larger, and attackers choose their own sizes. The defensive posture is the same either way: set an explicit maximum size, and never assume a body fits in memory.&lt;/p&gt;

&lt;p&gt;For truly large data, prefer a “thin event” or claim-check pattern. The webhook carries a reference such as an ID or a pre-signed URL, and the consumer fetches the data separately.&lt;/p&gt;

&lt;p&gt;The common anti-pattern is reading the whole body into memory before verifying it. Buffering 50 concurrent 200 MB bodies means roughly 10 GB of RAM. On a container limited to 2 GB, that ends in an out-of-memory kill that takes legitimate traffic down with it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Signature Verification Constraints
&lt;/h3&gt;

&lt;p&gt;Two constraints shape the design:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;HMAC verification must run over the &lt;strong&gt;exact raw bytes&lt;/strong&gt; received. Parsing the JSON first and re-serializing it will break the signature.&lt;/li&gt;
&lt;li&gt;A hash can be computed incrementally while bytes stream past, so verification never needs the full body in memory.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The pattern is: stream the body through the hasher into a size-limited spool (a temporary file or object storage), verify the signature, durably hand off, return a 2xx quickly, and parse asynchronously. Returning quickly matters because of the provider timeouts above. Shopify, for instance, &lt;a href="https://shopify.dev/docs/apps/build/webhooks/troubleshooting" rel="noopener noreferrer"&gt;advises delaying processing until after you’ve sent a response&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Go
&lt;/h3&gt;

&lt;p&gt;Several details are easy to get wrong in Go:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;io.LimitReader&lt;/code&gt; silently truncates at the limit and returns EOF. A truncated body would then be hashed and accepted as if complete. Use &lt;code&gt;http.MaxBytesReader&lt;/code&gt;, which returns an error (&lt;code&gt;*http.MaxBytesError&lt;/code&gt;, Go 1.19+) and signals the server to close the connection.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;hmac.New&lt;/code&gt; requires a key. &lt;code&gt;hmac.Equal&lt;/code&gt; is already a constant-time comparison.&lt;/li&gt;
&lt;li&gt;Limiting body size does not stop slow-sender attacks. You also need server timeouts such as &lt;code&gt;ReadHeaderTimeout&lt;/code&gt;, and per-request read deadlines for large uploads.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;package&lt;/span&gt; &lt;span class="n"&gt;main&lt;/span&gt;

&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s"&gt;"crypto/hmac"&lt;/span&gt;
    &lt;span class="s"&gt;"crypto/sha256"&lt;/span&gt;
    &lt;span class="s"&gt;"encoding/hex"&lt;/span&gt;
    &lt;span class="s"&gt;"errors"&lt;/span&gt;
    &lt;span class="s"&gt;"io"&lt;/span&gt;
    &lt;span class="s"&gt;"log"&lt;/span&gt;
    &lt;span class="s"&gt;"net/http"&lt;/span&gt;
    &lt;span class="s"&gt;"os"&lt;/span&gt;
    &lt;span class="s"&gt;"path/filepath"&lt;/span&gt;
    &lt;span class="s"&gt;"strings"&lt;/span&gt;
    &lt;span class="s"&gt;"time"&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;maxWebhookSize&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="m"&gt;50&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class="m"&gt;20&lt;/span&gt; &lt;span class="c"&gt;// 50 MiB&lt;/span&gt;
    &lt;span class="n"&gt;spoolDir&lt;/span&gt;       &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"/var/spool/webhooks"&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;webhookHandler&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="kt"&gt;byte&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;HandlerFunc&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;func&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ResponseWriter&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Request&lt;/span&gt;&lt;span class="p"&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;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Method&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;MethodPost&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"method not allowed"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StatusMethodNotAllowed&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="c"&gt;// GitHub-style header: "sha256=&amp;lt;hex&amp;gt;"&lt;/span&gt;
        &lt;span class="n"&gt;hexSig&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;strings&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TrimPrefix&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Header&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"X-Hub-Signature-256"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="s"&gt;"sha256="&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;provided&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;hex&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DecodeString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hexSig&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;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;provided&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"missing or malformed signature"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StatusUnauthorized&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="c"&gt;// Allow slow-but-legitimate large uploads, but not forever&lt;/span&gt;
        &lt;span class="n"&gt;rc&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NewResponseController&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;rc&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SetReadDeadline&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Second&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

        &lt;span class="c"&gt;// Hard cap: returns an error (not silent truncation) when exceeded&lt;/span&gt;
        &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;MaxBytesReader&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;maxWebhookSize&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="n"&gt;tmp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CreateTemp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;spoolDir&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"incoming-*.json"&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;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"internal error"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StatusInternalServerError&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="n"&gt;accepted&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="no"&gt;false&lt;/span&gt;
        &lt;span class="k"&gt;defer&lt;/span&gt; &lt;span class="k"&gt;func&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;tmp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Close&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;accepted&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Remove&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tmp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Name&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="c"&gt;// Bytes flow to the hasher and to disk in one pass, using a small&lt;/span&gt;
        &lt;span class="c"&gt;// fixed-size copy buffer instead of the whole payload in memory&lt;/span&gt;
        &lt;span class="n"&gt;mac&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;hmac&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;New&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sha256&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&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="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;io&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Copy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;io&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;MultiWriter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mac&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tmp&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Body&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;tooBig&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;MaxBytesError&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;errors&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;As&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;tooBig&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"payload too large"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StatusRequestEntityTooLarge&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"read failed"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StatusBadRequest&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="p"&gt;}&lt;/span&gt;

        &lt;span class="c"&gt;// hmac.Equal compares in constant time&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;hmac&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Equal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mac&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;provided&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"invalid signature"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StatusUnauthorized&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="c"&gt;// Hand the verified file to a worker (rename into a queue directory,&lt;/span&gt;
        &lt;span class="c"&gt;// publish a pointer to a durable queue, etc.), then acknowledge fast&lt;/span&gt;
        &lt;span class="n"&gt;final&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;filepath&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;spoolDir&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"verified-"&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="n"&gt;filepath&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Base&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tmp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Name&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;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Rename&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tmp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Name&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;final&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"internal error"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StatusInternalServerError&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="n"&gt;accepted&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;true&lt;/span&gt;
        &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WriteHeader&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StatusAccepted&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="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;main&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;secret&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="kt"&gt;byte&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Getenv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"WEBHOOK_SECRET"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nb"&gt;len&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="o"&gt;==&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Fatal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"WEBHOOK_SECRET is required"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;mux&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NewServeMux&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;mux&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Handle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/webhook"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;webhookHandler&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;srv&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Server&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;Addr&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;              &lt;span class="s"&gt;":8080"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;Handler&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;           &lt;span class="n"&gt;mux&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;ReadHeaderTimeout&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="m"&gt;5&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Second&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c"&gt;// mitigates slow-header (Slowloris-style) clients&lt;/span&gt;
        &lt;span class="n"&gt;IdleTimeout&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;       &lt;span class="m"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Second&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Fatal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;srv&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ListenAndServe&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;Verification here has no timestamp check. A leaked signed request can be replayed forever. Providers that sign a timestamp alongside the body, such as Stripe and the Standard Webhooks convention, expect receivers to reject messages outside a tolerance window. The &lt;a href="https://www.svix.com/resources/glossary/webhook-signature/" rel="noopener noreferrer"&gt;Standard Webhooks specification recommends 300 seconds&lt;/a&gt;, and Stripe’s libraries default to the same. Combine that with the event-ID deduplication from section 2.&lt;/p&gt;

&lt;p&gt;A worker can then parse the spooled file token by token, without ever materializing the whole document. For a top-level JSON array of events:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;processEvents&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;handle&lt;/span&gt; &lt;span class="k"&gt;func&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RawMessage&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&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;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&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;err&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;defer&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Close&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="n"&gt;dec&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NewDecoder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;f&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;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;dec&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Token&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="c"&gt;// consume the opening '['&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;dec&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;More&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;ev&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RawMessage&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;dec&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;ev&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&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;err&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;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ev&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&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;err&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="no"&gt;nil&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(Add &lt;code&gt;encoding/json&lt;/code&gt; to the imports.) Decoding the whole body into a &lt;code&gt;map[string]interface{}&lt;/code&gt; would defeat the purpose, because that holds the entire document in memory.&lt;/p&gt;

&lt;h3&gt;
  
  
  Node.js / TypeScript
&lt;/h3&gt;

&lt;p&gt;Express’s &lt;code&gt;express.json()&lt;/code&gt; reads and parses the whole body into memory, with a default limit of 100 KB. Raising the limit to &lt;code&gt;100mb&lt;/code&gt; for webhook routes means a flood of large requests can exhaust the V8 heap. Instead, consume the request as a stream and apply backpressure.&lt;/p&gt;

&lt;p&gt;Common mistakes in naive stream handlers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Calling &lt;code&gt;fileStream.write()&lt;/code&gt; in a loop ignores backpressure. Use &lt;code&gt;stream.pipeline&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;crypto.timingSafeEqual&lt;/code&gt; throws if the two buffers differ in length, so check lengths first.&lt;/li&gt;
&lt;li&gt;Without a byte counter, nothing stops an unbounded upload.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;http&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:http&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;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;path&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:path&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="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;createWriteStream&lt;/span&gt; &lt;span class="p"&gt;}&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:fs&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="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;rename&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;unlink&lt;/span&gt; &lt;span class="p"&gt;}&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:fs/promises&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="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;pipeline&lt;/span&gt; &lt;span class="p"&gt;}&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:stream/promises&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="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Transform&lt;/span&gt; &lt;span class="p"&gt;}&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:stream&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;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="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;SECRET&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&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_SECRET is required&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;MAX_BYTES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1024&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;SPOOL_DIR&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/var/spool/webhooks&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;PayloadTooLargeError&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;Error&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;server&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;http&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createServer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &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="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;method&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&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;url&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/webhook&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;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;writeHead&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;404&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;end&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;return&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;header&lt;/span&gt; &lt;span class="o"&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;x-hub-signature-256&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="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;header&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;string&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;header&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startsWith&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="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="nf"&gt;writeHead&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;401&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;end&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;return&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;provided&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;header&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&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;length&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;hex&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c1"&gt;// Cheap early rejection; the byte counter below is the real enforcement&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&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;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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;content-length&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="mi"&gt;0&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;MAX_BYTES&lt;/span&gt;&lt;span class="p"&gt;)&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="nf"&gt;writeHead&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;413&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;Connection&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;close&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;end&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;return&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;hmac&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="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;received&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&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;meter&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Transform&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="nf"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="na"&gt;chunk&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Buffer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;_enc&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;cb&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&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;chunk&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&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;received&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;MAX_BYTES&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;cb&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;PayloadTooLargeError&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
      &lt;span class="nx"&gt;hmac&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;chunk&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="nf"&gt;cb&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;chunk&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;id&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;randomUUID&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;tmpPath&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;SPOOL_DIR&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;.part`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// pipeline handles backpressure and cleans up streams on error&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;pipeline&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;meter&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;createWriteStream&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tmpPath&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;hmac&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;provided&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;||&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;timingSafeEqual&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;provided&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="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;unlink&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tmpPath&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="k"&gt;catch&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="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;writeHead&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;401&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;end&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;rename&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tmpPath&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;SPOOL_DIR&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;.json`&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt; &lt;span class="c1"&gt;// visible to workers&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;writeHead&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;202&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;end&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;unlink&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tmpPath&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="k"&gt;catch&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="nx"&gt;err&lt;/span&gt; &lt;span class="k"&gt;instanceof&lt;/span&gt; &lt;span class="nx"&gt;PayloadTooLargeError&lt;/span&gt;&lt;span class="p"&gt;)&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="nf"&gt;writeHead&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;413&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;Connection&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;close&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;end&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&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 ingestion error&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;err&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="nf"&gt;writeHead&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;end&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="p"&gt;});&lt;/span&gt;

&lt;span class="nx"&gt;server&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;requestTimeout&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;// total time allowed to receive the request&lt;/span&gt;
&lt;span class="nx"&gt;server&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headersTimeout&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;// time allowed to receive headers&lt;/span&gt;
&lt;span class="nx"&gt;server&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;listen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;8080&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A worker process can then parse the verified file incrementally with a streaming parser such as &lt;a href="https://github.com/uhop/stream-json" rel="noopener noreferrer"&gt;&lt;code&gt;stream-json&lt;/code&gt;&lt;/a&gt;:&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="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;createReadStream&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;node:fs&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="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;pipeline&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;node:stream/promises&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="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;parser&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;stream-json&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="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;streamArray&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;stream-json/streamers/StreamArray&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;processEvents&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;filePath&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;handleEvent&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;pipeline&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nf"&gt;createReadStream&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;filePath&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="nf"&gt;parser&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="nf"&gt;streamArray&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="c1"&gt;// emits { key, value } for each element of a top-level array&lt;/span&gt;
    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;function &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;source&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;await &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;value&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;source&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;handleEvent&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="p"&gt;}&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Summary &amp;amp; Architectural Checklist
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Layered rate limiting.&lt;/strong&gt; Use a local token bucket in each proxy for burst absorption and a Redis-backed global limiter for fine-grained per-tenant limits. Do cheap, body-independent checks first, and decide fail-open versus fail-closed behavior for your limiter in advance.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Geo-distributed ingress.&lt;/strong&gt; Front your endpoint with an anycast edge for TLS termination and DDoS absorption. Verify signatures at an ingress tier where you control full-body streaming, not in edge functions that truncate or cap bodies.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Honest idempotency.&lt;/strong&gt; Partition publishers to home regions and enforce uniqueness with a strongly consistent constraint there. Treat cross-region replication as eventually consistent unless you deliberately pay for strong consistency, and make downstream side effects idempotent regardless.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bounded, streaming ingestion.&lt;/strong&gt; Enforce explicit size limits that error instead of truncating. Hash and spool in one pass, verify in constant time, check timestamps for replay protection, and set server timeouts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Acknowledge fast, process later.&lt;/strong&gt; Provider timeouts are as short as 5 to 10 seconds. Durably enqueue, return a 2xx, and do the heavy parsing and business logic asynchronously.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Know your providers’ retry windows.&lt;/strong&gt; Your outage tolerance is the shortest retry window among your publishers (about 4 hours for Shopify, up to 3 days for Stripe). Plan reconciliation jobs for anything longer.&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  Sources Referenced
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;GitHub webhook payload cap and delivery headers: &lt;a href="https://docs.github.com/en/enterprise-server@3.13/webhooks/webhook-events-and-payloads" rel="noopener noreferrer"&gt;GitHub Docs&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;GitHub 10-second timeout: &lt;a href="https://developer.github.com/changes/2017-09-12-changes-to-maximum-webhook-timeout-period/" rel="noopener noreferrer"&gt;GitHub Developer changelog&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Stripe retry behavior: &lt;a href="https://stripe.com/docs/webhooks" rel="noopener noreferrer"&gt;Stripe webhook documentation&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Shopify timeouts and retries: &lt;a href="https://shopify.dev/docs/apps/build/webhooks/troubleshooting" rel="noopener noreferrer"&gt;Shopify webhook troubleshooting&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Envoy local and global rate limiting: &lt;a href="https://envoyproxy.io/docs/envoy/v1.24.5/intro/arch_overview/other_features/global_rate_limiting.html" rel="noopener noreferrer"&gt;Envoy global rate limiting overview&lt;/a&gt; and &lt;a href="https://www.envoyproxy.io/docs/envoy/latest/configuration/listeners/listener_filters/local_rate_limit_filter" rel="noopener noreferrer"&gt;local rate limit filter&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Lambda@Edge body limits: &lt;a href="https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/lambda-at-edge-function-restrictions.html" rel="noopener noreferrer"&gt;AWS CloudFront documentation&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Cloudflare request body limits: &lt;a href="https://developers.cloudflare.com/workers/about/limits" rel="noopener noreferrer"&gt;Cloudflare limits documentation&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;DynamoDB global table consistency modes: &lt;a href="https://docs.aws.amazon.com/prescriptive-guidance/latest/dynamodb-global-tables/overview.html" rel="noopener noreferrer"&gt;AWS Prescriptive Guidance&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Standard Webhooks signing and replay tolerance: &lt;a href="https://www.svix.com/resources/glossary/webhook-signature/" rel="noopener noreferrer"&gt;Svix glossary&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://instawebhook.com/blog/web-scale-webhook-ingress-traffic-management-architecting-resilient-high-through" rel="noopener noreferrer"&gt;https://instawebhook.com/blog/web-scale-webhook-ingress-traffic-management-architecting-resilient-high-through&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>devops</category>
    </item>
    <item>
      <title>High-Performance Webhook Architecture: Scaling Ingress, Partitioning Queues, and Neutralizing Poison Pills</title>
      <dc:creator>InstaWebhook</dc:creator>
      <pubDate>Sat, 10 Oct 2026 02:04:26 +0000</pubDate>
      <link>https://dev.to/instawebhook/high-performance-webhook-architecture-scaling-ingress-partitioning-queues-and-neutralizing-3el9</link>
      <guid>https://dev.to/instawebhook/high-performance-webhook-architecture-scaling-ingress-partitioning-queues-and-neutralizing-3el9</guid>
      <description>&lt;p&gt;Webhooks drive asynchronous, event-driven integrations between microservices, SaaS vendors, and enterprise customers. At millions of events per day, the usual failure modes are a slow downstream consumer, a burst from one tenant, or a malformed payload that keeps crashing workers. Any of these can turn a local problem into a platform-wide one.&lt;/p&gt;

&lt;p&gt;This guide covers three pillars of a resilient webhook pipeline:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Partitioning and fair scheduling&lt;/strong&gt; to contain noisy tenants and head-of-line blocking.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Low-allocation ingress&lt;/strong&gt;, meaning ring buffers and zero-copy techniques, plus what they can and cannot deliver.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Poison pill detection and quarantine&lt;/strong&gt;, using dead-letter queues, bounded retries, and safe replay.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The patterns apply to a webhook platform that receives events and fans them out to tenant endpoints. Where a technique applies only to inbound traffic or only to outbound delivery, the text says so.&lt;/p&gt;




&lt;h2&gt;
  
  
  1. Partition Strategy: Containing Noisy Tenants and Head-of-Line Blocking
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The head-of-line blocking problem
&lt;/h3&gt;

&lt;p&gt;In a multi-tenant pipeline, events are written to a queue or table and then picked up by workers. When all tenants share one global queue, a single slow tenant becomes a problem. If workers stall on its slow endpoint, they hold connections, threads, and in-flight slots, and healthy tenants wait behind them. This is &lt;strong&gt;head-of-line (HoL) blocking&lt;/strong&gt;, a close relative of the “noisy neighbor” problem.&lt;/p&gt;

&lt;p&gt;Senders impose their own deadlines, so the cost of being slow is real. GitHub, for example, expects a 2xx response within 10 seconds, treats a slower response as a failed delivery, and recommends queueing payloads and processing them asynchronously. Stripe tells receivers to return a successful status quickly and defer complex logic.&lt;/p&gt;

&lt;h3&gt;
  
  
  Partitioning helps, but it is not a complete fix
&lt;/h3&gt;

&lt;p&gt;Partitioning by tenant (or by a hash of the tenant ID) narrows the blast radius, but two caveats apply:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Hash partitioning still co-locates tenants.&lt;/strong&gt; If tenants 17 and 902 hash to the same partition, one slow tenant still delays the other. In a classic Kafka consumer group, each partition is assigned to exactly one consumer in the group, so a stalled consumer stalls everything on its partitions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Partitioning a database table is about data layout, not worker isolation.&lt;/strong&gt; It helps with pruning, index size, and cheap retention (detaching or dropping old partitions). It does not stop a stalled worker pool from starving other tenants.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Incoming webhooks
       │
       ▼
[Ingress: authenticate, size-limit, persist, ack]
       │
       ├─► Partition 0 (tenant hash bucket 0) ──► Worker pool 0
       ├─► Partition 1 (tenant hash bucket 1) ──► Worker pool 1
       ├─► Partition N ...                    ──► Worker pool N
       └─► Dedicated queue for very large or unreliable tenants
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Practical techniques
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Tenant-aware keys in relational storage.&lt;/strong&gt; PostgreSQL requires that a primary key or unique constraint on a partitioned table include all partition key columns, and the partition key cannot contain expressions. If you hash-partition on &lt;code&gt;tenant_id&lt;/code&gt;, a key such as &lt;code&gt;(tenant_id, webhook_id)&lt;/code&gt; works. If you also range-partition on &lt;code&gt;created_at&lt;/code&gt; for retention, &lt;code&gt;created_at&lt;/code&gt; must appear in the key too. Note that time-based partitioning separates data by time, not by tenant, so use hash or list partitioning on &lt;code&gt;tenant_id&lt;/code&gt; (optionally sub-partitioned by time) when tenant segregation is the goal.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Route by tenant at the edge.&lt;/strong&gt; Extract the tenant identifier from the URL path, a header, or an authenticated token. The URL or header is usually better than the payload, because you can read it without parsing the body. Use it as the Kafka message key, the SQS message group ID, or the Redis Stream key.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use fair scheduling, not only partitioning.&lt;/strong&gt; There are now managed building blocks for this:

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Amazon SQS fair queues&lt;/strong&gt; (announced July 2025) work on standard queues. You set a &lt;code&gt;MessageGroupId&lt;/code&gt; as the tenant identifier. When one tenant builds a backlog, SQS prioritizes delivery of other tenants’ messages. The noisy tenant’s messages are not dropped or throttled and are delivered as capacity allows. Messages without a group ID are each treated as their own tenant.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Kafka share groups (KIP-932, “queues for Kafka”)&lt;/strong&gt; let consumers scale beyond the partition count and provide per-message processing controls. They became production-ready with Apache Kafka 4.2.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Add per-tenant limits as well.&lt;/strong&gt; A token bucket per tenant caps request rate at ingress. Pair it with a concurrency limit for outbound delivery. Rate limiting stops a tenant from flooding you, while fair queueing shares worker capacity among tenants that are all legitimately busy. You usually want both.&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  2. Low-Allocation Ingress: Ring Buffers and Zero-Copy Techniques
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Where the cost actually is
&lt;/h3&gt;

&lt;p&gt;At 50,000 requests per second with 10 KB payloads, the pipeline moves about 500 MB/s of payload data. That is a modest rate for modern hardware, and in practice most webhook platforms are limited by durable writes, TLS, JSON parsing, and slow downstream HTTP calls rather than by raw memory copies. Measure before optimizing.&lt;/p&gt;

&lt;p&gt;Allocation pressure still matters in managed runtimes, because frequent short-lived allocations increase garbage collection work and can hurt tail latency (p99/p999). Reducing allocations is a legitimate goal, as long as you are clear about which techniques achieve it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Ring buffers: the Disruptor pattern
&lt;/h3&gt;

&lt;p&gt;The LMAX Disruptor is an inter-thread messaging library for the JVM built around a pre-allocated ring buffer. Its main ideas:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Pre-allocation.&lt;/strong&gt; Ring buffer entries are created once at startup. Publishers claim an existing entry and overwrite its fields instead of allocating new objects, which reduces garbage in steady state.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Single-writer principle.&lt;/strong&gt; One thread writing to a given piece of state avoids write contention.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sequence counters instead of locks.&lt;/strong&gt; Producers and consumers coordinate through sequence numbers rather than mutexes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cache-line padding.&lt;/strong&gt; The sequence counters are padded so that independent variables written by different threads do not share a cache line (typically 64 bytes on common x86 CPUs). This avoids false sharing.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[Network I/O thread] ──► [Ring buffer: pre-allocated slots]
                                  │
                      (sequence barriers / dependencies)
                                  │
                                  ▼
                       [Parse / validate stage]
                          │               │
                          ▼               ▼
                   [Route stage]    [Journal stage]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three limits to keep in mind:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A ring buffer is in-process and in-memory.&lt;/strong&gt; It is not durable, so it does not make a webhook safe to acknowledge. For inbound webhooks, only return a 2xx after the event has been written to durable storage or a durable queue.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It is bounded.&lt;/strong&gt; When the buffer is full, you must apply backpressure. At the HTTP edge that means returning a retryable status such as &lt;code&gt;429&lt;/code&gt; or &lt;code&gt;503&lt;/code&gt; rather than dropping events silently. Stripe retries non-2xx responses for up to three days in live mode, and many senders retry in a similar way, so a retryable failure beats data loss.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Its latency numbers do not describe your whole request.&lt;/strong&gt; In-process handoff can be very fast, but end-to-end webhook latency also includes the network, TLS, and the durable write.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  What “zero-copy” really means on Linux
&lt;/h3&gt;

&lt;p&gt;The claim that &lt;code&gt;mmap&lt;/code&gt; lets network data flow from the NIC into shared memory without copies is not accurate. &lt;code&gt;mmap&lt;/code&gt; maps files or devices into a process’s address space, and it is useful for file-backed logs and queues, but it does not by itself remove copies on the socket receive path. The real options are narrower:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Transmit side.&lt;/strong&gt; &lt;code&gt;MSG_ZEROCOPY&lt;/code&gt; lets the kernel send from user pages without copying them first. It mainly benefits large sends, such as outbound webhook delivery with big payloads, and less so small ones.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Receive side, in-kernel TCP.&lt;/strong&gt; Linux 6.15 added &lt;strong&gt;io_uring zero-copy receive&lt;/strong&gt;, which delivers packet payload directly into user memory. It needs specific NIC features (header/data split, flow steering, and RSS configuration), and the kernel still processes packet headers and runs the TCP stack as normal.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Receive side, kernel bypass.&lt;/strong&gt; Frameworks such as &lt;strong&gt;DPDK&lt;/strong&gt; take the network stack out of the kernel entirely, which means you must supply your own TCP/TLS/HTTP handling. This is rarely justified for an HTTP webhook receiver.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;TLS.&lt;/strong&gt; Webhook traffic is HTTPS (Stripe requires TLS 1.2 or higher), so the payload has to be decrypted before it can be parsed. In practice most teams terminate TLS at a load balancer or proxy and focus on efficient buffer reuse downstream.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For persistence, an append-only log is the proven pattern. Kafka, for example, leans on the OS page cache and sequential I/O. If you build your own memory-mapped journal, treat durability settings (when data is flushed to disk) as an explicit design decision.&lt;/p&gt;

&lt;h3&gt;
  
  
  Realistic benefits
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Fewer allocations&lt;/strong&gt; in the hot path, which means less GC pressure in JVM-based ingress.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lower lock contention&lt;/strong&gt; between pipeline stages inside one process.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Better CPU cache behavior&lt;/strong&gt; from contiguous, pre-allocated structures.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These are targeted gains for a specific hot path, not a replacement for sound queueing, partitioning, and acknowledgment design.&lt;/p&gt;




&lt;h2&gt;
  
  
  3. Handling Poison Pills: Detection, Quarantine, and Safe Replay
&lt;/h2&gt;

&lt;h3&gt;
  
  
  What is a poison pill?
&lt;/h3&gt;

&lt;p&gt;A &lt;strong&gt;poison pill&lt;/strong&gt; is a message that repeatedly fails processing, for example a payload that crashes a parser, triggers an unhandled exception, or hangs a worker. The exact loop depends on the broker:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;With &lt;strong&gt;Amazon SQS&lt;/strong&gt;, a consumer that fails to delete a message sees it reappear after the visibility timeout, and it is then retried.&lt;/li&gt;
&lt;li&gt;With &lt;strong&gt;Kafka&lt;/strong&gt;, a consumer that fails before committing its offset re-reads the same record after a restart or rebalance.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Without a limit, the same message keeps consuming worker capacity. In a partitioned or ordered queue, it can also block everything behind it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Phase 1: Reject early, but choose responses carefully
&lt;/h3&gt;

&lt;p&gt;For &lt;strong&gt;inbound&lt;/strong&gt; webhooks from third parties, what you return to the sender affects what happens next:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Verify signatures on the raw request body.&lt;/strong&gt; Both Stripe and GitHub document signature verification. A request that fails verification should get a 4xx response and should not be queued.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Apply cheap, strict limits at the edge.&lt;/strong&gt; Enforce maximum body size, content type, and basic structural checks.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Be careful about rejecting well-formed requests from legitimate senders.&lt;/strong&gt; Stripe retries any non-2xx response, and if an endpoint fails to return 2xx for multiple days, Stripe emails the account owner and may disable the endpoint. If you reject a payload with a 400 because of your own schema or processing problem, the sender will keep retrying a message that can never succeed. For authenticated senders, it is usually better to acknowledge after durable persistence and do deep schema validation asynchronously, sending failures to quarantine.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Return 5xx or 429 only for transient problems on your side,&lt;/strong&gt; such as the queue being unavailable, so the sender’s retry logic can help you.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Phase 2: Bounded retries and dead-letter queues
&lt;/h3&gt;

&lt;p&gt;Bounded retries, then isolation, is the core defense:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Count attempts per message.&lt;/strong&gt; Brokers can do this for you. In SQS, when a message’s receive count exceeds the queue’s &lt;code&gt;maxReceiveCount&lt;/code&gt;, SQS moves it to the configured dead-letter queue. A dead-letter queue for a standard queue must itself be a standard queue (and FIFO for FIFO). SQS also supports redriving messages from a DLQ back to the source queue.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pick the threshold deliberately.&lt;/strong&gt; Too low and transient failures (a brief database blip) land in the DLQ. Too high and a true poison pill burns capacity. Use exponential backoff between attempts, and consider distinguishing crashes and hangs from ordinary retryable errors.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;On Kafka,&lt;/strong&gt; a consumer group has no built-in per-message retry or dead-letter mechanism, so teams typically write failed records to a separate “dead-letter” topic from application code. Kafka 4.2 adds built-in dead-letter-queue handling to Kafka Streams (KIP-1034), and share groups bring per-message acknowledgment semantics.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[Active queue]
     │
     ├──► [Worker] ──► success ──► ack
     │
     └──► [Worker] ──► failure ──► retry with backoff
                                       │
                                       ▼ (attempts exceeded)
                               [Quarantine / DLQ] ──► [Classifier] ──► [Review &amp;amp; replay]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Phase 3: Classify, notify, and replay safely
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Enrich with metadata.&lt;/strong&gt; Record the error signature, stack trace, attempt count, timestamps, tenant ID, payload size, and a payload hash. Be careful with the payload itself: webhook bodies often contain sensitive customer or payment data. Encrypt quarantined payloads, restrict access, and set a retention limit.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Classify automatically.&lt;/strong&gt; Simple rules on error type give you categories such as &lt;code&gt;JSON_SYNTAX_ERROR&lt;/code&gt;, &lt;code&gt;SCHEMA_MISMATCH&lt;/code&gt;, &lt;code&gt;PAYLOAD_TOO_LARGE&lt;/code&gt;, or &lt;code&gt;HANDLER_EXCEPTION&lt;/code&gt;. Group by signature so one bug that produces thousands of failures shows up as one alert.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use circuit breakers on outbound delivery.&lt;/strong&gt; If a tenant’s endpoint keeps failing, open a circuit breaker, pause delivery to it, keep buffering events (within retention limits), and probe periodically. Notify the tenant through email or a dashboard, not only by webhook, since the webhook endpoint is the thing that is broken. Stripe behaves similarly: it retries for up to three days with exponential backoff and warns account owners about misconfigured endpoints.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Make replay idempotent.&lt;/strong&gt; Replays and sender retries both produce duplicates, so deduplicate on a stable event ID. GitHub sends an &lt;code&gt;X-GitHub-Delivery&lt;/code&gt; header that stays the same when you request a redelivery, and Stripe events carry their own event ID. Store processed IDs and make handlers safe to run twice.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Let senders help.&lt;/strong&gt; Both Stripe and GitHub support redelivering or resending events, so a quarantined event that you cannot recover may be recoverable from the sender.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Summary Table: Architectural Comparison
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Dimension&lt;/th&gt;
&lt;th&gt;Naive pipeline&lt;/th&gt;
&lt;th&gt;Partitioned and fair-scheduled&lt;/th&gt;
&lt;th&gt;Hardened ingress and quarantine&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Ingestion bottleneck&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Single table or global queue&lt;/td&gt;
&lt;td&gt;Tenant-keyed partitions or fair queues&lt;/td&gt;
&lt;td&gt;Durable log behind a low-allocation ingress stage&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Failure isolation&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Low: one slow tenant can delay all&lt;/td&gt;
&lt;td&gt;Moderate: tenants sharing a partition can still interfere&lt;/td&gt;
&lt;td&gt;Higher: per-tenant limits, circuit breakers, and DLQs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Memory and GC behavior&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Frequent per-request allocation&lt;/td&gt;
&lt;td&gt;Unchanged&lt;/td&gt;
&lt;td&gt;Reduced by pre-allocated buffers in hot paths&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Poison pill handling&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Endless retries or worker crashes&lt;/td&gt;
&lt;td&gt;Basic DLQ after max attempts&lt;/td&gt;
&lt;td&gt;Bounded retries, quarantine, classification, and idempotent replay&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;Reliable webhook infrastructure comes from layering several measures, not from a single trick:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Acknowledge fast and durably.&lt;/strong&gt; Persist first, process asynchronously, and return retryable errors only for genuinely transient problems.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Isolate tenants at several levels.&lt;/strong&gt; Partition by tenant, add per-tenant rate and concurrency limits, and use fair queueing (such as SQS fair queues or Kafka share groups) so a noisy tenant slows itself rather than everyone.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Optimize the hot path only where measurements justify it.&lt;/strong&gt; Ring buffers and zero-copy I/O can reduce allocation and contention, but they do not provide durability and their gains are narrower than often claimed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bound retries and quarantine failures.&lt;/strong&gt; Dead-letter queues, classification, circuit breakers, and idempotent replay keep one bad message or tenant from taking down the fleet.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Amazon SQS fair queues announcement and documentation: &lt;a href="https://aws.amazon.com/about-aws/whats-new/2025/07/amazon-sqs-introduces-fair/" rel="noopener noreferrer"&gt;https://aws.amazon.com/about-aws/whats-new/2025/07/amazon-sqs-introduces-fair/&lt;/a&gt; and &lt;a href="https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/sqs-fair-queues.html" rel="noopener noreferrer"&gt;https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/sqs-fair-queues.html&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Amazon SQS dead-letter queues: &lt;a href="https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/sqs-dead-letter-queues.html" rel="noopener noreferrer"&gt;https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/sqs-dead-letter-queues.html&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Stripe webhooks documentation: &lt;a href="https://docs.stripe.com/webhooks" rel="noopener noreferrer"&gt;https://docs.stripe.com/webhooks&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;GitHub webhook best practices: &lt;a href="https://docs.github.com/webhooks/using-webhooks/best-practices-for-using-webhooks" rel="noopener noreferrer"&gt;https://docs.github.com/webhooks/using-webhooks/best-practices-for-using-webhooks&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;PostgreSQL table partitioning: &lt;a href="https://www.postgresql.org/docs/current/ddl-partitioning.html" rel="noopener noreferrer"&gt;https://www.postgresql.org/docs/current/ddl-partitioning.html&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;LMAX Disruptor: &lt;a href="https://github.com/lmax-exchange/disruptor" rel="noopener noreferrer"&gt;https://github.com/lmax-exchange/disruptor&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Linux io_uring zero-copy receive: &lt;a href="https://docs.kernel.org/6.15/networking/iou-zcrx.html" rel="noopener noreferrer"&gt;https://docs.kernel.org/6.15/networking/iou-zcrx.html&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Queues for Kafka (KIP-932) and Kafka 4.2: &lt;a href="https://www.confluent.io/blog/kafka-queue-semantics-share-consumer-ga/" rel="noopener noreferrer"&gt;https://www.confluent.io/blog/kafka-queue-semantics-share-consumer-ga/&lt;/a&gt; and &lt;a href="https://www.instaclustr.com/support/documentation/announcements/apache-kafka-and-kafka-connect/instaclustr-for-apache-kafka-and-kafka-connect-4-2-1-are-now-generally-available/" rel="noopener noreferrer"&gt;https://www.instaclustr.com/support/documentation/announcements/apache-kafka-and-kafka-connect/instaclustr-for-apache-kafka-and-kafka-connect-4-2-1-are-now-generally-available/&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://instawebhook.com/blog/high-performance-webhook-architecture-scaling-ingress-partitioning-queues-and-ne" rel="noopener noreferrer"&gt;https://instawebhook.com/blog/high-performance-webhook-architecture-scaling-ingress-partitioning-queues-and-ne&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>programming</category>
    </item>
    <item>
      <title>eBPF-Based Webhook Observability: Catching Dropped Packets Before the Application Layer</title>
      <dc:creator>InstaWebhook</dc:creator>
      <pubDate>Fri, 09 Oct 2026 04:54:34 +0000</pubDate>
      <link>https://dev.to/instawebhook/ebpf-based-webhook-observability-catching-dropped-packets-before-the-application-layer-39lg</link>
      <guid>https://dev.to/instawebhook/ebpf-based-webhook-observability-catching-dropped-packets-before-the-application-layer-39lg</guid>
      <description>&lt;h2&gt;
  
  
  Executive Summary: The Invisible Webhook Drop Problem
&lt;/h2&gt;

&lt;p&gt;Webhooks are the connective tissue of event-driven systems. Payment processors, code hosts and commerce platforms push state changes to subscriber backends over plain HTTP POST requests. Each provider has its own delivery rules, and those rules decide what a lost connection costs you:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Provider&lt;/th&gt;
&lt;th&gt;Delivery behavior (per provider docs)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://docs.stripe.com/webhooks" rel="noopener noreferrer"&gt;Stripe&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;In live mode, retries for up to three days with exponential backoff. In a sandbox, retries three times over a few hours.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://shopify.dev/docs/apps/build/webhooks/troubleshooting" rel="noopener noreferrer"&gt;Shopify&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Requires a 2xx response within five seconds. Retries failed deliveries up to eight times over about four hours, then removes the subscription if failures persist.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&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;/td&gt;
&lt;td&gt;Records a failure if your server is down or takes longer than 10 seconds to respond. It does not automatically redeliver failed deliveries.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Retries can mask a kernel-level drop, but they also add delay and duplicate deliveries. With a sender that never retries, the event is simply lost.&lt;/p&gt;

&lt;p&gt;Application Performance Monitoring (APM) is the usual way to watch webhook endpoints, and it has a structural blind spot. SDK-based agents (Datadog, New Relic, OpenTelemetry SDKs) run inside your process. Even the newer eBPF-based auto-instrumentation, such as &lt;a href="https://grafana.com/blog/opentelemetry-and-grafana-labs-whats-new-and-whats-next-in-2026" rel="noopener noreferrer"&gt;OpenTelemetry eBPF Instrumentation&lt;/a&gt; (OBI, donated by Grafana Labs as Beyla in 2025), observes requests that have already reached a process. A connection the kernel never hands to your application produces no span, no log line and no status code.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[ Incoming Webhook Burst ]
           │
           ▼
┌───────────────────────────────────────────┐
│          Linux Kernel Network Stack       │
│ ┌───────────────────────────────────────┐ │
│ │ NIC Rx ring → (XDP) → sk_buff alloc   │ │
│ └──────────────────┬────────────────────┘ │
│ ┌──────────────────▼────────────────────┐ │
│ │ TCP: SYN queue → Accept queue         │ │ ──► [ DROPPED HERE ]
│ └──────────────────┬────────────────────┘ │     (APM never sees it)
└────────────────────┼──────────────────────┘
                     │ accept()
                     ▼
┌───────────────────────────────────────────┐
│       User-Space Application Runtime      │
│ ┌───────────────────────────────────────┐ │
│ │ Webhook worker / HTTP framework       │ │ ──► Seen by APM (HTTP 2xx/5xx)
│ └───────────────────────────────────────┘ │
└───────────────────────────────────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When the kernel discards a SYN or the final ACK of the handshake because a listening socket’s queues are full, the sender sees a connect timeout, a stalled request, or a reset. Your APM dashboard shows nothing unusual.&lt;/p&gt;

&lt;p&gt;This article shows how to find those drops with eBPF, how to measure the latency they hide, and where XDP does and does not help. It also covers the kernel and application tuning that prevents most of them.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Anatomy of a Kernel-Level Webhook Drop
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1.1 The ingress path
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;NIC and DMA.&lt;/strong&gt; Packets land in a receive ring buffer on the network interface card (NIC).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;XDP (optional).&lt;/strong&gt; If an XDP program is attached in native mode, it runs in the driver’s receive path, before the kernel allocates an &lt;code&gt;sk_buff&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;NAPI and SoftIRQ.&lt;/strong&gt; The driver polls the ring and the kernel builds a &lt;code&gt;struct sk_buff&lt;/code&gt; for each packet.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;IP and TCP processing.&lt;/strong&gt; The stack validates the packet and looks up the listening socket.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Listener queues.&lt;/strong&gt; New connections pass through the SYN queue and then the accept queue.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;accept()&lt;/code&gt;.&lt;/strong&gt; Only now does your application see the connection, whether it is a Node.js event loop, a Go server or a Python worker.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  1.2 The two queues where webhooks die
&lt;/h3&gt;

&lt;p&gt;As Cloudflare describes in &lt;a href="https://blog.cloudflare.com/syn-packet-handling-in-the-wild/" rel="noopener noreferrer"&gt;SYN packet handling in the wild&lt;/a&gt;, every listening TCP socket has two queues:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The SYN queue&lt;/strong&gt; holds half-open connections (state &lt;code&gt;SYN_RECV&lt;/code&gt;) that have been sent a SYN+ACK and are waiting for the client’s final ACK.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The accept queue&lt;/strong&gt; holds fully established connections waiting for the application to call &lt;code&gt;accept()&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The maximum length of both queues derives from the &lt;code&gt;backlog&lt;/code&gt; argument the application passes to &lt;code&gt;listen(2)&lt;/code&gt;. The kernel silently caps that value at &lt;code&gt;net.core.somaxconn&lt;/code&gt;. The default for &lt;code&gt;somaxconn&lt;/code&gt; was 128 before Linux 5.4 and has been 4096 &lt;a href="https://man7.org/linux/man-pages/man2/listen.2.html" rel="noopener noreferrer"&gt;since 5.4&lt;/a&gt;. The default for &lt;code&gt;tcp_max_syn_backlog&lt;/code&gt; scales with machine memory, and the &lt;a href="https://code.opensuse.org/kernel/kernel-source/blob/042e8ebb8c7c150b62a6060b5b063b2916bae4de/f/patches.suse/tcp-increase-tcp_max_syn_backlog-max-value.patch" rel="noopener noreferrer"&gt;5.4 kernel raised its maximum default from 2048 to 4096&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Accept queue overflow is the common webhook killer.&lt;/strong&gt; If your runtime is busy, whether from a blocked event loop, an exhausted worker pool or a GC pause, it calls &lt;code&gt;accept()&lt;/code&gt; late and the accept queue fills. Per Cloudflare’s analysis, once the accept queue is full, the kernel drops both inbound SYN packets and inbound final-handshake ACK packets. The counters that move are &lt;code&gt;TcpExtListenOverflows&lt;/code&gt; and &lt;code&gt;TcpExtListenDrops&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;What the sender experiences depends on &lt;code&gt;net.ipv4.tcp_abort_on_overflow&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;0&lt;/code&gt; (default).&lt;/strong&gt; The kernel &lt;a href="https://www.netdata.cloud/guides/uwsgi/uwsgi-listen-backlog-somaxconn/" rel="noopener noreferrer"&gt;silently drops the final ACK&lt;/a&gt;. From the client’s side the handshake looks complete. Its request data goes unacknowledged until the server’s SYN+ACK retransmission timer and the client’s own retransmissions eventually line up with free space in the queue, or the connection times out. Dropped SYNs appear to the client as connect timeouts that are retried with exponential backoff.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;1&lt;/code&gt;.&lt;/strong&gt; The kernel sends a RST after the final ACK, and the sender sees a connection reset. Cloudflare’s advice is that it is &lt;a href="https://blog.cloudflare.com/syn-packet-handling-in-the-wild/" rel="noopener noreferrer"&gt;better to leave this setting alone&lt;/a&gt;, because resets make overload worse for clients that would otherwise retry.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;SYN queue overflow is rarer than it used to be.&lt;/strong&gt; Linux enables SYN cookies by default (&lt;code&gt;net.ipv4.tcp_syncookies=1&lt;/code&gt;). When a listener’s SYN queue is full, the kernel answers with a stateless SYN cookie instead of storing the request, so legitimate connections generally survive a SYN flood. Plain SYN queue drops still show up in &lt;code&gt;TCPReqQFullDrop&lt;/code&gt;-style counters when cookies are disabled or unavailable.&lt;/p&gt;

&lt;p&gt;Either way, the application receives zero bytes for the affected connection, so an APM agent has nothing to record.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Tracing Ingress Drops with eBPF
&lt;/h2&gt;

&lt;p&gt;Counters like &lt;code&gt;nstat -az TcpExtListenOverflows TcpExtListenDrops&lt;/code&gt; tell you that drops happened, but not which source, port or connection was hit. For a quick check on a live host, &lt;code&gt;ss -ltn&lt;/code&gt; is the fastest tool. For listening sockets, &lt;strong&gt;Recv-Q&lt;/strong&gt; is the current accept queue depth and &lt;strong&gt;Send-Q&lt;/strong&gt; is its configured maximum. A Recv-Q pinned at Send-Q means the application is not accepting fast enough.&lt;/p&gt;

&lt;p&gt;eBPF adds per-event context without recompiling the kernel or attaching intrusive debuggers.&lt;/p&gt;

&lt;h3&gt;
  
  
  2.1 The &lt;code&gt;skb:kfree_skb&lt;/code&gt; tracepoint and drop reasons
&lt;/h3&gt;

&lt;p&gt;Since Linux 5.17, &lt;code&gt;kfree_skb&lt;/code&gt; carries a &lt;a href="https://developers.redhat.com/articles/2023/07/19/how-retrieve-packet-drop-reasons-linux-kernel" rel="noopener noreferrer"&gt;drop reason&lt;/a&gt; that explains why a socket buffer was discarded. These reasons are available in RHEL 8.8 and 9.2 and later, and the tracepoint is the main interface for reading them. Coverage has grown over time but is not complete. Kernel patches in 2022 added reasons for &lt;a href="https://lkml.iu.edu/hypermail/linux/kernel/2204.3/06465.html" rel="noopener noreferrer"&gt;accept queue overflow and request queue full&lt;/a&gt;, originally named &lt;code&gt;LISTENOVERFLOWS&lt;/code&gt; and &lt;code&gt;TCP_REQQFULLDROP&lt;/code&gt;. Enum names have been reorganized in later releases, so check the symbols your kernel actually exposes in &lt;code&gt;/sys/kernel/tracing/events/skb/kfree_skb/format&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;On older kernels, some listen-queue drops are not reported through &lt;code&gt;kfree_skb&lt;/code&gt; at all, because the packet is freed as if it had been consumed normally. If a trace shows nothing while &lt;code&gt;ListenDrops&lt;/code&gt; keeps climbing, this is the likely reason. Rely on the counters, and use the kprobe approach in Script 2.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;A note on the snippets below.&lt;/strong&gt; They are illustrative sketches. They assume a kernel with BTF (&lt;code&gt;CONFIG_DEBUG_INFO_BTF&lt;/code&gt;), bpftrace 0.21 or later (older releases use &lt;code&gt;args-&amp;gt;field&lt;/code&gt; instead of &lt;code&gt;args.field&lt;/code&gt;), and IPv4 traffic. Kprobe targets are not a stable ABI, so confirm the function names exist on your kernel (&lt;code&gt;bpftrace -l 'kprobe:tcp_v4_*'&lt;/code&gt;) and test in staging before running them in production.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h4&gt;
  
  
  Script 1: Who is being dropped, and why
&lt;/h4&gt;

&lt;p&gt;This script counts dropped inbound TCP packets for webhook ports, by source address and drop reason.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight bpf"&gt;&lt;code&gt;#!/usr/bin/env bpftrace

&lt;span class="nl"&gt;tracepoint&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="nl"&gt;skb&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;kfree_skb
/args.protocol &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="m"&gt;0x0800&lt;/span&gt;/
{
    $skb &lt;span class="o"&gt;=&lt;/span&gt; (struct sk_buff *)args.skbaddr;
    $iph &lt;span class="o"&gt;=&lt;/span&gt; (struct iphdr *)($skb-&amp;gt;head + $skb-&amp;gt;network_header);

    if ($iph-&amp;gt;protocol &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="m"&gt;6&lt;/span&gt;) {                      &lt;span class="c1"&gt;// TCP
&lt;/span&gt;        $tcp   &lt;span class="o"&gt;=&lt;/span&gt; (struct tcphdr *)($skb-&amp;gt;head + $skb-&amp;gt;transport_header);
        $dport &lt;span class="o"&gt;=&lt;/span&gt; bswap($tcp-&amp;gt;dest);

        if ($dport &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="m"&gt;8080&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;|| $dport &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="m"&gt;443&lt;/span&gt;) {
            @drops[ntop($iph-&amp;gt;saddr), $dport, args.reason] &lt;span class="o"&gt;=&lt;/span&gt; count();
        }
    }
}

&lt;span class="nl"&gt;interval&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="nl"&gt;s&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="m"&gt;10&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;{
    time("%&lt;span class="nl"&gt;H&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;%&lt;span class="nl"&gt;M&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;%S  drops by (source, dport, reason):\n");
    print(@drops);
    clear(@drops);
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;reason&lt;/code&gt; field prints as a number. Map it to a name using the &lt;code&gt;format&lt;/code&gt; file mentioned above. Packets freed before the TCP header is parsed may carry an unset &lt;code&gt;transport_header&lt;/code&gt;, so treat results as indicative rather than exhaustive.&lt;/p&gt;

&lt;h4&gt;
  
  
  Script 2: Accept queue pressure per listening port
&lt;/h4&gt;

&lt;p&gt;The kernel considers the accept queue full when &lt;code&gt;sk_ack_backlog&lt;/code&gt; exceeds &lt;code&gt;sk_max_ack_backlog&lt;/code&gt;. The check runs on the listener socket in both &lt;code&gt;tcp_v4_conn_request&lt;/code&gt; (incoming SYN) and &lt;code&gt;tcp_v4_syn_recv_sock&lt;/code&gt; (final ACK). Both functions receive the listener as their first argument, so one probe covers both paths.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight bpf"&gt;&lt;code&gt;#!/usr/bin/env bpftrace

&lt;span class="nl"&gt;kprobe&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;tcp_v&lt;span class="m"&gt;4&lt;/span&gt;_conn_request,
&lt;span class="nl"&gt;kprobe&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;tcp_v&lt;span class="m"&gt;4&lt;/span&gt;_syn_recv_sock
{
    $sk &lt;span class="o"&gt;=&lt;/span&gt; (struct sock *)arg&lt;span class="m"&gt;0&lt;/span&gt;;

    @max_depth[$sk-&amp;gt;sk_num] &lt;span class="o"&gt;=&lt;/span&gt; max($sk-&amp;gt;sk_ack_backlog);

    if ($sk-&amp;gt;sk_ack_backlog &amp;gt; $sk-&amp;gt;sk_max_ack_backlog) {
        @&lt;span class="n"&gt;queue_fu&lt;/span&gt;&lt;span class="kt"&gt;ll&lt;/span&gt;[probe, $sk-&amp;gt;sk_num] &lt;span class="o"&gt;=&lt;/span&gt; count();
    }
}

&lt;span class="nl"&gt;interval&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="nl"&gt;s&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="m"&gt;10&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;{
    time("%&lt;span class="nl"&gt;H&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;%&lt;span class="nl"&gt;M&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;%S\n");
    print(@max_depth);
    print(@&lt;span class="n"&gt;queue_fu&lt;/span&gt;&lt;span class="kt"&gt;ll&lt;/span&gt;);
    clear(@max_depth);
    clear(@&lt;span class="n"&gt;queue_fu&lt;/span&gt;&lt;span class="kt"&gt;ll&lt;/span&gt;);
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you would rather not maintain probes, BCC and libbpf-tools ship maintained utilities for this area. &lt;code&gt;tcpdrop&lt;/code&gt;, &lt;code&gt;tcpretrans&lt;/code&gt; and &lt;code&gt;tcpsynbl&lt;/code&gt; cover drops, retransmissions and SYN backlog depth.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Correlating Kernel Waits with Latency Spikes
&lt;/h2&gt;

&lt;p&gt;When the kernel drops a SYN, the sender waits for its retransmission timer. Per &lt;a href="https://www.rfc-editor.org/rfc/rfc6298" rel="noopener noreferrer"&gt;RFC 6298&lt;/a&gt;, the initial retransmission timeout is 1 second, and each failed attempt doubles it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;RTO_n = RTO_0 × 2^n        (RTO_0 = 1 s for the initial SYN)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So a client’s SYN retries leave at roughly 1 s, 3 s, 7 s, 15 s and so on after the first attempt. Linux’s &lt;code&gt;tcp_syn_retries&lt;/code&gt; setting caps how many times a client tries before giving up. Once a connection has RTT measurements, the minimum RTO in Linux is 200 ms, so established connections recover faster than new ones. A first-SYN drop is therefore especially expensive.&lt;/p&gt;

&lt;p&gt;A webhook whose first SYN is dropped and whose retry succeeds arrives about a second late, even though your handler runs in milliseconds:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; Sender (Webhook Provider)                      Receiver (Your Pod)
───────────┬───────────                        ───────────────┬───────────────
           │ 1. SYN                                           │
           │─────────────────────────────────────────────────►│ ──► Dropped (accept queue full)
           │                                                  │
           │ [ sender waits: initial RTO ≈ 1 s ]              │
           │                                                  │
           │ 2. SYN (retransmission #1)                       │
           │─────────────────────────────────────────────────►│ ──► Accepted
           │ 3. SYN-ACK / 4. ACK                              │
           │◄────────────────────────────────────────────────►│
           │ 5. HTTP POST /webhook                            │
           │─────────────────────────────────────────────────►│ ──► Handled in ~15 ms
           ▼                                                  ▼
   [ Sender-side latency ≈ 1,015 ms ]               [ APM duration ≈ 15 ms ]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This matters because the delay is short enough to hide inside a retry policy but long enough to cross a hard deadline. Shopify’s five-second response limit and GitHub’s ten-second limit are both reachable after two or three SYN retransmissions.&lt;/p&gt;

&lt;h3&gt;
  
  
  Measuring the hidden wait
&lt;/h3&gt;

&lt;p&gt;You can measure this on the receiver. The idea is to remember when each client’s first SYN was dropped, then measure the gap when the same client port tries again. That gap is the retransmission wait your APM never sees.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight bpf"&gt;&lt;code&gt;#!/usr/bin/env bpftrace

&lt;span class="nl"&gt;tracepoint&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="nl"&gt;skb&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;kfree_skb
/args.protocol &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="m"&gt;0x0800&lt;/span&gt;/
{
    $skb &lt;span class="o"&gt;=&lt;/span&gt; (struct sk_buff *)args.skbaddr;
    $iph &lt;span class="o"&gt;=&lt;/span&gt; (struct iphdr *)($skb-&amp;gt;head + $skb-&amp;gt;network_header);
    if ($iph-&amp;gt;protocol &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="m"&gt;6&lt;/span&gt;) {
        $tcp &lt;span class="o"&gt;=&lt;/span&gt; (struct tcphdr *)($skb-&amp;gt;head + $skb-&amp;gt;transport_header);
        if (bswap($tcp-&amp;gt;dest) &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="m"&gt;8080&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&amp;amp;&amp;amp; $tcp-&amp;gt;syn &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&amp;amp;&amp;amp; $tcp-&amp;gt;ack &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;) {
            if (@first_drop[$iph-&amp;gt;saddr, $tcp-&amp;gt;source] &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;) {
                @first_drop[$iph-&amp;gt;saddr, $tcp-&amp;gt;source] &lt;span class="o"&gt;=&lt;/span&gt; nsecs;
            }
            @syn_drops &lt;span class="o"&gt;=&lt;/span&gt; count();
        }
    }
}

&lt;span class="nl"&gt;kprobe&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;tcp_v&lt;span class="m"&gt;4&lt;/span&gt;_conn_request
{
    $skb &lt;span class="o"&gt;=&lt;/span&gt; (struct sk_buff *)arg&lt;span class="m"&gt;1&lt;/span&gt;;
    $iph &lt;span class="o"&gt;=&lt;/span&gt; (struct iphdr *)($skb-&amp;gt;head + $skb-&amp;gt;network_header);
    $tcp &lt;span class="o"&gt;=&lt;/span&gt; (struct tcphdr *)($skb-&amp;gt;head + $skb-&amp;gt;transport_header);
    if (bswap($tcp-&amp;gt;dest) &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="m"&gt;8080&lt;/span&gt;) {
        $t&lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; @first_drop[$iph-&amp;gt;saddr, $tcp-&amp;gt;source];
        if ($t&lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;!&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;) {
            @syn_retry_gap_ms &lt;span class="o"&gt;=&lt;/span&gt; hist((nsecs - $t&lt;span class="m"&gt;0&lt;/span&gt;) / &lt;span class="m"&gt;1000000&lt;/span&gt;);
            delete(@first_drop[$iph-&amp;gt;saddr, $tcp-&amp;gt;source]);
        }
    }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A histogram with clusters near 1,000 ms, 2,000 ms and 4,000 ms is the signature of SYN retransmission backoff. Bpftrace maps have a bounded size, so for long-running production use, port this logic to a libbpf program with an LRU hash map. Then export the histogram with a tool such as Cloudflare’s &lt;a href="https://github.com/cloudflare/ebpf_exporter" rel="noopener noreferrer"&gt;ebpf_exporter&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Metrics worth alerting on
&lt;/h3&gt;

&lt;p&gt;You do not need a custom exporter to start. The Prometheus &lt;code&gt;node_exporter&lt;/code&gt; netstat collector exposes the relevant kernel counters by default:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;node_netstat_TcpExt_ListenOverflows&lt;/code&gt; and &lt;code&gt;node_netstat_TcpExt_ListenDrops&lt;/code&gt; track accept queue overflows and listener drops.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;node_netstat_TcpExt_TCPSynRetrans&lt;/code&gt; tracks SYN retransmissions.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;node_netstat_TcpExt_SyncookiesSent&lt;/code&gt; shows when a SYN queue overflowed and cookies kicked in.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Alert on any sustained increase in &lt;code&gt;ListenDrops&lt;/code&gt;. Compare the rate of change to your APM request rate, and a divergence tells you traffic is arriving that your application never sees.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Mitigation Patterns: Where eBPF/XDP Helps and Where It Doesn’t
&lt;/h2&gt;

&lt;p&gt;Tracing finds the problem. Fixing it is mostly about how fast your application accepts connections, and secondarily about what the kernel does under load.&lt;/p&gt;

&lt;h3&gt;
  
  
  4.1 First, fix the queue
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Raise the effective backlog.&lt;/strong&gt; The kernel uses the smaller of the application’s &lt;code&gt;backlog&lt;/code&gt; and &lt;code&gt;net.core.somaxconn&lt;/code&gt;, so raise both. Frameworks differ: Node.js’s &lt;code&gt;server.listen()&lt;/code&gt; defaults to a backlog of 511, and Go’s standard library derives its default from &lt;code&gt;somaxconn&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Know the container caveat.&lt;/strong&gt; &lt;code&gt;net.core.somaxconn&lt;/code&gt; is namespaced per container. In Kubernetes it is classed as an &lt;em&gt;unsafe&lt;/em&gt; sysctl, so setting it from a pod’s &lt;code&gt;securityContext.sysctls&lt;/code&gt; requires the kubelet to allow it with &lt;code&gt;--allowed-unsafe-sysctls&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Accept fast, process later.&lt;/strong&gt; Shopify’s own guidance is to delay processing until after you have sent a response. A small receiver that verifies the signature, enqueues the payload to a durable queue (Kafka, SQS) and returns 2xx keeps &lt;code&gt;accept()&lt;/code&gt; and response times short, even when downstream work is slow.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  4.2 What XDP is good for
&lt;/h3&gt;

&lt;p&gt;XDP is a driver-level eBPF hook. In native mode it runs before &lt;code&gt;sk_buff&lt;/code&gt; allocation, so dropping a packet costs very little. In Cloudflare’s &lt;a href="https://blog.cloudflare.com/how-to-drop-10-million-packets/" rel="noopener noreferrer"&gt;benchmark&lt;/a&gt;, &lt;code&gt;XDP_DROP&lt;/code&gt; discarded roughly 10 million packets per second on a single core. Newer drivers and hardware report substantially higher figures. Dropping at the socket or in the application costs far more per packet, and that gap is why XDP is used for DDoS mitigation at Cloudflare and for L4 load balancing in Meta’s &lt;a href="https://github.com/facebookincubator/katran" rel="noopener noreferrer"&gt;Katran&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;That makes XDP a strong fit for removing &lt;strong&gt;traffic you never wanted&lt;/strong&gt; before it can compete with real webhooks for CPU and queue slots. It is a poor fit for rate-limiting legitimate webhook senders.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do not rate-limit webhook providers per source IP at the packet level.&lt;/strong&gt; Providers send from a small set of addresses. Stripe, for example, &lt;a href="https://docs.stripe.com/ips" rel="noopener noreferrer"&gt;publishes a list of about a dozen webhook IPs&lt;/a&gt;, and GitHub publishes hook ranges through its &lt;a href="https://api.github.com/meta" rel="noopener noreferrer"&gt;&lt;code&gt;/meta&lt;/code&gt; API&lt;/a&gt;. A per-IP, per-packet limiter would throttle your most important traffic, including its retransmissions and the data packets of a single request.&lt;/p&gt;

&lt;p&gt;A safer use is a blocklist, or the inverse, an allowlist. Drop traffic to your webhook port that does not come from provider-published ranges:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight bpf"&gt;&lt;code&gt;#include "vmlinux.h"
#include &amp;lt;bpf/bpf_helpers.h&amp;gt;
#include &amp;lt;bpf/bpf_endian.h&amp;gt;

#define ETH_P_IP &lt;span class="m"&gt;0x0800&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;struct {
    __uint(type, BPF_&lt;span class="k"&gt;MAP&lt;/span&gt;_TYPE_HASH);
    __uint(max_entries, &lt;span class="m"&gt;65536&lt;/span&gt;);
    __type(key, __u&lt;span class="m"&gt;32&lt;/span&gt;);      &lt;span class="c1"&gt;// IPv4 source address
&lt;/span&gt;    __type(value, __u&lt;span class="m"&gt;8&lt;/span&gt;);
} b&lt;span class="k"&gt;lock&lt;/span&gt;list SEC(".&lt;span class="k"&gt;map&lt;/span&gt;s");

SEC("xdp")
int xdp_b&lt;span class="k"&gt;lock&lt;/span&gt;list(struct xdp_md *ctx)
{
    void *data     &lt;span class="o"&gt;=&lt;/span&gt; (void *)(long)ctx-&amp;gt;data;
    void *data_end &lt;span class="o"&gt;=&lt;/span&gt; (void *)(long)ctx-&amp;gt;data_end;

    struct ethhdr *eth &lt;span class="o"&gt;=&lt;/span&gt; data;
    if ((void *)(eth + &lt;span class="m"&gt;1&lt;/span&gt;) &amp;gt; data_end)
        return XDP_PASS;
    if (eth-&amp;gt;h_proto !&lt;span class="o"&gt;=&lt;/span&gt; bpf_htons(ETH_P_IP))
        return XDP_PASS;

    struct iphdr *ip &lt;span class="o"&gt;=&lt;/span&gt; (void *)(eth + &lt;span class="m"&gt;1&lt;/span&gt;);
    if ((void *)(ip + &lt;span class="m"&gt;1&lt;/span&gt;) &amp;gt; data_end)
        return XDP_PASS;
    if (ip-&amp;gt;protocol !&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="m"&gt;6&lt;/span&gt;)           &lt;span class="c1"&gt;// TCP only
&lt;/span&gt;        return XDP_PASS;

    __u&lt;span class="m"&gt;32&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;src &lt;span class="o"&gt;=&lt;/span&gt; ip-&amp;gt;saddr;
    if (bpf_&lt;span class="k"&gt;map&lt;/span&gt;_lookup_elem(&amp;amp;b&lt;span class="k"&gt;lock&lt;/span&gt;list, &amp;amp;src))
        return XDP_DROP;

    return XDP_PASS;
}

char LICENSE[] SEC("license") &lt;span class="o"&gt;=&lt;/span&gt; "GPL";
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For provider ranges (CIDR blocks rather than single addresses), use a &lt;code&gt;BPF_MAP_TYPE_LPM_TRIE&lt;/code&gt; instead of a hash map. A user-space controller can refresh the map from the providers’ published lists.&lt;/p&gt;

&lt;p&gt;Three limits to keep in mind:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;XDP cannot read TLS-protected HTTP.&lt;/strong&gt; It sees packet headers, not webhook payloads, endpoints or signatures. Anything that needs the HTTP layer still belongs in your ingress or application.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Native XDP needs driver support.&lt;/strong&gt; Generic (SKB-mode) XDP works anywhere but runs after &lt;code&gt;sk_buff&lt;/code&gt; allocation, losing most of the performance advantage.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;XDP_REDIRECT&lt;/code&gt; does not forward to another node.&lt;/strong&gt; &lt;code&gt;BPF_MAP_TYPE_DEVMAP&lt;/code&gt; redirects to another network device on the same host, and &lt;code&gt;BPF_MAP_TYPE_CPUMAP&lt;/code&gt; redirects to another CPU. Steering overflow traffic to a buffering tier on a different machine needs an encapsulating load balancer (Katran uses IPIP encapsulation) or a normal L4/L7 proxy in front of your workers. It is not something a stock XDP program does on its own.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  5. Production Checklist for SREs
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;Recommended action&lt;/th&gt;
&lt;th&gt;Why it matters&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Kernel tuning&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Confirm &lt;code&gt;net.core.somaxconn&lt;/code&gt; (4096 default on 5.4+, 128 before) and &lt;code&gt;net.ipv4.tcp_max_syn_backlog&lt;/code&gt;. Keep &lt;code&gt;tcp_syncookies=1&lt;/code&gt; and leave &lt;code&gt;tcp_abort_on_overflow=0&lt;/code&gt; unless you have measured a reason to change it.&lt;/td&gt;
&lt;td&gt;Prevents low queue limits on older kernels from dropping bursts.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Application&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Set an explicit listen backlog, and return 2xx quickly. Move processing off the request path.&lt;/td&gt;
&lt;td&gt;Keeps &lt;code&gt;accept()&lt;/code&gt; fast; this is the only fix for the root cause of accept queue overflow.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Kubernetes&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Allow and set &lt;code&gt;net.core.somaxconn&lt;/code&gt; per pod where needed (unsafe sysctl).&lt;/td&gt;
&lt;td&gt;The setting is per network namespace, so node-level tuning does not reach pods.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Counters&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Alert on &lt;code&gt;node_netstat_TcpExt_ListenDrops&lt;/code&gt;, &lt;code&gt;ListenOverflows&lt;/code&gt; and &lt;code&gt;TCPSynRetrans&lt;/code&gt;.&lt;/td&gt;
&lt;td&gt;Cheapest possible signal that the kernel is dropping what the app never sees.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;eBPF tracing&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Use &lt;code&gt;skb:kfree_skb&lt;/code&gt; (kernel 5.17+ for drop reasons) and listener-queue kprobes; try BCC/libbpf-tools &lt;code&gt;tcpdrop&lt;/code&gt;, &lt;code&gt;tcpretrans&lt;/code&gt;, &lt;code&gt;tcpsynbl&lt;/code&gt;.&lt;/td&gt;
&lt;td&gt;Adds source, port and reason context to the raw counters.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Edge filtering&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Use XDP blocklists or provider-range allowlists for webhook ports; do not packet-rate-limit providers.&lt;/td&gt;
&lt;td&gt;Removes unwanted traffic cheaply without harming real senders.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Resilience&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Reconcile against provider APIs (GitHub’s delivery API, Stripe’s event list), and make handlers idempotent.&lt;/td&gt;
&lt;td&gt;Covers deliveries lost without retry, and duplicates created by retry.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;Application-layer observability starts after &lt;code&gt;accept()&lt;/code&gt;. The drops that matter most during a webhook burst happen before it, in listener queues the application cannot see. eBPF closes that gap: kernel counters tell you something is wrong, &lt;code&gt;kfree_skb&lt;/code&gt; drop reasons and listener-queue probes tell you where and why, and a retransmission-gap histogram turns invisible kernel waits into a number you can alert on.&lt;/p&gt;

&lt;p&gt;The fix usually lives in the application and its queue sizing, with XDP as an efficient front-line filter for traffic you do not want. Add idempotent handlers and provider-side reconciliation, because providers differ sharply in whether they retry at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sources and Further Reading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Cloudflare, &lt;a href="https://blog.cloudflare.com/syn-packet-handling-in-the-wild/" rel="noopener noreferrer"&gt;SYN packet handling in the wild&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Cloudflare, &lt;a href="https://blog.cloudflare.com/how-to-drop-10-million-packets/" rel="noopener noreferrer"&gt;How to drop 10 million packets per second&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Red Hat, &lt;a href="https://developers.redhat.com/articles/2023/07/19/how-retrieve-packet-drop-reasons-linux-kernel" rel="noopener noreferrer"&gt;How to retrieve packet drop reasons in the Linux kernel&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Linux man-pages, &lt;a href="https://man7.org/linux/man-pages/man2/listen.2.html" rel="noopener noreferrer"&gt;&lt;code&gt;listen(2)&lt;/code&gt;&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;IETF, &lt;a href="https://www.rfc-editor.org/rfc/rfc6298" rel="noopener noreferrer"&gt;RFC 6298: Computing TCP’s Retransmission Timer&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Stripe docs: &lt;a href="https://docs.stripe.com/webhooks" rel="noopener noreferrer"&gt;webhooks&lt;/a&gt; and &lt;a href="https://docs.stripe.com/ips" rel="noopener noreferrer"&gt;IP addresses&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;GitHub docs, &lt;a href="https://docs.github.com/en/webhooks/using-webhooks/handling-failed-webhook-deliveries" rel="noopener noreferrer"&gt;Handling failed webhook deliveries&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Shopify docs, &lt;a href="https://shopify.dev/docs/apps/build/webhooks/troubleshooting" rel="noopener noreferrer"&gt;Troubleshoot webhooks&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Grafana Labs, &lt;a href="https://grafana.com/blog/opentelemetry-and-grafana-labs-whats-new-and-whats-next-in-2026" rel="noopener noreferrer"&gt;OpenTelemetry and Grafana Labs: what’s new in 2026&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://instawebhook.com/blog/ebpf-based-webhook-observability-catching-dropped-packets-before-the-application" rel="noopener noreferrer"&gt;https://instawebhook.com/blog/ebpf-based-webhook-observability-catching-dropped-packets-before-the-application&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>programming</category>
    </item>
    <item>
      <title>Debugging Webhooks in Local Dev Environments: Intercepting, Replaying, and Mocking Downstream Failures</title>
      <dc:creator>InstaWebhook</dc:creator>
      <pubDate>Thu, 08 Oct 2026 02:12:04 +0000</pubDate>
      <link>https://dev.to/instawebhook/debugging-webhooks-in-local-dev-environments-intercepting-replaying-and-mocking-downstream-4p48</link>
      <guid>https://dev.to/instawebhook/debugging-webhooks-in-local-dev-environments-intercepting-replaying-and-mocking-downstream-4p48</guid>
      <description>&lt;p&gt;Event-driven architectures lean heavily on webhooks to connect third-party platforms such as Stripe, Shopify, GitHub, and Twilio to internal backend services. Debugging them locally is still a common source of friction. Providers send HTTP callbacks over the public internet, so they cannot reach a server running on &lt;code&gt;localhost:3000&lt;/code&gt; or &lt;code&gt;127.0.0.1&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Closing that gap raises follow-up questions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;How do you inspect the exact delivery payload and headers without touching production data?&lt;/li&gt;
&lt;li&gt;How do you replay a captured event locally without re-triggering the upstream action?&lt;/li&gt;
&lt;li&gt;How do you force your handler through failure paths such as slow downstream calls, &lt;code&gt;429 Too Many Requests&lt;/code&gt; responses, &lt;code&gt;5xx&lt;/code&gt; errors, and duplicate deliveries?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This guide covers how to receive webhooks locally, inspect deliveries, replay them, and simulate failures deterministically. Provider limits quoted below come from each provider's own documentation as of October 2026. They change, so check the current docs before you depend on a specific number.&lt;/p&gt;




&lt;h2&gt;
  
  
  1. The Local Webhook Debugging Architecture
&lt;/h2&gt;

&lt;p&gt;In production, the provider sends an HTTPS POST to a public ingress route. Locally, you place a tunnel or proxy between the provider and your machine.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[ Upstream Provider ] (e.g., Stripe, Shopify, GitHub)
       │
       │ HTTP POST (event payload + signature headers)
       ▼
[ Public Endpoint / Tunnel / Webhook Gateway ] (e.g., ngrok, Hookdeck, Cloudflare Tunnel)
       │
       ├─► [ Inspection UI ] (headers, body, status, timing)
       │
       │ Forwarded request
       ▼
[ Local Dev Server ] (localhost:3000/api/webhooks)
       │
       └─► [ Mocked or faulty dependencies ] (simulated downstream failures)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Three Common Approaches
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Public tunnels.&lt;/strong&gt; Tools such as ngrok and Cloudflare Tunnel give your local port a public HTTPS URL. ngrok also ships a local request inspector with a replay button (see Section 3). Shopify's CLI uses a Cloudflare tunnel by default when you run &lt;code&gt;shopify app dev&lt;/code&gt;, and the tunnel URL changes on every run, so a webhook subscription pointing at an old URL will stop working.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Provider CLIs.&lt;/strong&gt; Tools such as the Stripe CLI listen to your account's events and forward them to a local endpoint, so no public URL is needed. The Shopify CLI can send a sample payload for a chosen topic to any address.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Capture-and-replay gateways.&lt;/strong&gt; Services such as Hookdeck sit between the provider and your machine. The Hookdeck CLI (&lt;code&gt;hookdeck listen &amp;lt;port&amp;gt; &amp;lt;source-name&amp;gt;&lt;/code&gt;) forwards events to your local server while the gateway keeps them, so you can retry any event on demand. If your local handler returns a non-2xx response, the event stays available for retry.&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  2. Inspecting Deliveries and Raw Payloads
&lt;/h2&gt;

&lt;p&gt;When a webhook "does nothing", the cause is usually in the details: a signature mismatch, a re-serialized body, or a response that arrived too late. Check these first.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What to inspect&lt;/th&gt;
&lt;th&gt;What to look for&lt;/th&gt;
&lt;th&gt;Typical failure&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Response status&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;2xx&lt;/code&gt; returned promptly&lt;/td&gt;
&lt;td&gt;Handler throws (&lt;code&gt;500&lt;/code&gt;), or the provider gives up waiting.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Raw request body&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;The exact bytes the provider sent&lt;/td&gt;
&lt;td&gt;Middleware parsed and re-serialized the JSON, changing the bytes.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Signature headers&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;Stripe-Signature&lt;/code&gt;, &lt;code&gt;X-Shopify-Hmac-SHA256&lt;/code&gt;, &lt;code&gt;X-Hub-Signature-256&lt;/code&gt; (GitHub)&lt;/td&gt;
&lt;td&gt;Wrong secret, wrong body, or clock skew.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Response time&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Time until your handler returns&lt;/td&gt;
&lt;td&gt;Slow work done before responding.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Delivery IDs&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;X-Shopify-Webhook-Id&lt;/code&gt;, &lt;code&gt;X-GitHub-Delivery&lt;/code&gt;, Stripe's &lt;code&gt;event.id&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Same ID arriving more than once (retry or redelivery).&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Know the Provider Limits
&lt;/h3&gt;

&lt;p&gt;Timeout and retry behavior differs by provider, so test against the one you actually integrate with:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Provider&lt;/th&gt;
&lt;th&gt;Documented behavior&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Shopify&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Expects a response within about 5 seconds, with a 1-second connection timeout. On no response or an error, it retries 8 times over the next 4 hours.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Stripe&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Tells you to return a &lt;code&gt;2xx&lt;/code&gt; before any complex logic that could cause a timeout. In live mode it retries for up to 3 days with exponential backoff. In a sandbox it retries 3 times over a few hours. Stripe does not publish a fixed response-time limit.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;GitHub&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Expects a &lt;code&gt;2xx&lt;/code&gt; within 10 seconds. GitHub does not automatically redeliver failed deliveries, but you can redeliver them manually or through the REST API.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Twilio&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Connection timeouts and retries are configurable per webhook URL through connection overrides. For call-related requests there is a hard upper timeout of 15 seconds.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Preserve the Raw Body for Signature Verification
&lt;/h3&gt;

&lt;p&gt;A very common local bug is signature verification failing because a body parser such as &lt;code&gt;express.json()&lt;/code&gt; ran first. Stripe signs the exact bytes it sent, so a re-serialized body no longer matches. The same applies to Shopify's HMAC, which is computed over the raw request body.&lt;/p&gt;

&lt;p&gt;With Stripe's official Node library, use &lt;code&gt;express.raw()&lt;/code&gt; on the webhook route only, and register that route before any global JSON parser:&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;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="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;Stripe&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;stripe&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;stripe&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Stripe&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;STRIPE_SECRET_KEY&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;endpointSecret&lt;/span&gt; &lt;span class="o"&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;STRIPE_WEBHOOK_SECRET&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// whsec_...&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="c1"&gt;// Raw body parser applies to this route only&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;/api/webhooks&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;signature&lt;/span&gt; &lt;span class="o"&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;stripe-signature&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
    &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="c1"&gt;// req.body is a Buffer here, which is what verification needs&lt;/span&gt;
      &lt;span class="nx"&gt;event&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;webhooks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;constructEvent&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;signature&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;endpointSecret&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Signature verification failed: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&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="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;status&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="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Webhook Error: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&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="c1"&gt;// Acknowledge first, then do the real work in the background&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;status&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="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;received&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="nf"&gt;processWebhookAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="k"&gt;catch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;error&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="c1"&gt;// Register JSON parsing for the rest of the app after the webhook route&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;use&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;json&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details to remember when testing locally:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Use the right signing secret.&lt;/strong&gt; When you run &lt;code&gt;stripe listen&lt;/code&gt;, the CLI prints a webhook signing secret for that forwarding session. It differs from the secret of an endpoint registered in the Dashboard.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mind the timestamp tolerance.&lt;/strong&gt; Stripe's libraries reject signatures whose timestamp is more than five minutes from the current time by default. Large clock drift on your machine makes every verification fail, and so does replaying an old captured Stripe request (see Section 3).&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  3. Replaying Webhooks Locally Without Upstream Triggers
&lt;/h2&gt;

&lt;p&gt;Generating a real event, such as completing a test checkout, is slow and hard to repeat. Replaying a captured event is faster and deterministic.&lt;/p&gt;

&lt;h3&gt;
  
  
  Method 1: Replay from the ngrok Inspector
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;Start a tunnel:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;   ngrok http 3000
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;Open the agent's web inspection interface at &lt;code&gt;http://127.0.0.1:4040&lt;/code&gt; (or &lt;code&gt;http://localhost:4040&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;Select a captured webhook request.&lt;/li&gt;
&lt;li&gt;Click &lt;strong&gt;Replay&lt;/strong&gt; in the top-right corner of the request to resend it to your local server. Use the dropdown on the Replay button to &lt;strong&gt;Replay with modifications&lt;/strong&gt;, which lets you edit the method, path, headers, trailers, and body first.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;ngrok also offers a cloud-based Traffic Inspector in its dashboard, which adds longer retention and search. Its replay feature works only for fully captured, non-truncated requests, and it adds an &lt;code&gt;Ngrok-Replay-Original-Request-ID&lt;/code&gt; header to the replayed request.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Signature caveat:&lt;/strong&gt; a replay resends the original request, including its original signature headers. Shopify's HMAC has no timestamp, so a replay still verifies. A Stripe replay older than the tolerance window will fail timestamp validation. When replaying older Stripe events, either use the Stripe CLI (below) or temporarily widen the tolerance in your &lt;strong&gt;development&lt;/strong&gt; configuration only.&lt;/p&gt;

&lt;h3&gt;
  
  
  Method 2: Use the Provider's CLI
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Stripe CLI.&lt;/strong&gt; Forward events to your local endpoint and trigger test events on demand:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Terminal 1: forward events and print the signing secret for this session&lt;/span&gt;
stripe listen &lt;span class="nt"&gt;--forward-to&lt;/span&gt; localhost:3000/api/webhooks

&lt;span class="c"&gt;# Terminal 2: create a test event&lt;/span&gt;
stripe trigger payment_intent.succeeded
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You can also run &lt;code&gt;stripe listen --load-from-webhooks-api --forward-to localhost:4242/webhook&lt;/code&gt; to forward to your local server using the configuration of the endpoints you've registered. To re-send an event that already exists, use &lt;code&gt;stripe events resend &amp;lt;event_id&amp;gt; --webhook-endpoint=&amp;lt;endpoint_id&amp;gt;&lt;/code&gt;. Stripe's documentation says manual retries from the CLI work for events up to 30 days old, and retries from the Dashboard work for up to 15 days.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Shopify CLI.&lt;/strong&gt; &lt;code&gt;shopify app webhook trigger&lt;/code&gt; sends a sample payload for an Admin API topic to an address you choose. Shopify says to use it for experimentation, initial configuration checks, and unit testing, and to verify the real end-to-end flow by performing the related action in Shopify. For local HTTP testing, use an address like &lt;code&gt;http://localhost:{port}/{path}&lt;/code&gt;. If you target an ngrok URL, leave the local port out of it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;shopify app webhook trigger &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--topic&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;orders/create &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--address&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;http://localhost:3000/webhooks &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--delivery-method&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;http
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Samples produced this way are not real store events, and in the Shopify Remix app template the &lt;code&gt;admin&lt;/code&gt; object is undefined for CLI-triggered webhooks.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;GitHub.&lt;/strong&gt; In a repository, organization, or app webhook's settings you can inspect recent deliveries and redeliver them. Because GitHub doesn't redeliver automatically, redelivery is also your recovery tool after your local server was down.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Hookdeck CLI.&lt;/strong&gt; Run &lt;code&gt;hookdeck listen 3000 &amp;lt;source-name&amp;gt;&lt;/code&gt; to forward events to your local server. In the CLI's interactive view, press &lt;code&gt;r&lt;/code&gt; to retry the selected event, or use Retry in the Hookdeck dashboard.&lt;/p&gt;

&lt;h3&gt;
  
  
  Method 3: Replay a Saved Payload with cURL
&lt;/h3&gt;

&lt;p&gt;For offline work or fully scripted tests, save a captured body and its headers to disk and replay them yourself:&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/api/webhooks &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&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="s2"&gt;"X-Shopify-Topic: orders/create"&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;"X-Shopify-Hmac-SHA256: &amp;lt;valid_hmac_for_this_exact_body&amp;gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--data-binary&lt;/span&gt; @payloads/order_created_sample.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Use &lt;code&gt;--data-binary&lt;/code&gt; rather than &lt;code&gt;--data&lt;/code&gt; so the file's bytes are sent unchanged. Newline handling in &lt;code&gt;--data&lt;/code&gt; can alter the body and break HMAC verification. If the HMAC isn't valid for the saved body, your handler should reject the request, so either regenerate the signature with your app's secret or enable a dev-only verification bypass.&lt;/p&gt;




&lt;h2&gt;
  
  
  4. Mocking Downstream Failures: Timeouts, Rate Limits, and Duplicates
&lt;/h2&gt;

&lt;p&gt;Making the happy path return &lt;code&gt;200 OK&lt;/code&gt; is only part of the job. Your handler also has to behave when something it depends on is slow, rate-limited, or down, and when the provider sends the same event more than once.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[ Inbound Webhook ] ──► [ Local Ingress Handler ]
                                  │
                 ┌────────────────┴────────────────┐
                 ▼                                 ▼
        [ Idempotency Guard ]            [ Processing Pipeline ]
        - duplicate event check          - queue / workers / database
        - skip already-processed         - calls to downstream APIs
                                                   │
                                                   ▼
                                       [ Fault Injection Layer ]
                                       - HTTP 429 with Retry-After
                                       - added latency / timeouts
                                       - 5xx errors, dropped connections
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Scenario 1: Slow Handlers and Provider Timeouts
&lt;/h3&gt;

&lt;p&gt;If your handler does heavy work before responding (image processing, email, multi-table writes), the provider may treat the delivery as failed and retry it. Shopify allows about 5 seconds, GitHub 10. The fix is to acknowledge quickly and process in the background:&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="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;/api/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="k"&gt;async &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="c1"&gt;// ...verify the signature against req.body first...&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&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="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;utf8&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

  &lt;span class="c1"&gt;// Enqueue and return immediately&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;taskQueue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;processWebhook&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="na"&gt;eventId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;event&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="na"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;event&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="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;202&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;queued&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;event&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="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To check that your system is correct when the &lt;em&gt;worker&lt;/em&gt; is slow, inject an artificial delay on the processing side and confirm the HTTP response still returns quickly:&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sleep&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ms&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ms&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;handleWebhookJob&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;job&lt;/span&gt;&lt;span class="p"&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;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;SIMULATE_SLOW_DOWNSTREAM&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;true&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="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;6000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// longer than Shopify's ~5 s limit, to prove the ack doesn't wait for it&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;updateDatabase&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;job&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&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;To test the opposite case, where the provider really does time out, temporarily make the handler wait longer than the limit before it responds. Then watch how the provider reports the failure and retries. With Stripe's sandbox this happens a few times over a few hours. With Shopify it follows its 8-retry schedule, and with GitHub it does not retry at all.&lt;/p&gt;

&lt;p&gt;For network-level faults (added latency, dropped connections, hard timeouts) between your app and a dependency such as Redis or a database, Shopify's open-source &lt;strong&gt;Toxiproxy&lt;/strong&gt; is a TCP proxy that can inject "latency" and "timeout" toxics, among others, and it has client libraries for several languages.&lt;/p&gt;

&lt;h3&gt;
  
  
  Scenario 2: Downstream &lt;code&gt;429 Too Many Requests&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;When a webhook makes your server call another API, bursts of webhooks can trigger rate limiting there. Use an HTTP interceptor such as Nock (or MSW) to return a &lt;code&gt;429&lt;/code&gt; with a &lt;code&gt;Retry-After&lt;/code&gt; header and confirm your code backs off:&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;nock&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;nock&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nf"&gt;nock&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://api.internal-service.com&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="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;/v1/sync&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="nf"&gt;reply&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="mi"&gt;429&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;rate_limit_exceeded&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Too many requests. Retry after 30 seconds.&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Retry-After&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;30&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Interceptors like this only affect requests made from the same Node process, so they suit integration tests. They won't affect a separate worker process. For those, run a small stub server that returns the failure and point the worker's base URL at it.&lt;/p&gt;

&lt;p&gt;Then assert the behavior you want: the job is rescheduled using the &lt;code&gt;Retry-After&lt;/code&gt; value rather than retried in a tight loop, and the webhook itself was already acknowledged.&lt;/p&gt;

&lt;h3&gt;
  
  
  Scenario 3: Duplicate Deliveries and Idempotency
&lt;/h3&gt;

&lt;p&gt;Providers can deliver the same event more than once, so handlers must tolerate duplicates. Stripe notes that endpoints might occasionally receive the same event more than once, and Shopify says the same after a network timeout or retry. Shopify recommends the &lt;code&gt;X-Shopify-Webhook-Id&lt;/code&gt; header for detecting duplicate deliveries. It also sends an &lt;code&gt;X-Shopify-Event-Id&lt;/code&gt; header that stays the same across deliveries originating from the same merchant action, so you can use that one to correlate them. For Stripe, use &lt;code&gt;event.id&lt;/code&gt;. For GitHub, use the &lt;code&gt;X-GitHub-Delivery&lt;/code&gt; header.&lt;/p&gt;

&lt;p&gt;Here is a Redis-based guard you can exercise locally. The first caller claims the event, duplicates are skipped, and a failure releases the claim so a retry can try again:&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;Redis&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;ioredis&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;redis&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Redis&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;CLAIM_TTL_SECONDS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;24&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&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="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;processIdempotentWebhook&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;eventId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;payload&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;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`webhook:event:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;eventId&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="c1"&gt;// SET ... NX returns 'OK' only if the key did not already exist&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;claimed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;processing&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;EX&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;CLAIM_TTL_SECONDS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;NX&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;claimed&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;OK&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;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`[Idempotency] Duplicate event skipped: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;eventId&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="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ignored&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;duplicate_event&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="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;executeOrderFulfillment&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;completed&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;EX&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;CLAIM_TTL_SECONDS&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;success&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="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Release the claim so a later retry can reprocess the event&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;del&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="nx"&gt;error&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Choose the retention period to cover the provider's retry window. Stripe retries for up to 3 days in live mode, so a 24-hour claim does not cover that window. In production, a durable database table with a unique constraint on the event ID is a safer record than a cache key alone.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Test it:&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Replay a webhook locally.&lt;/li&gt;
&lt;li&gt;Confirm the database was updated once and the log shows &lt;code&gt;success&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Replay the exact same request again, with the same event ID.&lt;/li&gt;
&lt;li&gt;Confirm the log shows &lt;code&gt;Duplicate event skipped&lt;/code&gt; and no second record exists.&lt;/li&gt;
&lt;li&gt;Make &lt;code&gt;executeOrderFulfillment&lt;/code&gt; throw once, replay, and confirm the claim was released and the next replay succeeds.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Choose Your Error Responses Deliberately
&lt;/h3&gt;

&lt;p&gt;What your endpoint returns controls what the provider does next. A non-&lt;code&gt;2xx&lt;/code&gt; response, including a &lt;code&gt;400&lt;/code&gt;, generally causes Stripe and Shopify to retry the delivery. Return &lt;code&gt;2xx&lt;/code&gt; once you have safely accepted the event, even if downstream processing will happen later or may fail. Return a non-&lt;code&gt;2xx&lt;/code&gt; code when you want the provider to try again. Reserve &lt;code&gt;4xx&lt;/code&gt; responses for requests you genuinely want rejected, such as failed signature verification. Handle invalid business payloads inside your own queue and dead-letter process instead of relying on the provider's retries.&lt;/p&gt;




&lt;h2&gt;
  
  
  5. Local Webhook Debugging Checklist
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Signature verification&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The raw body reaches the verification code, and the webhook route is registered before any global JSON parser.&lt;/li&gt;
&lt;li&gt;The signing secret comes from an environment variable and matches the source of the events (for example, the secret printed by &lt;code&gt;stripe listen&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;Your system clock is accurate. Stripe's default tolerance is five minutes.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Transport and delivery&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The tunnel or CLI is running, and the registered URL matches the current one (Shopify's CLI tunnel URL changes on each run).&lt;/li&gt;
&lt;li&gt;The handler responds well inside the shortest provider limit you support (about 5 seconds for Shopify, 10 seconds for GitHub).&lt;/li&gt;
&lt;li&gt;Heavy work runs in a background queue, not in the request handler.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Resilience and idempotency&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Duplicate deliveries are detected using the provider's delivery or event ID.&lt;/li&gt;
&lt;li&gt;Transient downstream failures (&lt;code&gt;429&lt;/code&gt;, &lt;code&gt;502&lt;/code&gt;, &lt;code&gt;503&lt;/code&gt;, timeouts) are retried with backoff inside your own job system.&lt;/li&gt;
&lt;li&gt;You have tested a failed delivery and its retry or redelivery on each provider you rely on.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;Local webhook debugging works best when you can reproduce a problem on demand. Capture real payloads and headers, verify signatures against the raw body, replay events through your tunnel, provider CLI, or a capture gateway, and inject slow, rate-limited, failing, and duplicate scenarios deliberately. Because each provider has its own timeout and retry rules, test against the specific limits of the platforms you integrate with, and confirm them in the provider's current documentation before relying on them.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://instawebhook.com/blog/debugging-webhooks-in-local-dev-environments-intercepting-replaying-and-mocking" rel="noopener noreferrer"&gt;https://instawebhook.com/blog/debugging-webhooks-in-local-dev-environments-intercepting-replaying-and-mocking&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>devops</category>
    </item>
    <item>
      <title>Modern Webhook Engineering: Beyond Tunnels, Race Conditions, and Flaky Pipelines</title>
      <dc:creator>InstaWebhook</dc:creator>
      <pubDate>Wed, 07 Oct 2026 05:49:37 +0000</pubDate>
      <link>https://dev.to/instawebhook/modern-webhook-engineering-beyond-tunnels-race-conditions-and-flaky-pipelines-480m</link>
      <guid>https://dev.to/instawebhook/modern-webhook-engineering-beyond-tunnels-race-conditions-and-flaky-pipelines-480m</guid>
      <description>&lt;p&gt;Webhooks are the standard way for payment providers, SaaS platforms, and job systems to tell your application that something happened. Reliable ingestion matters, but the testing and local-development workflows around webhooks are often fragile.&lt;/p&gt;

&lt;p&gt;Public tunnels such as ngrok are excellent for a quick demo. They are a poor foundation for automated pipelines, because they add an external dependency, a public ingress path, and usage quotas to every run. Replaying production-shaped payloads against a local container causes a different class of problems: state pollution, stale signatures, and races with hot reloads.&lt;/p&gt;

&lt;p&gt;This guide covers three things:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Deterministic webhook testing in CI without tunnels.&lt;/li&gt;
&lt;li&gt;Correct HMAC signing and verification in tests.&lt;/li&gt;
&lt;li&gt;Isolated replay and queue-based processing in local development.&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  Part 1: Beyond Localhost Tunnels: Testing Webhooks in CI/CD
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Where tunnels hurt in automation
&lt;/h3&gt;

&lt;p&gt;A tunnel exposes a local port through a public URL, which is exactly what you want when a vendor's sandbox must reach your laptop. In CI the same property causes problems:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Quotas and rate limits.&lt;/strong&gt; ngrok's free plan currently allows up to 20,000 HTTP requests per month, 1 GB of outbound data transfer, and a single assigned dev domain. A busy pipeline can exhaust these, and a quota failure looks like a flaky test. Limits change, so check the &lt;a href="https://ngrok.com/docs/pricing-limits/free-plan-limits" rel="noopener noreferrer"&gt;current ngrok limits&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Shared, non-ephemeral URLs.&lt;/strong&gt; With one fixed dev domain, parallel jobs would compete for the same public endpoint. Per-job URLs generally require a paid plan.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;External availability.&lt;/strong&gt; Every run now depends on the tunnel provider's uptime and on routing between the provider and your runner.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Public ingress to a build environment.&lt;/strong&gt; Anything reachable from the internet can be probed. A webhook endpoint that verifies signatures is still an unauthenticated public surface. CI runners are often less hardened than production hosts, and you rarely need that exposure.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Time-sensitive signatures.&lt;/strong&gt; Many providers sign a timestamp together with the body and reject old requests. Stripe's libraries, for example, reject signatures whose timestamp is more than five minutes old by default. Slow or stalled pipelines can trip such windows, and replaying captured payloads later will always trip them.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;None of this makes tunnels wrong. It means they belong in manual, exploratory work, not in the automated test path.&lt;/p&gt;

&lt;h3&gt;
  
  
  Shift the webhook source inside the test network
&lt;/h3&gt;

&lt;p&gt;In CI you do not need a vendor to call you. You need something that produces a correctly formed, correctly signed request. Run that producer inside the same network as the application under test:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;+----------------------------------------------------------------------+
| CI job / local Docker network                                        |
|                                                                      |
|  +-------------------+   signed POST    +-----------------------+    |
|  | Test runner or    | ---------------&amp;gt; | Webhook receiver      |    |
|  | webhook sender    |                  | (application)         |    |
|  +-------------------+                  +-----------+-----------+    |
|            |                                        |                |
|            | asserts on state                       | writes         |
|            v                                        v                |
|  +-------------------+                  +-----------------------+    |
|  | Assertions        | &amp;lt;--------------- | Test DB / Redis       |    |
|  +-------------------+     reads        +-----------------------+    |
+----------------------------------------------------------------------+
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three tools fit this role:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;WireMock&lt;/strong&gt; mocks the vendor's HTTP API and can send callbacks. &lt;a href="https://wiremock.org/docs/webhooks-and-callbacks/" rel="noopener noreferrer"&gt;From version 3.1.0, webhook support is part of WireMock core&lt;/a&gt; and is enabled by default. Before 3.1.0 it required a separate extension.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prism&lt;/strong&gt; is a mock server driven by an OpenAPI description. It is useful for mocking the vendor's request/response API, not for signing callbacks.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Vendor CLIs&lt;/strong&gt; such as the Stripe CLI can forward real sandbox events to a local endpoint (&lt;code&gt;stripe listen --forward-to ...&lt;/code&gt;) and fire test events (&lt;code&gt;stripe trigger ...&lt;/code&gt;). This still depends on network access to the vendor, so it suits local development more than hermetic CI.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Signing and verifying correctly
&lt;/h3&gt;

&lt;p&gt;Most of the "my test signature is rejected" bugs come from one mistake: &lt;strong&gt;signatures are computed over the raw request bytes, not over a re-serialized JSON object.&lt;/strong&gt; If your framework parses the body and you re-stringify it, whitespace and key order can differ from what was signed. Capture the raw body and verify against that.&lt;/p&gt;

&lt;p&gt;An Express receiver that does this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/app.ts&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="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;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;export&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;WEBHOOK_SECRET&lt;/span&gt; &lt;span class="o"&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="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// Keep the raw bytes for signature verification.&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;use&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;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;verify&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="nx"&gt;buf&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="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kr"&gt;any&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;rawBody&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;buf&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="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;verifySignature&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;Buffer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;header&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;boolean&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;header&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;WEBHOOK_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="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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;hex&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;a&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;expected&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;b&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;header&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="c1"&gt;// timingSafeEqual throws if lengths differ, so compare lengths first.&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;a&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;b&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;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;);&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;/api/v1/webhooks/payment&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ok&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;verifySignature&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kr"&gt;any&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;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;header&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;X-Signature-256&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;ok&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;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;401&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;invalid signature&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="c1"&gt;// Enqueue or process the event here (see Part 2).&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;status&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="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;received&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&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;This example uses a simple scheme (HMAC-SHA256 of the body, hex-encoded, in a custom header). Real providers differ. Stripe, for instance, signs &lt;code&gt;timestamp.body&lt;/code&gt; and sends it in a &lt;code&gt;Stripe-Signature&lt;/code&gt; header containing the timestamp and one or more &lt;code&gt;v1&lt;/code&gt; signatures. Always follow your provider's documented scheme and prefer its official verification helper, such as &lt;code&gt;stripe.webhooks.constructEvent&lt;/code&gt;, when one exists.&lt;/p&gt;

&lt;h3&gt;
  
  
  In-process tests: the fastest feedback loop
&lt;/h3&gt;

&lt;p&gt;For most suites, skip the network and containers for the sender entirely. Call the app in-process with &lt;a href="https://github.com/forwardemail/supertest" rel="noopener noreferrer"&gt;supertest&lt;/a&gt; and sign the exact string you send:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// test/integration/webhook.spec.ts&lt;/span&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;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;request&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;supertest&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="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="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;../../src/app&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;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="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ci_secret_key_12345&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;sign&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="kr"&gt;string&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="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;string&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;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="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="nf"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;hex&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="nf"&gt;describe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;payment webhook&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;accepts a correctly signed event&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &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="nx"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;event&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;payment.succeeded&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`evt_test_&lt;/span&gt;&lt;span class="p"&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;randomUUID&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="na"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2500&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;currency&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;usd&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;request&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="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;/api/v1/webhooks/payment&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="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Content-Type&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;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="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;X-Signature-256&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;sign&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;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;send&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="c1"&gt;// send the same string that was signed&lt;/span&gt;

    &lt;span class="nf"&gt;expect&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="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toBe&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="nf"&gt;expect&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="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toEqual&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;received&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;rejects a tampered body&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &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="nx"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;event&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;payment.succeeded&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;evt_x&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&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;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;request&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="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;/api/v1/webhooks/payment&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="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Content-Type&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;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="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;X-Signature-256&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;sign&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;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;send&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="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;"amount":1&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;"amount":999999&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

    &lt;span class="nf"&gt;expect&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="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;401&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details make this deterministic. The ID is a random UUID rather than a timestamp, so parallel tests never collide. The signed string and the sent string are the same value, so no serialization difference can break verification.&lt;/p&gt;

&lt;p&gt;Also add tests for the failure modes that matter in production: duplicate delivery of the same event ID (the handler must be idempotent), out-of-order events, and a timestamp outside your tolerance window if your scheme uses one.&lt;/p&gt;

&lt;h3&gt;
  
  
  Containerized mock: WireMock as the vendor
&lt;/h3&gt;

&lt;p&gt;Use a containerized mock when you need to test a flow in which your application calls the vendor API and the vendor later calls back. Define it in &lt;code&gt;docker-compose.ci.yml&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;app-receiver&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;.&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;NODE_ENV&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;test&lt;/span&gt;
      &lt;span class="na"&gt;WEBHOOK_SECRET&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ci_secret_key_12345&lt;/span&gt;
      &lt;span class="na"&gt;DATABASE_URL&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgres://user:pass@postgres:5432/testdb&lt;/span&gt;
      &lt;span class="na"&gt;VENDOR_API_URL&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;http://mock-webhook-provider:8080&lt;/span&gt;
    &lt;span class="na"&gt;depends_on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;postgres&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;condition&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;service_healthy&lt;/span&gt;
      &lt;span class="na"&gt;mock-webhook-provider&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;condition&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;service_started&lt;/span&gt;

  &lt;span class="na"&gt;mock-webhook-provider&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;wiremock/wiremock:3.13.2&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./wiremock/mappings:/home/wiremock/mappings&lt;/span&gt;

  &lt;span class="na"&gt;postgres&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgres:16-alpine&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;POSTGRES_USER&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;user&lt;/span&gt;
      &lt;span class="na"&gt;POSTGRES_PASSWORD&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;pass&lt;/span&gt;
      &lt;span class="na"&gt;POSTGRES_DB&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;testdb&lt;/span&gt;
    &lt;span class="na"&gt;healthcheck&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;test&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CMD-SHELL"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pg_isready&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;-U&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;user&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;-d&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;testdb"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
      &lt;span class="na"&gt;interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;2s&lt;/span&gt;
      &lt;span class="na"&gt;timeout&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;3s&lt;/span&gt;
      &lt;span class="na"&gt;retries&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;15&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few notes on this file:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The top-level &lt;code&gt;version:&lt;/code&gt; key is obsolete in current Docker Compose and can be omitted.&lt;/li&gt;
&lt;li&gt;No ports are published, so nothing is exposed on the host. Containers on the same Compose network reach each other by service name.&lt;/li&gt;
&lt;li&gt;Pin the WireMock image tag. At the time of writing, &lt;code&gt;3.13.2&lt;/code&gt; is the 3.x release shown on the WireMock download page. A WireMock 4.x beta line also exists, but it is labeled beta and expected to contain breaking changes.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;WireMock fires the callback through a &lt;strong&gt;serve event listener&lt;/strong&gt; named &lt;code&gt;webhook&lt;/code&gt;. Put this in &lt;code&gt;./wiremock/mappings/payment_success.json&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"request"&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;"method"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"POST"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"/mock-vendor/checkout"&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;"response"&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;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"headers"&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;"Content-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;"application/json"&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;"jsonBody"&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;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"initiated"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"checkout_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;"chk_99887766"&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;span class="nl"&gt;"serveEventListeners"&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="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"webhook"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"parameters"&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;"method"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"POST"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"http://app-receiver:3000/api/v1/webhooks/payment"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"headers"&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;"Content-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;"application/json"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"X-Signature-256"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"14caa87134ad8255a2b2337f38d95738842e4b35021cff21d664203cbccdfe29"&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;"body"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"{&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;event&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;payment.succeeded&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;,&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;id&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;evt_123&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;,&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;amount&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;:4999,&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;customer_id&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;cust_abc&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&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="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="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;Several points matter here:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;WireMock has no built-in HMAC helper.&lt;/strong&gt; Its templating ships many helpers (JSON/XML, dates, random values, &lt;code&gt;base64&lt;/code&gt;, and others), but none that computes an HMAC. Webhook parameters can be templated, but a signature has to come from somewhere else.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The signature above is precomputed.&lt;/strong&gt; It is the HMAC-SHA256 of the exact &lt;code&gt;body&lt;/code&gt; string using the secret &lt;code&gt;ci_secret_key_12345&lt;/code&gt;. You can reproduce it with &lt;code&gt;printf '%s' '&amp;lt;body&amp;gt;' | openssl dgst -sha256 -hmac 'ci_secret_key_12345'&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;This only works for schemes that sign the body alone.&lt;/strong&gt; If your provider signs a timestamp too, a static signature cannot work. In that case generate the request in your test code (as in the in-process example) or register a custom WireMock template helper through its extension mechanism.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Older tutorials differ.&lt;/strong&gt; Many online examples use &lt;code&gt;postServeActions&lt;/code&gt; and the separate webhooks extension from WireMock 2.x. The current documentation uses &lt;code&gt;serveEventListeners&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Template data comes from &lt;code&gt;originalRequest&lt;/code&gt;.&lt;/strong&gt; If you template the callback from the triggering request, reference &lt;code&gt;originalRequest&lt;/code&gt; (for example &lt;code&gt;{{jsonPath originalRequest.body '$.transactionId'}}&lt;/code&gt;), not &lt;code&gt;request&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Callbacks are asynchronous.&lt;/strong&gt; WireMock fires them after the triggering response, so tests should poll or wait for the expected state rather than asserting immediately.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Part 2: Developing Webhook Receivers with Hot Reload
&lt;/h2&gt;

&lt;p&gt;Local development brings its own problems. You run the receiver under a watcher such as &lt;code&gt;nodemon&lt;/code&gt; (Node.js), &lt;code&gt;air&lt;/code&gt; (Go), or &lt;code&gt;next dev&lt;/code&gt;, and you want realistic payloads. Replaying captured production payloads causes three recurring problems:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;State pollution.&lt;/strong&gt; Payloads carry real IDs and keys. Replaying them inserts foreign records into your local database, collides with unique constraints, or leaves orphaned rows after a restart.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Restarts mid-request.&lt;/strong&gt; If a file change restarts the process while a delivery is in flight, that request fails or half-completes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Signature mismatches.&lt;/strong&gt; A captured payload has a signature (and possibly a timestamp) computed for the original secret and time. It will not verify against your local secret, and a time-bound scheme rejects it as stale.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Replaying safely: a local sanitizer
&lt;/h3&gt;

&lt;p&gt;A local relay can rewrite a captured payload so it is safe to replay: namespace the IDs, refresh timestamps, and sign with the local secret. The version below fails safe. It runs only when explicitly enabled, instead of whenever &lt;code&gt;NODE_ENV&lt;/code&gt; is not &lt;code&gt;production&lt;/code&gt;, so a misconfigured environment cannot silently accept unsigned traffic.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;captured payload                relay / sanitizer                  local receiver
+---------------------+     +---------------------------+     +----------------------+
| id: evt_prod_99     | --&amp;gt; | 1. namespace the ID       | --&amp;gt; | verifies with local  |
| created: 1600000000 |     | 2. refresh timestamp      |     | secret, writes to    |
| (old signature)     |     | 3. re-sign with dev secret|     | local DB only        |
+---------------------+     +---------------------------+     +----------------------+
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The simplest design is a standalone replay script rather than middleware inside the receiver. That keeps test-only code out of the production code path and lets the receiver verify signatures exactly as it does in production:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// scripts/replay-webhook.ts&lt;/span&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;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;fs&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;fs&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="p"&gt;[,&lt;/span&gt; &lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;http://localhost:3000/api/v1/webhooks/payment&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="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;argv&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;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="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;file&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&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="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;usage: WEBHOOK_SECRET=... ts-node replay-webhook.ts &amp;lt;payload.json&amp;gt; [url]&lt;/span&gt;&lt;span class="dl"&gt;'&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="nf"&gt;exit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&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;runId&lt;/span&gt; &lt;span class="o"&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;DEV_RUN_ID&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;local_dev&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;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;readFileSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;utf8&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

&lt;span class="c1"&gt;// 1. Namespace the ID so replays never collide with real or previous data.&lt;/span&gt;
&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;original_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;payload&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="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="o"&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;runId&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;payload&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="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="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// 2. Refresh timestamps so freshness checks pass.&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;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;created&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;created&lt;/span&gt; &lt;span class="o"&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;floor&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="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// 3. Sign the exact bytes we send, using the local dev secret.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;payload&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;signature&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="nf"&gt;update&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="nf"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;hex&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;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&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;Content-Type&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;application/json&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;X-Signature-256&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;signature&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="p"&gt;});&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&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="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;await&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;text&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two cautions apply to any replay tooling:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Scrub personal data.&lt;/strong&gt; Production payloads usually contain customer data. Redact or synthesize it before it lands on a laptop or in a repository.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Never copy production secrets locally.&lt;/strong&gt; Use a dedicated dev secret. If you must receive real vendor events locally, use the vendor's test mode and its CLI forwarder.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Surviving hot reloads: acknowledge fast, process in a worker
&lt;/h3&gt;

&lt;p&gt;When a watcher restarts your process, any work still running in the HTTP handler is lost. The standard fix is the same one used in production: verify the signature, persist or enqueue the event, return a 2xx quickly, and do the real work in a separate worker. Most providers retry on timeouts or non-2xx responses, and Stripe documents that you should return a 2xx quickly and that it may deliver the same event more than once. So your processing must be idempotent regardless.&lt;/p&gt;

&lt;p&gt;Using &lt;a href="https://docs.bullmq.io" rel="noopener noreferrer"&gt;BullMQ&lt;/a&gt; (a Redis-backed queue for Node.js), use the event ID as the job ID. BullMQ ignores a second job added with a job ID that already exists, which gives you cheap deduplication:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/queue/webhookQueue.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Queue&lt;/span&gt; &lt;span class="p"&gt;}&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;bullmq&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;IORedis&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;ioredis&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// BullMQ workers require maxRetriesPerRequest to be null on their connection.&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;connection&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;IORedis&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;REDIS_URL&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;redis://localhost:6379&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="na"&gt;maxRetriesPerRequest&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;webhookQueue&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Queue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;incoming-webhooks&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;connection&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;enqueueWebhook&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Re-adding the same jobId is ignored, so duplicate deliveries collapse into one job.&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;webhookQueue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kd"&gt;type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;jobId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;event&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="na"&gt;attempts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;backoff&lt;/span&gt;&lt;span class="p"&gt;:&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;exponential&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;delay&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;removeOnComplete&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;removeOnFail&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;5000&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/queue/webhookWorker.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Worker&lt;/span&gt; &lt;span class="p"&gt;}&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;bullmq&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="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;connection&lt;/span&gt; &lt;span class="p"&gt;}&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;./webhookQueue&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;worker&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Worker&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;incoming-webhooks&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;job&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;await&lt;/span&gt; &lt;span class="nf"&gt;handleWebhookBusinessLogic&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;job&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;job&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// your logic, written to be idempotent&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;connection&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;concurrency&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="c1"&gt;// serialize work locally to avoid races on a shared dev DB&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;shutdown&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// close() waits for jobs that are currently running to finish.&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;worker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;connection&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;quit&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="nf"&gt;exit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&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="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;SIGTERM&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;shutdown&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="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;SIGINT&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;shutdown&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few operational notes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Setting &lt;code&gt;concurrency: 1&lt;/code&gt; is a local-development convenience. In production you will usually run higher concurrency and rely on idempotent handlers and database constraints instead.&lt;/li&gt;
&lt;li&gt;Job IDs deduplicate only while the job still exists in Redis. Keep a durable record of processed event IDs (for example, a unique constraint in your database) if you need long-term idempotency.&lt;/li&gt;
&lt;li&gt;Docker sends &lt;code&gt;SIGTERM&lt;/code&gt; when a container stops, followed by &lt;code&gt;SIGKILL&lt;/code&gt; after a grace period. Make sure your process receives the signal (running it directly, or using &lt;code&gt;init: true&lt;/code&gt; in Compose) and set &lt;code&gt;stop_grace_period&lt;/code&gt; long enough for in-flight jobs to finish.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Approach Comparison
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Approach&lt;/th&gt;
&lt;th&gt;Determinism&lt;/th&gt;
&lt;th&gt;Exposure&lt;/th&gt;
&lt;th&gt;Network dependency&lt;/th&gt;
&lt;th&gt;State isolation&lt;/th&gt;
&lt;th&gt;Setup effort&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Public tunnel (ngrok, etc.)&lt;/td&gt;
&lt;td&gt;Low&lt;/td&gt;
&lt;td&gt;Public ingress to a local port&lt;/td&gt;
&lt;td&gt;High (provider and internet)&lt;/td&gt;
&lt;td&gt;Poor unless you add it&lt;/td&gt;
&lt;td&gt;Very low&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;WireMock in the CI network&lt;/td&gt;
&lt;td&gt;High&lt;/td&gt;
&lt;td&gt;Low (internal network only)&lt;/td&gt;
&lt;td&gt;None at test time&lt;/td&gt;
&lt;td&gt;Good (fresh containers per run)&lt;/td&gt;
&lt;td&gt;Moderate&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;In-process integration tests&lt;/td&gt;
&lt;td&gt;High&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;Excellent (per-test data)&lt;/td&gt;
&lt;td&gt;Low&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Replay script plus queue&lt;/td&gt;
&lt;td&gt;High&lt;/td&gt;
&lt;td&gt;Low&lt;/td&gt;
&lt;td&gt;None for local replays&lt;/td&gt;
&lt;td&gt;Good (namespaced IDs)&lt;/td&gt;
&lt;td&gt;Moderate&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Vendor CLI forwarding (e.g. &lt;code&gt;stripe listen&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;Medium&lt;/td&gt;
&lt;td&gt;Low (outbound connection)&lt;/td&gt;
&lt;td&gt;Yes (vendor sandbox)&lt;/td&gt;
&lt;td&gt;Depends on your setup&lt;/td&gt;
&lt;td&gt;Low&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  Summary and Best Practices
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Keep tunnels out of automated pipelines.&lt;/strong&gt; Use them for manual exploration and vendor sandbox demos. For CI, produce the webhook inside the test network.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sign and verify the raw bytes.&lt;/strong&gt; Capture the raw request body for verification, compare signatures in constant time, and follow your provider's exact scheme, including timestamp tolerance.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Start with in-process tests.&lt;/strong&gt; They are the fastest and most deterministic option. Add a WireMock layer when you need to test vendor API calls and callbacks end to end.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Know WireMock's limits.&lt;/strong&gt; Use &lt;code&gt;serveEventListeners&lt;/code&gt; on WireMock 3.1 and later, template from &lt;code&gt;originalRequest&lt;/code&gt;, and remember there is no built-in HMAC helper.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sanitize replays, and make it opt-in.&lt;/strong&gt; Namespace IDs, refresh timestamps, re-sign with a dev secret, and scrub personal data. Never copy production secrets to a development machine.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Acknowledge fast, process asynchronously, and be idempotent.&lt;/strong&gt; Deduplicate by event ID, return a 2xx quickly, and let a worker with graceful shutdown do the work so hot reloads do not lose events.&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://wiremock.org/docs/webhooks-and-callbacks/" rel="noopener noreferrer"&gt;WireMock: Webhooks and Callbacks&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://wiremock.org/docs/response-templating/" rel="noopener noreferrer"&gt;WireMock: Response Templating&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://wiremock.org/docs/download-and-installation/" rel="noopener noreferrer"&gt;WireMock: Download and Installation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://ngrok.com/docs/pricing-limits/free-plan-limits" rel="noopener noreferrer"&gt;ngrok: Free plan limits&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.stripe.com/webhooks" rel="noopener noreferrer"&gt;Stripe: Webhooks documentation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.stripe.com/stripe-cli" rel="noopener noreferrer"&gt;Stripe CLI documentation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.bullmq.io" rel="noopener noreferrer"&gt;BullMQ documentation&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://instawebhook.com/blog/modern-webhook-engineering-beyond-tunnels-race-conditions-and-flaky-pipelines" rel="noopener noreferrer"&gt;https://instawebhook.com/blog/modern-webhook-engineering-beyond-tunnels-race-conditions-and-flaky-pipelines&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>devops</category>
    </item>
    <item>
      <title>High-Throughput Webhook Systems: Delivery Timelines and Split-Brain Prevention</title>
      <dc:creator>InstaWebhook</dc:creator>
      <pubDate>Tue, 06 Oct 2026 03:12:46 +0000</pubDate>
      <link>https://dev.to/instawebhook/high-throughput-webhook-systems-delivery-timelines-and-split-brain-prevention-391b</link>
      <guid>https://dev.to/instawebhook/high-throughput-webhook-systems-delivery-timelines-and-split-brain-prevention-391b</guid>
      <description>&lt;p&gt;Webhooks are the standard way to integrate systems asynchronously. Scaling a delivery platform to thousands of requests per second exposes two infrastructure problems:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Debugging the "black box":&lt;/strong&gt; Most webhook logs record only &lt;code&gt;Received&lt;/code&gt; and &lt;code&gt;Delivered&lt;/code&gt;. When a transient failure or partition occurs, engineers can't see the intermediate states (encrypted, leased, attempted, retried, dead-lettered).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Duplicate processing after lease expiry:&lt;/strong&gt; In large worker clusters, a stalled thread or latency spike can let a lease expire while the original worker is still alive. A second worker then claims the job, and the receiver may see the same event twice.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This guide covers a state-machine design for full-lifecycle visibility. It also covers lease-and-fencing patterns that keep your own state consistent, and the part fencing cannot solve: duplicates seen by the receiver.&lt;/p&gt;




&lt;h2&gt;
  
  
  1. Full-Lifecycle Timelines for Asynchronous Webhook Jobs
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The limits of &lt;code&gt;Received&lt;/code&gt; vs. &lt;code&gt;Delivered&lt;/code&gt; logging
&lt;/h3&gt;

&lt;p&gt;A basic setup writes to a central log sink (Elasticsearch, CloudWatch, Loki) at two points:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;When the payload reaches the ingest API (&lt;code&gt;Received&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;When the receiver answers with an HTTP &lt;code&gt;2xx&lt;/code&gt; status (&lt;code&gt;Delivered&lt;/code&gt;).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This works at low volume but breaks down at scale:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Unaccounted queue latency:&lt;/strong&gt; If an event sits for 30 seconds before a worker picks it up, two-point logging can't show whether the cause was queue lag, database lock contention, or worker starvation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Opaque retry behavior:&lt;/strong&gt; After a &lt;code&gt;503&lt;/code&gt;, a retry is scheduled. Without state transitions, operators can't tell a job sleeping in backoff from one whose worker crashed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Audit gaps:&lt;/strong&gt; If payloads contain sensitive data, you may need to show that encryption happened before storage. Without structured state events, that means correlating logs across several services.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Retry windows at the sending side are long, so visibility matters. Stripe, for example, attempts to deliver a given event to your webhook endpoint for up to 3 days with an exponential back off in live mode, while test mode retries three times over a few hours. A job can stay in flight for days, and operators need to see where it stands. &lt;a href="https://stripe.com/nl-be/docs/webhooks" rel="noopener noreferrer"&gt;stripe&lt;/a&gt;&lt;/p&gt;




&lt;h3&gt;
  
  
  A state-machine-driven delivery pipeline
&lt;/h3&gt;

&lt;p&gt;Model each delivery as a finite state machine (FSM). Every transition writes an immutable, append-only audit record with the state, worker identity, timestamp, and relevant metadata.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; RECEIVED ──► ENCRYPTED ──► LEASED ──► ATTEMPTED ──► DELIVERED
                              ▲            │
                              │            ├── retryable failure ──► RETRIED ──┐
                              └────────────┼───────────────────────────────────┘
                                           │   (re-queued, then leased again)
                                           └── retries exhausted / non-retryable ──► DLQ
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h4&gt;
  
  
  State descriptions
&lt;/h4&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;State&lt;/th&gt;
&lt;th&gt;Scope&lt;/th&gt;
&lt;th&gt;Trigger&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;RECEIVED&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Ingestion tier&lt;/td&gt;
&lt;td&gt;Payload validated and durably persisted.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ENCRYPTED&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Security / KMS layer&lt;/td&gt;
&lt;td&gt;Payload encrypted (for example with AES-256-GCM) before it is placed in queue storage.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;LEASED&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Queue / worker pool&lt;/td&gt;
&lt;td&gt;A worker claims the job via a lease with a fixed time-to-live (TTL).&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ATTEMPTED&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Outbound transport&lt;/td&gt;
&lt;td&gt;Worker sends the HTTP POST; status code and latency are recorded.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;RETRIED&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Scheduler&lt;/td&gt;
&lt;td&gt;Non-2xx response or timeout; next attempt time computed with exponential backoff and jitter.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;DELIVERED&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Terminal&lt;/td&gt;
&lt;td&gt;Receiver returned a &lt;code&gt;2xx&lt;/code&gt; response.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;DLQ&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Dead-letter storage&lt;/td&gt;
&lt;td&gt;Retry budget exhausted, or a non-retryable error such as most &lt;code&gt;4xx&lt;/code&gt; responses.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Treat &lt;code&gt;408&lt;/code&gt; and &lt;code&gt;429&lt;/code&gt; as retryable rather than as permanent client errors.&lt;/p&gt;

&lt;p&gt;Jitter matters. Exponential backoff alone reduces call volume but does not break up synchronized retry clusters, which is why production systems add randomness to the delay.&lt;/p&gt;




&lt;h3&gt;
  
  
  State transition schema
&lt;/h3&gt;

&lt;p&gt;Store delivery metadata separately from event payloads, so the hot path touches small rows and payload storage stays isolated. The schema below includes the lease columns used later in this guide.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;webhook_deliveries&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;id&lt;/span&gt;               &lt;span class="n"&gt;UUID&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;gen_random_uuid&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="n"&gt;event_id&lt;/span&gt;         &lt;span class="n"&gt;UUID&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;webhook_events&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;subscription_id&lt;/span&gt;  &lt;span class="n"&gt;UUID&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;current_state&lt;/span&gt;    &lt;span class="nb"&gt;VARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;32&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;attempt_count&lt;/span&gt;    &lt;span class="nb"&gt;INT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;max_attempts&lt;/span&gt;     &lt;span class="nb"&gt;INT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;next_attempt_at&lt;/span&gt;  &lt;span class="n"&gt;TIMESTAMPTZ&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;worker_id&lt;/span&gt;        &lt;span class="nb"&gt;VARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;64&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;lease_expires_at&lt;/span&gt; &lt;span class="n"&gt;TIMESTAMPTZ&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;fencing_token&lt;/span&gt;    &lt;span class="nb"&gt;BIGINT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;created_at&lt;/span&gt;       &lt;span class="n"&gt;TIMESTAMPTZ&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;clock_timestamp&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="n"&gt;updated_at&lt;/span&gt;       &lt;span class="n"&gt;TIMESTAMPTZ&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;clock_timestamp&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;-- Append-only audit trail: one row per state transition&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;webhook_delivery_attempts&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;id&lt;/span&gt;               &lt;span class="n"&gt;UUID&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;gen_random_uuid&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="n"&gt;delivery_id&lt;/span&gt;      &lt;span class="n"&gt;UUID&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;webhook_deliveries&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="k"&gt;state&lt;/span&gt;            &lt;span class="nb"&gt;VARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;32&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;worker_id&lt;/span&gt;        &lt;span class="nb"&gt;VARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;64&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;fencing_token&lt;/span&gt;    &lt;span class="nb"&gt;BIGINT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;http_status_code&lt;/span&gt; &lt;span class="nb"&gt;INT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;latency_ms&lt;/span&gt;       &lt;span class="nb"&gt;INT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;error_message&lt;/span&gt;    &lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;created_at&lt;/span&gt;       &lt;span class="n"&gt;TIMESTAMPTZ&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;clock_timestamp&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;idx_deliveries_claimable&lt;/span&gt;
    &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;webhook_deliveries&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;next_attempt_at&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;current_state&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'ENCRYPTED'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'RETRIED'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'LEASED'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;idx_attempts_delivery_time&lt;/span&gt;
    &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;webhook_delivery_attempts&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;delivery_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On PostgreSQL, &lt;code&gt;gen_random_uuid()&lt;/code&gt; is built in from version 13 (earlier versions need the &lt;code&gt;pgcrypto&lt;/code&gt; extension). &lt;code&gt;clock_timestamp()&lt;/code&gt; returns the actual current time, unlike &lt;code&gt;now()&lt;/code&gt;, which is frozen at transaction start. Use it for lease arithmetic. If you run multiple database nodes, prefer the database's clock over worker clocks for all lease comparisons.&lt;/p&gt;

&lt;p&gt;To let many workers claim jobs without blocking each other, combine the claim query with &lt;code&gt;FOR UPDATE SKIP LOCKED&lt;/code&gt;. Concurrent workers then skip rows another worker has already locked instead of waiting on them.&lt;/p&gt;




&lt;h3&gt;
  
  
  End-to-end timeline view
&lt;/h3&gt;

&lt;p&gt;With one record per transition, an SRE can open a single delivery and read its history instead of grepping log streams:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[00:00.000] RECEIVED  - Payload ingested via gateway node gw-us-east-1a
[00:00.004] ENCRYPTED - Encrypted via KMS (key id: ...8f2a)
[00:00.012] LEASED    - Worker wrk-402 claimed lease (30 s), token=1
[00:00.015] ATTEMPTED - POST https://api.client.example/webhook
[00:00.120] FAILED    - Endpoint returned HTTP 503 (latency 105 ms)
[00:00.122] RETRIED   - Rescheduled for +15 s (exponential backoff + jitter)
[00:15.125] LEASED    - Worker wrk-109 claimed lease, token=2
[00:15.128] ATTEMPTED - POST https://api.client.example/webhook
[00:15.210] DELIVERED - HTTP 200 OK (latency 82 ms)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Useful derived metrics from this table include time-in-state percentiles (queue wait is &lt;code&gt;LEASED&lt;/code&gt; minus &lt;code&gt;ENCRYPTED&lt;/code&gt;), retry rate per subscription, DLQ inflow, and lease-expiry counts. As one webhook-reliability guide notes, a single failed delivery is expected in an at-least-once system, so alert on sustained increases in failure rate, retry rate, queue depth, or dead-letter volume rather than on isolated events.&lt;/p&gt;

&lt;h4&gt;
  
  
  Build vs. buy
&lt;/h4&gt;

&lt;p&gt;Visual timelines, FSM tracking, and query pipelines are real engineering work. Managed options include Hookdeck, Svix, the open-source and self-hostable Convoy, and relay services such as InstaWebhook. Hookdeck's Event Gateway, for example, provides ingestion, signature verification, deduplication, retries, and replay. Whichever you evaluate, check that it exposes per-attempt history, replay, and DLQ inspection. Also check how it handles duplicate delivery, since that determines how much work stays on your side. &lt;a href="https://hookdeck.com/webhooks/platforms/cloudflare-queues-alternatives-for-webhooks" rel="noopener noreferrer"&gt;hookdeck&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  2. Preventing Split-Brain Processing: Heartbeats, Leases, and Fencing
&lt;/h2&gt;

&lt;p&gt;When many workers pull from shared queues, you must decide what happens when two workers both believe they own the same job.&lt;/p&gt;

&lt;h3&gt;
  
  
  Root cause: lease expiry during a pause
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Worker A                      Lease store (database)                 Worker B
   |--- 1. Acquire lease (TTL 10 s, token=1) --&amp;gt;|                        |
   |                                            |                        |
   | [GC pause / VM stall / network partition]  |                        |
   |                                            |&amp;lt;-- 2. Lease expired ---|
   |                                            |&amp;lt;-- 3. Acquire (token=2)|
   |                                            |                        |--&amp;gt; 5. POST to receiver
   |--- 4. Resume; still believes it owns it --&amp;gt;|                        |
   |        POST to receiver                    |                        |
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;Worker A claims a delivery with a 10-second lease.&lt;/li&gt;
&lt;li&gt;Worker A stalls, for example in a long garbage-collection pause or a partition.&lt;/li&gt;
&lt;li&gt;The lease expires without renewal. The system assumes Worker A is dead and makes the job claimable.&lt;/li&gt;
&lt;li&gt;Worker B claims it and sends the HTTP request.&lt;/li&gt;
&lt;li&gt;Worker A wakes up, unaware its lease is gone, and sends the same request.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The receiver now sees two requests for one event. This is the failure mode described in Martin Kleppmann's analysis of distributed locking. He calls leases a flawed basis for correctness unless every write is checked with a fencing token, a number that increases with each lock acquisition, which the storage service uses to reject stale writers. &lt;a href="https://dnitza.com/bookmarks/067fd0351446ceeb4b7a5d04c8a775d374900f5b" rel="noopener noreferrer"&gt;dnitza&lt;/a&gt;&lt;/p&gt;




&lt;h3&gt;
  
  
  Fencing tokens and atomic lease renewal
&lt;/h3&gt;

&lt;h4&gt;
  
  
  1. Acquire the lease and increment the token atomically
&lt;/h4&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;webhook_deliveries&lt;/span&gt;
&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;current_state&lt;/span&gt;    &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'LEASED'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;worker_id&lt;/span&gt;        &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'worker-node-102'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;lease_expires_at&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;clock_timestamp&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;INTERVAL&lt;/span&gt; &lt;span class="s1"&gt;'10 seconds'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;fencing_token&lt;/span&gt;    &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;fencing_token&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;updated_at&lt;/span&gt;       &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;clock_timestamp&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'d82c4f74-32e7-4b11-9a99-923f6e9112a2'&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;current_state&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'ENCRYPTED'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'RETRIED'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;OR&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;current_state&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'LEASED'&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;lease_expires_at&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;clock_timestamp&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
      &lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;RETURNING&lt;/span&gt; &lt;span class="n"&gt;fencing_token&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the query returns no row, another worker holds a live lease and this worker must walk away. Because the increment and the claim happen in one statement, tokens are strictly increasing per delivery.&lt;/p&gt;

&lt;h4&gt;
  
  
  2. Heartbeat loop
&lt;/h4&gt;

&lt;p&gt;While a job runs, a background thread extends the lease. The &lt;code&gt;WHERE&lt;/code&gt; clause checks both the worker ID and the token, so a stale worker cannot renew a lease it has lost.&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;threading&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;WorkerHeartbeat&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;__init__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;delivery_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;worker_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fencing_token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                 &lt;span class="n"&gt;interval_sec&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;lease_sec&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;delivery_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;delivery_id&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;worker_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;worker_id&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;fencing_token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;fencing_token&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;interval&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;interval_sec&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;lease_sec&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;lease_sec&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_stop&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;threading&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Event&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;lease_lost&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;threading&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Event&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;   &lt;span class="c1"&gt;# checked by the main work loop
&lt;/span&gt;        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_thread&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;threading&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Thread&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;target&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_loop&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;daemon&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;start&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_thread&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;start&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;stop&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_stop&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_thread&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_loop&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="c1"&gt;# Event.wait returns True when stop is set, ending the loop promptly
&lt;/span&gt;        &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_stop&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;wait&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;interval&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="n"&gt;rows&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;db&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;
                UPDATE webhook_deliveries
                SET lease_expires_at = clock_timestamp() + make_interval(secs =&amp;gt; %s)
                WHERE id = %s
                  AND worker_id = %s
                  AND fencing_token = %s
                  AND current_state = &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;LEASED&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="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;lease_sec&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;delivery_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;worker_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;fencing_token&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;rowcount&lt;/span&gt;

            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;rows&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="c1"&gt;# Lease lost or reassigned. Signal the worker; do not just raise here.
&lt;/span&gt;                &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;lease_lost&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
                &lt;span class="k"&gt;break&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details matter. First, check the affected row count, not just whether the query succeeded, because an &lt;code&gt;UPDATE&lt;/code&gt; that matches nothing still succeeds. Second, an exception raised in a background thread does not stop the main worker. The thread must set a flag or cancel the in-flight request, and the main loop must check it before every side effect. Run heartbeats at roughly a third of the lease TTL (3 seconds for a 10-second lease) so one missed beat doesn't expire the lease.&lt;/p&gt;

&lt;h4&gt;
  
  
  3. Guard every state write with the token
&lt;/h4&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;webhook_deliveries&lt;/span&gt;
&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;current_state&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'DELIVERED'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;updated_at&lt;/span&gt;    &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;clock_timestamp&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;fencing_token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;       &lt;span class="c1"&gt;-- fails if another worker has since taken over&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;worker_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If this updates zero rows, the worker has been fenced out. It must discard its result and write nothing further. The same check should apply to inserts into the audit table.&lt;/p&gt;




&lt;h3&gt;
  
  
  What fencing does not protect: the receiver
&lt;/h3&gt;

&lt;p&gt;Fencing tokens work when the resource being written can check them, as your database can. The receiver's HTTP endpoint cannot. If Worker A has already sent its request, or sends it in the instant before it discovers it lost the lease, no token check can recall it. Fencing also does not give mutual exclusion. Kleppmann has agreed that fencing tokens would not protect against two processes both believing they hold the lock, with the lower token reaching the shared resource before the higher one. &lt;a href="https://surfingcomplexity.blog/2025/03/03/locks-leases-fencing-tokens-fizzbee/" rel="noopener noreferrer"&gt;surfingcomplexity&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;So the honest guarantee of a lease-and-fencing design is this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Your &lt;strong&gt;internal state&lt;/strong&gt; stays consistent. Only the current lease holder can record outcomes.&lt;/li&gt;
&lt;li&gt;The &lt;strong&gt;window for duplicate sends&lt;/strong&gt; is minimized. Check &lt;code&gt;lease_lost&lt;/code&gt; and re-validate the lease immediately before sending.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Duplicate delivery to receivers remains possible.&lt;/strong&gt; Treat delivery as at-least-once.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The correct complement is idempotency. Send a stable event or delivery ID with every request, for example in a header, identical across retries and across workers. Document that receivers must deduplicate on it. Webhook providers behave this way in practice, which is why guidance for webhook consumers consistently stresses idempotent handlers.&lt;/p&gt;




&lt;h3&gt;
  
  
  Comparison of concurrency-control strategies
&lt;/h3&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;Optimistic locking&lt;/th&gt;
&lt;th&gt;Redis single-node lock&lt;/th&gt;
&lt;th&gt;Redlock (multi-node Redis)&lt;/th&gt;
&lt;th&gt;Database lease + fencing token&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Main risk&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;High retry rate under heavy contention&lt;/td&gt;
&lt;td&gt;Lock lost on failover; no fencing&lt;/td&gt;
&lt;td&gt;Relies on timing and clock assumptions&lt;/td&gt;
&lt;td&gt;Extra database write load&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Fencing support&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Version column can act as one&lt;/td&gt;
&lt;td&gt;None built in&lt;/td&gt;
&lt;td&gt;None built in; critics note no obvious way to add monotonic tokens&lt;/td&gt;
&lt;td&gt;Yes, if the token is checked on every write&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Safe for correctness-critical work&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Yes, for the guarded row&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Disputed (see below)&lt;/td&gt;
&lt;td&gt;Yes, for state in the same database&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Complexity&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Low&lt;/td&gt;
&lt;td&gt;Low&lt;/td&gt;
&lt;td&gt;Medium&lt;/td&gt;
&lt;td&gt;Medium to high&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Best for&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Low-contention updates&lt;/td&gt;
&lt;td&gt;Efficiency-only locks (avoiding duplicate work)&lt;/td&gt;
&lt;td&gt;Efficiency-style locks&lt;/td&gt;
&lt;td&gt;Job queues where correctness matters&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Redlock is contested. Kleppmann argues it is unsuitable where correctness depends on the lock. Its author, Salvatore Sanfilippo (antirez), responded with a rebuttal, and the two disagree on the timing assumptions. His position is that you can generate an incremental ID for each Redlock acquired if you have a linearizable store. Kleppmann's recommendation for correctness-critical locking is a consensus-backed system such as ZooKeeper, or a database with transactional guarantees plus fencing tokens. For efficiency-only locks, where an occasional duplicate is harmless, a single Redis node is usually enough. &lt;a href="https://www.antirez.com/news/101" rel="noopener noreferrer"&gt;antirez&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Many teams skip external lock services altogether. They use the database as the serialization point (&lt;code&gt;SELECT … FOR UPDATE SKIP LOCKED&lt;/code&gt; or the conditional &lt;code&gt;UPDATE&lt;/code&gt; above) or a managed queue with a visibility timeout. Amazon SQS works this way: a received message is hidden for a configurable period and becomes visible again if it isn't deleted in time. It is also at-least-once, so the same duplicate-handling caveat applies. SQS dead-letter queues use a &lt;code&gt;maxReceiveCount&lt;/code&gt; threshold to move repeatedly failing messages aside while keeping the original message ID.&lt;/p&gt;




&lt;h2&gt;
  
  
  Architecture Summary Checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Granular state engine:&lt;/strong&gt; Every delivery moves through explicit states (&lt;code&gt;RECEIVED&lt;/code&gt;, &lt;code&gt;ENCRYPTED&lt;/code&gt;, &lt;code&gt;LEASED&lt;/code&gt;, &lt;code&gt;ATTEMPTED&lt;/code&gt;, &lt;code&gt;RETRIED&lt;/code&gt;, &lt;code&gt;DELIVERED&lt;/code&gt;, &lt;code&gt;DLQ&lt;/code&gt;), with each transition recorded.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Immutable audit log:&lt;/strong&gt; Each attempt stores a timestamp, worker ID, fencing token, HTTP status, and latency.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Backoff with jitter:&lt;/strong&gt; Retries use capped exponential backoff plus randomness, with a defined retry budget and a clear retryable/non-retryable error policy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Atomic leases and fencing tokens:&lt;/strong&gt; Claims are single conditional statements, and every state write is guarded by the token.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Heartbeats with real abort handling:&lt;/strong&gt; Renewal checks affected rows, and the main worker loop stops before any further side effect once the lease is lost.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Idempotency end to end:&lt;/strong&gt; A stable delivery ID is sent on every attempt, and receivers are told to deduplicate.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Recoverable DLQ:&lt;/strong&gt; Dead-lettered events can be inspected and replayed in bulk, with alerts on sustained DLQ growth rather than single failures.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://instawebhook.com/blog/high-throughput-webhook-systems-delivery-timelines-and-split-brain-prevention" rel="noopener noreferrer"&gt;https://instawebhook.com/blog/high-throughput-webhook-systems-delivery-timelines-and-split-brain-prevention&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>devops</category>
    </item>
  </channel>
</rss>
