<?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>Integrating Legacy XML/SOAP Systems with Modern JSON Webhook Infrastructure</title>
      <dc:creator>InstaWebhook</dc:creator>
      <pubDate>Sat, 26 Sep 2026 05:46:09 +0000</pubDate>
      <link>https://dev.to/instawebhook/integrating-legacy-xmlsoap-systems-with-modern-json-webhook-infrastructure-gcb</link>
      <guid>https://dev.to/instawebhook/integrating-legacy-xmlsoap-systems-with-modern-json-webhook-infrastructure-gcb</guid>
      <description>&lt;p&gt;Integrating Legacy XML/SOAP Systems with Modern JSON Webhook Infrastructure Executive Summary &amp;amp; Architecture Overview Modern SaaS platforms — Stripe, Shopify, GitHub, Salesforce...&lt;/p&gt;

&lt;p&gt;API event notification&lt;br&gt;
asynchronous webhook processing&lt;br&gt;
bridging JSON and XML&lt;br&gt;
buffering incoming webhooks&lt;br&gt;
EDI system integration&lt;br&gt;
enterprise messaging queue&lt;br&gt;
enterprise webhook architecture&lt;br&gt;
enterprise webhook solutions&lt;br&gt;
event-driven architecture&lt;br&gt;
event-driven SOAP API&lt;br&gt;
high-volume webhook processing&lt;br&gt;
integrating legacy endpoints&lt;br&gt;
JSON payload conversion&lt;br&gt;
JSON to SOAP mapping&lt;br&gt;
JSON to XML conversion&lt;br&gt;
JSON webhook ingestion&lt;br&gt;
legacy API concurrency limits&lt;br&gt;
legacy API gateway&lt;br&gt;
legacy API modernization&lt;br&gt;
legacy enterprise architecture&lt;br&gt;
legacy enterprise webhook integration&lt;br&gt;
legacy middleware solutions&lt;br&gt;
legacy SOAP endpoint&lt;br&gt;
legacy SOAP webhooks&lt;br&gt;
legacy system integration&lt;br&gt;
middleware for webhooks&lt;br&gt;
middleware transformation&lt;br&gt;
modernize legacy endpoints&lt;br&gt;
modern webhook infrastructure&lt;br&gt;
payload transformation&lt;br&gt;
rate limiting webhooks&lt;br&gt;
reliable webhook trickling&lt;br&gt;
robust webhook delivery&lt;br&gt;
SOAP API event driven&lt;br&gt;
SOAP API limits&lt;br&gt;
SOAP endpoint delivery&lt;br&gt;
SOAP message queuing&lt;br&gt;
SOAP web services integration&lt;br&gt;
transform JSON to SOAP envelope&lt;br&gt;
webhook delivery reliability&lt;br&gt;
webhook integration patterns&lt;br&gt;
webhook message broker&lt;br&gt;
webhook payload parsing&lt;br&gt;
webhook proxy&lt;br&gt;
webhook queueing&lt;br&gt;
webhook retry mechanisms&lt;br&gt;
webhook to EDI mapping&lt;br&gt;
webhook transformation layer&lt;br&gt;
webhook translation layer&lt;br&gt;
XML schema webhook&lt;br&gt;
XML webhooks&lt;br&gt;
Integrating Legacy XMLSOAP Systems With Modern JSON Webhook Infrastructure&lt;br&gt;
Integrating Legacy XML/SOAP Systems with Modern JSON Webhook Infrastructure&lt;br&gt;
Executive Summary &amp;amp; Architecture Overview&lt;br&gt;
Modern SaaS platforms — Stripe, Shopify, GitHub, Salesforce, and most others — rely heavily on JSON-based webhooks to stream real-time events. These HTTP callbacks push updates instantly, enabling reactive, event-driven architectures.&lt;/p&gt;

&lt;p&gt;However, many established enterprise backends (legacy SAP deployments, on-premise Oracle ERPs, mainframes, or bespoke internal services) still rely on XML, SOAP, or EDI formats. SOAP hasn't disappeared the way REST advocates predicted a decade ago — it remains common in banking, insurance, telecom, healthcare, and government systems precisely because those industries prioritize strict contracts, transactional guarantees, and compliance mandates over the flexibility that made REST/JSON popular elsewhere.&lt;/p&gt;

&lt;p&gt;That persistence creates two distinct integration challenges:&lt;/p&gt;

&lt;p&gt;Legacy systems are unable to natively parse inbound JSON payloads or handle dynamic HTTP push callbacks.&lt;br&gt;
They're architected around synchronous, heavy transactional models with strict concurrency limits and cannot handle the bursty nature of modern webhook triggers.&lt;br&gt;
To bridge this gap without rewriting core enterprise infrastructure, engineering teams implement an Anti-Corruption Layer (ACL). This architecture ingests high-frequency JSON webhooks, queues them durably in a message broker, transforms JSON payloads into valid SOAP envelopes or XML structures, and trickles requests into legacy SOAP endpoints at controlled concurrency rates.&lt;/p&gt;

&lt;p&gt;The Core Impedance Mismatches&lt;br&gt;
Connecting modern JSON event streams to legacy enterprise systems involves overcoming four fundamental engineering challenges:&lt;/p&gt;

&lt;p&gt;Architectural Metric    Modern JSON Webhook Stream  Legacy SOAP / XML Enterprise System&lt;br&gt;
Payload Structure   Lightweight JSON, flexible schema, key-value trees  Strictly typed XML, WSDL contract, mandatory XML namespaces&lt;br&gt;
Delivery Mechanism  Asynchronous HTTP POST push (at-least-once) Synchronous HTTP/HTTPS request-response, WS-Addressing&lt;br&gt;
Concurrency &amp;amp; Volume    High burstiness (thousands of events per second)    Low concurrency thresholds (5–20 concurrent connections max)&lt;br&gt;
Authentication  HMAC signatures (X-Hub-Signature), bearer tokens    WS-Security (wsse:Security), mTLS, XML digital signatures&lt;br&gt;
Fault Tolerance Expects HTTP 200/202 ACK within &amp;lt; 2 seconds Processing times can span 1,000ms–10,000ms per transaction&lt;br&gt;
Directly exposing a legacy SOAP API endpoint to third-party JSON webhooks leads to predictable failure modes:&lt;/p&gt;

&lt;p&gt;Connection exhaustion — a sudden traffic spike from a third-party event stream can overwhelm the limited thread pool of an application server running a legacy SOAP service.&lt;br&gt;
Payload incompatibility — legacy XML parsers reject JSON payloads immediately, resulting in HTTP 400 or 500 responses.&lt;br&gt;
Rate-limit bans — webhook providers flag unacknowledged or timed-out requests as failed and eventually disable the webhook subscription entirely.&lt;br&gt;
To resolve these issues, you need an event-driven middleware bridge that acts as both a protocol adapter and a concurrency buffer.&lt;/p&gt;

&lt;p&gt;Architectural Blueprint: The Transformation Layer&lt;br&gt;
The recommended architecture isolates the legacy system behind a resilient middleware pipeline built on four core components:&lt;/p&gt;

&lt;p&gt;Code example&lt;br&gt;
Copy code&lt;br&gt;
[ Third-Party SaaS ]&lt;br&gt;
         │ (HTTP POST - JSON Webhook)&lt;br&gt;
         ▼&lt;br&gt;
┌────────────────────────────────────────────────────────┐&lt;br&gt;
│ 1. Ingestion Layer (Fast API Gateway / Ingress Edge)   │&lt;br&gt;
│    - Verifies HMAC signatures                          │&lt;br&gt;
│    - Fast ACK (HTTP 202 Accepted &amp;lt; 50ms)                │&lt;br&gt;
└────────────────────────┬───────────────────────────────┘&lt;br&gt;
                          │ (Raw JSON Event)&lt;br&gt;
                          ▼&lt;br&gt;
┌────────────────────────────────────────────────────────┐&lt;br&gt;
│ 2. Durable Buffer Layer (Queue / Message Broker)        │&lt;br&gt;
│    - SQS / RabbitMQ / Redis BullMQ / Kafka              │&lt;br&gt;
│    - Persistent Storage &amp;amp; Dead-Letter Queue (DLQ)       │&lt;br&gt;
└────────────────────────┬───────────────────────────────┘&lt;br&gt;
                          │ (Decoupled Stream)&lt;br&gt;
                          ▼&lt;br&gt;
┌────────────────────────────────────────────────────────┐&lt;br&gt;
│ 3. Worker Transformation Engine                         │&lt;br&gt;
│    - JSON Parsing &amp;amp; Schema Normalization                │&lt;br&gt;
│    - XML Building &amp;amp; SOAP Envelope Wrapping               │&lt;br&gt;
│    - WS-Security Headers &amp;amp; Authentication Insertion      │&lt;br&gt;
└────────────────────────┬───────────────────────────────┘&lt;br&gt;
                          │ (Rate-Controlled SOAP XML)&lt;br&gt;
                          ▼&lt;br&gt;
┌────────────────────────────────────────────────────────┐&lt;br&gt;
│ 4. Rate-Limited Dispatcher (Leaky Bucket / Worker)       │&lt;br&gt;
│    - Strict Concurrency Pooling (e.g., max 5 requests)   │&lt;br&gt;
│    - Exponential Backoff &amp;amp; Retry Logic                   │&lt;br&gt;
└────────────────────────┬───────────────────────────────┘&lt;br&gt;
                          │ (Synchronous SOAP Request)&lt;br&gt;
                          ▼&lt;br&gt;
[ Legacy ERP / SOAP System ]&lt;br&gt;
Component functions:&lt;/p&gt;

&lt;p&gt;Ingress Ingestion Layer — receives the raw HTTP POST request, validates the cryptographic signature (HMAC-SHA256) sent by the webhook provider, and immediately returns an HTTP 202 Accepted response. Processing happens asynchronously to ensure zero dropped webhooks.&lt;br&gt;
Durable Message Broker — stores incoming events in a message queue (AWS SQS, RabbitMQ, Kafka, or Redis-backed BullMQ). This absorbs sudden traffic bursts and decouples the webhook source from your internal backend.&lt;br&gt;
Transformation Engine — a stateless worker pool that reads JSON payloads, maps data fields, converts types, and constructs valid, namespace-compliant XML SOAP envelopes.&lt;br&gt;
Throttled Dispatcher — executes requests against the legacy SOAP API while adhering strictly to predefined rate limits, thread pools, and mTLS/WS-Security requirements.&lt;br&gt;
Step-by-Step Implementation Guide&lt;br&gt;
Below is a working implementation pattern using Node.js, TypeScript, Express, and BullMQ/Redis.&lt;/p&gt;

&lt;p&gt;Step 1: Secure Ingestion and Instant Acknowledgement&lt;br&gt;
The ingestion endpoint validates the webhook signature, pushes the raw body into a queue, and returns an instant acknowledgment.&lt;/p&gt;

&lt;p&gt;Code example&lt;br&gt;
Copy code&lt;br&gt;
// ingress-server.ts&lt;br&gt;
import express, { Request, Response } from 'express';&lt;br&gt;
import crypto from 'crypto';&lt;br&gt;
import { Queue } from 'bullmq';&lt;/p&gt;

&lt;p&gt;const app = express();&lt;br&gt;
// Capture raw body buffer for HMAC validation&lt;br&gt;
app.use(express.json({&lt;br&gt;
  verify: (req: any, _res, buf) =&amp;gt; {&lt;br&gt;
    req.rawBody = buf;&lt;br&gt;
  }&lt;br&gt;
}));&lt;/p&gt;

&lt;p&gt;const webhookQueue = new Queue('legacy-soap-transform-queue', {&lt;br&gt;
  connection: { host: 'localhost', port: 6379 }&lt;br&gt;
});&lt;/p&gt;

&lt;p&gt;const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET || 'supersecretkey';&lt;/p&gt;

&lt;p&gt;function verifyHmacSignature(req: any): boolean {&lt;br&gt;
  const signature = req.headers['x-hub-signature-256'] as string;&lt;br&gt;
  if (!signature) return false;&lt;/p&gt;

&lt;p&gt;const hmac = crypto.createHmac('sha256', WEBHOOK_SECRET);&lt;br&gt;
  const digest = 'sha256=' + hmac.update(req.rawBody).digest('hex');&lt;br&gt;
  return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(digest));&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;app.post('/api/v1/webhooks/orders', async (req: Request, res: Response) =&amp;gt; {&lt;br&gt;
  // 1. Verify request integrity&lt;br&gt;
  if (!verifyHmacSignature(req)) {&lt;br&gt;
    return res.status(401).json({ error: 'Invalid signature verification' });&lt;br&gt;
  }&lt;/p&gt;

&lt;p&gt;// 2. Queue payload for asynchronous processing&lt;br&gt;
  await webhookQueue.add('order_created_event', {&lt;br&gt;
    eventId: req.headers['x-request-id'] || crypto.randomUUID(),&lt;br&gt;
    receivedAt: new Date().toISOString(),&lt;br&gt;
    payload: req.body&lt;br&gt;
  }, {&lt;br&gt;
    attempts: 5,&lt;br&gt;
    backoff: { type: 'exponential', delay: 2000 },&lt;br&gt;
    removeOnComplete: true&lt;br&gt;
  });&lt;/p&gt;

&lt;p&gt;// 3. Immediately acknowledge reception (&amp;lt;50ms)&lt;br&gt;
  return res.status(202).json({ status: 'ACCEPTED', queued: true });&lt;br&gt;
});&lt;/p&gt;

&lt;p&gt;app.listen(3000, () =&amp;gt; console.log('Webhook Ingress Server listening on port 3000'));&lt;br&gt;
2026 update — a standard is emerging for this layer. The bespoke X-Hub-Signature-256 pattern above (popularized by GitHub and Stripe) still works fine, but a community effort called Standard Webhooks — backed by companies including Zapier, Twilio, Lob, and Mux — now defines a common set of headers (webhook-id, webhook-timestamp, webhook-signature) and an HMAC-SHA256 verification scheme with a five-minute replay-tolerance window, with maintained reference libraries for TypeScript, Python, Go, Java/Kotlin, and Rust. Newer providers (Svix-backed platforms, Clerk, and others) are adopting it directly. If you're designing a new ingestion contract in 2026 rather than matching an existing provider's format, it's worth adopting Standard Webhooks instead of a bespoke header scheme — it saves you from re-solving signature verification, secret rotation, and timestamp tolerance yourself.&lt;/p&gt;

&lt;p&gt;Step 2: The Payload Transformation Layer (JSON to SOAP XML)&lt;br&gt;
Legacy SOAP services require precise XML formatting, including strict namespace definitions (xmlns), outer SOAP envelopes, and structured bodies.&lt;/p&gt;

&lt;p&gt;Sample inbound JSON webhook payload:&lt;/p&gt;

&lt;p&gt;Code example&lt;br&gt;
Copy code&lt;br&gt;
{&lt;br&gt;
  "event": "order.created",&lt;br&gt;
  "data": {&lt;br&gt;
    "order_id": "ORD-99281",&lt;br&gt;
    "customer": {&lt;br&gt;
      "id": "CUST-402",&lt;br&gt;
      "email": "&lt;a href="mailto:customer@example.com"&gt;customer@example.com&lt;/a&gt;"&lt;br&gt;
    },&lt;br&gt;
    "amount": 249.99,&lt;br&gt;
    "currency": "USD"&lt;br&gt;
  }&lt;br&gt;
}&lt;br&gt;
Transformation utility code:&lt;/p&gt;

&lt;p&gt;Code example&lt;br&gt;
Copy code&lt;br&gt;
// transformer.ts&lt;br&gt;
import { Builder } from 'xml2js';&lt;/p&gt;

&lt;p&gt;interface OrderEvent {&lt;br&gt;
  order_id: string;&lt;br&gt;
  customer: { id: string; email: string };&lt;br&gt;
  amount: number;&lt;br&gt;
  currency: string;&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;export function buildSoapRequestEnvelope(eventData: OrderEvent, wsToken: string): string {&lt;br&gt;
  const builder = new Builder({&lt;br&gt;
    xmldec: { version: '1.0', encoding: 'UTF-8' },&lt;br&gt;
    renderOpts: { pretty: false }&lt;br&gt;
  });&lt;/p&gt;

&lt;p&gt;const soapObject = {&lt;br&gt;
    'soapenv:Envelope': {&lt;br&gt;
      $: {&lt;br&gt;
        'xmlns:soapenv': '&lt;a href="http://schemas.xmlsoap.org/soap/envelope/" rel="noopener noreferrer"&gt;http://schemas.xmlsoap.org/soap/envelope/&lt;/a&gt;',&lt;br&gt;
        'xmlns:erp': '&lt;a href="http://legacy.enterprise.com/erp/orders" rel="noopener noreferrer"&gt;http://legacy.enterprise.com/erp/orders&lt;/a&gt;'&lt;br&gt;
      },&lt;br&gt;
      'soapenv:Header': {&lt;br&gt;
        'wsse:Security': {&lt;br&gt;
          $: {&lt;br&gt;
            'xmlns:wsse': '&lt;a href="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd" rel="noopener noreferrer"&gt;http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd&lt;/a&gt;'&lt;br&gt;
          },&lt;br&gt;
          'wsse:UsernameToken': {&lt;br&gt;
            'wsse:Username': 'SERVICE_USER_WEBHOOK',&lt;br&gt;
            'wsse:Password': wsToken&lt;br&gt;
          }&lt;br&gt;
        }&lt;br&gt;
      },&lt;br&gt;
      'soapenv:Body': {&lt;br&gt;
        'erp:CreateOrderRequest': {&lt;br&gt;
          'erp:ExternalOrderId': eventData.order_id,&lt;br&gt;
          'erp:CustomerId': eventData.customer.id,&lt;br&gt;
          'erp:CustomerEmail': eventData.customer.email,&lt;br&gt;
          'erp:TotalAmount': eventData.amount.toFixed(2),&lt;br&gt;
          'erp:CurrencyCode': eventData.currency&lt;br&gt;
        }&lt;br&gt;
      }&lt;br&gt;
    }&lt;br&gt;
  };&lt;/p&gt;

&lt;p&gt;return builder.buildObject(soapObject);&lt;br&gt;
}&lt;br&gt;
Resulting outbound SOAP XML request:&lt;/p&gt;

&lt;p&gt;Code example&lt;br&gt;
Copy code&lt;br&gt;
&amp;lt;?xml version="1.0" encoding="UTF-8"?&amp;gt;&lt;br&gt;
&lt;br&gt;
  &lt;a&gt;soapenv:Header&lt;/a&gt;&lt;br&gt;
    &lt;br&gt;
      &lt;a&gt;wsse:UsernameToken&lt;/a&gt;&lt;br&gt;
        &lt;a&gt;wsse:Username&lt;/a&gt;SERVICE_USER_WEBHOOK&lt;a href="/wsse:Username"&gt;/wsse:Username&lt;/a&gt;&lt;br&gt;
        &lt;a&gt;wsse:Password&lt;/a&gt;token_secret_value&lt;a href="/wsse:Password"&gt;/wsse:Password&lt;/a&gt;&lt;br&gt;
      &lt;a href="/wsse:UsernameToken"&gt;/wsse:UsernameToken&lt;/a&gt;&lt;br&gt;
    &lt;a href="/wsse:Security"&gt;/wsse:Security&lt;/a&gt;&lt;br&gt;
  &lt;a href="/soapenv:Header"&gt;/soapenv:Header&lt;/a&gt;&lt;br&gt;
  &lt;a&gt;soapenv:Body&lt;/a&gt;&lt;br&gt;
    &lt;a&gt;erp:CreateOrderRequest&lt;/a&gt;&lt;br&gt;
      &lt;a&gt;erp:ExternalOrderId&lt;/a&gt;ORD-99281&lt;a href="/erp:ExternalOrderId"&gt;/erp:ExternalOrderId&lt;/a&gt;&lt;br&gt;
      &lt;a&gt;erp:CustomerId&lt;/a&gt;CUST-402&lt;a href="/erp:CustomerId"&gt;/erp:CustomerId&lt;/a&gt;&lt;br&gt;
      &lt;a&gt;erp:CustomerEmail&lt;/a&gt;&lt;a href="mailto:customer@example.com"&gt;customer@example.com&lt;/a&gt;&lt;a href="/erp:CustomerEmail"&gt;/erp:CustomerEmail&lt;/a&gt;&lt;br&gt;
      &lt;a&gt;erp:TotalAmount&lt;/a&gt;249.99&lt;a href="/erp:TotalAmount"&gt;/erp:TotalAmount&lt;/a&gt;&lt;br&gt;
      &lt;a&gt;erp:CurrencyCode&lt;/a&gt;USD&lt;a href="/erp:CurrencyCode"&gt;/erp:CurrencyCode&lt;/a&gt;&lt;br&gt;
    &lt;a href="/erp:CreateOrderRequest"&gt;/erp:CreateOrderRequest&lt;/a&gt;&lt;br&gt;
  &lt;a href="/soapenv:Body"&gt;/soapenv:Body&lt;/a&gt;&lt;br&gt;
&lt;a href="/soapenv:Envelope"&gt;/soapenv:Envelope&lt;/a&gt;&lt;br&gt;
2026 update — swap xml2js for fast-xml-parser. xml2js still works, but it has fallen behind: it has no built-in streaming support and community discussion around it has quieted, with several teams publicly noting they've moved off it. fast-xml-parser is the more actively maintained option today — it ships ESM builds (since v5.0, released early 2025), has an integrated XMLBuilder, sees tens of millions of weekly downloads, and is still receiving regular releases. A drop-in equivalent for the builder above looks like this:&lt;/p&gt;

&lt;p&gt;Code example&lt;br&gt;
Copy code&lt;br&gt;
import { XMLBuilder } from 'fast-xml-parser';&lt;/p&gt;

&lt;p&gt;const builder = new XMLBuilder({&lt;br&gt;
  ignoreAttributes: false,&lt;br&gt;
  attributeNamePrefix: '@_',&lt;br&gt;
  format: false&lt;br&gt;
});&lt;/p&gt;

&lt;p&gt;const xmlPayload = builder.build({&lt;br&gt;
  '?xml': { '@&lt;em&gt;version': '1.0', '@_encoding': 'UTF-8' },&lt;br&gt;
  'soapenv:Envelope': {&lt;br&gt;
    '@_xmlns:soapenv': '&lt;a href="http://schemas.xmlsoap.org/soap/envelope/" rel="noopener noreferrer"&gt;http://schemas.xmlsoap.org/soap/envelope/&lt;/a&gt;',&lt;br&gt;
    '@_xmlns:erp': '&lt;a href="http://legacy.enterprise.com/erp/orders" rel="noopener noreferrer"&gt;http://legacy.enterprise.com/erp/orders&lt;/a&gt;',&lt;br&gt;
    // ...header and body as before, using '@&lt;/em&gt;' for attributes&lt;br&gt;
  }&lt;br&gt;
});&lt;br&gt;
Either library gets the job done for occasional envelope-building; if you're validating output against a strict WSDL/XSD, it's worth adding a schema-validation pass regardless of which builder you use, since neither performs XSD validation on its own.&lt;/p&gt;

&lt;p&gt;Step 3: Throttled Dispatcher &amp;amp; Concurrency Control&lt;br&gt;
To protect legacy enterprise systems from performance degradation, worker instances should process queue items with concurrency throttling and rate limiting.&lt;/p&gt;

&lt;p&gt;Code example&lt;br&gt;
Copy code&lt;br&gt;
// worker.ts&lt;br&gt;
import { Worker, Job } from 'bullmq';&lt;br&gt;
import axios from 'axios';&lt;br&gt;
import { buildSoapRequestEnvelope } from './transformer';&lt;/p&gt;

&lt;p&gt;const LEGACY_SOAP_ENDPOINT = '&lt;a href="https://erp-internal.enterprise.local/soap/OrderService" rel="noopener noreferrer"&gt;https://erp-internal.enterprise.local/soap/OrderService&lt;/a&gt;';&lt;/p&gt;

&lt;p&gt;// Configure worker concurrency to prevent overloading the legacy server&lt;br&gt;
const worker = new Worker('legacy-soap-transform-queue', async (job: Job) =&amp;gt; {&lt;br&gt;
  const { payload } = job.data;&lt;/p&gt;

&lt;p&gt;// 1. Convert JSON to SOAP XML envelope&lt;br&gt;
  const xmlPayload = buildSoapRequestEnvelope(payload.data, process.env.ERP_SOAP_PASS || '');&lt;/p&gt;

&lt;p&gt;// 2. Dispatch with strict timeouts&lt;br&gt;
  try {&lt;br&gt;
    const response = await axios.post(LEGACY_SOAP_ENDPOINT, xmlPayload, {&lt;br&gt;
      headers: {&lt;br&gt;
        'Content-Type': 'text/xml;charset=UTF-8',&lt;br&gt;
        'SOAPAction': '&lt;a href="http://legacy.enterprise.com/erp/orders/CreateOrder" rel="noopener noreferrer"&gt;http://legacy.enterprise.com/erp/orders/CreateOrder&lt;/a&gt;'&lt;br&gt;
      },&lt;br&gt;
      timeout: 10000 // 10-second request timeout limit&lt;br&gt;
    });&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;// Parse legacy XML response for fault elements
if (response.data.includes('&amp;lt;soapenv:Fault&amp;gt;')) {
  throw new Error(`SOAP Application Error: ${response.data}`);
}

console.log(`[Job ${job.id}] Successfully dispatched to legacy SOAP API`);
return { status: 'SUCCESS' };
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;} catch (error: any) {&lt;br&gt;
    console.error(&lt;code&gt;[Job ${job.id}] Delivery Failed: ${error.message}&lt;/code&gt;);&lt;br&gt;
    // Re-throw error to trigger BullMQ exponential backoff retry mechanism&lt;br&gt;
    throw error;&lt;br&gt;
  }&lt;br&gt;
}, {&lt;br&gt;
  connection: { host: 'localhost', port: 6379 },&lt;br&gt;
  // RESTRICT CONCURRENCY: Maximum 5 parallel HTTP connections to the legacy backend&lt;br&gt;
  concurrency: 5,&lt;br&gt;
  limiter: {&lt;br&gt;
    max: 20,&lt;br&gt;
    duration: 1000 // Rate limit: Max 20 calls per second&lt;br&gt;
  }&lt;br&gt;
});&lt;br&gt;
2026 update — BullMQ has moved forward. BullMQ 5.x is now on the 5.7x line and remains the de-facto Redis-backed job queue for Node.js. Recent releases added native OpenTelemetry tracing, FlowProducer for DAG-style job dependencies (useful if a single webhook event needs to fan out into several dependent SOAP calls), and refined attemptsMade vs. attemptsStarted semantics for cleaner retry bookkeeping. If you're standing this up fresh, pin a current 5.7x release and consider wiring in the OpenTelemetry exporter from day one — tracing a request from X-Request-Id through the queue and into the SOAP call is the single biggest debugging win in this kind of pipeline.&lt;/p&gt;

&lt;p&gt;An alternative worth evaluating: durable execution engines. Where BullMQ gives you a queue plus manual retry/backoff logic, a durable-execution platform like Temporal models the whole "wait for ack, retry with backoff, eventually give up" sequence as ordinary code that survives process and server restarts — the workflow resumes exactly where it left off rather than replaying from a queue message. Temporal specifically documents this "delayed callback" pattern for HTTP webhook integrations, including inbound signal-based intake and outbound retryable HTTP activities. For a simple one-hop JSON-to-SOAP bridge, BullMQ is usually the simpler and cheaper choice; for pipelines with multi-step sagas, long waits (hours to days), or compensating transactions if the SOAP call partially succeeds, Temporal's durable-timer and workflow-history model removes a lot of the bookkeeping you'd otherwise hand-roll.&lt;/p&gt;

&lt;p&gt;Reliability, Security &amp;amp; Error Handling Strategies&lt;br&gt;
Integrating modern event streams into legacy enterprise architectures requires dedicated handling for security, retries, and data consistency.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Idempotency &amp;amp; Deduplication
Because webhooks use at-least-once delivery, duplicate events will occur. Many legacy SOAP endpoints do not support built-in idempotency keys.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;To prevent duplicate processing:&lt;/p&gt;

&lt;p&gt;Extract the unique event ID or transaction ID from the inbound JSON payload (e.g., evt_3Mtw2eLkd...).&lt;br&gt;
Store the event ID in a distributed cache (such as Redis) with a 24–48 hour TTL before calling the SOAP endpoint.&lt;br&gt;
If a duplicate event ID arrives, acknowledge it immediately without submitting another transaction to the legacy backend.&lt;br&gt;
Code example&lt;br&gt;
Copy code&lt;br&gt;
Incoming Webhook ──► Check Key in Redis? ─┬─► Yes ──► Skip Processing &amp;amp; ACK 200&lt;br&gt;
                                           └─► No  ──► Lock Key ──► Process SOAP Request&lt;br&gt;
2026 update — this is becoming a formal HTTP standard. The IETF's HTTPAPI working group has an active Internet-Draft, draft-ietf-httpapi-idempotency-key-header, standardizing a dedicated Idempotency-Key request header for exactly this purpose: making non-idempotent methods like POST fault-tolerant across retries. It's still a draft (not yet an RFC as of early 2026), so don't treat it as guaranteed-stable, but it's a good signal of where deduplication conventions are heading — and adopting the header name now costs nothing and makes your ingestion layer forward-compatible with clients that already send it (Stripe and several other providers have used this pattern for years).&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Handling SOAP Faults vs. Network Failures
Legacy SOAP APIs sometimes return an HTTP 200 OK status code even for application-level errors, wrapping the error inside a &lt;a&gt;soapenv:Fault&lt;/a&gt; payload.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Your transformation layer must inspect the response payload for structural faults:&lt;/p&gt;

&lt;p&gt;Transient errors (HTTP 502/503, timeout, database lock) — the worker should throw an exception to trigger an automated retry with exponential backoff and jitter.&lt;br&gt;
Deterministic errors (XML validation failure, invalid business key, malformed schema) — retrying will not fix these. Move the message directly to a Dead-Letter Queue (DLQ) and notify the integration team.&lt;br&gt;
Code example&lt;br&gt;
Copy code&lt;br&gt;
                  ┌──────────────────────────────┐&lt;br&gt;
                  │ Evaluate Legacy SOAP Response │&lt;br&gt;
                  └──────────────┬───────────────┘&lt;br&gt;
                                 │&lt;br&gt;
         ┌───────────────────────┴───────────────────────┐&lt;br&gt;
         ▼                                                ▼&lt;br&gt;
[ HTTP 200 + &lt;a&gt;soapenv:Fault&lt;/a&gt; ]                  [ Network Timeout / 503 ]&lt;br&gt;
         │                                                │&lt;br&gt;
         ▼                                                ▼&lt;br&gt;
Deterministic / Schema Error                    Transient Infrastructure Failure&lt;br&gt;
         │                                                │&lt;br&gt;
         ▼                                                ▼&lt;br&gt;
Route directly to Dead-Letter Queue (DLQ)       Retry with Exponential Backoff + Jitter&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Authentication &amp;amp; Credential Translation
Inbound JSON webhooks typically authenticate using bearer tokens or HMAC signatures in HTTP headers. Legacy enterprise backends, by contrast, often require older security patterns:&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;WS-Security (WSS) — username tokens, nonce hashes, and timestamp elements injected directly into the XML &lt;a&gt;soapenv:Header&lt;/a&gt;.&lt;br&gt;
Mutual TLS (mTLS) — client certificates configured at the transport layer of the outbound worker node.&lt;br&gt;
IP whitelisting — static egress IPs routed through a NAT Gateway for on-premise firewall traversal.&lt;br&gt;
The middleware bridge acts as a credential translator, validating incoming SaaS signatures at the edge and injecting required enterprise credentials during XML compilation.&lt;/p&gt;

&lt;p&gt;Technology Selection: Middleware vs. iPaaS vs. Custom Engine&lt;br&gt;
Depending on your enterprise architecture, several patterns exist for implementing this transformation layer:&lt;/p&gt;

&lt;p&gt;Code example&lt;br&gt;
Copy code&lt;br&gt;
                  ┌───────────────────────────────────────────────┐&lt;br&gt;
                  │ How complex are your transformation rules,     │&lt;br&gt;
                  │ rate limits, and compliance constraints?       │&lt;br&gt;
                  └───────────────────────┬───────────────────────┘&lt;br&gt;
                                           │&lt;br&gt;
                  ┌────────────────────────┴───────────────────────┐&lt;br&gt;
                  ▼                                                 ▼&lt;br&gt;
       [ High-Volume / Custom ]                          [ Enterprise Integration ]&lt;br&gt;
                  │                                                 │&lt;br&gt;
                  ▼                                                 ▼&lt;br&gt;
┌───────────────────────────────────┐             ┌───────────────────────────────────┐&lt;br&gt;
│ Custom Microservice Middleware     │             │ Enterprise Integration / iPaaS     │&lt;br&gt;
│ (Node.js, Go, Rust, BullMQ/SQS,    │             │ (MuleSoft, Apache Camel, Boomi)    │&lt;br&gt;
│  or Temporal for durable workflow) │             │                                     │&lt;br&gt;
├───────────────────────────────────┤             ├───────────────────────────────────┤&lt;br&gt;
│ • Ultra-low latency                │             │ • Low-code interface                │&lt;br&gt;
│ • Fine-grained rate limits         │             │ • Pre-built WSDL parsers            │&lt;br&gt;
│ • Custom pipeline tests            │             │ • Out-of-the-box governance          │&lt;br&gt;
└───────────────────────────────────┘             └───────────────────────────────────┘&lt;br&gt;
Strategy    Recommended Tools   Advantages  Disadvantages&lt;br&gt;
Custom Microservice Middleware  Node.js, Go, Python, BullMQ, AWS SQS, Temporal  Complete control over rate limiting, lower operational cost at scale, easily testable   Requires maintenance of custom application code&lt;br&gt;
Enterprise Integration Engine (ESB / iPaaS) Apache Camel, MuleSoft Anypoint, Dell Boomi Native WSDL parsers, drag-and-drop transformation, enterprise governance compliance Higher licensing costs, vendor lock-in, potential performance overhead&lt;br&gt;
Cloud-Native Serverless Pipeline    AWS EventBridge, Lambda, SQS, API Gateway   Zero server maintenance, automatic scaling, pay-per-use model   Cold starts, configuration complexity for strict connection pooling&lt;br&gt;
2026 update — the iPaaS market has kept consolidating, and the "serverless" row has a purpose-built feature now.&lt;/p&gt;

&lt;p&gt;The iPaaS market is projected to exceed $17 billion by 2028, and Dell Boomi was named a Leader in Gartner's Magic Quadrant for iPaaS for the 11th consecutive year in 2025, alongside MuleSoft (Salesforce-owned since 2018), which continues to lean on its API-led connectivity model and Anypoint connector ecosystem for exactly this kind of SOAP/legacy bridging work.&lt;br&gt;
If you're leaning toward the serverless row, look specifically at EventBridge API destinations rather than a raw Lambda/SQS combination: API destinations have a built-in invocationRateLimitPerSecond setting, enforced with a token-bucket algorithm, that throttles outbound calls to an HTTP(S) endpoint directly at the platform level — which is effectively the "Throttled Dispatcher" component from the architecture above, without you writing the concurrency-limiting code yourself. It won't build your SOAP envelope for you (that transformation still happens in a Lambda upstream of the API destination), but it removes one whole component from the custom build.&lt;br&gt;
Production Readiness Checklist&lt;br&gt;
Before deploying your JSON-to-SOAP integration bridge to production, verify the following operational safeguards:&lt;/p&gt;

&lt;p&gt;Cryptographic Verification — inbound endpoints validate webhook signatures using strict time-constant comparison to prevent timing attacks (whether via a custom HMAC scheme or the Standard Webhooks convention).&lt;br&gt;
 Stateless Ingestion ACK — ingress nodes return an HTTP 202 Accepted status code immediately after enqueuing payloads.&lt;br&gt;
 Rate Limiting &amp;amp; Concurrency Controls — worker threads (or your EventBridge API destination) are restricted to a maximum parallel connection count that matches the legacy system's capacity limits.&lt;br&gt;
 XML Namespace Validation — XML output is strictly validated against the target system's WSDL/XSD schema definitions.&lt;br&gt;
 Dead-Letter Queue (DLQ) — messages failing after maximum retry attempts are captured in a DLQ with automated alerting.&lt;br&gt;
 Idempotency Safeguards — event unique identifiers are cached (or carried via an Idempotency-Key-style header) to prevent duplicate processing from webhook retries.&lt;br&gt;
 Monitoring &amp;amp; Tracing — distributed tracing headers (such as traceparent or X-Correlation-ID) are passed from inbound JSON headers into XML SOAP headers for end-to-end visibility, ideally exported via OpenTelemetry.&lt;br&gt;
 Dependency Currency — XML libraries, queue clients, and SDKs are pinned to actively maintained, current major versions rather than long-abandoned forks.&lt;br&gt;
Conclusion&lt;br&gt;
Integrating modern event-driven JSON webhooks with legacy SOAP and XML infrastructure requires balancing two different architectural models. Attempting to connect these systems directly exposes legacy enterprise endpoints to unexpected traffic bursts, connection limits, and payload incompatibilities.&lt;/p&gt;

&lt;p&gt;By establishing an Anti-Corruption Integration Layer — built around fast ingestion, durable queueing, XML transformation, and rate-limited dispatching — organizations can leverage modern event-driven SaaS capabilities while preserving the stability of core enterprise legacy systems. The core pattern hasn't changed; what's shifted in the last year is that more of it is becoming standardized (Standard Webhooks, the IETF idempotency-key draft) or available as a managed building block (EventBridge API destinations, durable-execution platforms like Temporal), which means less of this pipeline needs to be hand-rolled than it did even a couple of years ago.&lt;/p&gt;

&lt;p&gt;Further Reading &amp;amp; Sources&lt;br&gt;
Standard Webhooks specification&lt;br&gt;
IETF Idempotency-Key HTTP header draft&lt;br&gt;
AWS EventBridge — create an API destination (rate limiting)&lt;br&gt;
AWS EventBridge quotas&lt;br&gt;
Temporal — Delayed Callback (Webhooks) design pattern&lt;br&gt;
fast-xml-parser (GitHub)&lt;br&gt;
Boomi named a Leader in the 2025 Gartner Magic Quadrant for iPaaS&lt;br&gt;
Boomi: Application Integration Trends for 2025&lt;/p&gt;

</description>
    </item>
    <item>
      <title>Server-Sent Events vs. Webhooks: Bridging the Backend to the Browser</title>
      <dc:creator>InstaWebhook</dc:creator>
      <pubDate>Fri, 25 Sep 2026 07:19:06 +0000</pubDate>
      <link>https://dev.to/instawebhook/server-sent-events-vs-webhooks-bridging-the-backend-to-the-browser-56n0</link>
      <guid>https://dev.to/instawebhook/server-sent-events-vs-webhooks-bridging-the-backend-to-the-browser-56n0</guid>
      <description>&lt;p&gt;API webhooks&lt;br&gt;
backend ingestion&lt;br&gt;
backend to browser real-time&lt;br&gt;
browser event streams&lt;br&gt;
client-side SSE&lt;br&gt;
continuous HTTP connection&lt;br&gt;
Event-Driven Architecture&lt;br&gt;
EventSource API&lt;br&gt;
frontend event listener&lt;br&gt;
frontend real-time updates&lt;br&gt;
full-stack real-time data&lt;br&gt;
HTTP streaming&lt;br&gt;
instant frontend updates&lt;br&gt;
InstaWebhook&lt;br&gt;
InstaWebhook integration&lt;br&gt;
Javascript EventSource&lt;br&gt;
live UI updates&lt;br&gt;
no WebSocket needed&lt;br&gt;
push API alternative&lt;br&gt;
push notifications architecture&lt;br&gt;
push real-time updates to client&lt;br&gt;
real-time dashboard updates&lt;br&gt;
real-time data streaming&lt;br&gt;
real-time UI updates&lt;br&gt;
real-time web applications&lt;br&gt;
receive webhooks in browser&lt;br&gt;
reliable backend ingestion&lt;br&gt;
server push technology&lt;br&gt;
Server-Sent Events&lt;br&gt;
Server-Sent Events architecture&lt;br&gt;
Server-Sent Events vs webhooks&lt;br&gt;
server-side events&lt;br&gt;
server to client push&lt;br&gt;
SSE connection&lt;br&gt;
SSE implementation&lt;br&gt;
SSE stream&lt;br&gt;
SSE vs webhooks&lt;br&gt;
SSE vs WebSockets&lt;br&gt;
SSE webhook gateway&lt;br&gt;
streaming data to client&lt;br&gt;
stream webhook data&lt;br&gt;
unidirectional data flow&lt;br&gt;
updating UI dynamically&lt;br&gt;
web development real-time&lt;br&gt;
webhook events to browser&lt;br&gt;
webhook frontend bridge&lt;br&gt;
webhook listener real-time&lt;br&gt;
webhook payload handling&lt;br&gt;
webhook processing&lt;br&gt;
webhook provider to backend&lt;br&gt;
webhooks for frontend&lt;br&gt;
webhook to browser&lt;br&gt;
WebSockets alternative&lt;br&gt;
Server Sent Events Vs Webhooks Bridging The Backend To The Browser&lt;br&gt;
Server-Sent Events vs. Webhooks: Bridging the Backend to the Browser&lt;br&gt;
Modern web apps are expected to update themselves. Live dashboards, notification badges, payment confirmations, and streaming progress bars all need to change the instant something happens — no refresh button required.&lt;/p&gt;

&lt;p&gt;Developers often treat Webhooks and Server-Sent Events (SSE) as if they're competing solutions to this problem. They aren't. They solve two different halves of it:&lt;/p&gt;

&lt;p&gt;Webhooks move an event from an external provider into your backend.&lt;br&gt;
SSE moves that event from your backend out to the browser.&lt;br&gt;
This article breaks down how each one works, where each one struggles, how to combine them into a single real-time pipeline, and — since this space has moved fast — what's actually changed in 2026: SSE has quietly become the default transport for AI streaming, and a genuine WebSocket alternative (WebTransport) just became usable in production for the first time.&lt;/p&gt;

&lt;p&gt;The Real-Time Delivery Problem&lt;br&gt;
Picture a typical flow:&lt;/p&gt;

&lt;p&gt;Code example&lt;br&gt;
Copy code&lt;br&gt;
[ External Service ]  ---&amp;gt;  [ Your Backend Server ]  ---&amp;gt;  &lt;a href="//e.g.,%20Stripe"&gt; User's Browser UI &lt;/a&gt;               (e.g., Node.js API)          (React / Vanilla JS)&lt;br&gt;
                    \                               /&lt;br&gt;
                     ---&amp;gt;   WEBHOOK JUMP          /---&amp;gt;  SSE JUMP&lt;br&gt;
A user pays an invoice on a Stripe-powered checkout page.&lt;br&gt;
Stripe needs to tell your backend the payment succeeded.&lt;br&gt;
Your backend needs to instantly flip the browser's UI from "Processing…" to "Payment Successful!"&lt;br&gt;
Two boundaries, two different constraints:&lt;/p&gt;

&lt;p&gt;Can Stripe send a webhook directly to the browser? No. Browsers sit behind NATs and dynamic IPs; they don't expose a public endpoint a third party can POST to.&lt;br&gt;
Can the browser open an SSE connection directly to Stripe? No. That would mean shipping your API credentials to client-side code and skipping your own auth and business logic entirely.&lt;br&gt;
So you need both: a webhook to get the event in, and an SSE stream to push it back out.&lt;/p&gt;

&lt;p&gt;Deep Dive: Webhooks (Provider → Backend)&lt;br&gt;
A webhook is an event-driven HTTP POST sent from a provider to your server when something happens on their end.&lt;/p&gt;

&lt;p&gt;Code example&lt;br&gt;
Copy code&lt;br&gt;
+-------------------+                           +-------------------+&lt;br&gt;
|  Event Provider    | --- HTTP POST /webhook --&amp;gt;|   Your Backend     |&lt;br&gt;
|  (Stripe / GitHub) | &amp;lt;------ 200 OK ---------- |   (Webhook Receiver)|&lt;br&gt;
+-------------------+                           +-------------------+&lt;br&gt;
Key characteristics&lt;/p&gt;

&lt;p&gt;Server-to-server. Both ends are HTTP servers reachable on public networks.&lt;br&gt;
Stateless and discrete. Every event is its own isolated POST; nothing stays open between them.&lt;br&gt;
Push, not poll. The provider notifies you the moment something changes instead of you hitting their REST API on a timer.&lt;br&gt;
Where webhooks struggle in production&lt;/p&gt;

&lt;p&gt;Downtime windows. If your endpoint is redeploying or overloaded when the POST arrives, that delivery fails.&lt;br&gt;
No browser support. A browser can't act as a webhook receiver — this is exactly the gap SSE fills.&lt;br&gt;
Retry logic is the provider's problem, then yours. If delivery fails, the provider has to decide how long to keep retrying, and you have to handle duplicate or out-of-order deliveries.&lt;br&gt;
Stripe is a useful concrete example here: in live mode it retries a failed webhook delivery immediately, then again after roughly 5 minutes, 30 minutes, 2 hours, 5 hours and 10 hours, then every 12 hours after that, for up to three days total, before disabling the endpoint and notifying you. GitHub, similarly, will redeliver a failed webhook and lets you manually replay recent deliveries from its UI. The pattern is the same everywhere: you own the reliability problem the moment the provider gives up retrying.&lt;/p&gt;

&lt;p&gt;This is why a category of "webhook gateway" services has grown up around this exact pain point — Svix, Hookdeck, Hooklistener, Convoy, and InstaWebhook are current examples. They sit in front of your application, verify signatures, absorb retries and spikes, queue events, and give you a dashboard of what was delivered and what failed, so you're not building that infrastructure yourself. If you're prototyping, plain Express is fine; if you're running this in production against real payment or deployment events, a gateway like this is usually a better use of engineering time than a hand-rolled retry queue.&lt;/p&gt;

&lt;p&gt;Deep Dive: Server-Sent Events (Backend → Browser)&lt;br&gt;
SSE was introduced during the original HTML5 effort and now lives in the WHATWG HTML Living Standard — the specification browsers actually implement today. It lets a server hold open a single HTTP connection and stream text events to a client asynchronously.&lt;/p&gt;

&lt;p&gt;Code example&lt;br&gt;
Copy code&lt;br&gt;
+-------------------+                                   +-------------------+&lt;br&gt;
|   Your Backend     | === Persistent HTTP Connection ==&amp;gt;|   User Browser      |&lt;br&gt;
|   (SSE Server)     | -- event: update\ndata: {...} ---&amp;gt;|   EventSource API   |&lt;br&gt;
+-------------------+                                   +-------------------+&lt;br&gt;
How it works&lt;/p&gt;

&lt;p&gt;Handshake. The browser opens a normal HTTP GET request via the native EventSource API.&lt;br&gt;
Streaming headers. The server responds with headers that signal the body will never close:&lt;br&gt;
Code example&lt;br&gt;
Copy code&lt;br&gt;
HTTP/1.1 200 OK&lt;br&gt;
Content-Type: text/event-stream&lt;br&gt;
Cache-Control: no-cache&lt;br&gt;
Connection: keep-alive&lt;br&gt;
Event format. The server writes UTF-8 plain text in the SSE wire format, with a blank line ending each message:&lt;br&gt;
Code example&lt;br&gt;
Copy code&lt;br&gt;
event: order_status&lt;br&gt;
id: 1001&lt;br&gt;
retry: 5000&lt;br&gt;
data: {"orderId": "8492", "status": "SHIPPED"}&lt;/p&gt;

&lt;p&gt;Why SSE usually beats WebSockets for one-way pushes&lt;/p&gt;

&lt;p&gt;WebSockets are the reflex reach for "real-time," but they bring real overhead: a separate ws:// protocol, no built-in reconnection, and connection state that gets awkward across load balancers. For a one-way, server-to-client feed, SSE is simpler on every axis:&lt;/p&gt;

&lt;p&gt;Automatic reconnection. If the connection drops, EventSource reconnects on its own.&lt;br&gt;
Built-in resume via Last-Event-ID. On reconnect, the browser sends back the ID of the last event it saw, so your server can replay anything missed.&lt;br&gt;
Plain HTTP. SSE rides on normal HTTP/HTTPS, so it works out of the box with cookies, CORS, reverse proxies, and standard TLS — no protocol upgrade needed.&lt;br&gt;
No per-domain connection ceiling under HTTP/2+. Under HTTP/1.1, browsers typically cap open connections at around six per domain, which used to bite apps with several open SSE streams. Under HTTP/2 or HTTP/3, streams are multiplexed over a single connection, so that ceiling effectively disappears.&lt;br&gt;
A limitation worth knowing about: native EventSource can't send custom headers&lt;/p&gt;

&lt;p&gt;This is the one gap the original spec never closed. EventSource only issues GET requests and gives you no way to attach an Authorization: Bearer … header or a custom API key header — you can set withCredentials for cookies, and that's about it. Putting a token in the URL as a query parameter is a common workaround, but it leaks into server logs, browser history, and Referer headers, so it's a real anti-pattern for anything sensitive.&lt;/p&gt;

&lt;p&gt;The common fix in production code today is to skip EventSource and parse the SSE stream yourself off fetch() and the Web Streams API, where you have full control over request headers:&lt;/p&gt;

&lt;p&gt;Code example&lt;br&gt;
Copy code&lt;br&gt;
const response = await fetch('/api/stream', {&lt;br&gt;
  headers: { Authorization: &lt;code&gt;Bearer ${token}&lt;/code&gt; }&lt;br&gt;
});&lt;br&gt;
const reader = response.body.getReader();&lt;br&gt;
const decoder = new TextDecoder();&lt;/p&gt;

&lt;p&gt;while (true) {&lt;br&gt;
  const { done, value } = await reader.read();&lt;br&gt;
  if (done) break;&lt;br&gt;
  const chunk = decoder.decode(value, { stream: true });&lt;br&gt;
  // parse "event:" / "data:" lines out of chunk here&lt;br&gt;
}&lt;br&gt;
This is exactly the pattern most AI chat interfaces use, which brings us to the biggest shift in this space since SSE was first specified.&lt;/p&gt;

&lt;p&gt;What's Changed Recently: SSE and the AI Streaming Boom&lt;br&gt;
If you've used any LLM chat product, you've already used SSE without knowing it. When you call the Anthropic Messages API, the OpenAI Chat Completions API, or Google's Gemini API with streaming turned on, the response comes back as a text/event-stream — each data: line carrying the next slice of generated text, rendered as it arrives instead of after the whole answer is ready. Streaming doesn't make the model faster; it just gets partial text on screen immediately, which is most of what "feels fast" actually means to a user.&lt;/p&gt;

&lt;p&gt;This has effectively made SSE the default transport for AI product UIs, and it's why the "fetch + ReadableStream" pattern above matters so much right now: an authenticated, POST-based streaming call to an LLM API is precisely the case native EventSource can't handle.&lt;/p&gt;

&lt;p&gt;What's Changed Recently: WebTransport Reached Baseline&lt;br&gt;
For years, the honest answer to "what if I need bidirectional, low-latency streaming — gaming, live cursors, telemetry — not just server push?" was WebSockets, with all their TCP head-of-line-blocking baggage, or the much heavier WebRTC data channel stack.&lt;/p&gt;

&lt;p&gt;That changed in March 2026. WebTransport — a browser API built on HTTP/3 and QUIC that supports both reliable streams and unreliable, UDP-style datagrams — reached Baseline "Newly available" status when Safari 26.4 shipped support, joining Chrome, Edge, and Firefox, which had supported it for a few years already. That's the web platform's way of saying it now works, without flags, in every major browser engine — a real inflection point for anything that previously had to avoid it because of Safari and iOS.&lt;/p&gt;

&lt;p&gt;Where does that leave SSE? Firmly in place for the use case it was built for. WebTransport is genuinely useful for bidirectional or loss-tolerant traffic — multiplayer state, live video, telemetry where a dropped frame beats a stall. But it needs HTTP/3, its browser API is still newer and less battle-tested, and the spec itself is still a W3C Working Draft, so it can still change. For plain, one-way "tell the browser what just happened," SSE remains the simpler, more broadly deployable tool — it's why Cloudflare Workers and Vercel Edge Functions both support returning a streaming text/event-stream response with essentially no extra setup.&lt;/p&gt;

&lt;p&gt;SSE vs. Webhooks: Technical Comparison&lt;br&gt;
Feature Webhooks    Server-Sent Events (SSE)&lt;br&gt;
Primary purpose Ingest external events into your backend    Stream updates to a connected client&lt;br&gt;
Directionality  Server-to-server, one request per event Server-to-client, one persistent stream&lt;br&gt;
Transport   HTTP POST   Long-lived HTTP GET (text/event-stream)&lt;br&gt;
Connection lifecycle    Opens, posts, closes    Stays open&lt;br&gt;
Target consumer A public backend endpoint   Browsers, mobile WebViews, frontend UIs&lt;br&gt;
Reconnection    Provider retries on 5xx/timeout, on its own schedule    Native EventSource auto-reconnect + Last-Event-ID&lt;br&gt;
Auth    HMAC signatures, shared secrets Cookies, bearer tokens (via fetch, not native EventSource)&lt;br&gt;
Proxy considerations    Standard REST handling  Must disable response buffering (e.g. X-Accel-Buffering: no)&lt;br&gt;
The End-to-End Architecture&lt;br&gt;
Put together, webhooks and SSE form one pipeline:&lt;/p&gt;

&lt;p&gt;Code example&lt;br&gt;
Copy code&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;External Event Occurs (payment processed, deploy finished, AI job done)
                │
                ▼  HTTP POST (webhook payload)&lt;/li&gt;
&lt;li&gt;Webhook Ingestion Layer

&lt;ul&gt;
&lt;li&gt;Verifies signature, authenticates the request&lt;/li&gt;
&lt;li&gt;Logs/buffers the payload, retries on your backend's behalf if needed
            │
            ▼  Verified, guaranteed dispatch&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;Your Application Backend (Express / Node.js, etc.)

&lt;ul&gt;
&lt;li&gt;Ingests the payload, updates the database&lt;/li&gt;
&lt;li&gt;Publishes the event to an internal SSE stream manager
            │
            ▼  text/event-stream&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;Client Web App (Browser)

&lt;ul&gt;
&lt;li&gt;Connected via EventSource('/api/events') or fetch + ReadableStream&lt;/li&gt;
&lt;li&gt;Renders the update instantly, no polling
This gets you three things: third parties never touch your frontend directly, retries and spikes are absorbed before they hit your app logic, and the browser only needs native primitives — no bundled WebSocket client library.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Hands-On Tutorial: Building a Webhook-to-SSE Bridge&lt;br&gt;
Prerequisites: Node.js 18+, basic Express familiarity.&lt;/p&gt;

&lt;p&gt;Step 1: Set up the project&lt;br&gt;
Code example&lt;br&gt;
Copy code&lt;br&gt;
mkdir webhook-sse-bridge&lt;br&gt;
cd webhook-sse-bridge&lt;br&gt;
npm init -y&lt;br&gt;
npm install express&lt;br&gt;
Step 2: The backend (ingestion + SSE broadcast)&lt;br&gt;
Create server.js with two endpoints: one that receives webhooks, one that streams updates to browsers.&lt;/p&gt;

&lt;p&gt;Code example&lt;br&gt;
Copy code&lt;br&gt;
// server.js&lt;br&gt;
const express = require('express');&lt;br&gt;
const path = require('path');&lt;/p&gt;

&lt;p&gt;const app = express();&lt;br&gt;
const PORT = process.env.PORT || 3000;&lt;/p&gt;

&lt;p&gt;app.use(express.json());&lt;br&gt;
app.use(express.static(path.join(__dirname, 'public')));&lt;/p&gt;

&lt;p&gt;// Active SSE client connections&lt;br&gt;
const sseClients = new Set();&lt;/p&gt;

&lt;p&gt;// --- 1. SSE endpoint: persistent backend-to-browser stream ---&lt;br&gt;
app.get('/api/events', (req, res) =&amp;gt; {&lt;br&gt;
  res.writeHead(200, {&lt;br&gt;
    'Content-Type': 'text/event-stream',&lt;br&gt;
    'Cache-Control': 'no-cache',&lt;br&gt;
    'Connection': 'keep-alive',&lt;br&gt;
    'X-Accel-Buffering': 'no' // disable proxy buffering (e.g. NGINX)&lt;br&gt;
  });&lt;/p&gt;

&lt;p&gt;res.write(&lt;code&gt;event: connected\ndata: ${JSON.stringify({ message: 'SSE connection established' })}\n\n&lt;/code&gt;);&lt;/p&gt;

&lt;p&gt;sseClients.add(res);&lt;br&gt;
  console.log(&lt;code&gt;[SSE] Client connected. Total: ${sseClients.size}&lt;/code&gt;);&lt;/p&gt;

&lt;p&gt;req.on('close', () =&amp;gt; {&lt;br&gt;
    sseClients.delete(res);&lt;br&gt;
    console.log(&lt;code&gt;[SSE] Client disconnected. Total: ${sseClients.size}&lt;/code&gt;);&lt;br&gt;
  });&lt;br&gt;
});&lt;/p&gt;

&lt;p&gt;function broadcastToClients(eventType, data) {&lt;br&gt;
  const message = &lt;code&gt;event: ${eventType}\ndata: ${JSON.stringify(data)}\n\n&lt;/code&gt;;&lt;br&gt;
  sseClients.forEach((client) =&amp;gt; client.write(message));&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;// --- 2. Webhook ingestion endpoint ---&lt;br&gt;
app.post('/api/webhooks', (req, res) =&amp;gt; {&lt;br&gt;
  const webhookData = req.body;&lt;br&gt;
  console.log('[Webhook Received]:', JSON.stringify(webhookData, null, 2));&lt;/p&gt;

&lt;p&gt;const eventType = webhookData.event || 'payment_updated';&lt;br&gt;
  const payload = webhookData.payload || webhookData;&lt;/p&gt;

&lt;p&gt;// ...persist to your database here...&lt;/p&gt;

&lt;p&gt;broadcastToClients(eventType, {&lt;br&gt;
    timestamp: new Date().toISOString(),&lt;br&gt;
    details: payload&lt;br&gt;
  });&lt;/p&gt;

&lt;p&gt;res.status(200).json({ status: 'success', message: 'Webhook ingested and broadcasted' });&lt;br&gt;
});&lt;/p&gt;

&lt;p&gt;app.listen(PORT, () =&amp;gt; {&lt;br&gt;
  console.log(&lt;code&gt;Server running on http://localhost:${PORT}&lt;/code&gt;);&lt;br&gt;
});&lt;br&gt;
Step 3: The frontend (browser client)&lt;br&gt;
Create public/index.html. It uses the native EventSource API to render events as they arrive.&lt;/p&gt;

&lt;p&gt;Code example&lt;br&gt;
Copy code&lt;br&gt;
&amp;lt;!DOCTYPE html&amp;gt;&lt;br&gt;
&lt;br&gt;
&lt;br&gt;
  &lt;br&gt;
  &lt;/p&gt;
Live Webhook-to-SSE Dashboard
&lt;br&gt;
  &amp;lt;br&amp;gt;
    body { font-family: system-ui, sans-serif; background: #0f172a; color: #f8fafc; padding: 2rem; }&amp;lt;br&amp;gt;
    .status-card { padding: 1rem 1.5rem; border-radius: 8px; background: #1e293b; margin-bottom: 2rem; display: flex; justify-content: space-between; }&amp;lt;br&amp;gt;
    .event-card { background: #1e293b; border-left: 4px solid #38bdf8; padding: 1rem; border-radius: 4px; margin-bottom: 1rem; }&amp;lt;br&amp;gt;
    pre { background: #090d16; padding: 0.75rem; border-radius: 4px; overflow-x: auto; color: #38bdf8; }&amp;lt;br&amp;gt;
  &lt;br&gt;
&lt;br&gt;
&lt;br&gt;
  &lt;h1&gt;Live Application Dashboard&lt;/h1&gt;
&lt;br&gt;
  &lt;br&gt;
    &lt;span&gt;Status: &lt;strong id="state"&gt;Connecting…&lt;/strong&gt;&lt;/span&gt;&lt;br&gt;
  &lt;br&gt;
  

&lt;p&gt;&amp;lt;br&amp;gt;
    const stateEl = document.getElementById(&amp;amp;#39;state&amp;amp;#39;);&amp;lt;br&amp;gt;
    const feedEl = document.getElementById(&amp;amp;#39;feed&amp;amp;#39;);&amp;lt;br&amp;gt;
    const source = new EventSource(&amp;amp;#39;/api/events&amp;amp;#39;);&amp;lt;/p&amp;gt;
&amp;lt;div class="highlight"&amp;gt;&amp;lt;pre class="highlight plaintext"&amp;gt;&amp;lt;code&amp;gt;source.onopen = () =&amp;amp;gt; { stateEl.textContent = 'Connected'; };
source.onerror = () =&amp;amp;gt; { stateEl.textContent = 'Reconnecting…'; };

source.addEventListener('order_completed', (e) =&amp;amp;gt; renderEvent('order_completed', JSON.parse(e.data)));
source.addEventListener('payment_updated', (e) =&amp;amp;gt; renderEvent('payment_updated', JSON.parse(e.data)));

function renderEvent(type, data) {
  const card = document.createElement('div');
  card.className = 'event-card';
  card.innerHTML = `&amp;amp;lt;strong&amp;amp;gt;${type}&amp;amp;lt;/strong&amp;amp;gt; — ${new Date(data.timestamp).toLocaleTimeString()}
    &amp;amp;lt;pre&amp;amp;gt;${JSON.stringify(data.details, null, 2)}&amp;amp;lt;/pre&amp;amp;gt;`;
  feedEl.prepend(card);
}
&amp;lt;/code&amp;gt;&amp;lt;/pre&amp;gt;&amp;lt;/div&amp;gt;
&amp;lt;p&amp;gt;&lt;br&gt;
&lt;br&gt;
&lt;br&gt;
Step 4: Test it end to end&lt;br&gt;
Start the server:&lt;/p&gt;

&lt;p&gt;Code example&lt;br&gt;
Copy code&lt;br&gt;
node server.js&lt;br&gt;
Open &lt;a href="http://localhost:3000" rel="noopener noreferrer"&gt;http://localhost:3000&lt;/a&gt; — the status should flip to "Connected". Then, in a second terminal, simulate an incoming webhook:&lt;/p&gt;

&lt;p&gt;Code example&lt;br&gt;
Copy code&lt;br&gt;
curl -X POST &lt;a href="http://localhost:3000/api/webhooks" rel="noopener noreferrer"&gt;http://localhost:3000/api/webhooks&lt;/a&gt; \&lt;br&gt;
  -H "Content-Type: application/json" \&lt;br&gt;
  -d '{&lt;br&gt;
    "event": "order_completed",&lt;br&gt;
    "payload": {&lt;br&gt;
      "orderId": "ORD-9921",&lt;br&gt;
      "customer": "Alex Mercer",&lt;br&gt;
      "amount": 149.99,&lt;br&gt;
      "status": "PAID"&lt;br&gt;
    }&lt;br&gt;
  }'&lt;br&gt;
The moment that request lands, the browser tab updates — no refresh, no polling.&lt;/p&gt;

&lt;p&gt;Scaling This to Production&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;Broadcast across multiple backend instances with Redis Pub/Sub. SSE connections are pinned to whichever server node the browser connected to. If a webhook lands on Server A but the relevant browser is streaming from Server B, Server A needs to publish the event to a shared broker (Redis Pub/Sub is the common choice) so every node can forward it to its own connected clients.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Turn off reverse proxy buffering. NGINX, HAProxy, and similar proxies buffer response chunks by default, which delays streaming until the buffer fills. Set proxy_buffering off; in NGINX config, or send the X-Accel-Buffering: no header from your app; for Cloudflare, make sure buffering is disabled on streaming routes.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Send heartbeats. Idle connections can get killed by firewalls or load balancers on a timeout. A periodic SSE comment line keeps the connection alive without triggering any client-side event handler:&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Code example&lt;br&gt;
Copy code&lt;br&gt;
setInterval(() =&amp;gt; {&lt;br&gt;
  sseClients.forEach((client) =&amp;gt; client.write(': ping\n\n'));&lt;br&gt;
}, 20000);&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Use id + Last-Event-ID to catch clients up after a drop. Tag every event with a unique id. On reconnect, the browser automatically sends back Last-Event-ID in the request headers; read req.headers['last-event-id'] on your server, pull anything the client missed from a cache or database, and replay it before resuming the live stream.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Conclusion&lt;br&gt;
SSE vs. webhooks was never really a choice between two competitors — they're two halves of the same pipeline. Webhooks get an external event safely into your backend; SSE gets your backend's reaction back out to a browser, using nothing more exotic than plain HTTP and a native browser API.&lt;/p&gt;

&lt;p&gt;What's shifted since this pattern became popular isn't the pattern itself — it's the volume of traffic running through it. SSE is now the default way LLM APIs stream tokens to chat interfaces, which has pushed most production frontends toward fetch-based SSE parsing instead of bare EventSource, precisely to get auth headers working. And with WebTransport now at Baseline across all major browsers, there's finally a real option for the bidirectional, loss-tolerant cases SSE was never meant to cover — while leaving SSE exactly where it's always been the right tool: simple, one-way, server-to-browser push.&lt;/p&gt;

&lt;p&gt;Further reading&lt;br&gt;
WHATWG HTML Living Standard — Server-sent events&lt;br&gt;
MDN — Using server-sent events&lt;br&gt;
Stripe — Webhook retry behavior (via Hookdeck's guide)&lt;br&gt;
web-features — WebTransport Baseline status&lt;br&gt;
webrtc.ventures — WebTransport is now Baseline&lt;/p&gt;

</description>
    </item>
    <item>
      <title>Why Your Browser Blocks Webhook Endpoints: CORS and Frontend Security</title>
      <dc:creator>InstaWebhook</dc:creator>
      <pubDate>Thu, 24 Sep 2026 06:08:26 +0000</pubDate>
      <link>https://dev.to/instawebhook/why-your-browser-blocks-webhook-endpoints-cors-and-frontend-security-39hf</link>
      <guid>https://dev.to/instawebhook/why-your-browser-blocks-webhook-endpoints-cors-and-frontend-security-39hf</guid>
      <description>&lt;p&gt;API webhook CORS&lt;br&gt;
backend proxy webhooks&lt;br&gt;
backend webhook routing&lt;br&gt;
block webhook requests&lt;br&gt;
browser security policy CORS&lt;br&gt;
bypass CORS webhook&lt;br&gt;
catch Stripe webhook React&lt;br&gt;
catch webhooks in React&lt;br&gt;
client side routing webhooks&lt;br&gt;
client side webhook&lt;br&gt;
client vs server webhooks&lt;br&gt;
cross origin resource sharing webhook&lt;br&gt;
fetch webhook error&lt;br&gt;
fix webhook CORS&lt;br&gt;
frontend API security&lt;br&gt;
frontend developer webhooks&lt;br&gt;
frontend network security&lt;br&gt;
frontend security webhooks&lt;br&gt;
frontend webhook listener&lt;br&gt;
fullstack webhook guide&lt;br&gt;
handle webhook Next.js&lt;br&gt;
listen for webhooks Vue&lt;br&gt;
localhost webhook CORS&lt;br&gt;
Next.js API routes webhooks&lt;br&gt;
Next.js webhook listener&lt;br&gt;
ngrok webhook React&lt;br&gt;
Node.js webhook ingress&lt;br&gt;
React API endpoints&lt;br&gt;
React frontend webhooks&lt;br&gt;
React webhook tutorial&lt;br&gt;
receive webhook in browser&lt;br&gt;
receiving API callbacks React&lt;br&gt;
secure webhook endpoint&lt;br&gt;
serverless webhook receiver&lt;br&gt;
Stripe checkout success webhook&lt;br&gt;
Stripe webhook CORS error&lt;br&gt;
Vue webhooks&lt;br&gt;
webhook architecture frontend&lt;br&gt;
webhook blocked by browser&lt;br&gt;
webhook CORS error&lt;br&gt;
webhook CORS policy&lt;br&gt;
webhook ingress layer&lt;br&gt;
webhook integration React&lt;br&gt;
webhook listener setup&lt;br&gt;
webhook payload React&lt;br&gt;
webhook reverse proxy&lt;br&gt;
webhook server vs client&lt;br&gt;
webhooks explained frontend&lt;br&gt;
webhook testing React&lt;br&gt;
webhook troubleshooting&lt;br&gt;
why webhooks fail on frontend&lt;br&gt;
Why Your Browser Blocks Webhook Endpoints CORS And Frontend Security&lt;br&gt;
Why Your Browser Blocks Webhook Endpoints: CORS and Frontend Security&lt;br&gt;
It's a rite of passage for many junior and frontend developers: you're building an e-commerce store or a SaaS app in React. You set up a Stripe checkout flow, open your webhook settings, and paste in your client-side React URL — something like &lt;a href="https://my-app.com/checkout/success" rel="noopener noreferrer"&gt;https://my-app.com/checkout/success&lt;/a&gt; or &lt;a href="http://localhost:3000/webhook" rel="noopener noreferrer"&gt;http://localhost:3000/webhook&lt;/a&gt; — expecting your frontend to catch the event when a payment succeeds.&lt;/p&gt;

&lt;p&gt;Instead, you hit a wall. Either your browser throws a CORS error, your dev server returns a 404 Not Found, or the third-party provider flags your endpoint as unreachable.&lt;/p&gt;

&lt;p&gt;Here's the short version: you cannot directly catch webhooks in a client-side React app. Browsers are explicitly designed to block this architecture, for good security and networking reasons.&lt;/p&gt;

&lt;p&gt;This guide breaks down why webhooks fail in client-side apps, how CORS and browser sandboxing actually work, the real security risks of trying to work around it, and the production architecture you need instead — updated with how providers like Stripe are evolving their webhook payloads in 2025–2026.&lt;/p&gt;

&lt;p&gt;What Is a Webhook, and Why Is It Different From a Normal API Call?&lt;br&gt;
Code example&lt;br&gt;
Copy code&lt;br&gt;
POLLING (Client-Driven)&lt;/p&gt;

&lt;p&gt;+------------------+     1. GET /api/status     +---------------+&lt;br&gt;
  |  Browser Client  | -------------------------&amp;gt; |  Third-Party  |&lt;br&gt;
  |  (React App)     | &amp;lt;------------------------- |  Server       |&lt;br&gt;
  +------------------+     2. "Still Pending"     +---------------+&lt;/p&gt;

&lt;p&gt;WEBHOOK (Server-to-Server)&lt;/p&gt;

&lt;p&gt;+------------------+    HTTP POST /webhook      +---------------+&lt;br&gt;
  |  Your Backend    | &amp;lt;------------------------- |  Third-Party  |&lt;br&gt;
  |  Server          |    (Payment Succeeded)     |  Server       |&lt;br&gt;
  +------------------+                            +---------------+&lt;br&gt;
Traditional REST calls (pull model): your frontend initiates an outbound request asking for data — fetch('&lt;a href="https://api.stripe.com/v1/charges'" rel="noopener noreferrer"&gt;https://api.stripe.com/v1/charges'&lt;/a&gt;) — and the server responds.&lt;/p&gt;

&lt;p&gt;Webhooks (push model): an automated HTTP POST sent by a third-party server (Stripe, GitHub, Shopify, Clerk) to your system when something happens. The provider is the HTTP client; your infrastructure is the HTTP server.&lt;/p&gt;

&lt;p&gt;That last part is the key. Webhooks require an active HTTP listener bound to a public IP or domain, ready to accept incoming POST requests around the clock. A React app compiled into static HTML/CSS/JS files running inside a browser tab isn't that — and was never designed to be.&lt;/p&gt;

&lt;p&gt;The Anatomy of a Webhook CORS Error&lt;br&gt;
There are really two separate failures hiding behind "it doesn't work."&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The Single-Page App Misconception
When you build a React app with Vite, Create React App, or a statically exported Next.js site, your code compiles into static assets. The browser downloads them and runs the JavaScript locally, in the user's tab.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Your React app is not a web server. It doesn't open a socket listening for inbound HTTP calls, and it can't accept a POST request sent across the public internet. If Stripe or GitHub sends a POST to &lt;a href="https://my-app.com/webhook" rel="noopener noreferrer"&gt;https://my-app.com/webhook&lt;/a&gt;, your static host (Vercel, Netlify, S3, Nginx) either serves index.html regardless of the method, or returns 405 Method Not Allowed, because static file servers are generally only wired up to answer GET.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Why CORS Triggers in the Browser
If a developer tries to work around this with a client-side call to a webhook-style endpoint, they run into a genuine CORS error.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Cross-Origin Resource Sharing (CORS) is a browser-enforced rule that governs whether JavaScript running on Origin A (&lt;a href="https://my-app.com" rel="noopener noreferrer"&gt;https://my-app.com&lt;/a&gt;) is allowed to read the response from a request made to Origin B (&lt;a href="https://api.stripe.com" rel="noopener noreferrer"&gt;https://api.stripe.com&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;Code example&lt;br&gt;
Copy code&lt;br&gt;
BROWSER CORS PREFLIGHT CHECK&lt;/p&gt;

&lt;p&gt;[ React App ]                                  [ Third-Party Server ]&lt;br&gt;
 &lt;a href="https://my-app.com" rel="noopener noreferrer"&gt;https://my-app.com&lt;/a&gt;                             &lt;a href="https://provider.com" rel="noopener noreferrer"&gt;https://provider.com&lt;/a&gt;&lt;br&gt;
      |                                                   |&lt;br&gt;
      | --- 1. OPTIONS /webhook (Preflight) -----------&amp;gt; |&lt;br&gt;
      |      Origin: &lt;a href="https://my-app.com" rel="noopener noreferrer"&gt;https://my-app.com&lt;/a&gt;                  |&lt;br&gt;
      |      Access-Control-Request-Method: POST         |&lt;br&gt;
      |                                                   |&lt;br&gt;
      | &amp;lt;-- 2. Response missing CORS headers ------------ |&lt;br&gt;
      |      (no Access-Control-Allow-Origin)             |&lt;br&gt;
      |                                                   |&lt;br&gt;
 [X] BROWSER BLOCKS THE REQUEST&lt;br&gt;
     Console: "CORS header 'Access-Control-Allow-Origin' missing"&lt;br&gt;
When a browser sees a cross-origin request with a non-simple method or content type (like POST with application/json), it sends a preflight OPTIONS request first. Webhook providers build their infrastructure for automated, server-to-server ingestion — not browser calls — so they don't return browser-friendly headers like Access-Control-Allow-Origin on these preflights. The browser sees that and blocks the whole request before your JavaScript ever touches the response.&lt;/p&gt;

&lt;p&gt;This is browser behavior working exactly as intended. It isn't a bug in your code, and it isn't something a "CORS fix" library should paper over for this use case.&lt;/p&gt;

&lt;p&gt;Three Real Security Risks of Frontend Webhook Processing&lt;br&gt;
Even setting aside the technical impossibility of accepting inbound connections in a browser tab, trying to route around it introduces real vulnerabilities.&lt;/p&gt;

&lt;p&gt;Risk 1 — Exposing signing secrets&lt;br&gt;
Webhooks are authenticated with HMAC (Hash-based Message Authentication Code) signatures. When Stripe sends an event, it signs the payload with a secret unique to your account (whsec_...) and includes the resulting hash in a header (Stripe-Signature).&lt;/p&gt;

&lt;p&gt;Code example&lt;br&gt;
Copy code&lt;br&gt;
// NEVER do this in frontend code (React / Vue / Vite)&lt;br&gt;
const webhookSecret = "whsec_live_9F8a7B6c5D4e3F2a1..."; // visible to anyone in DevTools&lt;/p&gt;

&lt;p&gt;export function verifyWebhook(payload, signature) {&lt;br&gt;
  // any user can inspect your JS bundle and steal this secret&lt;br&gt;
}&lt;br&gt;
Anything bundled into client-side JavaScript — including variables prefixed VITE_ or NEXT_PUBLIC_ — ships to every visitor's browser and is fully readable in DevTools. A leaked signing secret lets an attacker forge events (payment_intent.succeeded, for example) and grant themselves things they didn't pay for.&lt;/p&gt;

&lt;p&gt;Risk 2 — Replay and spoofing attacks&lt;br&gt;
Without server-side HMAC verification, there's no way to distinguish a genuine event from Stripe from a forged curl request. An attacker can flood a client-side "listener" with fake success payloads, bypass paywalls, or corrupt application state.&lt;/p&gt;

&lt;p&gt;Risk 3 — Lost events from ephemeral sessions&lt;br&gt;
A browser tab is transient — users close it, switch networks, or their laptop sleeps. If a customer finishes checkout and closes the tab before your client-side code would have "received" the webhook, that event is gone for good. Webhook handling needs to live on infrastructure with high uptime that can acknowledge the provider immediately with an HTTP 200.&lt;/p&gt;

&lt;p&gt;The Correct Architecture: Backend Ingress + Real-Time Push to the Client&lt;br&gt;
To handle webhooks securely and still update your React UI live, you need a two-tier setup: a backend that ingests and verifies the webhook, and a separate real-time channel that notifies the browser.&lt;/p&gt;

&lt;p&gt;Code example&lt;br&gt;
Copy code&lt;br&gt;
SECURE WEBHOOK ARCHITECTURE&lt;/p&gt;

&lt;p&gt;+-----------------------+&lt;br&gt;
  |  Webhook Provider     |&lt;br&gt;
  |  (Stripe, GitHub)     |&lt;br&gt;
  +-----------------------+&lt;br&gt;
              |&lt;br&gt;
              | 1. HTTP POST /api/webhook (signed payload)&lt;br&gt;
              v&lt;br&gt;
  +------------------------------------------------------------------+&lt;br&gt;
  |  YOUR BACKEND INGRESS LAYER (Node.js / Express / Next.js route)  |&lt;br&gt;
  |                                                                  |&lt;br&gt;
  |  a. Read the RAW request body                                   |&lt;br&gt;
  |  b. Verify the HMAC signature using your secret key             |&lt;br&gt;
  |  c. Update your database                                        |&lt;br&gt;
  |  d. Return 200 OK immediately                                   |&lt;br&gt;
  +------------------------------------------------------------------+&lt;br&gt;
              |&lt;br&gt;
              | 2. Push an update via WebSocket / SSE / pub-sub&lt;br&gt;
              v&lt;br&gt;
  +------------------------------------------------------------------+&lt;br&gt;
  |  CLIENT BROWSER (React / Vue / SPA)                              |&lt;br&gt;
  |                                                                  |&lt;br&gt;
  |  a. Listens over Server-Sent Events or WebSockets                |&lt;br&gt;
  |  b. Updates React state when notified                            |&lt;br&gt;
  +------------------------------------------------------------------+&lt;br&gt;
Step 1 — A server-side ingress layer&lt;br&gt;
This layer is the public target for the webhook. It can be a Node/Express server, a Next.js App Router route handler, a serverless function (AWS Lambda, Vercel Functions), or a backend-as-a-service function (Supabase Edge Functions).&lt;/p&gt;

&lt;p&gt;Here's a Next.js App Router example (app/api/webhook/route.ts):&lt;/p&gt;

&lt;p&gt;Code example&lt;br&gt;
Copy code&lt;br&gt;
// app/api/webhook/route.ts&lt;br&gt;
import { headers } from 'next/headers';&lt;br&gt;
import { NextResponse } from 'next/server';&lt;br&gt;
import Stripe from 'stripe';&lt;/p&gt;

&lt;p&gt;const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {&lt;br&gt;
  apiVersion: '2026-07-29.dahlia', // pin an explicit API version — see note below&lt;br&gt;
});&lt;/p&gt;

&lt;p&gt;const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET!;&lt;/p&gt;

&lt;p&gt;export async function POST(req: Request) {&lt;br&gt;
  // 1. Read the raw body as text — required for signature verification&lt;br&gt;
  const body = await req.text();&lt;br&gt;
  const headersList = await headers(); // headers() is async since Next.js 15&lt;br&gt;
  const sig = headersList.get('stripe-signature');&lt;/p&gt;

&lt;p&gt;if (!sig) {&lt;br&gt;
    return NextResponse.json(&lt;br&gt;
      { error: 'Missing stripe-signature header' },&lt;br&gt;
      { status: 400 }&lt;br&gt;
    );&lt;br&gt;
  }&lt;/p&gt;

&lt;p&gt;let event: Stripe.Event;&lt;/p&gt;

&lt;p&gt;try {&lt;br&gt;
    // 2. Verify the HMAC signature on the server, never in the browser&lt;br&gt;
    event = stripe.webhooks.constructEvent(body, sig, endpointSecret);&lt;br&gt;
  } catch (err: any) {&lt;br&gt;
    console.error(&lt;code&gt;Webhook signature verification failed: ${err.message}&lt;/code&gt;);&lt;br&gt;
    return NextResponse.json(&lt;br&gt;
      { error: &lt;code&gt;Webhook Error: ${err.message}&lt;/code&gt; },&lt;br&gt;
      { status: 400 }&lt;br&gt;
    );&lt;br&gt;
  }&lt;/p&gt;

&lt;p&gt;// 3. Handle the verified event&lt;br&gt;
  switch (event.type) {&lt;br&gt;
    case 'payment_intent.succeeded': {&lt;br&gt;
      const paymentIntent = event.data.object as Stripe.PaymentIntent;&lt;br&gt;
      console.log(&lt;code&gt;Payment succeeded: ${paymentIntent.id}&lt;/code&gt;);&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  await fulfillOrder(paymentIntent);
  await notifyClient(paymentIntent.metadata.customerId);
  break;
}
default:
  console.log(`Unhandled event type: ${event.type}`);
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;}&lt;/p&gt;

&lt;p&gt;// 4. Acknowledge receipt right away&lt;br&gt;
  return NextResponse.json({ received: true }, { status: 200 });&lt;br&gt;
}&lt;br&gt;
A note on that apiVersion string: Stripe now names its yearly major releases after plants — Acacia, Basil, Clover, and, as of March 2026, Dahlia — with monthly dated sub-releases inside each one (for example 2026-07-29.dahlia). Pin a specific dated version in your code rather than relying on your account's dashboard default, and check Stripe's API versioning docs for whatever the current release is when you deploy, since a new dated version ships roughly monthly.&lt;/p&gt;

&lt;p&gt;A newer option: Stripe's "thin" events&lt;br&gt;
Historically, Stripe webhooks shipped the entire resource object in the payload ("snapshot" events) — which meant every time you upgraded your account's API version, your webhook handlers could break. Stripe has been rolling out thin events: compact notifications that tell you what happened and give you an ID, and your server then fetches the full object from the API if it needs the details. This makes handlers version-stable across API upgrades. Thin events are generally available for newer v2-style resources and, as of late 2025, in private preview for v1 resources like PaymentIntent and Charge. If you're starting a new integration today, it's worth checking Stripe's event destinations documentation to see whether thin events already cover the resources you need — it can save you a migration later.&lt;/p&gt;

&lt;p&gt;Step 2 — Push the update to React in real time&lt;br&gt;
Once your backend verifies and processes the webhook, you need a way to get that update into the UI. Three common patterns:&lt;/p&gt;

&lt;p&gt;Server-Sent Events (SSE) — lightweight, one-directional, uses the browser's built-in EventSource API over plain HTTP. Good default for "notify the UI when X happens."&lt;br&gt;
WebSockets / managed realtime (Pusher, Ably, Supabase Realtime) — better when you need bidirectional communication or need to broadcast to many connected clients at once.&lt;br&gt;
Short polling with React Query / SWR — a pragmatic fallback: the client periodically re-checks a status endpoint until it flips from pending to complete.&lt;br&gt;
Backend SSE endpoint (Node/Express):&lt;/p&gt;

&lt;p&gt;Code example&lt;br&gt;
Copy code&lt;br&gt;
// server.js — SSE notification channel&lt;br&gt;
app.get('/api/events/:userId', (req, res) =&amp;gt; {&lt;br&gt;
  res.setHeader('Content-Type', 'text/event-stream');&lt;br&gt;
  res.setHeader('Cache-Control', 'no-cache');&lt;br&gt;
  res.setHeader('Connection', 'keep-alive');&lt;/p&gt;

&lt;p&gt;const userId = req.params.userId;&lt;/p&gt;

&lt;p&gt;const sendNotification = (data) =&amp;gt; {&lt;br&gt;
    res.write(&lt;code&gt;data: ${JSON.stringify(data)}\n\n&lt;/code&gt;);&lt;br&gt;
  };&lt;/p&gt;

&lt;p&gt;eventEmitter.on(&lt;code&gt;payment_success_${userId}&lt;/code&gt;, sendNotification);&lt;/p&gt;

&lt;p&gt;req.on('close', () =&amp;gt; {&lt;br&gt;
    eventEmitter.off(&lt;code&gt;payment_success_${userId}&lt;/code&gt;, sendNotification);&lt;br&gt;
  });&lt;br&gt;
});&lt;br&gt;
Frontend React hook:&lt;/p&gt;

&lt;p&gt;Code example&lt;br&gt;
Copy code&lt;br&gt;
// src/hooks/usePaymentStatus.ts&lt;br&gt;
import { useEffect, useState } from 'react';&lt;/p&gt;

&lt;p&gt;export function usePaymentStatus(userId: string) {&lt;br&gt;
  const [status, setStatus] = useState&amp;lt;'pending' | 'success' | 'failed'&amp;gt;('pending');&lt;br&gt;
  const [paymentData, setPaymentData] = useState(null);&lt;/p&gt;

&lt;p&gt;useEffect(() =&amp;gt; {&lt;br&gt;
    if (!userId) return;&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;// Connect to your own backend SSE endpoint — never a third-party webhook URL directly
const eventSource = new EventSource(`/api/events/${userId}`);

eventSource.onmessage = (event) =&amp;gt; {
  const data = JSON.parse(event.data);
  if (data.type === 'PAYMENT_COMPLETE') {
    setStatus('success');
    setPaymentData(data.payload);
    eventSource.close();
  }
};

eventSource.onerror = (error) =&amp;gt; {
  console.error('SSE error:', error);
  eventSource.close();
};

return () =&amp;gt; eventSource.close();
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;}, [userId]);&lt;/p&gt;

&lt;p&gt;return { status, paymentData };&lt;br&gt;
}&lt;br&gt;
A cross-provider option: the Standard Webhooks spec&lt;br&gt;
If you're building your own outbound webhooks, or consuming webhooks from a growing list of providers, it's worth knowing about Standard Webhooks — an open specification for signing and sending webhooks consistently, backed by the webhook infrastructure company Svix along with a steering group that includes Zapier, Twilio, ngrok, and Supabase. Several AI providers, including OpenAI, Anthropic, and Google Gemini, send their webhooks in this format.&lt;/p&gt;

&lt;p&gt;A Standard Webhooks request carries three headers:&lt;/p&gt;

&lt;p&gt;Code example&lt;br&gt;
Copy code&lt;br&gt;
webhook-id: msg_2eaf7c9b10&lt;br&gt;
webhook-timestamp: 1753193011&lt;br&gt;
webhook-signature: v1,g0hM9SsE9BqjT8pReExtn4hQoK7oX0dY9lNv2xY6r1o=&lt;br&gt;
The signature is an HMAC-SHA256 hash of {webhook-id}.{webhook-timestamp}.{raw body}, and the v1, prefix lets the scheme version itself and support multiple valid signatures during a secret rotation. Rather than hand-rolling this, use the standardwebhooks (or provider-specific svix) library:&lt;/p&gt;

&lt;p&gt;Code example&lt;br&gt;
Copy code&lt;br&gt;
const { Webhook } = require('standardwebhooks');&lt;/p&gt;

&lt;p&gt;const wh = new Webhook(process.env.WEBHOOK_SECRET); // whsec_...&lt;br&gt;
const payload = wh.verify(rawBody, {&lt;br&gt;
  'webhook-id': req.headers['webhook-id'],&lt;br&gt;
  'webhook-timestamp': req.headers['webhook-timestamp'],&lt;br&gt;
  'webhook-signature': req.headers['webhook-signature'],&lt;br&gt;
});&lt;br&gt;
This still runs on your backend ingress layer, not in the browser — it's a drop-in replacement for the manual HMAC check, not a way around the architecture above.&lt;/p&gt;

&lt;p&gt;Testing Webhooks During Local Development&lt;br&gt;
Locally, your ingress route runs on &lt;a href="http://localhost:3000" rel="noopener noreferrer"&gt;http://localhost:3000&lt;/a&gt;. Providers like Stripe or GitHub can't send a POST to localhost, since it isn't reachable from the public internet. You need a tunnel.&lt;/p&gt;

&lt;p&gt;Option 1 — The Stripe CLI (best for Stripe)&lt;br&gt;
Code example&lt;br&gt;
Copy code&lt;/p&gt;

&lt;h1&gt;
  
  
  Install and authenticate
&lt;/h1&gt;

&lt;p&gt;brew install stripe/stripe-cli/stripe&lt;br&gt;
stripe login&lt;/p&gt;

&lt;h1&gt;
  
  
  Forward live events to your local route
&lt;/h1&gt;

&lt;p&gt;stripe listen --forward-to localhost:3000/api/webhook&lt;br&gt;
The CLI prints a local webhook signing secret (whsec_...) — drop that into your .env.local so signature verification works locally too. Under the hood, the CLI opens a direct connection to Stripe rather than routing through a public tunnel, which is why it doesn't need a registered endpoint in test mode.&lt;/p&gt;

&lt;p&gt;Option 2 — Cloudflare Tunnel or ngrok (for anything else)&lt;br&gt;
For GitHub, Shopify, Twilio, Clerk, and most other providers, expose your local server through a public HTTPS tunnel:&lt;/p&gt;

&lt;p&gt;Code example&lt;br&gt;
Copy code&lt;/p&gt;

&lt;h1&gt;
  
  
  Cloudflare Tunnel (free)
&lt;/h1&gt;

&lt;p&gt;cloudflared tunnel --url &lt;a href="http://localhost:3000" rel="noopener noreferrer"&gt;http://localhost:3000&lt;/a&gt;&lt;/p&gt;

&lt;h1&gt;
  
  
  or ngrok
&lt;/h1&gt;

&lt;p&gt;ngrok http 3000&lt;br&gt;
Either tool gives you a public HTTPS URL (e.g. &lt;a href="https://random-subdomain.trycloudflare.com" rel="noopener noreferrer"&gt;https://random-subdomain.trycloudflare.com&lt;/a&gt;) that forwards to &lt;a href="http://localhost:3000" rel="noopener noreferrer"&gt;http://localhost:3000&lt;/a&gt;. Paste &lt;a href="https://random-subdomain.trycloudflare.com/api/webhook" rel="noopener noreferrer"&gt;https://random-subdomain.trycloudflare.com/api/webhook&lt;/a&gt; into the provider's webhook dashboard, and payloads will tunnel straight to your dev server.&lt;/p&gt;

&lt;p&gt;Architecture Checklist&lt;br&gt;
Layer   Responsibility  Where it runs&lt;br&gt;
Frontend React app  Renders the UI, kicks off checkout, listens for updates via SSE/WebSockets  Client browser&lt;br&gt;
Backend ingress API Receives the webhook POST, verifies the signature, updates the database, returns 200 OK Node.js, Next.js route handlers, serverless functions&lt;br&gt;
Database    Source of truth for payment/subscription state  PostgreSQL, MongoDB, Redis, Supabase&lt;br&gt;
Tunneling tool  Bridges public webhooks to localhost during development Stripe CLI, Cloudflare Tunnel, ngrok&lt;br&gt;
Production Readiness Checklist&lt;br&gt;
Before shipping a webhook integration, confirm:&lt;/p&gt;

&lt;p&gt;Raw body parsing — your route reads the raw request body (req.text(), or raw-body middleware) before any JSON parsing, since the HMAC signature is computed over the exact bytes sent.&lt;br&gt;
 Secret isolation — webhook secrets live only in server-side environment variables and are never bundled into client-side JavaScript.&lt;br&gt;
 Fast responses — your endpoint returns 200 OK within a few seconds. Heavy work (PDFs, emails, downstream API calls) gets queued and processed asynchronously instead of blocking the response.&lt;br&gt;
 Idempotency — you check the event's ID before processing, so a provider's automatic retries (which happen if you're slow to respond, or don't respond at all) don't double-charge or double-fulfill.&lt;br&gt;
 A pinned API version — for Stripe specifically, pin a dated apiVersion in code rather than depending on your dashboard's default, so an account-level upgrade can't silently change your webhook payload shape.&lt;br&gt;
The Bottom Line&lt;br&gt;
Moving webhook ingestion off the client browser and onto a proper backend layer isn't a workaround — it's the only architecture that actually works, because browsers are deliberately built to refuse inbound connections and to block cross-origin responses that lack the right headers. Once the backend verifies and stores the event, pushing a lightweight real-time update to React over SSE or WebSockets gives you the same "instant UI" experience developers are usually chasing when they first try to catch a webhook directly — without exposing a signing secret to anyone who opens DevTools.&lt;/p&gt;

</description>
    </item>
    <item>
      <title>The Strangler Fig Pattern: Migrating Legacy Webhook Monoliths to Microservices</title>
      <dc:creator>InstaWebhook</dc:creator>
      <pubDate>Wed, 23 Sep 2026 05:14:32 +0000</pubDate>
      <link>https://dev.to/instawebhook/the-strangler-fig-pattern-migrating-legacy-webhook-monoliths-to-microservices-3m5n</link>
      <guid>https://dev.to/instawebhook/the-strangler-fig-pattern-migrating-legacy-webhook-monoliths-to-microservices-3m5n</guid>
      <description>&lt;p&gt;The Strangler Fig Pattern: Migrating Legacy Webhook Monoliths to Microservices Executive Summary Enterprise systems built on mature monolithic frameworks — Ruby on Rails, Django...&lt;/p&gt;

&lt;p&gt;API gateway migration&lt;br&gt;
asynchronous processing&lt;br&gt;
AWS lambda webhooks&lt;br&gt;
breaking the monolith&lt;br&gt;
cloud migration strategy&lt;br&gt;
decouple monolith&lt;br&gt;
decouple webhooks&lt;br&gt;
Django monolith&lt;br&gt;
Django webhook migration&lt;br&gt;
duplicate webhook events&lt;br&gt;
duplicate webhooks&lt;br&gt;
EDA migration&lt;br&gt;
edge webhook routing&lt;br&gt;
enterprise architecture patterns&lt;br&gt;
enterprise webhook processing&lt;br&gt;
event driven architecture&lt;br&gt;
event driven microservices migration&lt;br&gt;
event router&lt;br&gt;
Google cloud functions webhooks&lt;br&gt;
handle massive webhooks&lt;br&gt;
high volume webhooks&lt;br&gt;
InstaWebhook&lt;br&gt;
InstaWebhook routing&lt;br&gt;
IT infrastructure modernization&lt;br&gt;
legacy API migration&lt;br&gt;
legacy application modernization&lt;br&gt;
legacy systems to cloud&lt;br&gt;
legacy webhook systems&lt;br&gt;
microservices architecture&lt;br&gt;
migrate webhook monolith&lt;br&gt;
modernize legacy apps&lt;br&gt;
modern serverless functions&lt;br&gt;
monolithic architecture breakdown&lt;br&gt;
monolith to microservices&lt;br&gt;
payload routing&lt;br&gt;
Rails webhook migration&lt;br&gt;
risk free migration&lt;br&gt;
Ruby on Rails monolith&lt;br&gt;
scalable webhooks&lt;br&gt;
scale webhooks&lt;br&gt;
serverless architectures&lt;br&gt;
serverless functions&lt;br&gt;
serverless webhooks&lt;br&gt;
Strangler Fig architecture&lt;br&gt;
strangler fig pattern&lt;br&gt;
strangle the monolith&lt;br&gt;
webhook delivery&lt;br&gt;
webhook gateway&lt;br&gt;
webhook management&lt;br&gt;
webhook migration&lt;br&gt;
webhook processing infrastructure&lt;br&gt;
webhook routing&lt;br&gt;
webhook topic routing&lt;br&gt;
webhook topics&lt;br&gt;
zero downtime migration&lt;br&gt;
The Strangler Fig Pattern Migrating Legacy Webhook Monoliths To Microservices&lt;br&gt;
The Strangler Fig Pattern: Migrating Legacy Webhook Monoliths to Microservices&lt;br&gt;
Executive Summary&lt;br&gt;
Enterprise systems built on mature monolithic frameworks — Ruby on Rails, Django, Laravel, Spring Boot — frequently hit a wall around inbound webhook processing. When providers like Stripe, Shopify, GitHub, or Twilio fire off sudden bursts of HTTP POST payloads, the monolith has to parse, verify, and queue those events while competing for the same thread pool and database connections as real user traffic.&lt;/p&gt;

&lt;p&gt;A full rewrite to peel webhook handling into serverless functions or microservices is tempting but risky: webhooks carry revenue-critical state (payments, fulfillment, entitlement changes), and a botched cutover can take production down with it. The Strangler Fig Pattern — Martin Fowler's incremental modernization strategy — offers a lower-risk path: put an intelligent routing layer at the edge, and migrate webhook event types one at a time while the legacy monolith keeps handling everything else.&lt;/p&gt;

&lt;p&gt;This guide walks through the architecture, implementation, edge cases, and realistic trade-offs of that migration, and points to real tools you can actually evaluate today.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The Webhook Monolith Problem
Monolithic architectures are genuinely good for early-stage products: one codebase, simple deploys, centralized data access. But inbound webhook ingestion becomes a structural pain point as traffic grows, for a few concrete reasons:&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Code example&lt;br&gt;
Copy code&lt;br&gt;
                     +---------------------------------------+&lt;br&gt;
                     |        Legacy Monolith App             |&lt;br&gt;
Inbound Webhooks ---&amp;gt;|  (Rails / Django / Express / Spring)   |&lt;br&gt;
(Stripe, Shopify)    |  - Synchronous ingress router          |&lt;br&gt;
                     |  - HMAC signature verification         |&lt;br&gt;
                     |  - DB transaction locks                |&lt;br&gt;
                     |  - Background workers (Sidekiq/Celery) |&lt;br&gt;
                     +---------------------------------------+&lt;br&gt;
                                        |&lt;br&gt;
                                        v&lt;br&gt;
                               &lt;a href="https://dev.toSaturated%20connection%20pool"&gt; Monolithic DB &lt;/a&gt;&lt;br&gt;
Bursty traffic and thread exhaustion. Webhook providers don't throttle to match your capacity. A flash sale or a billing run can trigger tens of thousands of requests per minute, and synchronous app servers (Puma, Gunicorn, Unicorn) exhaust their thread pools waiting on payload parsing or synchronous DB writes.&lt;/p&gt;

&lt;p&gt;The "noisy neighbor" effect. A webhook surge competes for the same CPU and memory as your customer-facing frontend, so unrelated user traffic slows down too.&lt;/p&gt;

&lt;p&gt;HMAC verification overhead. Verifying cryptographic signatures (HMAC-SHA256) inside the application runtime spends CPU cycles before you even know whether the payload is legitimate.&lt;/p&gt;

&lt;p&gt;Queue and connection exhaustion. When background workers (Sidekiq, Celery, BullMQ) try to ingest thousands of payloads at once, the database's connection limit saturates, and that can cascade into timeouts across the whole platform.&lt;/p&gt;

&lt;p&gt;The risk of a full rewrite&lt;br&gt;
It's tempting to respond to these problems by proposing a full rewrite of the webhook domain into a new stack. Independent analyst and industry research is not encouraging about that path: Gartner, McKinsey, and the Standish Group's long-running CHAOS report have each, in different ways, found that a large share of major IT modernization and rewrite programs — commonly cited in the 60–80% range across different studies and years — fail to meet their goals, run significantly over budget, or get abandoned outright. The exact percentage varies by study and definition of "failure," so treat any single number as directional rather than a precise, universal statistic.&lt;/p&gt;

&lt;p&gt;A concrete, well-documented example: in April 2018, UK bank TSB attempted a single-weekend "big bang" migration of roughly 5.2 million customer records to a new banking platform. The cutover went wrong immediately — a significant share of customers were locked out of their accounts, some could see other customers' account details, and the disruption took until December 2018 to fully resolve. The UK's Financial Conduct Authority and Prudential Regulation Authority later fined TSB £48.65 million for the failure, on top of roughly £330 million in total costs, compensation, and lost income the bank had already absorbed. CEO Paul Pester resigned months later. It's not a webhook-specific case, but it's a real, regulator-documented illustration of what a single-cutover migration can cost when it goes wrong.&lt;/p&gt;

&lt;p&gt;Halting feature development for months to execute an all-or-nothing cutover on a system that touches payments and order fulfillment is a hard sell for exactly this reason.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The Strangler Fig Pattern, Applied to Webhooks
Martin Fowler introduced this pattern in a 2004 blog post, inspired by a trip to the rainforests of Queensland, Australia, where strangler figs germinate in the upper branches of a host tree and gradually grow downward, enveloping it until the host eventually dies and the fig stands alone. He originally titled the post "StranglerApplication." Years later he retitled it "Strangler Fig Application," specifically to push back against people using the bare word "strangler" — which reads as needlessly violent — and to keep the botanical metaphor in view.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The idea: instead of a single cutover, you build new capability around the edges of the legacy system and migrate one thin slice of functionality at a time, until the old system can be safely retired.&lt;/p&gt;

&lt;p&gt;Code example&lt;br&gt;
Copy code&lt;br&gt;
                   +-----------------------------------+&lt;br&gt;
                   |     Edge Ingress Router / Facade   |&lt;br&gt;
                   |      (your webhook gateway)        |&lt;br&gt;
                   +-----------------------------------+&lt;br&gt;
                               /           \&lt;br&gt;
               (Legacy Topics)/             (Migrated Topics)&lt;br&gt;
                             v               v&lt;br&gt;
                +-----------------+     +-----------------------+&lt;br&gt;
                | Legacy Monolith |     |  Modern Microservice  |&lt;br&gt;
                |   (Rails App)   |     | (Lambda / Cloud Run)  |&lt;br&gt;
                +-----------------+     +-----------------------+&lt;br&gt;
Why webhooks need a different approach than a typical Strangler Fig migration&lt;br&gt;
The classic Strangler Fig implementation uses a reverse proxy (NGINX, an API gateway) to route by URL path — /api/v1/users versus /api/v2/users. That doesn't work for webhooks, because most providers send every event type to a single destination URL (e.g., &lt;a href="https://api.yourcompany.com/webhooks/stripe" rel="noopener noreferrer"&gt;https://api.yourcompany.com/webhooks/stripe&lt;/a&gt;) regardless of what the event actually is. You can't decide whether a payload is a payment_intent.succeeded or a customer.subscription.updated event without unmarshalling the JSON body — path-based routing alone can't see inside it.&lt;/p&gt;

&lt;p&gt;So a webhook-aware edge layer needs to do more than a typical reverse proxy:&lt;/p&gt;

&lt;p&gt;Payload inspection — parse the JSON body to determine the event type, not just the URL path&lt;br&gt;
Topic extraction — read fields like event.type or topic to decide where a given event should go&lt;br&gt;
Signature verification — validate HMAC or RSA signatures before forwarding anything downstream&lt;br&gt;
Selective fan-out / shadowing — send specific event types to a new microservice while everything else keeps going to the legacy monolith, or duplicate traffic to both for verification&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A Phased Rollout Blueprint
Phase   Legacy Monolith Microservices   Edge Router's Job
0: Baseline Handles 100% of events  Not built yet   Proxy everything to the monolith
1: Edge validation  Relieved of signature checks    Inactive    Verify signatures at the edge; reject invalid payloads
2: Shadow traffic   Still authoritative Ingesting duplicate traffic for testing Fan out target event types to both systems
3: Topic cutover    Handles remaining (unmigrated) topics   Authoritative for migrated topics   Route extracted topics exclusively to the new service
4: Full strangulation   Decommissioned  Handles 100% of events  Streams directly into an event mesh (Kafka, NATS, EventBridge)&lt;/li&gt;
&lt;li&gt;Step-by-Step Implementation
The walkthrough below models migrating Stripe payment webhooks from a Django/Rails monolith to an AWS Lambda function, using a hypothetical edge gateway configuration. The YAML syntax is illustrative — you'd adapt it to whatever gateway or reverse proxy you're actually using.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Note on tooling: rather than build this edge layer from scratch, most teams evaluate a dedicated webhook infrastructure product. As of 2026 the commonly evaluated options include Hookdeck (purpose-built for receiving, routing, and fanning out inbound webhooks — the closest match to the use case in this guide), Svix (focused more on reliably sending outbound webhooks to your own customers, with a claimed 99.99999% historical uptime by its own reporting), and Convoy (an open-source, self-hostable webhooks gateway that handles both directions). Evaluate current pricing, support status, and feature sets directly with each vendor before committing, since this space moves quickly.&lt;/p&gt;

&lt;p&gt;Step 1 — Re-point the webhook endpoint&lt;br&gt;
Update your provider's dashboard (Stripe, GitHub, etc.) or your DNS to send events to the new edge endpoint instead of directly to your monolith:&lt;/p&gt;

&lt;p&gt;Code example&lt;br&gt;
Copy code&lt;br&gt;
OLD: &lt;a href="https://api.yourcompany.com/v1/webhooks/stripe" rel="noopener noreferrer"&gt;https://api.yourcompany.com/v1/webhooks/stripe&lt;/a&gt;&lt;br&gt;
NEW: &lt;a href="https://ingress.your-gateway.example.com/v1/ingest/wh_live_98x723a109" rel="noopener noreferrer"&gt;https://ingress.your-gateway.example.com/v1/ingest/wh_live_98x723a109&lt;/a&gt;&lt;br&gt;
Step 2 — Baseline: proxy everything to the monolith&lt;br&gt;
Configure signature verification and a default rule that forwards 100% of traffic to your existing application. At this point no business logic has changed — you've only added a layer that can reject malformed or unauthenticated payloads before they hit your infrastructure.&lt;/p&gt;

&lt;p&gt;Code example&lt;br&gt;
Copy code&lt;/p&gt;

&lt;h1&gt;
  
  
  gateway-config.yaml
&lt;/h1&gt;

&lt;p&gt;version: "2.0"&lt;br&gt;
provider: stripe&lt;br&gt;
signing_secret: "env(STRIPE_WEBHOOK_SECRET)"&lt;/p&gt;

&lt;p&gt;ingress:&lt;br&gt;
  path: "/v1/ingest/wh_live_98x723a109"&lt;/p&gt;

&lt;p&gt;routes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;name: "default-legacy-fallback"
match:
  topic: "*"  # matches every event type not otherwise routed
destination:
  type: "http"
  url: "&lt;a href="https://legacy-monolith.internal.yourcompany.com/v1/webhooks/stripe" rel="noopener noreferrer"&gt;https://legacy-monolith.internal.yourcompany.com/v1/webhooks/stripe&lt;/a&gt;"
  timeout_ms: 5000
  retry_policy:
    max_retries: 3
    backoff: "exponential"
Step 3 — Extract the first event type into a microservice
Pick a high-volume, relatively isolated event type to migrate first. payment_intent.succeeded is a common candidate. Build a small, single-purpose handler:&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Code example&lt;br&gt;
Copy code&lt;/p&gt;

&lt;h1&gt;
  
  
  microservices/payment_processor/handler.py
&lt;/h1&gt;

&lt;p&gt;import json&lt;br&gt;
import logging&lt;br&gt;
from typing import Dict, Any&lt;/p&gt;

&lt;p&gt;logger = logging.getLogger()&lt;br&gt;
logger.setLevel(logging.INFO)&lt;/p&gt;

&lt;p&gt;def lambda_handler(event: Dict[str, Any], context: Any) -&amp;gt; Dict[str, Any]:&lt;br&gt;
    """&lt;br&gt;
    Serverless handler for payment_intent.succeeded webhooks,&lt;br&gt;
    decoupled from the legacy monolith.&lt;br&gt;
    """&lt;br&gt;
    try:&lt;br&gt;
        payload = json.loads(event.get("body", "{}"))&lt;br&gt;
        event_type = payload.get("type")&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;    if event_type != "payment_intent.succeeded":
        logger.warning(f"Unexpected event type received: {event_type}")
        return {"statusCode": 400, "body": json.dumps({"error": "Invalid event topic"})}

    payment_intent = payload["data"]["object"]
    customer_id = payment_intent.get("customer")
    amount = payment_intent.get("amount")
    currency = payment_intent.get("currency")

    logger.info(f"Processing payment {payment_intent['id']} for customer {customer_id}")
    process_successful_payment(customer_id, amount, currency)

    return {
        "statusCode": 200,
        "body": json.dumps({"status": "success", "processed_id": payment_intent["id"]})
    }

except Exception as e:
    logger.error(f"Error processing webhook payload: {str(e)}", exc_info=True)
    return {"statusCode": 500, "body": json.dumps({"error": "Internal processing failure"})}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;def process_successful_payment(customer_id: str, amount: int, currency: str) -&amp;gt; None:&lt;br&gt;
    # Isolated DB transaction or event bus emission&lt;br&gt;
    pass&lt;br&gt;
Step 4 — Shadow the traffic before cutting over&lt;br&gt;
Before trusting the new service with production behavior, mirror live traffic to it without letting its response reach the provider or affect the customer-facing status code.&lt;/p&gt;

&lt;p&gt;Code example&lt;br&gt;
Copy code&lt;br&gt;
routes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;name: "shadow-payment-succeeded"&lt;br&gt;
match:&lt;br&gt;
  topic: "payment_intent.succeeded"&lt;br&gt;
mode: "shadow"&lt;br&gt;
destinations:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;name: "legacy-monolith"
type: "http"
url: "&lt;a href="https://legacy-monolith.internal.yourcompany.com/v1/webhooks/stripe" rel="noopener noreferrer"&gt;https://legacy-monolith.internal.yourcompany.com/v1/webhooks/stripe&lt;/a&gt;"
primary: true  # this response is what actually goes back to Stripe&lt;/li&gt;
&lt;li&gt;name: "new-payment-lambda"
type: "http"
url: "&lt;a href="https://payments.api.yourcompany.com/v1/events" rel="noopener noreferrer"&gt;https://payments.api.yourcompany.com/v1/events&lt;/a&gt;"
primary: false  # runs in dry-run/shadow mode&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;name: "default-legacy-fallback"&lt;br&gt;
match:&lt;br&gt;
  topic: "*"&lt;br&gt;
destination:&lt;br&gt;
  type: "http"&lt;br&gt;
  url: "&lt;a href="https://legacy-monolith.internal.yourcompany.com/v1/webhooks/stripe" rel="noopener noreferrer"&gt;https://legacy-monolith.internal.yourcompany.com/v1/webhooks/stripe&lt;/a&gt;"&lt;br&gt;
Before moving on, verify:&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Log output matches between the monolith's background job and the Lambda execution&lt;br&gt;
The microservice meets or beats current processing-time targets&lt;br&gt;
Side effects (outbound API calls to third parties) are pointed at sandboxes during shadow mode, not production&lt;br&gt;
Step 5 — Cut over the event type&lt;br&gt;
Once shadow traffic checks out, make the new service authoritative for that event type while everything else keeps flowing to the monolith:&lt;/p&gt;

&lt;p&gt;Code example&lt;br&gt;
Copy code&lt;br&gt;
routes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;name: "migrated-payment-succeeded"&lt;br&gt;
match:&lt;br&gt;
  topic: "payment_intent.succeeded"&lt;br&gt;
mode: "authoritative"&lt;br&gt;
destination:&lt;br&gt;
  type: "http"&lt;br&gt;
  url: "&lt;a href="https://payments.api.yourcompany.com/v1/events" rel="noopener noreferrer"&gt;https://payments.api.yourcompany.com/v1/events&lt;/a&gt;"&lt;br&gt;
  timeout_ms: 3000&lt;br&gt;
  circuit_breaker:&lt;br&gt;
    error_threshold_percentage: 15&lt;br&gt;
    fallback_destination: "legacy-monolith"  # automatic failover&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;name: "default-legacy-fallback"&lt;br&gt;
match:&lt;br&gt;
  topic: "*"&lt;br&gt;
destination:&lt;br&gt;
  type: "http"&lt;br&gt;
  url: "&lt;a href="https://legacy-monolith.internal.yourcompany.com/v1/webhooks/stripe" rel="noopener noreferrer"&gt;https://legacy-monolith.internal.yourcompany.com/v1/webhooks/stripe&lt;/a&gt;"&lt;br&gt;
Step 6 — Repeat, then decommission&lt;br&gt;
Repeat Steps 3–5 for remaining event types (customer.subscription.deleted, invoice.payment_failed, etc.). Once every topic is migrated: point the default fallback at your new services mesh, remove the legacy controllers and background workers, and reclaim the monolith's database connections and instance capacity.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;ol&gt;
&lt;li&gt;Distributed-System Edge Cases
Splitting webhook handling across services introduces problems a monolith's single database quietly used to hide.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Challenge   Common Solution&lt;br&gt;
Duplicate payloads  Edge- or service-level deduplication (e.g., Redis with a TTL)&lt;br&gt;
Out-of-order events Timestamp checks or explicit state-machine transition guards&lt;br&gt;
Keeping monolith and microservice data in sync  Change Data Capture (e.g., Debezium) streaming into an event bus&lt;br&gt;
Microservice outages    Dead-letter queues plus a replay mechanism at the edge&lt;br&gt;
Idempotency across service boundaries&lt;br&gt;
Providers like Stripe deliver webhooks at least once, so duplicates are expected, not exceptional. A monolith typically enforces idempotency with a single ACID transaction:&lt;/p&gt;

&lt;p&gt;Code example&lt;br&gt;
Copy code&lt;/p&gt;

&lt;h1&gt;
  
  
  Legacy Rails monolith
&lt;/h1&gt;

&lt;p&gt;ActiveRecord::Base.transaction do&lt;br&gt;
  return if WebhookLog.exists?(event_id: payload['id'])&lt;br&gt;
  WebhookLog.create!(event_id: payload['id'])&lt;br&gt;
  process_event(payload)&lt;br&gt;
end&lt;br&gt;
Without a shared database, you need a fast distributed lock instead — an atomic "set if not exists" against Redis works well:&lt;/p&gt;

&lt;p&gt;Code example&lt;br&gt;
Copy code&lt;br&gt;
import redis&lt;/p&gt;

&lt;p&gt;redis_client = redis.Redis(host="redis-cluster.internal", port=6379)&lt;/p&gt;

&lt;p&gt;def process_webhook_with_idempotency(event_id: str, payload: dict) -&amp;gt; bool:&lt;br&gt;
    lock_key = f"idempotency:webhook:{event_id}"&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# NX = only set if the key doesn't already exist; expire after 24h
is_new_event = redis_client.set(lock_key, "processing", nx=True, ex=86400)

if not is_new_event:
    logger.info(f"Duplicate event received: {event_id}. Skipping.")
    return True  # still return 200 OK to the provider

try:
    execute_business_logic(payload)
    redis_client.set(lock_key, "completed", ex=86400)
    return True
except Exception as e:
    redis_client.delete(lock_key)  # allow a retry to reprocess
    raise e
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;Out-of-order delivery&lt;br&gt;
A subscription.updated event can arrive before a delayed subscription.created event, purely because of network variance between requests. Two common mitigations: compare the event's own timestamp against what's currently recorded on the entity and discard stale updates, or enforce explicit state-machine rules (an order can't go from FULFILLED back to PENDING, for instance).&lt;/p&gt;

&lt;p&gt;Keeping the monolith and microservices in sync&lt;br&gt;
During a multi-month migration, a new microservice will sometimes need data that still lives only in the monolith's database. Querying the monolith's database synchronously from the microservice reintroduces the tight coupling you're trying to remove. The standard alternative is Change Data Capture: a tool like Debezium (a real, actively maintained open-source CDC project built on Kafka Connect) streams row-level database changes out of the monolith into an event bus, so microservices can build their own local read models without querying the source database directly.&lt;/p&gt;

&lt;p&gt;Resilience: circuit breakers and dead-letter queues&lt;br&gt;
If a newly deployed microservice starts erroring, the edge layer needs to shield the provider from seeing 5xx responses — some providers, including Stripe, will automatically disable an endpoint after enough consecutive delivery failures. Route failed deliveries into a persistent dead-letter queue with a defined retry policy, and give engineers a way to bulk-replay events once a bug is fixed, without needing the provider to resend anything.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;What to Actually Expect
It's tempting to attach a tidy "before/after" metrics table to a migration story like this, but real numbers depend heavily on your traffic shape, current infrastructure, and how much of the work you offload to a managed vendor versus build yourself — so treat any specific percentage you see in a vendor's marketing (including this kind of article) with some skepticism unless it's backed by a named, reproducible benchmark.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;What's well-supported instead:&lt;/p&gt;

&lt;p&gt;Provider timeout pressure is real, even if the exact number isn't published. Stripe doesn't publish an official webhook response deadline, and developer reports of the practical cutoff vary — commonly somewhere in the 10–20 second range — but Stripe's own guidance is unambiguous that you should verify the signature, return a 2xx response quickly, and do slow work asynchronously rather than during the request. Stripe also retries failed deliveries on an exponential backoff schedule for up to three days in live mode before disabling the endpoint.&lt;br&gt;
Dedicated webhook infrastructure is a mature market, not a hypothetical. Vendors like Hookdeck, Svix, and Convoy exist specifically because retries, replay, signature verification, and noisy-neighbor isolation are hard to get right, and building all of it yourself is a real cost most teams underestimate.&lt;br&gt;
Offloading signature verification and routing to the edge does reduce load on the monolith and the new services, because rejected or misrouted traffic never reaches your application code — but the magnitude of that improvement is workload-specific, so measure it in your own environment rather than assuming a fixed percentage.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Migration Checklist
Phase 1: Preparation &amp;amp; edge setup&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Audit every inbound webhook endpoint across providers (Stripe, GitHub, Shopify, etc.)&lt;br&gt;
 Map each event type to its internal business consumer&lt;br&gt;
 Deploy the edge gateway and configure provider signing secrets&lt;br&gt;
 Re-point ingestion URLs with a 100% baseline fallback to the existing application&lt;br&gt;
Phase 2: Extraction &amp;amp; parity testing&lt;/p&gt;

&lt;p&gt;Pick a low-risk or high-volume event type as the first candidate&lt;br&gt;
 Build the replacement service&lt;br&gt;
 Enable shadow traffic to the new service&lt;br&gt;
 Verify idempotency handling, data sync, and log parity against the monolith&lt;br&gt;
Phase 3: Cutover &amp;amp; cleanup&lt;/p&gt;

&lt;p&gt;Route the migrated event type's live traffic to the new service&lt;br&gt;
 Monitor error rates, latency, and circuit-breaker activity&lt;br&gt;
 Repeat extraction for remaining event types&lt;br&gt;
 Decommission the legacy controllers, workers, and unused schemas&lt;br&gt;
FAQ&lt;br&gt;
What's the main benefit of the Strangler Fig pattern for webhooks? Risk reduction. You extract and validate one event type at a time instead of cutting the entire system over at once, and the legacy monolith keeps handling everything you haven't migrated yet.&lt;/p&gt;

&lt;p&gt;Does re-pointing webhook URLs to an edge gateway cause downtime? Not if you set up a 100% fallback route to your existing monolith before changing the provider-side URL. Traffic keeps flowing through the new ingress point while you build out routing behind it.&lt;/p&gt;

&lt;p&gt;What happens if a newly deployed microservice fails in production? A well-configured edge gateway includes circuit breakers and failover routing: once errors cross a threshold, traffic can automatically fail back to the legacy monolith or land in a dead-letter queue for later replay.&lt;/p&gt;

&lt;p&gt;Does Stripe really enforce a strict timeout on webhook responses? Stripe doesn't publish an exact figure, and reports of the practical cutoff vary by source — commonly cited in the 10–20 second range. The safe practice regardless of the exact number is the same: verify and acknowledge quickly, and process asynchronously.&lt;/p&gt;

&lt;p&gt;Sources&lt;br&gt;
Martin Fowler, "Strangler Fig Application", martinfowler.com&lt;br&gt;
Strangler fig pattern, AWS Prescriptive Guidance&lt;br&gt;
Strangler fig pattern, Microsoft Azure Architecture Center&lt;br&gt;
UK bank TSB fined nearly £49m over IT system meltdown, TechInformed&lt;br&gt;
Guide to Stripe Webhooks: Features and Best Practices, Hookdeck&lt;br&gt;
Svix vs Hookdeck vs Convoy (2026), PkgPulse&lt;br&gt;
Convoy — webhooks gateway, getconvoy.io&lt;br&gt;
Debezium — open-source change data capture project&lt;/p&gt;

</description>
    </item>
    <item>
      <title>GraphQL Subscriptions vs. Webhooks: Choosing the Right Event-Driven Pattern</title>
      <dc:creator>InstaWebhook</dc:creator>
      <pubDate>Tue, 22 Sep 2026 05:53:45 +0000</pubDate>
      <link>https://dev.to/instawebhook/graphql-subscriptions-vs-webhooks-choosing-the-right-event-driven-pattern-17kh</link>
      <guid>https://dev.to/instawebhook/graphql-subscriptions-vs-webhooks-choosing-the-right-event-driven-pattern-17kh</guid>
      <description>&lt;p&gt;Apollo client GraphQL subscriptions&lt;br&gt;
Apollo GraphQL subscriptions tutorial&lt;br&gt;
Apollo server event driven&lt;br&gt;
Apollo server GraphQL subscriptions&lt;br&gt;
Apollo server real time architecture&lt;br&gt;
Apollo server webhooks&lt;br&gt;
asynchronous API communication&lt;br&gt;
backend communication patterns&lt;br&gt;
backend event delivery webhooks&lt;br&gt;
backend microservice event communication&lt;br&gt;
backend to backend webhooks&lt;br&gt;
browser WebSockets GraphQL subscriptions&lt;br&gt;
building scalable event driven APIs&lt;br&gt;
enterprise event driven integration&lt;br&gt;
event driven API design patterns&lt;br&gt;
event driven API GraphQL&lt;br&gt;
event driven API patterns&lt;br&gt;
event driven architecture webhooks&lt;br&gt;
event driven microservices GraphQL&lt;br&gt;
GraphQL client side real time&lt;br&gt;
GraphQL event driven architecture&lt;br&gt;
GraphQL pub sub backend&lt;br&gt;
GraphQL push notifications&lt;br&gt;
GraphQL query subscription difference&lt;br&gt;
GraphQL real time client updates&lt;br&gt;
GraphQL real time updates&lt;br&gt;
GraphQL subscription performance&lt;br&gt;
GraphQL subscriptions browser clients&lt;br&gt;
GraphQL subscription scalability&lt;br&gt;
GraphQL subscription server load&lt;br&gt;
GraphQL subscriptions stateful server&lt;br&gt;
GraphQL subscriptions vs webhooks&lt;br&gt;
GraphQL subscriptions vs WebSockets&lt;br&gt;
GraphQL subscription vs polling&lt;br&gt;
GraphQL WebSocket overhead&lt;br&gt;
persistent connection vs HTTP callback&lt;br&gt;
persistent open connections GraphQL&lt;br&gt;
real time web applications GraphQL&lt;br&gt;
scalable event driven APIs&lt;br&gt;
serverless webhooks vs GraphQL&lt;br&gt;
stateless backend webhooks&lt;br&gt;
webhook infrastructure design&lt;br&gt;
webhooks architecture best practices&lt;br&gt;
webhooks event triggers&lt;br&gt;
webhooks HTTP callbacks&lt;br&gt;
webhooks reliability and retries&lt;br&gt;
webhooks server to server&lt;br&gt;
webhooks vs event driven API&lt;br&gt;
webhooks vs GraphQL subscriptions&lt;br&gt;
webhooks vs GraphQL system integration&lt;br&gt;
webhooks vs WebSockets backend&lt;br&gt;
webhooks vs WebSockets performance&lt;br&gt;
WebSockets vs webhooks&lt;br&gt;
when to use webhooks vs GraphQL subscriptions&lt;br&gt;
Graph QL Subscriptions Vs Webhooks Choosing The Right Event Driven Pattern&lt;br&gt;
GraphQL Subscriptions vs. Webhooks: Choosing the Right Event-Driven Pattern&lt;br&gt;
When you're designing an event-driven API, one of the biggest architectural decisions is how you propagate state changes across your system. Two patterns dominate real-time data distribution: GraphQL Subscriptions and Webhooks.&lt;/p&gt;

&lt;p&gt;Both patterns replace the resource-wasteful practice of HTTP short-polling, but they solve fundamentally different problems at opposite ends of the architectural spectrum. GraphQL Subscriptions maintain persistent WebSocket (or SSE) connections designed primarily for client-to-server, UI-facing interactions. Webhooks rely on stateless, asynchronous HTTP POST callbacks optimized for decoupled backend-to-backend communication.&lt;/p&gt;

&lt;p&gt;This guide breaks down the mechanics, resource efficiency, serverless compatibility, security models, and code implementations of each — including how they intersect in a typical Apollo Server setup, and how the tooling around both has shifted heading into 2026.&lt;/p&gt;

&lt;p&gt;Executive Summary &amp;amp; Comparison Table&lt;br&gt;
Feature / Dimension GraphQL Subscriptions   Webhooks&lt;br&gt;
Primary Use Case    Real-time UI updates (Client-to-Server) Asynchronous notifications (Server-to-Server)&lt;br&gt;
Transport Protocol  WebSockets (ws:///wss://) or Server-Sent Events Stateless HTTP / HTTPS POST&lt;br&gt;
Connection Type Stateful, persistent, long-lived    Stateless, request-response, ephemeral&lt;br&gt;
Directionality  Bidirectional handshake / server-push after that    Unidirectional (server pushing to server)&lt;br&gt;
Payload Customization   Dynamic — defined by the client's GraphQL selection set   Static — defined by the publisher's schema&lt;br&gt;
Server Resource Overhead    Higher memory/file-descriptor footprint per client  Low; scales with request volume, not connection count&lt;br&gt;
Serverless Compatibility    Historically awkward; improving via managed pub/sub (see below) Native — maps cleanly to Lambda/Edge functions&lt;br&gt;
Reliability &amp;amp; Retries   Connection-bound; client must resubscribe on drop   Publisher-side retry queues and dead-letter queues&lt;br&gt;
Security Mechanism  Auth during the connection handshake (tokens/headers)   HMAC signatures, IP allow-listing, mutual TLS&lt;br&gt;
What Are GraphQL Subscriptions?&lt;br&gt;
GraphQL Subscriptions are a GraphQL operation type that lets a server push real-time updates to subscribed clients whenever a specific event occurs.&lt;/p&gt;

&lt;p&gt;Unlike Queries (read) and Mutations (write), which run over a standard request-response cycle, Subscriptions are long-lived operations that stay open for the lifetime of the client's interest in that data.&lt;/p&gt;

&lt;p&gt;Code example&lt;br&gt;
Copy code&lt;br&gt;
+------------------+         1. WebSocket Connection Handshake        +-------------------+&lt;br&gt;
|                  | -----------------------------------------------&amp;gt; |                   |&lt;br&gt;
|                  |         2. SUBSCRIBE: subscription { ... }       |                   |&lt;br&gt;
|  Browser / Client | -----------------------------------------------&amp;gt; |   Apollo Server   |&lt;br&gt;
|   (Apollo/Urql)  |                                                  |   (Pub/Sub Engine)|&lt;br&gt;
|                  | &amp;lt;----------------------------------------------- |                   |&lt;br&gt;
+------------------+          3. Real-Time Data Push (Event Payload)  +-------------------+&lt;br&gt;
Technical Mechanics&lt;br&gt;
Protocol handshake: The client initiates an HTTP upgrade to establish a WebSocket connection (using the graphql-transport-ws protocol, implemented by the graphql-ws library), or opens a Server-Sent Events stream via graphql-sse.&lt;br&gt;
Subscription registration: The client sends a subscription query defining exactly the shape of data it wants:&lt;br&gt;
Code example&lt;br&gt;
Copy code&lt;br&gt;
subscription OnOrderUpdated($orderId: ID!) {&lt;br&gt;
  orderUpdated(id: $orderId) {&lt;br&gt;
    id&lt;br&gt;
    status&lt;br&gt;
    estimatedDelivery&lt;br&gt;
  }&lt;br&gt;
}&lt;br&gt;
Event publishing: When an event occurs (say, an order status changes via a mutation), the server's pub/sub layer triggers the subscription resolver and pushes the transformed result to every matching active connection.&lt;br&gt;
Major Strengths&lt;br&gt;
Zero over-fetching: The client specifies exactly the fields it needs, so a bandwidth-constrained mobile client only pulls down status, not the whole object graph.&lt;br&gt;
Unified schema: Subscriptions share the same schema, types, and auth context as your existing queries and mutations.&lt;br&gt;
Cache integration: Apollo Client and Urql automatically merge incoming subscription payloads into their normalized caches, triggering UI re-renders without extra plumbing.&lt;br&gt;
Major Drawbacks&lt;br&gt;
Higher memory footprint: Every open socket consumes kernel buffer memory and a file descriptor, so servers holding tens of thousands of concurrent connections need real capacity planning (OS ulimit tuning, load balancer socket limits, etc.) — the exact bytes-per-socket vary by OS and TCP buffer settings, but the direction is always "more connections, more baseline memory," unlike stateless HTTP.&lt;br&gt;
Distributed state complexity: Scaling subscriptions across multiple server instances requires an external message broker (Redis Pub/Sub, NATS, Kafka) so an event published on one node reaches clients connected to another.&lt;br&gt;
Note on tooling: subscriptions-transport-ws, the original Apollo-created WebSocket transport, has been unmaintained since 2018 and its repository is now archived. If you see it in a tutorial, treat that as a signal the content is outdated — the actively maintained, Apollo-recommended replacement is graphql-ws, which implements the newer graphql-transport-ws protocol. The two protocols are not wire-compatible, so migrating means upgrading both client and server.&lt;/p&gt;

&lt;p&gt;What Are Webhooks?&lt;br&gt;
Webhooks (sometimes called "reverse APIs" or HTTP callbacks) are subscriber-defined HTTP endpoints that react to events in someone else's system. When an event happens in the publishing system, it serializes the event and POSTs it directly to a pre-registered URL owned by the subscriber.&lt;/p&gt;

&lt;p&gt;Code example&lt;br&gt;
Copy code&lt;br&gt;
+-------------------+                                                 +-------------------+&lt;br&gt;
|                   |          1. Event Occurs (e.g., Payment)        |                   |&lt;br&gt;
|  Publishing System | ---------------------------------------------&amp;gt; |  Receiving Server |&lt;br&gt;
|  (e.g., Stripe)   |          HTTP POST payload + HMAC Signature     |  (Webhook Handler)|&lt;br&gt;
|                   |                                                 |                   |&lt;br&gt;
|                   | &amp;lt;---------------------------------------------- |                   |&lt;br&gt;
+-------------------+          2. HTTP 200 OK Response                +-------------------+&lt;br&gt;
Technical Mechanics&lt;br&gt;
Registration: System B registers an HTTPS endpoint (&lt;a href="https://api.system-b.com/webhooks/orders" rel="noopener noreferrer"&gt;https://api.system-b.com/webhooks/orders&lt;/a&gt;) with System A.&lt;br&gt;
Event dispatch: When an event occurs in System A, it serializes the event data to JSON and sends a POST request to System B's URL.&lt;br&gt;
Acknowledgment and retries: System B processes the payload and returns a status code (200 OK or 202 Accepted). If it returns a 5xx or times out, System A queues the event for retry with exponential backoff.&lt;br&gt;
Major Strengths&lt;br&gt;
Stateless and scalable: No open connections to maintain — receivers handle each delivery as ordinary HTTP traffic and can scale to zero when idle.&lt;br&gt;
Reliability infrastructure is standard practice: Mature webhook publishers pair delivery with a queue (SQS, Kafka) plus retries and dead-letter queues, so temporary receiver downtime doesn't mean lost events.&lt;br&gt;
Language- and protocol-agnostic: Any server that can parse an HTTP POST body can receive a webhook.&lt;br&gt;
Major Drawbacks&lt;br&gt;
Fixed payload shapes: Subscribers get whatever JSON the publisher decided to send, which often means a follow-up API call to fetch missing context.&lt;br&gt;
Public exposure: Receiving endpoints must be reachable from the public internet, which makes signature verification (not just "security through obscurity") mandatory.&lt;br&gt;
No standardization, historically: Every provider invented its own header names, signing scheme, and retry cadence — this is exactly the gap the Standard Webhooks initiative (covered below) is trying to close.&lt;br&gt;
The Architectural Conflict: Statefulness vs. Scalability&lt;br&gt;
Choosing between GraphQL Subscriptions and Webhooks comes down to balancing connection statefulness against infrastructure scalability.&lt;/p&gt;

&lt;p&gt;Code example&lt;br&gt;
Copy code&lt;br&gt;
                           +-------------------------------------+&lt;br&gt;
                           | Is the consumer a frontend app      |&lt;br&gt;
                           | or web browser needing instant UI?  |&lt;br&gt;
                           +-------------------------------------+&lt;br&gt;
                                      /               \&lt;br&gt;
                                     /                 \&lt;br&gt;
                                   YES                  NO&lt;br&gt;
                                   /                     \&lt;br&gt;
                                  v                       v&lt;br&gt;
                    +--------------------------+  +--------------------------+&lt;br&gt;
                    | Use GraphQL Subscriptions|  | Is it a server-to-server |&lt;br&gt;
                    | (over WebSockets / SSE)  |  | integration across system |&lt;br&gt;
                    +--------------------------+  | boundaries?              |&lt;br&gt;
                                                  +--------------------------+&lt;br&gt;
                                                               |&lt;br&gt;
                                                               | YES&lt;br&gt;
                                                               v&lt;br&gt;
                                                  +--------------------------+&lt;br&gt;
                                                  |       Use Webhooks       |&lt;br&gt;
                                                  +--------------------------+&lt;br&gt;
Connection Exhaustion: The WebSocket Bottleneck&lt;br&gt;
A single Node.js process running Apollo Server can handle a very large volume of stateless HTTP requests per second, because each connection closes as soon as the response is sent.&lt;/p&gt;

&lt;p&gt;Over WebSockets, it's different: every subscribed client holds a persistent TCP socket open indefinitely. That has real costs:&lt;/p&gt;

&lt;p&gt;Memory: Each open socket reserves kernel send/receive buffers, so total memory scales roughly linearly with connection count — the specific per-socket number depends heavily on your OS and network stack tuning, but it's a cost stateless HTTP simply doesn't have.&lt;br&gt;
File descriptors: Unix treats sockets as file descriptors, so high concurrency runs into ulimit -n and load-balancer connection caps unless you tune for it deliberately.&lt;br&gt;
Heartbeats: To detect dead connections (a phone switching from Wi-Fi to cellular, for instance), servers send periodic pings, which adds constant low-level CPU and bandwidth overhead.&lt;br&gt;
The Serverless &amp;amp; Edge Disconnect&lt;br&gt;
Modern infrastructure leans heavily on serverless runtimes — AWS Lambda, Cloudflare Workers, Vercel Functions — that spin up on demand and terminate when the response finishes.&lt;/p&gt;

&lt;p&gt;Webhooks thrive here: an incoming webhook triggers a stateless function execution, processes the payload in tens of milliseconds, returns 200 OK, and terminates. Cost tracks execution time directly.&lt;br&gt;
GraphQL Subscriptions historically struggled here: a standard serverless function can't hold a WebSocket open for hours, so teams offloaded connection management to an external stateful layer — managed WebSocket gateways backed by a database, or third-party real-time platforms like Ably, Pusher, or AWS AppSync.&lt;br&gt;
That gap has narrowed. As of March 2025, AWS AppSync Events provides a managed, serverless WebSocket pub/sub API specifically so teams don't have to hand-roll connection management on top of Lambda and DynamoDB — you publish events over HTTP and AppSync handles fan-out to connected WebSocket clients. It's a purpose-built pub/sub product that sits alongside (not strictly inside) AppSync's original GraphQL subscription model, and it's one of a few signs that "subscriptions on serverless" is becoming a solved problem rather than a workaround.&lt;/p&gt;

&lt;p&gt;Code Implementations&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;GraphQL Subscription Implementation (graphql-ws + Apollo Server)
This sets up a subscription server using Node.js, graphql-ws, and Apollo Server, letting clients subscribe to real-time commentAdded events. Note this already uses graphql-ws, not the deprecated subscriptions-transport-ws — that's the correct, currently supported approach.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Code example&lt;br&gt;
Copy code&lt;br&gt;
import { createServer } from 'http';&lt;br&gt;
import { expressMiddleware } from '&lt;a class="mentioned-user" href="https://dev.to/apollo"&gt;@apollo&lt;/a&gt;/server/express4';&lt;br&gt;
import { ApolloServer } from '&lt;a class="mentioned-user" href="https://dev.to/apollo"&gt;@apollo&lt;/a&gt;/server';&lt;br&gt;
import { ApolloServerPluginDrainHttpServer } from '&lt;a class="mentioned-user" href="https://dev.to/apollo"&gt;@apollo&lt;/a&gt;/server/plugin/drainHttpServer';&lt;br&gt;
import express from 'express';&lt;br&gt;
import { WebSocketServer } from 'ws';&lt;br&gt;
import { useServer } from 'graphql-ws/lib/use/ws';&lt;br&gt;
import { makeExecutableSchema } from '@graphql-tools/schema';&lt;br&gt;
import { PubSub } from 'graphql-subscriptions';&lt;br&gt;
import bodyParser from 'body-parser';&lt;/p&gt;

&lt;p&gt;const pubsub = new PubSub();&lt;br&gt;
const COMMENT_ADDED = 'COMMENT_ADDED';&lt;/p&gt;

&lt;p&gt;// 1. Schema Definition&lt;br&gt;
const typeDefs = `#graphql&lt;br&gt;
  type Comment {&lt;br&gt;
    id: ID!&lt;br&gt;
    content: String!&lt;br&gt;
    author: String!&lt;br&gt;
  }&lt;/p&gt;

&lt;p&gt;type Query {&lt;br&gt;
    comments: [Comment!]!&lt;br&gt;
  }&lt;/p&gt;

&lt;p&gt;type Mutation {&lt;br&gt;
    addComment(content: String!, author: String!): Comment!&lt;br&gt;
  }&lt;/p&gt;

&lt;p&gt;type Subscription {&lt;br&gt;
    commentAdded: Comment!&lt;br&gt;
  }&lt;br&gt;
`;&lt;/p&gt;

&lt;p&gt;// 2. Resolvers&lt;br&gt;
const resolvers = {&lt;br&gt;
  Query: {&lt;br&gt;
    comments: () =&amp;gt; [],&lt;br&gt;
  },&lt;br&gt;
  Mutation: {&lt;br&gt;
    addComment: (_, { content, author }) =&amp;gt; {&lt;br&gt;
      const newComment = { id: Date.now().toString(), content, author };&lt;br&gt;
      // Publish event to subscribers&lt;br&gt;
      pubsub.publish(COMMENT_ADDED, { commentAdded: newComment });&lt;br&gt;
      return newComment;&lt;br&gt;
    },&lt;br&gt;
  },&lt;br&gt;
  Subscription: {&lt;br&gt;
    commentAdded: {&lt;br&gt;
      subscribe: () =&amp;gt; pubsub.asyncIterator([COMMENT_ADDED]),&lt;br&gt;
    },&lt;br&gt;
  },&lt;br&gt;
};&lt;/p&gt;

&lt;p&gt;const schema = makeExecutableSchema({ typeDefs, resolvers });&lt;/p&gt;

&lt;p&gt;// 3. HTTP and WebSocket Server Setup&lt;br&gt;
const app = express();&lt;br&gt;
const httpServer = createServer(app);&lt;/p&gt;

&lt;p&gt;const wsServer = new WebSocketServer({&lt;br&gt;
  server: httpServer,&lt;br&gt;
  path: '/graphql',&lt;br&gt;
});&lt;/p&gt;

&lt;p&gt;// Bind WebSocket server with GraphQL schema&lt;br&gt;
const serverCleanup = useServer({ schema }, wsServer);&lt;/p&gt;

&lt;p&gt;const server = new ApolloServer({&lt;br&gt;
  schema,&lt;br&gt;
  plugins: [&lt;br&gt;
    ApolloServerPluginDrainHttpServer({ httpServer }),&lt;br&gt;
    {&lt;br&gt;
      async serverWillStart() {&lt;br&gt;
        return {&lt;br&gt;
          async drainServer() {&lt;br&gt;
            await serverCleanup.dispose();&lt;br&gt;
          },&lt;br&gt;
        };&lt;br&gt;
      },&lt;br&gt;
    },&lt;br&gt;
  ],&lt;br&gt;
});&lt;/p&gt;

&lt;p&gt;await server.start();&lt;br&gt;
app.use('/graphql', bodyParser.json(), expressMiddleware(server));&lt;/p&gt;

&lt;p&gt;httpServer.listen(4000, () =&amp;gt; {&lt;br&gt;
  console.log('🚀 Query/Mutation server ready at &lt;a href="http://localhost:4000/graphql'" rel="noopener noreferrer"&gt;http://localhost:4000/graphql'&lt;/a&gt;);&lt;br&gt;
  console.log('🚀 Subscription server ready at ws://localhost:4000/graphql');&lt;br&gt;
});&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Secure Webhook Publisher &amp;amp; Receiver with HMAC Verification
Below is a webhook receiver verifying an HMAC-SHA256 signature with a custom header, plus a publisher dispatching events. This is the "roll your own" pattern most companies used before any standardization existed — and it's still perfectly valid for a single internal integration.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Webhook Receiver (Express + TypeScript)&lt;br&gt;
Code example&lt;br&gt;
Copy code&lt;br&gt;
import express, { Request, Response } from 'express';&lt;br&gt;
import crypto from 'crypto';&lt;/p&gt;

&lt;p&gt;const app = express();&lt;br&gt;
// Capture raw buffer for cryptographic signature verification&lt;br&gt;
app.use(express.json({&lt;br&gt;
  verify: (req: any, _res, buf) =&amp;gt; {&lt;br&gt;
    req.rawBody = buf;&lt;br&gt;
  }&lt;br&gt;
}));&lt;/p&gt;

&lt;p&gt;const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET || 'super-secret-key-123';&lt;/p&gt;

&lt;p&gt;function verifyHmacSignature(rawBody: Buffer, signatureHeader: string | undefined): boolean {&lt;br&gt;
  if (!signatureHeader) return false;&lt;/p&gt;

&lt;p&gt;const expectedSignature = crypto&lt;br&gt;
    .createHmac('sha256', WEBHOOK_SECRET)&lt;br&gt;
    .update(rawBody)&lt;br&gt;
    .digest('hex');&lt;/p&gt;

&lt;p&gt;const trustedBuffer = Buffer.from(&lt;code&gt;sha256=${expectedSignature}&lt;/code&gt;, 'utf8');&lt;br&gt;
  const untrustedBuffer = Buffer.from(signatureHeader, 'utf8');&lt;/p&gt;

&lt;p&gt;if (trustedBuffer.length !== untrustedBuffer.length) return false;&lt;/p&gt;

&lt;p&gt;// Use timingSafeEqual to protect against timing attacks&lt;br&gt;
  return crypto.timingSafeEqual(trustedBuffer, untrustedBuffer);&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;app.post('/webhooks/payment-events', (req: Request, res: Response) =&amp;gt; {&lt;br&gt;
  const signature = req.headers['x-signature-256'] as string;&lt;br&gt;
  const rawBody = (req as any).rawBody;&lt;/p&gt;

&lt;p&gt;if (!verifyHmacSignature(rawBody, signature)) {&lt;br&gt;
    console.error('❌ Invalid Webhook Signature. Rejecting payload.');&lt;br&gt;
    return res.status(401).json({ error: 'Invalid cryptographic signature' });&lt;br&gt;
  }&lt;/p&gt;

&lt;p&gt;const event = req.body;&lt;br&gt;
  console.log(&lt;code&gt;✅ Valid Webhook Received: ${event.eventType}&lt;/code&gt;, event.data);&lt;/p&gt;

&lt;p&gt;// Perform background task asynchronously or enqueue to SQS/Redis&lt;br&gt;
  // Acknowledge immediately with 200 OK&lt;br&gt;
  return res.status(200).json({ received: true });&lt;br&gt;
});&lt;/p&gt;

&lt;p&gt;app.listen(3000, () =&amp;gt; console.log('Webhook receiver running on port 3000'));&lt;br&gt;
Webhook Publisher Dispatch Logic&lt;br&gt;
Code example&lt;br&gt;
Copy code&lt;br&gt;
import axios from 'axios';&lt;br&gt;
import crypto from 'crypto';&lt;/p&gt;

&lt;p&gt;interface WebhookEvent {&lt;br&gt;
  id: string;&lt;br&gt;
  eventType: string;&lt;br&gt;
  timestamp: number;&lt;br&gt;
  data: Record;&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;async function dispatchWebhook(targetUrl: string, secret: string, payload: WebhookEvent) {&lt;br&gt;
  const jsonBody = JSON.stringify(payload);&lt;/p&gt;

&lt;p&gt;// Calculate SHA256 HMAC Signature&lt;br&gt;
  const signature = crypto&lt;br&gt;
    .createHmac('sha256', secret)&lt;br&gt;
    .update(jsonBody)&lt;br&gt;
    .digest('hex');&lt;/p&gt;

&lt;p&gt;try {&lt;br&gt;
    const response = await axios.post(targetUrl, jsonBody, {&lt;br&gt;
      headers: {&lt;br&gt;
        'Content-Type': 'application/json',&lt;br&gt;
        'X-Signature-256': &lt;code&gt;sha256=${signature}&lt;/code&gt;,&lt;br&gt;
        'User-Agent': 'MyApp-Webhook-Dispatcher/1.0',&lt;br&gt;
      },&lt;br&gt;
      timeout: 5000, // 5-second timeout safeguard&lt;br&gt;
    });&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;console.log(`Webhook delivered successfully. Status: ${response.status}`);
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;} catch (error: any) {&lt;br&gt;
    console.error(&lt;code&gt;Webhook delivery failed: ${error.message}. Schedule retry in worker queue.&lt;/code&gt;);&lt;br&gt;
    // Push to retry queue with exponential backoff&lt;br&gt;
  }&lt;br&gt;
}&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The Standardized Alternative: Verifying with the Standard Webhooks Spec
If you're building a new webhook integration in 2026, it's worth signing with the open Standard Webhooks specification instead of a bespoke header. It's a small but meaningful upgrade: instead of a single X-Signature-256 header, the receiver gets three standardized headers — webhook-id, webhook-timestamp, and webhook-signature — and the signed content is {webhook-id}.{webhook-timestamp}.{raw-body}, hashed with HMAC-SHA256 and base64-encoded with a v1, prefix. Verification libraries also enforce a timestamp tolerance (5 minutes by default) to reject replayed requests. Using a maintained SDK instead of hand-rolled crypto.createHmac calls means one less place to get constant-time comparison or replay protection wrong:&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Code example&lt;br&gt;
Copy code&lt;br&gt;
import express from 'express';&lt;br&gt;
import { Webhook } from 'standardwebhooks';&lt;/p&gt;

&lt;p&gt;const app = express();&lt;br&gt;
app.use(express.raw({ type: 'application/json' }));&lt;/p&gt;

&lt;p&gt;const wh = new Webhook(process.env.WEBHOOK_SECRET!); // e.g. "whsec_..."&lt;/p&gt;

&lt;p&gt;app.post('/webhooks/orders', (req, res) =&amp;gt; {&lt;br&gt;
  try {&lt;br&gt;
    const event = wh.verify(req.body, {&lt;br&gt;
      'webhook-id': req.headers['webhook-id'] as string,&lt;br&gt;
      'webhook-timestamp': req.headers['webhook-timestamp'] as string,&lt;br&gt;
      'webhook-signature': req.headers['webhook-signature'] as string,&lt;br&gt;
    });&lt;br&gt;
    console.log('✅ Verified event:', event);&lt;br&gt;
    res.status(200).json({ received: true });&lt;br&gt;
  } catch (err) {&lt;br&gt;
    console.error('❌ Signature verification failed');&lt;br&gt;
    res.status(401).json({ error: 'Invalid signature' });&lt;br&gt;
  }&lt;br&gt;
});&lt;br&gt;
Apollo Server &amp;amp; Webhooks: Bridging the Gap&lt;br&gt;
A common point of confusion is whether Apollo Server and webhooks can be combined into a single event architecture.&lt;/p&gt;

&lt;p&gt;In enterprise environments, Apollo Server often acts as a GraphQL Gateway or BFF (Backend-For-Frontend). In this role, it receives inbound webhooks from external SaaS vendors (Stripe, GitHub, Shopify) and relays those updates to frontend clients via GraphQL Subscriptions.&lt;/p&gt;

&lt;p&gt;Code example&lt;br&gt;
Copy code&lt;br&gt;
+-------------------+           HTTP POST Webhook          +-----------------------------+&lt;br&gt;
|  External SaaS    | -----------------------------------&amp;gt; | Inbound Webhook Endpoint    |&lt;br&gt;
| (Stripe / GitHub) |                                      | (Express / Serverless API)  |&lt;br&gt;
+-------------------+                                      +-----------------------------+&lt;br&gt;
                                                                         |&lt;br&gt;
                                                                         | Triggers Event&lt;br&gt;
                                                                         v&lt;br&gt;
+-------------------+       WebSocket / SSE Subscription   +-----------------------------+&lt;br&gt;
|  Frontend Client  | &amp;lt;----------------------------------- | Redis Pub/Sub Engine        |&lt;br&gt;
|  (React/Apollo)   |                                      | &amp;amp; Apollo Server Gateway     |&lt;br&gt;
+-------------------+                                      +-----------------------------+&lt;br&gt;
Pattern: Inbound Webhook to GraphQL Subscription Relay&lt;br&gt;
Ingestion endpoint: An Express endpoint or serverless function dedicated to handling incoming third-party webhooks (e.g., /api/webhooks/stripe).&lt;br&gt;
Payload validation: Authenticate the incoming request signature using the third party's secret.&lt;br&gt;
Publish to message bus: Push the validated event into a shared pub/sub channel (Redis, AWS EventBridge).&lt;br&gt;
Broadcast via subscriptions: The Apollo Server instance listening to that channel triggers a GraphQL Subscription update, pushing refined data to connected browser clients.&lt;br&gt;
This bridge pattern plays to each protocol's strengths: webhooks give you a resilient server-to-server integration boundary; subscriptions give you clean, client-driven real-time updates for active frontend sessions.&lt;/p&gt;

&lt;p&gt;If You're on Apollo Federation / GraphOS&lt;br&gt;
If your subscriptions need to span a federated graph, there are two things worth knowing that changed the picture recently:&lt;/p&gt;

&lt;p&gt;Federation version matters. Subscription operations require Apollo Federation 2.4 or later in your subgraph schemas — earlier Federation versions don't support them at all.&lt;br&gt;
The router talks two different protocols. The GraphOS Router communicates with your subgraphs using the graphql-transport-ws WebSocket protocol, but it typically serves clients over multipart HTTP responses rather than a client-facing WebSocket — so browser clients don't need a WebSocket library at all in that setup.&lt;br&gt;
Cloud routers are being retired. Apollo is discontinuing its GraphOS Serverless and Dedicated cloud-router plans (Serverless after February 1, 2026; Dedicated after March 15, 2026). If you're relying on a cloud router for subscription support, plan a migration to a self-hosted router well ahead of those dates.&lt;br&gt;
What's Changed Heading Into 2026&lt;br&gt;
A few developments are worth folding into how you think about this decision today:&lt;/p&gt;

&lt;p&gt;The WebSocket transport question is settled — mostly. graphql-ws is the de facto standard for GraphQL-over-WebSocket; subscriptions-transport-ws is archived and shouldn't be used in new projects. Some GraphQL federation runtimes (WunderGraph Cosmo, for example) now support WebSockets, Server-Sent Events, and multipart HTTP as interchangeable subscription transports, treating the choice as a deployment detail rather than a schema-level commitment.&lt;br&gt;
Serverless-native real-time is real now, not just a workaround. AWS AppSync Events (GA since March 2025) gives teams a managed WebSocket pub/sub layer without operating their own connection state, closing much of the historical gap between "needs persistent connections" and "wants to run on Lambda."&lt;br&gt;
Webhooks finally have a real standard. The Standard Webhooks specification — driven by Svix with a steering group that includes Zapier, Twilio, ngrok, and Supabase — defines the webhook-id / webhook-timestamp / webhook-signature header scheme described above. It's been adopted well beyond its original backers: OpenAI, Anthropic, and Google Gemini all sign their webhooks this way, alongside smaller platforms like Clerk and GrowthBook. If you're building a webhook publisher today, adopting the spec (or one of its open-source SDKs) instead of inventing your own header format is close to a free win for interoperability.&lt;br&gt;
Webhook infrastructure is now its own category. Dedicated platforms — Svix (outbound, multi-tenant), Hookdeck (inbound routing, replay, fan-out), and the open-source, self-hostable Convoy — exist specifically so teams don't have to build retry queues and dead-letter handling from scratch.&lt;br&gt;
Architectural Decision Matrix&lt;br&gt;
Choose GraphQL Subscriptions when:&lt;br&gt;
The consumer is a browser or mobile app: You need live UI updates — chat, collaborative editing, live tickers, notification bells.&lt;br&gt;
Clients need payload granularity: Bandwidth-constrained clients benefit from field-level selection instead of a fixed JSON blob.&lt;br&gt;
You already run a GraphQL ecosystem: Apollo Client, Relay, or Urql can ingest subscription updates directly into their existing cache/state layers.&lt;br&gt;
Connection count is predictable: Concurrent connections fit your server's memory budget, or you're using a managed real-time platform (AppSync Events, Ably, Pusher) instead of self-hosting sockets.&lt;br&gt;
Choose Webhooks when:&lt;br&gt;
Communication is backend-to-backend: Connecting microservices, integrating third-party SaaS, or triggering CI/CD.&lt;br&gt;
You run on serverless/edge compute: Lambda, Vercel, or Cloudflare Workers, where persistent connections are impractical.&lt;br&gt;
Guaranteed delivery matters more than instant delivery: Combined with a queue, webhooks give you at-least-once delivery even across receiver downtime.&lt;br&gt;
You need to absorb bursty traffic: Receivers can buffer into a background queue (BullMQ, SQS) to smooth spikes without dropping connections.&lt;br&gt;
Frequently Asked Questions (FAQ)&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;Is GraphQL over Server-Sent Events (SSE) better than WebSockets?&lt;br&gt;
For many web applications, yes, in specific ways. SSE runs over standard HTTP, making it easier to route through corporate firewalls, proxies, and load balancers, and the browser's EventSource API handles reconnection automatically. Its trade-off is that SSE is unidirectional (server-to-client only), while WebSockets support bidirectional messages over one connection. In practice, several GraphQL runtimes now let you choose either transport for the same schema rather than forcing a single choice up front.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;How do you handle authentication in GraphQL Subscriptions?&lt;br&gt;
Because standard browser WebSocket APIs don't support custom HTTP headers during the connection upgrade, auth is usually handled inside the protocol handshake payload. With graphql-ws, the client sends an auth token (a JWT, say) inside connectionParams during connection initialization, and the server validates it in the onConnect callback before accepting any subscription requests.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Can I use GraphQL Subscriptions for microservice-to-microservice communication?&lt;br&gt;
Technically possible, but it's generally considered an anti-pattern. Persistent WebSockets between backend nodes add coupling and connection-drop failure modes that internal services don't need. For internal sync, reach for webhooks, gRPC, or an event stream like Kafka or EventBridge instead.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;What happens if a receiver is down when a webhook fires?&lt;br&gt;
A well-designed publisher queues the event and retries with exponential backoff rather than dropping it. Stripe is a useful concrete example: on a delivery failure it retries immediately, then again after roughly 5 minutes, 30 minutes, 2 hours, 5 hours, and 10 hours, then every 12 hours after that, for up to 3 days total — after which it disables the endpoint and notifies you. Exact schedules vary by provider, but "retry with growing delays, then eventually dead-letter or disable" is the near-universal pattern.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;What is the Standard Webhooks specification, and should I use it?&lt;br&gt;
It's an open specification for signing and structuring webhook deliveries consistently across providers, so a receiver can verify signatures from any conforming sender using one SDK instead of custom logic per integration. It defines three headers (webhook-id, webhook-timestamp, webhook-signature), an HMAC-SHA256 signing scheme, and built-in replay protection via timestamp tolerance. It's worth adopting for new webhook publishers — it costs little and several major API providers already sign this way.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Conclusion&lt;br&gt;
Choosing between GraphQL Subscriptions and Webhooks isn't about which technology is newer or "better" — it's about which protocol matches your system's architecture and network boundaries.&lt;/p&gt;

&lt;p&gt;Use GraphQL Subscriptions for real-time, client-facing experiences over WebSockets or SSE, where you want field-level query control and tight integration with your existing GraphQL client cache.&lt;br&gt;
Use Webhooks as the backbone for scalable, resilient backend-to-backend integrations, backed by stateless HTTP, cryptographic signatures (ideally Standard Webhooks in new builds), and durable retry infrastructure.&lt;br&gt;
Used together — subscriptions for the last mile to the browser, webhooks for everything crossing a system boundary — you get an event-driven architecture that's scalable, cost-effective, and easier to reason about than forcing one pattern to do both jobs.&lt;/p&gt;

&lt;p&gt;Further Reading&lt;br&gt;
graphql-ws (the actively maintained WebSocket transport)&lt;br&gt;
Apollo: subscriptions-transport-ws is deprecated&lt;br&gt;
AWS AppSync Events documentation&lt;br&gt;
Apollo GraphOS: GraphQL Subscriptions&lt;br&gt;
Standard Webhooks specification&lt;br&gt;
Stripe: webhook retry behavior&lt;/p&gt;

</description>
    </item>
    <item>
      <title>Protecting Webhook Endpoints from Replay Attacks (And Why Timestamps Aren't Enough)</title>
      <dc:creator>InstaWebhook</dc:creator>
      <pubDate>Mon, 21 Sep 2026 07:17:53 +0000</pubDate>
      <link>https://dev.to/instawebhook/protecting-webhook-endpoints-from-replay-attacks-and-why-timestamps-arent-enough-162a</link>
      <guid>https://dev.to/instawebhook/protecting-webhook-endpoints-from-replay-attacks-and-why-timestamps-arent-enough-162a</guid>
      <description>&lt;p&gt;API gateway webhook protection&lt;br&gt;
API replay attack prevention&lt;br&gt;
API security replay attack&lt;br&gt;
backend security webhooks&lt;br&gt;
cryptographic nonce webhooks&lt;br&gt;
distributed nonce cache&lt;br&gt;
distributed systems idempotency&lt;br&gt;
HMAC signature replay attack&lt;br&gt;
HMAC signature verification&lt;br&gt;
idempotency keys webhooks&lt;br&gt;
idempotent webhook handling&lt;br&gt;
microservice webhook security&lt;br&gt;
nonce cache implementation&lt;br&gt;
preventing double crediting webhooks&lt;br&gt;
preventing duplicate transactions webhooks&lt;br&gt;
prevent webhook replay attacks&lt;br&gt;
redis distributed caching&lt;br&gt;
redis for webhook security&lt;br&gt;
redis key expiration nonce&lt;br&gt;
redis nonce cache&lt;br&gt;
redis webhook idempotency&lt;br&gt;
replay attack prevention&lt;br&gt;
secure webhook architecture&lt;br&gt;
secure webhook endpoint design&lt;br&gt;
secure webhook verification&lt;br&gt;
securing API webhooks&lt;br&gt;
single use execution webhooks&lt;br&gt;
timestamp validation webhooks&lt;br&gt;
webhook attack mitigation&lt;br&gt;
webhook attack vector analysis&lt;br&gt;
webhook attack vectors&lt;br&gt;
webhook authentication methods&lt;br&gt;
webhook duplicate payload protection&lt;br&gt;
webhook endpoint protection&lt;br&gt;
webhook event security&lt;br&gt;
webhook listener security&lt;br&gt;
webhook message integrity&lt;br&gt;
webhook nonce cache&lt;br&gt;
webhook payload verification&lt;br&gt;
webhook replay attack&lt;br&gt;
webhook replay prevention redis&lt;br&gt;
webhook replay vulnerability&lt;br&gt;
webhook request validation&lt;br&gt;
webhook security architecture&lt;br&gt;
webhook security best practices&lt;br&gt;
webhook security checklist&lt;br&gt;
webhook security engineering&lt;br&gt;
webhook security guidelines&lt;br&gt;
webhook security headers&lt;br&gt;
webhook security redis&lt;br&gt;
webhook security tutorial&lt;br&gt;
webhook signature verification&lt;br&gt;
webhooks timestamp nonce&lt;br&gt;
webhook threat modeling&lt;br&gt;
webhook timestamp validity window&lt;br&gt;
16xr Ealg4&lt;br&gt;
Protecting Webhook Endpoints from Replay Attacks (And Why Timestamps Aren't Enough)&lt;br&gt;
Last updated: September 21, 2026&lt;/p&gt;

&lt;p&gt;Webhook signatures and timestamps stop forged and stale requests, but neither stops someone from resending a genuine, freshly signed request many times inside the tolerance window. This guide explains why, shows how the major providers handle it, and walks through a tested Node.js + Redis implementation that guarantees single-use processing.&lt;/p&gt;

&lt;p&gt;Table of Contents&lt;br&gt;
The Hidden Gap in Webhook Verification&lt;br&gt;
What Is a Webhook Replay Attack?&lt;br&gt;
Why HMAC Signatures Still Pass on a Replay&lt;br&gt;
What Real Providers Sign (and Don't)&lt;br&gt;
Why Timestamps Aren't Enough&lt;br&gt;
Two Different Windows: Replays vs. Provider Retries&lt;br&gt;
Architecture: A Distributed Webhook Nonce Cache&lt;br&gt;
Production Code: Express.js + Redis&lt;br&gt;
Testing Your Replay Protection&lt;br&gt;
Adapting the Pattern to Stripe, GitHub and Shopify&lt;br&gt;
Edge Cases and Resilience&lt;br&gt;
Webhook Hardening Checklist&lt;br&gt;
References&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The Hidden Gap in Webhook Verification
Webhooks are how modern systems talk to each other in real time: Stripe tells you a payment succeeded, GitHub tells you a push happened, Shopify tells you an order was created. Because a webhook endpoint is just a public HTTP URL, anyone can send it a request, so every consumer has to verify that a request is genuine.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The standard recipe has two parts:&lt;/p&gt;

&lt;p&gt;HMAC signature verification proves the payload came from someone holding the shared secret and wasn't modified.&lt;br&gt;
Timestamp verification proves the request is recent, typically within a five-minute tolerance, so an old capture can't be replayed next week.&lt;br&gt;
Together these stop forged messages, tampered payloads, and stale replays. They do not stop this:&lt;/p&gt;

&lt;p&gt;An attacker (or a buggy proxy) obtains one valid signed request and sends it 500 times within the next few minutes.&lt;/p&gt;

&lt;p&gt;Every copy carries a correct signature and a fresh-enough timestamp. Unless your application deduplicates, each copy triggers its side effect: a duplicate credit, a second shipment, an extra refund, another provisioned resource.&lt;/p&gt;

&lt;p&gt;The fix is a nonce cache: remember the unique ID of every webhook you've accepted and reject any ID you've seen before. This article explains why that layer is needed, how to build it correctly with Redis, and where the popular "just cache the ID for five minutes" advice falls short.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;What Is a Webhook Replay Attack?
A replay attack happens when someone captures a valid webhook request and resends it to your endpoint without modifying it.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Code example&lt;br&gt;
Copy code&lt;br&gt;
+------------------+        captured request        +-------------------+&lt;br&gt;
| Webhook Provider | ------------------------------&amp;gt; |  Attacker / Bot   |&lt;br&gt;
+------------------+   (logs, proxies, debug tools)  +-------------------+&lt;br&gt;
        |                                                     |&lt;br&gt;
        | original delivery                                   | repeated deliveries&lt;br&gt;
        v                                                     v&lt;br&gt;
+--------------------------------------------------------------------------+&lt;br&gt;
|                            Your Webhook Endpoint                         |&lt;br&gt;
|   1. Signature valid      [PASS]                                         |&lt;br&gt;
|   2. Timestamp in window  [PASS]                                         |&lt;br&gt;
|   3. Business logic       [RUNS ONCE PER COPY]                           |&lt;br&gt;
+--------------------------------------------------------------------------+&lt;br&gt;
Unlike a classic man-in-the-middle attack, nothing is decrypted or altered. The attacker doesn't need your secret, and doesn't need to understand the payload. The replayed request is byte-for-byte identical to the original, so it passes every cryptographic check.&lt;/p&gt;

&lt;p&gt;How do attackers get a valid request?&lt;br&gt;
If your endpoint uses HTTPS, on-path eavesdropping is not the realistic route. (The Standard Webhooks specification points out that signatures provide authenticity, not confidentiality, which is why HTTPS is still required.) Captured requests usually leak from the edges of your own infrastructure:&lt;/p&gt;

&lt;p&gt;Logs and observability tools that record raw headers and bodies: log aggregators, error trackers, APM tools, API gateway access logs.&lt;br&gt;
Reverse proxies, gateways, and WAFs that archive request bodies for debugging or inspection.&lt;br&gt;
Staging and development environments that receive copies of production webhook traffic with weaker access controls.&lt;br&gt;
Request inspectors and tunnels used during development, plus payloads pasted into tickets and chat threads.&lt;br&gt;
Compromised CI jobs or developer machines that hold recorded requests.&lt;br&gt;
Hooklistener's Stripe webhook security guide describes the same pattern: a logging sidecar archives raw payloads, and someone later re-sends one to double-credit an account.&lt;/p&gt;

&lt;p&gt;Not every replay is malicious, either. Providers deliberately retry deliveries, and networks occasionally duplicate requests. Stripe's documentation states plainly that an endpoint may receive the same event more than once. A nonce cache protects you from both hostile and accidental duplicates.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Why HMAC Signatures Still Pass on a Replay
To see why a valid signature can't prevent replays, look at how signing works. In the Standard Webhooks scheme (an open specification whose 1.0.0 version defines the headers webhook-id, webhook-timestamp, and webhook-signature), the provider builds a string from three parts joined by full stops, and signs it with HMAC-SHA256:&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Code example&lt;br&gt;
Copy code&lt;br&gt;
signed_content = msg_id + "." + timestamp + "." + raw_body&lt;br&gt;
signature      = base64( HMAC-SHA256(secret, signed_content) )&lt;br&gt;
header value   = "v1," + signature&lt;br&gt;
A delivery looks like this (example values):&lt;/p&gt;

&lt;p&gt;Code example&lt;br&gt;
Copy code&lt;br&gt;
POST /webhooks/payments HTTP/1.1&lt;br&gt;
Host: api.example.com&lt;br&gt;
Content-Type: application/json&lt;br&gt;
webhook-id: msg_2KWPBgLlAfxdpx2AI54pPJ85f4W&lt;br&gt;
webhook-timestamp: 1674087231&lt;br&gt;
webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=&lt;/p&gt;

&lt;p&gt;{"type":"payment.succeeded","data":{"id":"pay_123","amount":1000}}&lt;br&gt;
Your server re-computes the HMAC from the raw body and the two headers, and compares. If they match, the message is authentic and unmodified.&lt;/p&gt;

&lt;p&gt;The blind spot: an HMAC proves who sent a message and that it wasn't altered. It is stateless, so it says nothing about how many times you've already acted on it. If an attacker resends the captured bytes five seconds later, the secret is the same, the body is the same, the headers are the same, and the computed HMAC matches perfectly. Cryptographically, it is a valid message, because it is one.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;What Real Providers Sign (and Don't)
Every provider invented its own scheme before Standard Webhooks, and the differences matter for replay protection:&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Provider    Signature header    What's signed   Signed timestamp?   Identifier to dedupe on&lt;br&gt;
Standard Webhooks   webhook-signature (v1,) id.timestamp.body   Yes webhook-id&lt;br&gt;
Stripe  Stripe-Signature (t=…,v1=…) timestamp.body  Yes (official libraries default to a 5-minute tolerance)    event.id in the body&lt;br&gt;
Slack   X-Slack-Signature (v0=)    v0:timestamp:body   Yes (X-Slack-Request-Timestamp; docs use a 5-minute check)  An ID from the payload, where the payload type provides one&lt;br&gt;
GitHub  X-Hub-Signature-256 (sha256=)  body only   No  X-GitHub-Delivery (GUID)&lt;br&gt;
Shopify X-Shopify-Hmac-SHA256 (base64)  raw body only   No (none in the documented scheme)  X-Shopify-Webhook-Id&lt;br&gt;
Two takeaways:&lt;/p&gt;

&lt;p&gt;Providers that sign a timestamp (Standard Webhooks, Stripe, Slack) give you a bounded replay window. After roughly five minutes, a captured request is worthless.&lt;br&gt;
Providers that sign only the body (GitHub, Shopify) give you no built-in freshness at all. Their HMAC alone can't tell a request from ten seconds ago from one captured last year. For these providers, delivery-ID deduplication isn't a nice-to-have; it is the only replay defense you have, and the ID store has to be durable (Shopify's docs explicitly tell you to check a persistent store).&lt;br&gt;
Also note that, according to Hookdeck's guide, OpenAI's webhooks follow Standard Webhooks with the same five-minute timestamp tolerance, so the pattern in this article applies to a growing number of APIs.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Why Timestamps Aren't Enough
Timestamp validation compares the request's timestamp to your clock:&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Code example&lt;br&gt;
Copy code&lt;br&gt;
const now = Math.floor(Date.now() / 1000);&lt;br&gt;
const requestTimestamp = Number(req.get('webhook-timestamp'));&lt;br&gt;
const TOLERANCE_SECONDS = 300; // 5 minutes&lt;/p&gt;

&lt;p&gt;if (Math.abs(now - requestTimestamp) &amp;gt; TOLERANCE_SECONDS) {&lt;br&gt;
  // reject: too old (or too far in the future)&lt;br&gt;
}&lt;br&gt;
Because the timestamp is part of the signed content, an attacker can't "freshen" a captured request. Change the timestamp and the signature no longer matches. Stripe's documentation describes exactly this mechanism, and it does close the long-tail replay: something captured yesterday is useless today.&lt;/p&gt;

&lt;p&gt;But look at what the check actually guarantees: that the request is recent. It does not guarantee the request is unique. A five-minute window is 300 seconds, and automated scripts can send many requests per second, so even a modest rate turns one captured delivery into hundreds of duplicates before the timestamp expires.&lt;/p&gt;

&lt;p&gt;Threat scenario: the high-speed replay&lt;br&gt;
An e-commerce platform receives a payment.succeeded webhook for order #8492.&lt;/p&gt;

&lt;p&gt;Code example&lt;br&gt;
Copy code&lt;br&gt;
T = 0s      Provider delivers the event; your server processes it correctly.&lt;br&gt;
T = 1s      A debug proxy in front of a staging environment logs the raw request.&lt;br&gt;
T = 2s      An attacker who can read those logs starts re-sending it.&lt;br&gt;
T = 2s..299s  Every copy:&lt;br&gt;
                signature  -&amp;gt; VALID  (identical bytes)&lt;br&gt;
                timestamp  -&amp;gt; VALID  (still inside 300s)&lt;br&gt;
                result     -&amp;gt; your side effect runs again&lt;br&gt;
T = 300s    Timestamp check finally starts rejecting the copies.&lt;br&gt;
The Svix documentation on replay attacks makes the same point: the tolerance window still leaves a few minutes in which a captured message can be replayed successfully, and the specification's recommended remedy is to track the webhook-id as an idempotency key.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Two Different Windows: Replays vs. Provider Retries
Most guides collapse two separate problems into one. This distinction is what determines how long you must remember an ID.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A replay is a copy of an already-delivered attempt. Its timestamp and signature are frozen, so it can only succeed until the timestamp leaves your tolerance window.&lt;/p&gt;

&lt;p&gt;A provider retry is a new attempt of the same event. The Standard Webhooks specification says the timestamp reflects the attempt (it changes on every retry) while the ID stays the same across all retries. Stripe behaves the same way: it generates a fresh timestamp and signature for each delivery attempt.&lt;/p&gt;

&lt;p&gt;Replay of a captured request    Legitimate provider retry&lt;br&gt;
Who sends it    Attacker, bot, or buggy proxy   The provider&lt;br&gt;
Timestamp / signature   Identical to the original   Fresh on every attempt&lt;br&gt;
ID  Same    Same&lt;br&gt;
How late can it arrive? Until the timestamp leaves the tolerance window (about 5 min)   As long as the provider's retry schedule runs&lt;br&gt;
Minimum ID retention    Tolerance + clock-skew buffer   The provider's full retry window&lt;br&gt;
The retry windows are long. Stripe retries live-mode deliveries for up to three days with exponential backoff. The Standard Webhooks spec's example schedule stretches to about 75 hours, and a hosted guide to OpenAI webhooks describes retries for up to 72 hours.&lt;/p&gt;

&lt;p&gt;Consequence: a Redis TTL of "tolerance + 30 seconds" (a figure you'll often see) is enough to stop replays, but if a provider retries an event after that key has expired, the retry arrives with a fresh timestamp, passes every check, and gets processed a second time.&lt;/p&gt;

&lt;p&gt;You have two good options, and the best setups use both:&lt;/p&gt;

&lt;p&gt;Keep processed IDs in Redis for longer than the provider's retry window (days, not minutes). The code in this article does this.&lt;br&gt;
Back the cache with a durable unique constraint in your database, so correctness never depends on cache retention.&lt;br&gt;
(The Standard Webhooks spec's own example of saving IDs "in redis for 5 minutes" is a reasonable minimum for replay protection alone; it is not designed to absorb multi-day retries.)&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Architecture: A Distributed Webhook Nonce Cache
A nonce ("number used once") is a unique identifier attached to a single message. In webhooks this is the event or delivery ID: webhook-id for Standard Webhooks, event.id for Stripe, X-GitHub-Delivery for GitHub, X-Shopify-Webhook-Id for Shopify.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If a provider gives you no ID at all, the fallback is to derive one by hashing the raw body together with the timestamp header. That catches byte-identical replays, but not provider retries (their timestamps differ), so treat it as a last resort.&lt;/p&gt;

&lt;p&gt;Why local in-memory storage fails&lt;br&gt;
A JavaScript Set or Go map inside your process is not enough in production:&lt;/p&gt;

&lt;p&gt;State isolation: requests are load-balanced across instances. Node A knows nothing about what Node B processed.&lt;br&gt;
Short lifecycle: serverless instances and rolling deploys erase local memory.&lt;br&gt;
Race conditions: two copies of the same request hitting different instances at the same moment both pass a local check.&lt;br&gt;
Why Redis is a good fit&lt;br&gt;
Atomic conditional write. SET key value NX EX seconds creates the key only if it doesn't already exist and sets its expiry, in one atomic step. Two simultaneous requests can never both succeed. (Redis has deprecated the older SETNX command since version 2.6.12 in favor of SET with the NX option.)&lt;br&gt;
Automatic expiry keeps memory bounded.&lt;br&gt;
Low latency. One round trip to a nearby Redis instance is typically about a millisecond, though this depends on your network.&lt;br&gt;
The request flow&lt;br&gt;
Code example&lt;br&gt;
Copy code&lt;br&gt;
Incoming webhook&lt;br&gt;
      |&lt;br&gt;
      v&lt;br&gt;
+---------------------------+&lt;br&gt;
| 1. Timestamp in window?   | -- no --&amp;gt; 400&lt;br&gt;
+---------------------------+&lt;br&gt;
      | yes&lt;br&gt;
      v&lt;br&gt;
+---------------------------+&lt;br&gt;
| 2. HMAC signature valid?  | -- no --&amp;gt; 401&lt;br&gt;
+---------------------------+&lt;br&gt;
      | yes&lt;br&gt;
      v&lt;br&gt;
+---------------------------+&lt;br&gt;
| 3. Redis: SET id          |&lt;br&gt;
|    'processing' NX EX 60  |&lt;br&gt;
+---------------------------+&lt;br&gt;
   |                    |&lt;br&gt;
   | key created        | key already exists&lt;br&gt;
   v                    v&lt;br&gt;
 Process event     state == 'done'       -&amp;gt; 200 (duplicate, ignored)&lt;br&gt;
   |               state == 'processing' -&amp;gt; 409 + Retry-After&lt;br&gt;
   |&lt;br&gt;
   +-- success --&amp;gt; SET id 'done' EX   -&amp;gt; 200&lt;br&gt;
   +-- failure --&amp;gt; DEL id                   -&amp;gt; 500 (provider will retry)&lt;br&gt;
Two design details are worth calling out because they're easy to get wrong:&lt;/p&gt;

&lt;p&gt;Verify the signature before touching Redis. Otherwise anyone on the internet can fill your cache with junk IDs.&lt;/p&gt;

&lt;p&gt;Don't burn the nonce before the work succeeds. A common implementation sets the key once and returns "duplicate" forever after. If your handler then crashes or your database hiccups, you return a 500, the provider retries with the same ID, and your endpoint replies "already processed" to an event that was never processed. The event is silently lost. The two-state pattern above (processing with a short TTL, then done with a long TTL, and delete on failure) avoids this.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Production Code: Express.js + Redis
The following implements the Standard Webhooks scheme (webhook-id, webhook-timestamp, webhook-signature) with a Redis nonce cache. It has been tested against a local Redis server, including a concurrent burst of 50 identical requests (exactly one is processed).&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Setup&lt;br&gt;
Code example&lt;br&gt;
Copy code&lt;br&gt;
npm install express ioredis&lt;br&gt;
Set "type": "module" in your package.json to use the import syntax below.&lt;/p&gt;

&lt;p&gt;Note: don't npm install crypto. crypto is a built-in Node.js module, and the package of that name on npm is a deprecated placeholder. Just import crypto from 'node:crypto'.&lt;/p&gt;

&lt;p&gt;Generate a signing secret in the Standard Webhooks format (the specification calls for 24 to 64 random bytes, base64-encoded, with a whsec_ prefix):&lt;/p&gt;

&lt;p&gt;Code example&lt;br&gt;
Copy code&lt;br&gt;
node -e "console.log('whsec_' + require('crypto').randomBytes(32).toString('base64'))"&lt;br&gt;
webhookHandler.js&lt;br&gt;
Code example&lt;br&gt;
Copy code&lt;br&gt;
import express from 'express';&lt;br&gt;
import crypto from 'node:crypto';&lt;br&gt;
import Redis from 'ioredis';&lt;/p&gt;

&lt;p&gt;const redis = new Redis(process.env.REDIS_URL ?? 'redis://localhost:6379');&lt;/p&gt;

&lt;p&gt;// --- Configuration -----------------------------------------------------------&lt;br&gt;
// Standard Webhooks secrets look like "whsec_"; the HMAC key is the&lt;br&gt;
// base64-decoded part after the prefix.&lt;br&gt;
const SECRET_BYTES = Buffer.from(&lt;br&gt;
  (process.env.WEBHOOK_SECRET ?? '').replace(/^whsec_/, ''),&lt;br&gt;
  'base64'&lt;br&gt;
);&lt;br&gt;
const TOLERANCE_SECONDS = 300;              // reject timestamps further than 5 min from now&lt;br&gt;
const CLOCK_SKEW_BUFFER_SECONDS = 30;&lt;br&gt;
const IN_FLIGHT_TTL_SECONDS = 60;           // how long one worker may "own" an event while processing&lt;br&gt;
const DONE_TTL_SECONDS = 4 * 24 * 60 * 60;  // must outlive the provider's retry schedule (see article)&lt;/p&gt;

&lt;p&gt;// --- Helpers -----------------------------------------------------------------&lt;br&gt;
function signatureMatches(headerValue, id, timestamp, rawBody) {&lt;br&gt;
  const expected = crypto&lt;br&gt;
    .createHmac('sha256', SECRET_BYTES)&lt;br&gt;
    .update(&lt;code&gt;${id}.${timestamp}.&lt;/code&gt;)&lt;br&gt;
    .update(rawBody)            // raw bytes: never re-serialize parsed JSON&lt;br&gt;
    .digest();&lt;/p&gt;

&lt;p&gt;// The header can carry several space-separated signatures (secret rotation).&lt;br&gt;
  // Only "v1," (HMAC-SHA256) is handled here; other schemes are ignored.&lt;br&gt;
  return headerValue.split(' ').some((part) =&amp;gt; {&lt;br&gt;
    const [version, b64] = part.split(',');&lt;br&gt;
    if (version !== 'v1' || !b64) return false;&lt;br&gt;
    const candidate = Buffer.from(b64, 'base64');&lt;br&gt;
    return candidate.length === expected.length &amp;amp;&amp;amp;&lt;br&gt;
           crypto.timingSafeEqual(candidate, expected);&lt;br&gt;
  });&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;// --- Middleware: signature + timestamp + nonce claim ----------------------------&lt;br&gt;
async function verifyWebhook(req, res, next) {&lt;br&gt;
  const id = req.get('webhook-id');&lt;br&gt;
  const timestamp = req.get('webhook-timestamp');&lt;br&gt;
  const signature = req.get('webhook-signature');&lt;/p&gt;

&lt;p&gt;if (!id || !timestamp || !signature || !Buffer.isBuffer(req.body) || req.body.length === 0) {&lt;br&gt;
    return res.status(400).json({ error: 'Missing webhook headers or body' });&lt;br&gt;
  }&lt;br&gt;
  if (!/^\d{1,12}$/.test(timestamp)) {&lt;br&gt;
    return res.status(400).json({ error: 'Invalid timestamp' });&lt;br&gt;
  }&lt;/p&gt;

&lt;p&gt;// Pillar 2: signed timestamp inside tolerance (both directions)&lt;br&gt;
  const now = Math.floor(Date.now() / 1000);&lt;br&gt;
  const ts = Number(timestamp);&lt;br&gt;
  if (Math.abs(now - ts) &amp;gt; TOLERANCE_SECONDS) {&lt;br&gt;
    return res.status(400).json({ error: 'Timestamp outside tolerance window' });&lt;br&gt;
  }&lt;/p&gt;

&lt;p&gt;// Pillar 1: HMAC signature (constant-time)&lt;br&gt;
  if (!signatureMatches(signature, id, timestamp, req.body)) {&lt;br&gt;
    return res.status(401).json({ error: 'Invalid signature' });&lt;br&gt;
  }&lt;/p&gt;

&lt;p&gt;// Pillar 3: atomic single-use claim in Redis. Only runs AFTER the signature&lt;br&gt;
  // is verified, so unauthenticated traffic can't fill the cache.&lt;br&gt;
  const key = &lt;code&gt;webhook:nonce:${id}&lt;/code&gt;;&lt;br&gt;
  try {&lt;br&gt;
    const claimed = await redis.set(key, 'processing', 'EX', IN_FLIGHT_TTL_SECONDS, 'NX');&lt;br&gt;
    if (claimed !== 'OK') {&lt;br&gt;
      const state = await redis.get(key);&lt;br&gt;
      if (state === 'done') {&lt;br&gt;
        // Already handled successfully: acknowledge so the sender stops retrying.&lt;br&gt;
        return res.status(200).json({ status: 'duplicate_ignored' });&lt;br&gt;
      }&lt;br&gt;
      // Another worker is processing this event right now. Ask the sender to retry later.&lt;br&gt;
      res.set('Retry-After', '30');&lt;br&gt;
      return res.status(409).json({ status: 'in_flight' });&lt;br&gt;
    }&lt;br&gt;
  } catch (err) {&lt;br&gt;
    // Fail closed: if the nonce store is down, don't process. The sender will retry.&lt;br&gt;
    console.error('Nonce store unavailable', err);&lt;br&gt;
    res.set('Retry-After', '30');&lt;br&gt;
    return res.status(503).json({ error: 'Temporarily unavailable' });&lt;br&gt;
  }&lt;/p&gt;

&lt;p&gt;req.webhook = { id, timestamp: ts, key };&lt;br&gt;
  next();&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;// --- Route -------------------------------------------------------------------&lt;br&gt;
const app = express();&lt;/p&gt;

&lt;p&gt;app.post(&lt;br&gt;
  '/webhooks/payments',&lt;br&gt;
  express.raw({ type: 'application/json', limit: '1mb' }), // must run before any JSON parser&lt;br&gt;
  verifyWebhook,&lt;br&gt;
  async (req, res) =&amp;gt; {&lt;br&gt;
    const { id, key } = req.webhook;&lt;br&gt;
    try {&lt;br&gt;
      const event = JSON.parse(req.body.toString('utf8'));&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  // Your business logic goes here. Make it idempotent as well, for example
  // INSERT ... ON CONFLICT (event_id) DO NOTHING inside the same DB transaction.
  await handleEvent(event);

  // Success: keep the id long enough to absorb provider retries and replays.
  await redis.set(key, 'done', 'EX', DONE_TTL_SECONDS);
  return res.status(200).json({ status: 'processed', id });
} catch (err) {
  console.error(`Processing failed for ${id}`, err);
  // Release the claim so the provider's next retry is allowed to run.
  await redis.del(key).catch(() =&amp;gt; {});
  return res.status(500).json({ error: 'Processing failed' });
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;}&lt;br&gt;
);&lt;/p&gt;

&lt;p&gt;async function handleEvent(event) {&lt;br&gt;
  // e.g. await db.payments.insertOnConflictDoNothing({ eventId: event.id, ... })&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;app.listen(3000, () =&amp;gt; console.log('Listening on :3000'));&lt;br&gt;
What this code does differently from typical examples&lt;br&gt;
Uses the real Standard Webhooks format. The signed content is id.timestamp.body, the header can contain several space-separated v1, signatures (needed for zero-downtime secret rotation), and the secret is base64-decoded after removing the whsec_ prefix.&lt;br&gt;
Compares raw bytes in constant time. crypto.timingSafeEqual on decoded buffers, after a length check, and the HMAC runs over the raw request Buffer, never over re-serialized JSON.&lt;br&gt;
Claims the nonce only after authentication, using SET ... NX EX for an atomic check-and-set.&lt;br&gt;
Two-state claim (processing then done) so failed work can be retried by the provider.&lt;br&gt;
Distinguishes duplicates from in-flight work. A completed duplicate gets a 200 so the provider stops retrying. A concurrent copy of something still being processed gets a 409 with Retry-After; the specification treats non-2xx responses as failures to be retried, so a legitimate retry is not lost.&lt;br&gt;
Fails closed. If Redis is unreachable, the endpoint returns 503 rather than processing without deduplication.&lt;br&gt;
Keeps DONE_TTL_SECONDS longer than the provider's retry schedule, as explained in section 6.&lt;br&gt;
Your business logic should still be idempotent on its own. For example, insert the event ID into a table with a unique constraint inside the same transaction as your state change:&lt;/p&gt;

&lt;p&gt;Code example&lt;br&gt;
Copy code&lt;br&gt;
INSERT INTO processed_events (event_id, processed_at)&lt;br&gt;
VALUES ($1, now())&lt;br&gt;
ON CONFLICT (event_id) DO NOTHING;&lt;br&gt;
-- if 0 rows were inserted, this event was already handled: skip the side effects&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Testing Your Replay Protection
Don't assume it works; try to break it. This script signs a webhook and sends it twice (adjust the URL and secret):&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Code example&lt;br&gt;
Copy code&lt;br&gt;
import crypto from 'node:crypto';&lt;/p&gt;

&lt;p&gt;const SECRET = process.env.WEBHOOK_SECRET; // whsec_...&lt;br&gt;
const key = Buffer.from(SECRET.replace(/^whsec_/, ''), 'base64');&lt;/p&gt;

&lt;p&gt;const id = &lt;code&gt;msg_${crypto.randomUUID()}&lt;/code&gt;;&lt;br&gt;
const body = JSON.stringify({ type: 'payment.succeeded', data: { id: 'pay_123' } });&lt;/p&gt;

&lt;p&gt;async function send() {&lt;br&gt;
  const ts = Math.floor(Date.now() / 1000);&lt;br&gt;
  const sig = crypto.createHmac('sha256', key).update(&lt;code&gt;${id}.${ts}.${body}&lt;/code&gt;).digest('base64');&lt;br&gt;
  const res = await fetch('&lt;a href="http://localhost:3000/webhooks/payments" rel="noopener noreferrer"&gt;http://localhost:3000/webhooks/payments&lt;/a&gt;', {&lt;br&gt;
    method: 'POST',&lt;br&gt;
    headers: {&lt;br&gt;
      'content-type': 'application/json',&lt;br&gt;
      'webhook-id': id,&lt;br&gt;
      'webhook-timestamp': String(ts),&lt;br&gt;
      'webhook-signature': &lt;code&gt;v1,${sig}&lt;/code&gt;,&lt;br&gt;
    },&lt;br&gt;
    body,&lt;br&gt;
  });&lt;br&gt;
  console.log(res.status, await res.text());&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;await send(); // 200 {"status":"processed", ...}&lt;br&gt;
await send(); // 200 {"status":"duplicate_ignored"}&lt;br&gt;
Also test these cases:&lt;/p&gt;

&lt;p&gt;Test    Expected result&lt;br&gt;
Same request, sent twice    Business logic runs once&lt;br&gt;
Body changed by one character, old signature    401&lt;br&gt;
Correct signature, timestamp 10 minutes old 400&lt;br&gt;
Handler throws, then the provider retries with the same ID  First 500, retry is processed&lt;br&gt;
50 identical requests fired concurrently    Exactly one processed&lt;br&gt;
Same ID, new timestamp and signature (simulated retry)  Treated as a duplicate&lt;br&gt;
Redis stopped   503, nothing processed&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Adapting the Pattern to Stripe, GitHub and Shopify
The claim, process, mark-done flow is provider-independent. Only two things change: how you verify the signature, and where you get the ID.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Stripe. Use the official library for verification (it enforces the 5-minute tolerance by default), then dedupe on event.id:&lt;/p&gt;

&lt;p&gt;Code example&lt;br&gt;
Copy code&lt;br&gt;
const event = stripe.webhooks.constructEvent(&lt;br&gt;
  req.body,                      // raw Buffer&lt;br&gt;
  req.get('stripe-signature'),&lt;br&gt;
  endpointSecret&lt;br&gt;
);&lt;br&gt;
const key = &lt;code&gt;webhook:stripe:${event.id}&lt;/code&gt;;&lt;br&gt;
// ...then reuse the same SET NX -&amp;gt; process -&amp;gt; mark done flow&lt;br&gt;
Stripe's documentation adds a subtle caveat: in some cases two separate Event objects are generated for the same underlying occurrence, so event-ID dedupe alone won't catch them. For those, combine the ID of the object in data.object with the event type. Stripe also warns that events are not guaranteed to arrive in the order they were generated, and that you shouldn't use the created timestamp to decide whether you've already handled an event. Track IDs instead.&lt;/p&gt;

&lt;p&gt;GitHub. GitHub signs the body only (X-Hub-Signature-256, HMAC-SHA256 hex with a sha256= prefix) and includes no timestamp among its delivery headers, so there's no built-in replay window at all. Use X-GitHub-Delivery as the ID and keep it in a durable store, not a short-TTL cache.&lt;/p&gt;

&lt;p&gt;Shopify. Verify the base64 HMAC in X-Shopify-Hmac-SHA256 over the raw body, and dedupe on X-Shopify-Webhook-Id. Shopify's documentation recommends checking a persistent store for that ID and skipping the delivery if it exists. Note that when you have multiple subscriptions for the same topic, each delivery gets a different webhook ID but shares the same X-Shopify-Event-Id.&lt;/p&gt;

&lt;p&gt;For every provider, namespace your keys (webhook:::) so IDs from different sources or tenants can never collide.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Edge Cases and Resilience&lt;/li&gt;
&lt;li&gt;Redis failure modes: fail closed or fail open?
If Redis is unreachable you must choose:&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Fail closed (the code above): reject with 503. Providers retry, so events aren't lost, and an attacker can't exploit an outage to replay freely. Best when duplicates cost money.&lt;br&gt;
Fail open: log a security alert, fall back to a database unique constraint, and process. Best when downtime is costlier than the small risk of a duplicate.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Redis is not a durable ledger
Redis replication is asynchronous by default. The primary acknowledges a write before it reaches replicas, so if it fails in that gap, a promoted replica may not have your latest nonce keys. The Redis documentation notes that the WAIT command can request acknowledgement from a number of replicas, but it explicitly does not make Redis a strongly consistent system: acknowledged writes can still be lost during failover depending on your persistence configuration. A restart without persistence loses the entire cache too.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Practical takeaway: treat Redis as the fast first line of defense, and put a unique constraint in your database as the source of truth. That combination survives failovers, flushes, and TTL mistakes.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;Slow handlers and lock expiry&lt;br&gt;
The processing state expires after IN_FLIGHT_TTL_SECONDS. If your handler runs longer than that, a duplicate could start while the first is still running. Stripe and Shopify both recommend returning 2xx quickly and doing the real work asynchronously. A robust pattern is to verify, claim the ID, enqueue the job (using the event ID as the job ID), and respond; the queue then owns retries. The Standard Webhooks spec suggests senders use request timeouts of 15 to 30 seconds, so stay well inside that.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Clock drift&lt;br&gt;
If your server's clock drifts from the provider's, legitimate requests fail the timestamp check. Run NTP (or chrony) on every host and container node, and alert on drift. Stripe's documentation recommends NTP for exactly this reason. Never "fix" drift by disabling the recency check: Stripe explicitly warns that a tolerance of 0 turns the check off entirely.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Raw body pitfalls&lt;br&gt;
Parsing JSON before verifying the signature is the classic bug. Different parsers order keys, escape Unicode, and handle whitespace differently, so an HMAC over JSON.stringify(req.body) will fail intermittently or always. The Standard Webhooks spec calls out this exact failure mode, and Stripe and Shopify both warn about it. Register express.raw() on the webhook route before any global express.json().&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Code example&lt;br&gt;
Copy code&lt;br&gt;
// WRONG: consumes and re-serializes the body before verification&lt;br&gt;
app.use(express.json());&lt;/p&gt;

&lt;p&gt;// RIGHT: keep the raw Buffer on webhook routes&lt;br&gt;
app.post('/webhooks/payments', express.raw({ type: 'application/json' }), handler);&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;Secret rotation&lt;br&gt;
Design verification to accept multiple signatures and multiple secrets from day one. Standard Webhooks senders can include several space-separated signatures during rotation, and Stripe lets you keep the previous secret active for up to 24 hours while it signs with both.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Don't leak the material attackers need&lt;br&gt;
Since logs and proxies are the most common source of captured requests, scrub the signature headers and bodies of webhook requests from access logs and error trackers, and keep any retained copies short-lived and access-controlled.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Ordering is a separate problem&lt;br&gt;
A nonce cache prevents duplicates. It doesn't fix ordering: Stripe explicitly doesn't guarantee events arrive in the order they were generated. Fetch current state from the provider's API, or compare version numbers, rather than assuming order.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Webhook Hardening Checklist&lt;br&gt;
HTTPS only. Signatures prove authenticity, not confidentiality.&lt;br&gt;
Raw body preserved for signature computation.&lt;br&gt;
Constant-time comparison (crypto.timingSafeEqual or equivalent).&lt;br&gt;
Signed timestamp with a tolerance window (5 minutes is the common default), never disabled.&lt;br&gt;
Signature verified before any cache or database write.&lt;br&gt;
Atomic nonce claim (SET key value NX EX ttl) on the event or delivery ID.&lt;br&gt;
Two-state claim: release the key on failure so provider retries aren't swallowed.&lt;br&gt;
ID retention longer than the provider's retry window (days, not minutes), or a durable database constraint.&lt;br&gt;
Database unique constraint on event ID as the source of truth.&lt;br&gt;
Return 2xx fast, and process asynchronously through a queue.&lt;br&gt;
Fail-closed or fail-open decision documented for Redis outages.&lt;br&gt;
NTP running on all nodes, with clock-drift alerts.&lt;br&gt;
Secret rotation supported (multiple signatures and secrets).&lt;br&gt;
Per-endpoint secrets stored in a secrets manager, never in source control.&lt;br&gt;
Signed request material scrubbed from logs and error trackers.&lt;br&gt;
Provider IP allowlisting where the provider publishes ranges (Stripe does), as an additional layer next to signature verification, not a replacement for it.&lt;br&gt;
Conclusion&lt;br&gt;
Signature verification proves who sent a webhook and that it wasn't altered. A signed timestamp limits how long a captured request stays useful. Neither tells you whether you've already acted on the message, and inside the tolerance window a replay is indistinguishable from the original.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Closing that gap takes a nonce cache: verify the signature, atomically claim the event ID, do the work, and mark it done, with failure releasing the claim. Get the retention right (long enough to absorb provider retries, not just replays), back it with a database constraint, and fail in the direction your business can tolerate. That is defense in depth against duplicate transactions, corrupted state, and abused resources, whether the duplicate came from an attacker or from a well-meaning retry.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;References
Standard Webhooks specification v1.0.0: signature scheme, headers, timestamp vs. ID semantics, retry schedule, HTTPS guidance
Stripe: Receive Stripe events in your webhook endpoint: signature verification, replay prevention, retries, duplicate events, ordering
GitHub Docs: Webhook events and payloads (delivery headers) and Validating webhook deliveries
Shopify: Verify webhook deliveries: HMAC header and duplicate detection
Slack: Verifying requests from Slack: v0 signature and timestamp check
Redis: SET command and SETNX (deprecated)
Redis: Replication: asynchronous replication and the WAIT command
Svix: What is a replay attack?
Hooklistener: Stripe webhook security guide: threat model for captured payloads
Hookdeck: Guide to OpenAI webhooks
npm: crypto (deprecated placeholder for the built-in module)
Provider behavior changes over time. Re-check each provider's current documentation before relying on specific defaults such as tolerance windows or retry schedules.&lt;/li&gt;
&lt;/ol&gt;

</description>
    </item>
    <item>
      <title>Taming "At-Least-Once" Delivery: Idempotent Webhook Ingestion in PostgreSQL</title>
      <dc:creator>InstaWebhook</dc:creator>
      <pubDate>Sun, 20 Sep 2026 14:20:08 +0000</pubDate>
      <link>https://dev.to/instawebhook/taming-at-least-once-delivery-idempotent-webhook-ingestion-in-postgresql-52a9</link>
      <guid>https://dev.to/instawebhook/taming-at-least-once-delivery-idempotent-webhook-ingestion-in-postgresql-52a9</guid>
      <description>&lt;p&gt;API webhooks tutorial&lt;br&gt;
build a webhook provider&lt;br&gt;
building a webhook system&lt;br&gt;
building SaaS webhooks&lt;br&gt;
custom webhook integration&lt;br&gt;
developer API design&lt;br&gt;
developer tools webhook&lt;br&gt;
emitting webhooks&lt;br&gt;
event-driven architecture&lt;br&gt;
event notifications API&lt;br&gt;
GitHub webhook design&lt;br&gt;
HMAC webhook signing&lt;br&gt;
how to build webhook infrastructure&lt;br&gt;
how to send webhooks&lt;br&gt;
InstaWebhook&lt;br&gt;
microservices webhooks&lt;br&gt;
outgoing webhooks&lt;br&gt;
publishing webhooks&lt;br&gt;
reliable webhook delivery&lt;br&gt;
REST API webhook design&lt;br&gt;
SaaS API design&lt;br&gt;
SaaS webhook architecture&lt;br&gt;
scalable webhook architecture&lt;br&gt;
sending webhooks to customers&lt;br&gt;
Stripe webhook design&lt;br&gt;
webhook API design&lt;br&gt;
webhook architecture&lt;br&gt;
webhook authentication&lt;br&gt;
webhook best practices&lt;br&gt;
webhook delivery engine&lt;br&gt;
webhook delivery system&lt;br&gt;
webhook developer guide&lt;br&gt;
webhook dispatch engine&lt;br&gt;
webhook dispatch queue&lt;br&gt;
webhook endpoint security&lt;br&gt;
webhook event types&lt;br&gt;
webhook exponential backoff&lt;br&gt;
webhook failure handling&lt;br&gt;
webhook headers best practices&lt;br&gt;
webhook idempotency&lt;br&gt;
webhook implementation guide&lt;br&gt;
webhook infrastructure&lt;br&gt;
webhook monitoring&lt;br&gt;
webhook notification system&lt;br&gt;
webhook payload design&lt;br&gt;
webhook payload encryption&lt;br&gt;
webhook payload schema&lt;br&gt;
webhook payload security&lt;br&gt;
webhook payload versioning&lt;br&gt;
webhook provider tutorial&lt;br&gt;
webhook queue system&lt;br&gt;
webhook rate limiting&lt;br&gt;
webhook receiver vs provider&lt;br&gt;
webhook retry logic&lt;br&gt;
webhook secret rotation&lt;br&gt;
webhook security&lt;br&gt;
webhook security best practices&lt;br&gt;
webhook signature headers&lt;br&gt;
webhook signature verification&lt;br&gt;
webhook worker architecture&lt;br&gt;
Taming At Least Once Delivery Idempotent Webhook Ingestion In Postgre SQL&lt;br&gt;
Taming "At-Least-Once" Delivery: Idempotent Webhook Ingestion in PostgreSQL&lt;br&gt;
Webhooks are how payment processors, commerce platforms and card issuers keep your system in sync with theirs. Stripe tells you a payment succeeded, Shopify tells you an order was created, Marqeta tells you a card transaction happened.&lt;/p&gt;

&lt;p&gt;Every one of those providers works under the same unspoken contract: at-least-once delivery. If a network timeout, a worker crash or a 5xx response leaves the sender unsure whether you received an event, it sends the event again. Sometimes the duplicate arrives milliseconds later, sometimes days later.&lt;/p&gt;

&lt;p&gt;Code example&lt;br&gt;
Copy code&lt;br&gt;
+----------------+            +-------------------+            +---------------+&lt;br&gt;
| Webhook sender |            | Application node  |            | PostgreSQL    |&lt;br&gt;
+----------------+            +-------------------+            +---------------+&lt;br&gt;
        |                               |                              |&lt;br&gt;
        |--- Event A (attempt 1) ------&amp;gt;|--- write order record ------&amp;gt;|&lt;br&gt;
        |    (response times out)       |                              |&lt;br&gt;
        |                               |                              |&lt;br&gt;
        |--- Event A (attempt 2) ------&amp;gt;|--- duplicate write attempt! -&amp;gt;|&lt;br&gt;
Naive ingestion turns that into charged-twice customers, inventory that goes negative, and reports that quietly drift away from reality.&lt;/p&gt;

&lt;p&gt;This guide shows how to push deduplication down into PostgreSQL, where it can be enforced atomically: an inbox table with INSERT ... ON CONFLICT DO NOTHING, conditional upserts for out-of-order events, advisory locks and FOR UPDATE SKIP LOCKED for contention, a comparison with MERGE, isolation-level behaviour, and a transactional outbox for side effects. PostgreSQL 18 is the current major release. The SQL in this article was run against PostgreSQL 16.15; the few snippets that need PostgreSQL 18 or 19 are labelled and were not executed.&lt;/p&gt;

&lt;p&gt;What providers actually promise&lt;br&gt;
Before designing anything, read the delivery contract of the provider you integrate with. The details differ, and some of them change what you build. The table below reflects vendor documentation as of September 2026.&lt;/p&gt;

&lt;p&gt;Stripe  Shopify Marqeta&lt;br&gt;
Retries Up to 3 days with exponential backoff in live mode; 3 attempts over a few hours in a sandbox    8 retries over 4 hours; 5-second response timeout   10 retries, exponential backoff by powers of four (4 s, 16 s, 64 s, ...) reaching just over 12 days&lt;br&gt;
Ordering    Not guaranteed  Not guaranteed  Not stated, so assume it is not guaranteed&lt;br&gt;
Deduplicate on  Event id (see caveat below) X-Shopify-Webhook-Id    Identifiers in each notification (messages can be batched)&lt;br&gt;
Manual replay   Dashboard: up to 15 days; CLI: up to 30 days    Not documented  Not documented&lt;br&gt;
A few details that matter for the rest of this article:&lt;/p&gt;

&lt;p&gt;Stripe occasionally generates two separate Event objects for the same underlying change. In that case the event ID differs, and Stripe's own guidance is to combine the ID of the object in data.object with event.type to spot the duplicate. Stripe also says not to use the event's created timestamp to order events or detect duplicates, because it has one-second resolution and distinct events can share a value.&lt;br&gt;
Shopify has changed its numbers over time. Current documentation says failed deliveries are retried 8 times over 4 hours, and that a subscription created through the Admin API is deleted after 8 consecutive failures. Older blog posts still quote "19 retries over 48 hours". Its docs also distinguish two headers: X-Shopify-Webhook-Id identifies a delivery and is the one to deduplicate on, while X-Shopify-Event-Id is shared by deliveries to different subscriptions for the same underlying event. Shopify also recommends periodic reconciliation jobs, because delivery is not guaranteed.&lt;br&gt;
Marqeta batches up to 10 notifications of the same type into a single HTTP message, so "one request equals one event" is the wrong mental model. Deduplicate per notification, not per request.&lt;br&gt;
Three design consequences follow:&lt;/p&gt;

&lt;p&gt;Your deduplication memory has to outlive the longest window in which the provider can redeliver: 3 days of automatic retries plus up to 30 days of manual replay for Stripe, a little over 12 days for Marqeta.&lt;br&gt;
You can never assume events arrive in order.&lt;br&gt;
A duplicate can be an HTTP retry, a manual resend, or a second logically identical event, so keep uniqueness at more than one level (more on that in section 2).&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The check-then-act anti-pattern
A handler written the obvious way looks like this:&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Code example&lt;br&gt;
Copy code&lt;/p&gt;

&lt;h1&gt;
  
  
  ANTI-PATTERN: do not use in production
&lt;/h1&gt;

&lt;p&gt;def handle_webhook(payload):&lt;br&gt;
    event_id = payload["id"]&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# 1. CHECK
existing = db.query("SELECT id FROM processed_events WHERE id = %s", event_id)

if not existing:
    # 2. ACT
    process_business_logic(payload)
    db.execute("INSERT INTO processed_events (id) VALUES (%s)", event_id)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;Now suppose the same event is delivered twice at the same moment, for example because of a sender-side retry racing a slow response. Two workers pick them up:&lt;/p&gt;

&lt;p&gt;Code example&lt;br&gt;
Copy code&lt;br&gt;
Worker A                                   Worker B&lt;br&gt;
  |--- SELECT ... FROM processed_events       |&lt;br&gt;
  |    (0 rows)                               |--- SELECT ... FROM processed_events&lt;br&gt;
  |                                           |    (0 rows)&lt;br&gt;
  |--- process_business_logic()               |--- process_business_logic()&lt;br&gt;
  |    (creates order #1001)                  |    (creates order #1001 AGAIN)&lt;br&gt;
  |--- INSERT INTO processed_events           |--- INSERT INTO processed_events&lt;br&gt;
  |    (succeeds)                             |    (unique violation, too late)&lt;br&gt;
PostgreSQL's default isolation level is READ COMMITTED, so Worker B cannot see Worker A's uncommitted insert. Both workers read "nothing there", both run the business logic, and only then does the unique constraint fire. Wrapping the check and the act in a single transaction does not change this: at READ COMMITTED each statement sees only data committed before that statement began, so both transactions can read the pre-commit state. This is a classic time-of-check to time-of-use (TOCTOU) race.&lt;/p&gt;

&lt;p&gt;Notice that the unique constraint did its job, since only one marker row exists. The bug is one of ordering: the constraint was consulted after the side effects had already happened. The fix is to make the constraint the gate that runs first, and to make the claim and the work commit or roll back together.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The event inbox: claim first, work second
The Event Inbox pattern lands every incoming event in a dedicated table before anything else happens.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Code example&lt;br&gt;
Copy code&lt;br&gt;
CREATE TABLE webhook_inbox (&lt;br&gt;
    provider     VARCHAR(64)  NOT NULL,&lt;br&gt;
    event_id     VARCHAR(255) NOT NULL,&lt;br&gt;
    event_type   VARCHAR(128) NOT NULL,&lt;br&gt;
    payload      JSONB        NOT NULL,&lt;br&gt;
    status       VARCHAR(16)  NOT NULL DEFAULT 'pending',  -- pending | done | failed&lt;br&gt;
    received_at  TIMESTAMPTZ  NOT NULL DEFAULT NOW(),&lt;br&gt;
    processed_at TIMESTAMPTZ,&lt;br&gt;
    PRIMARY KEY (provider, event_id)&lt;br&gt;
);&lt;/p&gt;

&lt;p&gt;CREATE INDEX webhook_inbox_pending_idx&lt;br&gt;
    ON webhook_inbox (received_at) WHERE status = 'pending';&lt;br&gt;
CREATE INDEX webhook_inbox_received_at_idx&lt;br&gt;
    ON webhook_inbox (received_at);   -- used by the retention job in section 9&lt;br&gt;
Claiming an event is a single atomic statement:&lt;/p&gt;

&lt;p&gt;Code example&lt;br&gt;
Copy code&lt;br&gt;
INSERT INTO webhook_inbox (provider, event_id, event_type, payload)&lt;br&gt;
VALUES ('shopify', 'a1b2c3', 'orders/create', '{"order_id": 1001, "total": "49.99"}'::jsonb)&lt;br&gt;
ON CONFLICT (provider, event_id) DO NOTHING&lt;br&gt;
RETURNING event_id;&lt;br&gt;
If the key is new, the row is inserted and event_id comes back. If the key already exists, the conflict is swallowed and the statement returns zero rows. That empty result is your "duplicate" signal.&lt;/p&gt;

&lt;p&gt;Code example&lt;br&gt;
Copy code&lt;br&gt;
def process_incoming_webhook(provider: str, event_id: str, event_type: str, payload: dict):&lt;br&gt;
    with db.transaction() as tx:&lt;br&gt;
        claimed = tx.execute("""&lt;br&gt;
            INSERT INTO webhook_inbox (provider, event_id, event_type, payload)&lt;br&gt;
            VALUES (%s, %s, %s, %s)&lt;br&gt;
            ON CONFLICT (provider, event_id) DO NOTHING&lt;br&gt;
            RETURNING event_id;&lt;br&gt;
        """, (provider, event_id, event_type, json.dumps(payload))).fetchone()&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;    if claimed is None:
        log.info("duplicate webhook %s:%s, skipping", provider, event_id)
        return {"status": "ignored", "reason": "duplicate"}

    apply_business_logic(tx, payload)   # same transaction as the claim
    return {"status": "processed"}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;Because the claim and the business writes share one transaction, a crash before COMMIT rolls back both. The sender sees a failed delivery, retries, and the retry claims the event afresh.&lt;/p&gt;

&lt;p&gt;What happens when two workers race&lt;br&gt;
When two transactions try to claim the same key at the same moment, the second one does not fail and does not proceed. It waits for the first transaction to finish. If the first commits, the second statement returns zero rows. If the first rolls back, the second inserts the row and carries on. Try it with two psql sessions: hold the first transaction open with pg_sleep(3) and the second INSERT blocks for about that long, then returns (0 rows).&lt;/p&gt;

&lt;p&gt;That is the correct behaviour, but it has a practical consequence: anything slow inside that transaction makes every duplicate wait too. Keep the claim transaction short, or use the receive-then-process variant below.&lt;/p&gt;

&lt;p&gt;Receive fast, process later&lt;br&gt;
Providers want a quick response. Shopify times out after 5 seconds, and Stripe tells you to return a 2xx before running any complex logic. A robust shape is to split ingestion into two steps:&lt;/p&gt;

&lt;p&gt;Code example&lt;br&gt;
Copy code&lt;br&gt;
def receive(provider, event_id, event_type, payload):&lt;br&gt;
    verify_signature(request)          # before touching the database&lt;br&gt;
    with db.transaction() as tx:&lt;br&gt;
        tx.execute("""&lt;br&gt;
            INSERT INTO webhook_inbox (provider, event_id, event_type, payload)&lt;br&gt;
            VALUES (%s, %s, %s, %s)&lt;br&gt;
            ON CONFLICT (provider, event_id) DO NOTHING;&lt;br&gt;
        """, (provider, event_id, event_type, json.dumps(payload)))&lt;br&gt;
    return 200                          # durable and deduplicated; work happens later&lt;br&gt;
A background worker then claims rows from the inbox (see SKIP LOCKED in section 5) and does the real work. The HTTP handler stays fast and the deduplication guarantee is unchanged.&lt;/p&gt;

&lt;p&gt;Batched deliveries&lt;br&gt;
For providers that batch, such as Marqeta, insert the whole batch in one statement and process only what actually got inserted:&lt;/p&gt;

&lt;p&gt;Code example&lt;br&gt;
Copy code&lt;br&gt;
INSERT INTO webhook_inbox (provider, event_id, event_type, payload)&lt;br&gt;
SELECT * FROM unnest($1::text[], $2::text[], $3::text[], $4::jsonb[])&lt;br&gt;
ON CONFLICT (provider, event_id) DO NOTHING&lt;br&gt;
RETURNING event_id, payload;&lt;br&gt;
Rows that were already in the table, and rows repeated inside the same batch, are silently skipped, and RETURNING gives you only the new ones.&lt;/p&gt;

&lt;p&gt;Two layers of idempotency&lt;br&gt;
Event-level deduplication answers "have I seen this delivery?". It does not answer "has this business effect already happened?". Because a provider can emit two distinct events for one change, back the inbox with a business-level constraint as well, for example a unique key on the external payment ID in your payments table. If you ever bypass or purge the inbox, the domain tables still refuse to double-apply.&lt;/p&gt;

&lt;p&gt;"Exactly once" is really "effectively once"&lt;br&gt;
Inside one database transaction you get effectively-once behaviour. Anything outside the database (an email, a call to a third-party API, a Kafka publish) cannot be rolled back with it, so those side effects remain at-least-once. Section 8 shows how to hand them off safely.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Atomic domain upserts: ON CONFLICT DO UPDATE
Sometimes the webhook is a state snapshot rather than a discrete event, for example a customer profile change, an inventory level or an order status. There, INSERT ... ON CONFLICT DO UPDATE is the tool:&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Code example&lt;br&gt;
Copy code&lt;br&gt;
CREATE TABLE orders (&lt;br&gt;
    order_id     VARCHAR(128) PRIMARY KEY,&lt;br&gt;
    customer_id  VARCHAR(128)   NOT NULL,&lt;br&gt;
    status       VARCHAR(64)    NOT NULL,&lt;br&gt;
    total_amount NUMERIC(12, 2) NOT NULL,&lt;br&gt;
    updated_at   TIMESTAMPTZ    NOT NULL DEFAULT NOW()&lt;br&gt;
);&lt;/p&gt;

&lt;p&gt;INSERT INTO orders (order_id, customer_id, status, total_amount, updated_at)&lt;br&gt;
VALUES ('ord_555', 'cust_999', 'processing', 120.00, NOW())&lt;br&gt;
ON CONFLICT (order_id) DO UPDATE&lt;br&gt;
SET status       = EXCLUDED.status,&lt;br&gt;
    total_amount = EXCLUDED.total_amount,&lt;br&gt;
    updated_at   = EXCLUDED.updated_at;&lt;br&gt;
EXCLUDED is a pseudo-table holding the row you proposed for insertion, so you never need to bind the same values twice. The PostgreSQL documentation guarantees that, barring an unrelated error, DO UPDATE yields either an insert or an update, even under high concurrency.&lt;/p&gt;

&lt;p&gt;Four gotchas worth knowing:&lt;/p&gt;

&lt;p&gt;Avoid no-op writes. Every DO UPDATE that fires writes a new row version, even if nothing changed. Duplicate webhooks therefore create dead tuples and WAL. Add a guard so identical replays do nothing:&lt;/p&gt;

&lt;p&gt;Code example&lt;br&gt;
Copy code&lt;br&gt;
... DO UPDATE SET status = EXCLUDED.status, total_amount = EXCLUDED.total_amount&lt;br&gt;
WHERE (orders.status, orders.total_amount)&lt;br&gt;
      IS DISTINCT FROM (EXCLUDED.status, EXCLUDED.total_amount);&lt;br&gt;
A key can appear only once per statement. If a single multi-row INSERT ... DO UPDATE proposes the same key twice, PostgreSQL raises ON CONFLICT DO UPDATE command cannot affect row a second time. Deduplicate the batch first, keeping the newest row per key.&lt;/p&gt;

&lt;p&gt;Conflicts consume sequence values. A DO NOTHING that skips a row still advanced any serial or identity column, so you will see gaps. Harmless, but do not use such IDs for gapless numbering.&lt;/p&gt;

&lt;p&gt;PostgreSQL 19 adds DO SELECT. Version 19 was in beta at the time of writing (beta 3 shipped on 13 August 2026, with the final release expected in autumn 2026). ON CONFLICT DO SELECT ... RETURNING returns the existing row on conflict without modifying it, which removes the need for the no-op DO UPDATE workaround. Check that 19 has shipped, and that your managed provider supports it, before relying on it.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Out-of-order delivery: version guards
Duplicates are only half the story. Suppose a shipped update at 12:00:00 and a delivered update at 12:00:05 arrive in reverse order. A blind SET status = EXCLUDED.status lets the old event overwrite the newer one.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Code example&lt;br&gt;
Copy code&lt;br&gt;
Event timeline:                     Arrival at the database:&lt;br&gt;
  12:00:00  status = shipped          1. delivered arrives -&amp;gt; row = delivered&lt;br&gt;
  12:00:05  status = delivered        2. shipped arrives   -&amp;gt; row = shipped   (state regression)&lt;br&gt;
Attach a monotonic version to the row and let the upsert refuse to go backwards:&lt;/p&gt;

&lt;p&gt;Code example&lt;br&gt;
Copy code&lt;br&gt;
CREATE TABLE orders (&lt;br&gt;
    order_id          VARCHAR(128) PRIMARY KEY,&lt;br&gt;
    customer_id       VARCHAR(128)   NOT NULL,&lt;br&gt;
    status            VARCHAR(64)    NOT NULL,&lt;br&gt;
    total_amount      NUMERIC(12, 2) NOT NULL,&lt;br&gt;
    source_updated_at TIMESTAMPTZ    NOT NULL&lt;br&gt;
);&lt;/p&gt;

&lt;p&gt;INSERT INTO orders AS o (order_id, customer_id, status, total_amount, source_updated_at)&lt;br&gt;
VALUES ('ord_555', 'cust_999', 'shipped', 120.00, '2026-09-20T12:00:00Z')&lt;br&gt;
ON CONFLICT (order_id) DO UPDATE&lt;br&gt;
SET customer_id       = EXCLUDED.customer_id,&lt;br&gt;
    status            = EXCLUDED.status,&lt;br&gt;
    total_amount      = EXCLUDED.total_amount,&lt;br&gt;
    source_updated_at = EXCLUDED.source_updated_at&lt;br&gt;
WHERE o.source_updated_at &amp;lt; EXCLUDED.source_updated_at&lt;br&gt;
RETURNING order_id;&lt;br&gt;
Scenario    Stored source_updated_at    Incoming    Result&lt;br&gt;
New record  none    12:00:00    Inserted, row returned&lt;br&gt;
Stale or duplicate event    12:00:05    12:00:00    WHERE is false, update skipped, no row returned&lt;br&gt;
Newer event 12:00:05    12:00:10    Update applied, row returned&lt;br&gt;
The PostgreSQL docs are explicit that RETURNING only reports rows that were actually inserted or updated. If the row was locked but the WHERE failed, nothing is returned. An empty result therefore means "this event was stale, ignore it".&lt;/p&gt;

&lt;p&gt;Telling an insert from an update&lt;br&gt;
Sometimes you want to know which branch ran.&lt;/p&gt;

&lt;p&gt;PostgreSQL 18 and later can do this with documented syntax. RETURNING accepts OLD and NEW, and for a plain insert every old value is NULL:&lt;/p&gt;

&lt;p&gt;Code example&lt;br&gt;
Copy code&lt;br&gt;
... RETURNING order_id, (old.order_id IS NULL) AS was_inserted;   -- PostgreSQL 18+&lt;br&gt;
Earlier versions rely on a well-known implementation detail: a freshly inserted row has xmax = 0:&lt;/p&gt;

&lt;p&gt;Code example&lt;br&gt;
Copy code&lt;br&gt;
... RETURNING order_id, (xmax = 0) AS was_inserted;               -- PostgreSQL 15-17&lt;br&gt;
This is not a documented interface, so treat it as a convenience and cover it with a test. Note that the xmin = 0 variant that circulates in some tutorials is wrong: xmin is the inserting transaction's ID and is never zero for a real row, so the expression is always false. On PostgreSQL 16 an inserted row returns xmax = 0 as true and xmin = 0 as false.&lt;/p&gt;

&lt;p&gt;Where does the version come from?&lt;br&gt;
A guard is only as good as its version, and not every provider gives you a trustworthy one:&lt;/p&gt;

&lt;p&gt;Stripe does not guarantee delivery order and tells you not to order by created. Its recommended approach is to treat the event as a hint and retrieve the current object from the API, for example fetching the invoice or subscription when invoice.paid shows up before the events you expected.&lt;br&gt;
Shopify does not guarantee order and suggests using the X-Shopify-Triggered-At header or the payload's updated_at to sequence events.&lt;br&gt;
If the payload carries a real revision counter, prefer it over a timestamp. Timestamps can tie, and with &amp;lt; a tie is treated as stale. That is the right default for identical replays, but decide deliberately.&lt;br&gt;
Snapshots versus deltas&lt;br&gt;
Version guards suit snapshot payloads, where the newest event fully replaces the state. They do not suit delta payloads such as "add 5 to inventory" or "deposit 10". You cannot skip a delta because a later one arrived first. For deltas, record every event once in the inbox (or a ledger table) and derive state from the set of events, which is what section 8 does.&lt;/p&gt;

&lt;p&gt;Finally, no amount of clever SQL replaces a periodic reconciliation job that re-fetches recent objects from the provider's API. Shopify explicitly recommends this because webhook delivery is not guaranteed.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Locking strategies under load
During a flash sale or a provider replay after an outage, hundreds of duplicates can land at once. Three mechanisms cover the common cases.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Code example&lt;br&gt;
Copy code&lt;br&gt;
                    Inbound webhook execution modes&lt;br&gt;
                                  |&lt;br&gt;
          +-----------------------+------------------------+&lt;br&gt;
          |                                                |&lt;br&gt;
  Heavy work per event                              Queue of pending events&lt;br&gt;
  Advisory lock (try, don't wait)                   FOR UPDATE SKIP LOCKED&lt;br&gt;
5.1 The unique index does the heavy lifting&lt;br&gt;
With ON CONFLICT, contention on one key is simply serialized: the first transaction wins, the rest wait and then take the conflict path. This is correct, and cheap for short transactions. The cost of many workers stacking up on the same duplicate is time and held connections, not wrong results. It becomes a problem only when the transaction holding the key is slow.&lt;/p&gt;

&lt;p&gt;5.2 Advisory locks for expensive work&lt;br&gt;
If handling an event involves heavy computation or external lookups before the first write, you would rather have duplicate deliveries bail out immediately than queue up behind the first. Advisory locks are application-defined locks on a 64-bit key (or two 32-bit keys); PostgreSQL does not tie them to any table row.&lt;/p&gt;

&lt;p&gt;pg_try_advisory_xact_lock attempts a transaction-scoped lock without waiting and returns false if someone else holds it. The lock is released automatically at commit or rollback.&lt;/p&gt;

&lt;p&gt;Code example&lt;br&gt;
Copy code&lt;br&gt;
import hashlib&lt;/p&gt;

&lt;p&gt;def advisory_key(provider: str, event_id: str) -&amp;gt; int:&lt;br&gt;
    # 64-bit signed integer derived from SHA-256, stable across processes and versions&lt;br&gt;
    digest = hashlib.sha256(f"{provider}:{event_id}".encode()).digest()&lt;br&gt;
    return int.from_bytes(digest[:8], "big", signed=True)&lt;/p&gt;

&lt;p&gt;def process_heavy_webhook(provider, event_id, payload):&lt;br&gt;
    with db.transaction() as tx:&lt;br&gt;
        got_lock = tx.execute(&lt;br&gt;
            "SELECT pg_try_advisory_xact_lock(%s)", (advisory_key(provider, event_id),)&lt;br&gt;
        ).scalar()&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;    if not got_lock:
        # Another worker is on this exact event right now.
        return {"status": "busy"}

    claimed = tx.execute("""
        INSERT INTO webhook_inbox (provider, event_id, event_type, payload)
        VALUES (%s, %s, %s, %s)
        ON CONFLICT (provider, event_id) DO NOTHING
        RETURNING event_id
    """, (provider, event_id, payload["type"], json.dumps(payload))).fetchone()

    if claimed is None:
        return {"status": "ignored"}

    apply_business_logic(tx, payload)
    return {"status": "processed"}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;Points to get right:&lt;/p&gt;

&lt;p&gt;Key size. hashtext() returns only 32 bits, so collisions are realistic at scale. A collision makes two different events contend for one lock, which costs a spurious "busy" but never a double-process. hashtextextended(text, seed) gives 64 bits in SQL (it exists since PostgreSQL 11, but it is an internal function rather than a documented one). Hashing in the application, as above, avoids depending on it.&lt;br&gt;
Connection poolers. Transaction-level advisory locks are safe with PgBouncer in transaction pooling mode, because the lock lives exactly as long as the transaction. Session-level advisory locks (pg_advisory_lock) are not: the lock stays on a server connection that PgBouncer may hand to someone else.&lt;br&gt;
Do not hold a transaction open across slow external calls. It pins a connection and can hold back vacuum. If the heavy work is a third-party API call, do it outside the transaction, guarded by an idempotency key, and keep the database transaction for the claim and the state change.&lt;br&gt;
Choose the "busy" response carefully. Returning a non-2xx status invites a retry, which is what you want if the other worker might crash. But repeated failures count against you: Shopify deletes an Admin-API subscription after 8 consecutive failed deliveries. The receive-then-process design in section 2 sidesteps this.&lt;br&gt;
5.3 Queue workers with FOR UPDATE SKIP LOCKED&lt;br&gt;
If you buffer events in a table and let several workers drain it, plain SELECT ... FOR UPDATE makes them line up behind whichever row is locked first. SKIP LOCKED lets each worker take rows nobody else has locked:&lt;/p&gt;

&lt;p&gt;Code example&lt;br&gt;
Copy code&lt;br&gt;
CREATE TABLE webhook_queue (&lt;br&gt;
    id         BIGSERIAL PRIMARY KEY,&lt;br&gt;
    status     VARCHAR(16) NOT NULL DEFAULT 'pending',&lt;br&gt;
    locked_at  TIMESTAMPTZ,&lt;br&gt;
    attempts   INT NOT NULL DEFAULT 0,&lt;br&gt;
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()&lt;br&gt;
);&lt;/p&gt;

&lt;p&gt;WITH next AS (&lt;br&gt;
    SELECT id&lt;br&gt;
    FROM webhook_queue&lt;br&gt;
    WHERE status = 'pending'&lt;br&gt;
    ORDER BY created_at, id&lt;br&gt;
    FOR UPDATE SKIP LOCKED&lt;br&gt;
    LIMIT 10&lt;br&gt;
)&lt;br&gt;
UPDATE webhook_queue q&lt;br&gt;
SET status = 'processing', locked_at = NOW(), attempts = attempts + 1&lt;br&gt;
FROM next&lt;br&gt;
WHERE q.id = next.id&lt;br&gt;
RETURNING q.*;&lt;br&gt;
In a two-session test, a worker holding rows 1 and 2 in an open transaction leaves them alone, and a second worker immediately receives rows 3 and 4.&lt;/p&gt;

&lt;p&gt;Two caveats:&lt;/p&gt;

&lt;p&gt;The PostgreSQL documentation warns that skipping locked rows gives an inconsistent view of the data, so it is not suitable for general-purpose queries. It is explicitly intended for queue-like tables with multiple consumers.&lt;br&gt;
Flipping a row to processing means a crashed worker leaves it stuck there. Run a reaper that returns rows to pending when locked_at is too old, and cap attempts so a poison message ends up in a failed state rather than looping forever.&lt;br&gt;
For short jobs there is a simpler variant that needs no reaper: keep the row lock for the duration of the work. SELECT ... FROM webhook_inbox WHERE status = 'pending' ORDER BY received_at FOR UPDATE SKIP LOCKED LIMIT 1, do the work, UPDATE ... SET status = 'done', processed_at = NOW(), commit. If the worker dies, the lock disappears with the connection and the row is simply picked up again. The trade-off is one open transaction per in-flight event.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;ON CONFLICT versus MERGE
PostgreSQL 15 added the standard SQL MERGE command, and people reasonably ask whether it should replace ON CONFLICT for idempotent ingestion. For webhooks, mostly no.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;INSERT ... ON CONFLICT  MERGE (PostgreSQL 15+)&lt;br&gt;
Designed for    Atomic insert-or-update against a unique index  Set-based synchronization with several WHEN MATCHED / WHEN NOT MATCHED branches, including DELETE&lt;br&gt;
Two sessions insert the same new key    Handled: the second waits, then takes the conflict path Can fail with a unique violation. The docs say MERGE does not fall back to an UPDATE when a concurrent insert wins&lt;br&gt;
RETURNING   Yes (since 9.5); PostgreSQL 18 adds OLD / NEW   No in 15 and 16; added in 17, with merge_action() to tell you which branch ran&lt;br&gt;
Needs a unique index    Yes, an arbiter unique index or constraint  No, it joins on any condition&lt;br&gt;
You can watch the difference yourself. Open two sessions on PostgreSQL 16, and in each run a MERGE that inserts the same absent key WHEN NOT MATCHED. Keep the first transaction open for a few seconds. When it commits, the second MERGE fails with duplicate key value violates unique constraint. Replace both statements with INSERT ... ON CONFLICT DO NOTHING and the second one returns zero rows instead. On PostgreSQL 16, adding RETURNING to a MERGE is a syntax error.&lt;/p&gt;

&lt;p&gt;MERGE is a fine choice for batch synchronization from a staging table, or where you need conditional deletes. If you use it for ingestion, be ready to catch unique_violation and retry, or serialize writers yourself. For single-key idempotent writes, ON CONFLICT remains the better fit.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Transaction isolation
Everything above assumes PostgreSQL's default, READ COMMITTED. It is worth knowing what changes if your connection or framework uses a stricter level.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Take the same race as before, but let the second transaction start (and take its snapshot) before the first one inserts and commits:&lt;/p&gt;

&lt;p&gt;Under READ COMMITTED, the second INSERT ... ON CONFLICT DO NOTHING sees the committed conflict and returns zero rows.&lt;br&gt;
Under REPEATABLE READ (and SERIALIZABLE), the same statement fails with could not serialize access due to concurrent update (SQLSTATE 40001), because the conflicting row is not visible to the transaction's snapshot.&lt;br&gt;
Higher isolation levels are legitimate, but the PostgreSQL docs are clear that applications using them must be prepared to retry transactions that hit serialization failures. For the inbox claim, READ COMMITTED plus a unique key is the simplest correct choice. If you run stricter, wrap the whole handler in a retry loop on SQLSTATE 40001, and remember that the retry re-reads the world and will now see the duplicate.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The complete pattern: inbox plus transactional outbox
Idempotent database writes cover everything that lives in PostgreSQL. To trigger external side effects (publishing to Kafka, notifying Slack, calling another service) without losing or duplicating them, combine the inbox with the transactional outbox: write the "please publish this" record in the same transaction as the state change, and let a separate relay deliver it.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Code example&lt;br&gt;
Copy code&lt;br&gt;
-- 1. Inbox: see section 2 (webhook_inbox)&lt;/p&gt;

&lt;p&gt;-- 2. Business entity&lt;br&gt;
CREATE TABLE accounts (&lt;br&gt;
    account_id VARCHAR(128) PRIMARY KEY,&lt;br&gt;
    balance    NUMERIC(14, 2) NOT NULL DEFAULT 0.00,&lt;br&gt;
    updated_at TIMESTAMPTZ    NOT NULL DEFAULT NOW()&lt;br&gt;
);&lt;/p&gt;

&lt;p&gt;-- 3. Outbox&lt;br&gt;
CREATE TABLE transactional_outbox (&lt;br&gt;
    id             BIGSERIAL PRIMARY KEY,&lt;br&gt;
    aggregate_type VARCHAR(64)  NOT NULL,&lt;br&gt;
    aggregate_id   VARCHAR(128) NOT NULL,&lt;br&gt;
    payload        JSONB        NOT NULL,&lt;br&gt;
    created_at     TIMESTAMPTZ  NOT NULL DEFAULT NOW(),&lt;br&gt;
    processed_at   TIMESTAMPTZ&lt;br&gt;
);&lt;/p&gt;

&lt;p&gt;CREATE INDEX outbox_unprocessed_idx&lt;br&gt;
    ON transactional_outbox (id) WHERE processed_at IS NULL;&lt;br&gt;
Code example&lt;br&gt;
Copy code&lt;br&gt;
CREATE OR REPLACE FUNCTION process_deposit_webhook(&lt;br&gt;
    p_provider   TEXT,&lt;br&gt;
    p_event_id   TEXT,&lt;br&gt;
    p_account_id TEXT,&lt;br&gt;
    p_amount     NUMERIC&lt;br&gt;
) RETURNS JSONB LANGUAGE plpgsql AS $$&lt;br&gt;
BEGIN&lt;br&gt;
    -- Step 1: claim the event&lt;br&gt;
    INSERT INTO webhook_inbox (provider, event_id, event_type, payload, status, processed_at)&lt;br&gt;
    VALUES (&lt;br&gt;
        p_provider, p_event_id, 'account.deposit',&lt;br&gt;
        jsonb_build_object('account_id', p_account_id, 'amount', p_amount),&lt;br&gt;
        'done', NOW()&lt;br&gt;
    )&lt;br&gt;
    ON CONFLICT (provider, event_id) DO NOTHING;&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;IF NOT FOUND THEN
    RETURN jsonb_build_object('status', 'duplicate');   -- no side effects
END IF;

-- Step 2: apply the state change
UPDATE accounts
SET balance = balance + p_amount, updated_at = NOW()
WHERE account_id = p_account_id;

IF NOT FOUND THEN
    RAISE EXCEPTION 'Account % not found', p_account_id;   -- rolls back the claim too
END IF;

-- Step 3: record the external side effect
INSERT INTO transactional_outbox (aggregate_type, aggregate_id, payload)
VALUES ('account', p_account_id,
        jsonb_build_object('event', 'balance_updated',
                           'account_id', p_account_id,
                           'deposit_amount', p_amount));

RETURN jsonb_build_object('status', 'processed');
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;END;&lt;br&gt;
$$;&lt;br&gt;
Running it twice with the same event ID returns processed once and duplicate the second time, and the balance changes only once. Calling it for a missing account raises, and because the claim is in the same transaction it leaves no inbox row behind, so a later retry can succeed once the account exists.&lt;/p&gt;

&lt;p&gt;A deposit is a delta, not a snapshot, which is why this function relies on the inbox for correctness instead of a version guard.&lt;/p&gt;

&lt;p&gt;A relay process then drains the outbox. It uses the same SKIP LOCKED technique so several relays can run in parallel:&lt;/p&gt;

&lt;p&gt;Code example&lt;br&gt;
Copy code&lt;br&gt;
WITH batch AS (&lt;br&gt;
    SELECT id FROM transactional_outbox&lt;br&gt;
    WHERE processed_at IS NULL&lt;br&gt;
    ORDER BY id&lt;br&gt;
    FOR UPDATE SKIP LOCKED&lt;br&gt;
    LIMIT 100&lt;br&gt;
)&lt;br&gt;
SELECT o.* FROM transactional_outbox o JOIN batch USING (id);&lt;br&gt;
-- publish each message, then:&lt;br&gt;
-- UPDATE transactional_outbox SET processed_at = NOW() WHERE id = ANY($1);&lt;br&gt;
The relay itself is at-least-once: if it crashes after publishing but before marking a row done, the message goes out again. So consumers must be idempotent too, for instance by deduplicating on the outbox id or a stable message key. The pattern moves the guarantee from "never lose, never duplicate" to "never lose, tolerate duplicates cheaply", which is the achievable one.&lt;/p&gt;

&lt;p&gt;Do not turn permanent errors into endless retries&lt;br&gt;
The function above raises for an unknown account. That is fine for a transient condition (the account row may simply not exist yet), but for a permanently bad event it means the provider keeps retrying until it gives up, and some providers penalize endpoints that fail repeatedly (Shopify deletes the subscription after 8 consecutive failures). For errors that will never succeed, store the event with status = 'failed' and the reason, return 2xx, and alert a human.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Retention and pruning
The inbox grows forever unless you trim it. Pick a retention period longer than the longest window in which your provider can redeliver: with Stripe's manual resend reaching back 30 days, 45 to 90 days is a defensible choice. Deleting keys removes protection against replays older than the retention window.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Be careful with time-based partitioning as the way to drop old data. PostgreSQL requires that a primary key or unique constraint on a partitioned table include all the partition key columns, because each partition's index can only enforce uniqueness within itself. If you partition the inbox by received_at, the primary key becomes (provider, event_id, received_at), and the database can no longer stop the same event ID being inserted into two different partitions. A duplicate that arrives after a month boundary would slip through.&lt;/p&gt;

&lt;p&gt;Two safer options:&lt;/p&gt;

&lt;p&gt;Keep the inbox unpartitioned and delete in small batches using the index on received_at, for example DELETE ... WHERE ctid IN (SELECT ctid FROM webhook_inbox WHERE received_at &amp;lt; NOW() - INTERVAL '60 days' LIMIT 5000) in a loop.&lt;br&gt;
If you need partition-drop speed, hash-partition on (provider, event_id) instead, which keeps uniqueness enforceable, and accept batch deletes for expiry.&lt;br&gt;
Production checklist&lt;br&gt;
 Verify the provider's signature before writing anything.&lt;br&gt;
 Deduplicate on the provider's documented ID (Stripe event id plus data.object ID and type for the twin-event case; Shopify X-Shopify-Webhook-Id), per notification if batches are possible.&lt;br&gt;
 Enforce uniqueness with a primary key or unique index, and make it the first thing that runs.&lt;br&gt;
 Put the claim and the state change in one transaction.&lt;br&gt;
 Add a business-level unique constraint behind the inbox.&lt;br&gt;
 Guard snapshot upserts with a version; model deltas as events. Never rely on delivery order.&lt;br&gt;
 Acknowledge quickly, process asynchronously, and use FOR UPDATE SKIP LOCKED for workers.&lt;br&gt;
 Use pg_try_advisory_xact_lock with a 64-bit key for expensive per-event work; avoid session-level advisory locks behind transaction-mode poolers.&lt;br&gt;
 Send external side effects through a transactional outbox, and make consumers idempotent.&lt;br&gt;
 Return 2xx for permanently bad events after recording them; alert instead of failing forever.&lt;br&gt;
 Set retention longer than the provider's replay window, and think twice before partitioning by time.&lt;br&gt;
 Run a reconciliation job against the provider's API.&lt;br&gt;
Summary&lt;br&gt;
Idempotency is a database concern, not an application-layer convention. A unique key turns "have we seen this?" into an atomic operation, a single transaction ties the claim to the work, version guards stop stale snapshots from winning, and an outbox keeps external effects honest. PostgreSQL gives you all the building blocks, but the guarantees are narrower than the marketing phrase "exactly once" suggests, and the provider's own delivery contract is part of your design. Read it, and re-read it, because the numbers change.&lt;/p&gt;

&lt;p&gt;Sources&lt;br&gt;
Stripe, Receive Stripe events in your webhook endpoint: retries, event ordering, duplicate events, manual resend windows.&lt;br&gt;
Shopify, Verify webhook deliveries, Ignore duplicate webhooks, Troubleshoot webhooks and Best practices for webhooks: headers, retry policy, ordering, reconciliation.&lt;br&gt;
Marqeta, About webhooks: retry schedule and batching.&lt;br&gt;
PostgreSQL documentation: INSERT (ON CONFLICT, RETURNING, OLD/NEW), MERGE support functions, Transaction isolation, Explicit locking (advisory locks), SELECT locking clause, Table partitioning limitations.&lt;br&gt;
pganalyze, Postgres 15 MERGE vs. INSERT ON CONFLICT.&lt;br&gt;
Amazon RDS, PostgreSQL release calendar: PostgreSQL 18 minor versions.&lt;br&gt;
Neon, PostgreSQL 19: ON CONFLICT DO SELECT and VictoriaMetrics, PostgreSQL 19 tour: PostgreSQL 19 beta status.&lt;/p&gt;

</description>
    </item>
    <item>
      <title>Building a Custom Webhook Provider: API Design Lessons from Stripe and GitHub</title>
      <dc:creator>InstaWebhook</dc:creator>
      <pubDate>Sat, 19 Sep 2026 08:22:01 +0000</pubDate>
      <link>https://dev.to/instawebhook/building-a-custom-webhook-provider-api-design-lessons-from-stripe-and-github-9pa</link>
      <guid>https://dev.to/instawebhook/building-a-custom-webhook-provider-api-design-lessons-from-stripe-and-github-9pa</guid>
      <description>&lt;p&gt;API webhooks tutorial&lt;br&gt;
build a webhook provider&lt;br&gt;
building a webhook system&lt;br&gt;
building SaaS webhooks&lt;br&gt;
custom webhook integration&lt;br&gt;
developer API design&lt;br&gt;
developer tools webhook&lt;br&gt;
emitting webhooks&lt;br&gt;
event-driven architecture&lt;br&gt;
event notifications API&lt;br&gt;
GitHub webhook design&lt;br&gt;
HMAC webhook signing&lt;br&gt;
how to build webhook infrastructure&lt;br&gt;
how to send webhooks&lt;br&gt;
InstaWebhook&lt;br&gt;
microservices webhooks&lt;br&gt;
outgoing webhooks&lt;br&gt;
publishing webhooks&lt;br&gt;
reliable webhook delivery&lt;br&gt;
REST API webhook design&lt;br&gt;
SaaS API design&lt;br&gt;
SaaS webhook architecture&lt;br&gt;
scalable webhook architecture&lt;br&gt;
sending webhooks to customers&lt;br&gt;
Stripe webhook design&lt;br&gt;
webhook API design&lt;br&gt;
webhook architecture&lt;br&gt;
webhook authentication&lt;br&gt;
webhook best practices&lt;br&gt;
webhook delivery engine&lt;br&gt;
webhook delivery system&lt;br&gt;
webhook developer guide&lt;br&gt;
webhook dispatch engine&lt;br&gt;
webhook dispatch queue&lt;br&gt;
webhook endpoint security&lt;br&gt;
webhook event types&lt;br&gt;
webhook exponential backoff&lt;br&gt;
webhook failure handling&lt;br&gt;
webhook headers best practices&lt;br&gt;
webhook idempotency&lt;br&gt;
webhook implementation guide&lt;br&gt;
webhook infrastructure&lt;br&gt;
webhook monitoring&lt;br&gt;
webhook notification system&lt;br&gt;
webhook payload design&lt;br&gt;
webhook payload encryption&lt;br&gt;
webhook payload schema&lt;br&gt;
webhook payload security&lt;br&gt;
webhook payload versioning&lt;br&gt;
webhook provider tutorial&lt;br&gt;
webhook queue system&lt;br&gt;
webhook rate limiting&lt;br&gt;
webhook receiver vs provider&lt;br&gt;
webhook retry logic&lt;br&gt;
webhook secret rotation&lt;br&gt;
webhook security&lt;br&gt;
webhook security best practices&lt;br&gt;
webhook signature headers&lt;br&gt;
webhook signature verification&lt;br&gt;
webhook worker architecture&lt;br&gt;
Building A Custom Webhook Provider API Design Lessons From Stripe And Git Hub&lt;br&gt;
Building a Custom Webhook Provider: API Design Lessons from Stripe and GitHub&lt;br&gt;
Fact-checked against the official Stripe, GitHub and Standard Webhooks documentation in September 2026. Sources are listed at the end.&lt;/p&gt;

&lt;p&gt;Table of contents&lt;br&gt;
Introduction: from webhook consumer to provider&lt;br&gt;
Payload design: events, envelopes and versions&lt;br&gt;
Security: signatures, replay protection and secrets&lt;br&gt;
Dispatch architecture: build for async resiliency&lt;br&gt;
Retries and fault tolerance&lt;br&gt;
Developer experience and observability&lt;br&gt;
Build vs. buy: what it takes to run this in production&lt;br&gt;
Conclusion: the webhook provider checklist&lt;br&gt;
Sources&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Introduction: from webhook consumer to provider
For most developers, webhooks start as an intake problem: expose an HTTP POST endpoint, verify a signature, parse the JSON, return 200 OK. Then your SaaS grows, and your power users ask for the reverse. They don't want to poll your REST API every minute to find out whether an invoice was paid. They want you to push events to their servers in near real time.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Once you become the sender, you're doing distributed systems work. You are making outbound HTTP requests to servers you don't control, and those servers will time out, drop connections, return 500s, get redeployed with the wrong secret, or disappear for a weekend. A careless retry loop can hammer a recovering customer, or clog your own queues. And because your payloads travel over the public internet, your customers need a way to prove a request really came from you.&lt;/p&gt;

&lt;p&gt;Two providers set the reference points most developers know:&lt;/p&gt;

&lt;p&gt;Stripe has one of the most mature webhook systems: a consistent event envelope, timestamped signatures, multi-day retries, and a delivery log with manual resend.&lt;br&gt;
GitHub shows a simpler design: metadata in headers, a bare resource in the body, no automatic retries, and a short redelivery window.&lt;br&gt;
They make different trade-offs, and the differences are instructive. A third reference, the open Standard Webhooks specification, distills common practice into a single set of conventions and is a good tie-breaker when you have to choose.&lt;/p&gt;

&lt;p&gt;Stripe, GitHub and Standard Webhooks at a glance&lt;br&gt;
Stripe  GitHub  Standard Webhooks&lt;br&gt;
Body shape  Event envelope (id, type, created, api_version, data.object)    Bare resource with an action field; event name is in the X-GitHub-Event header  type, timestamp (ISO 8601), data&lt;br&gt;
Unique ID   Event id    X-GitHub-Delivery (same value on manual redelivery) webhook-id (stable across retries)&lt;br&gt;
Signature header    Stripe-Signature: t=…,v1=…  X-Hub-Signature-256: sha256=… webhook-signature: v1,&lt;br&gt;
Timestamp is signed Yes No  Yes, along with the message ID&lt;br&gt;
Automatic retries   Live mode: up to 3 days, exponential backoff    None    Recommended: multi-day schedule with backoff and jitter&lt;br&gt;
Response deadline   Return a 2xx quickly (no figure given)  10 seconds  Suggested request timeout of 15–30 seconds&lt;br&gt;
Payload size    No limit stated in the reviewed docs    25 MB cap; larger events aren't delivered   Keep it small, usually under 20 kB&lt;br&gt;
Manual replay   Dashboard: 15 days. CLI: 30 days    UI or REST API: last 3 days Recommended, including bulk replay&lt;br&gt;
Keep this table in mind. Most of the design decisions below are choices between these columns.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Payload design: events, envelopes and versions
The payload is the first contract you make with external developers, and the hardest one to change later.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Code example&lt;br&gt;
Copy code&lt;br&gt;
┌───────────────────────────────────────────────────────────┐&lt;br&gt;
│                   WEBHOOK EVENT ENVELOPE                  │&lt;br&gt;
│                                                           │&lt;br&gt;
│  id:          "evt_123456789"                             │&lt;br&gt;
│  type:        "order.created"                             │&lt;br&gt;
│  created:     1773921600                                  │&lt;br&gt;
│  api_version: "2026-03-25.dahlia"                         │&lt;br&gt;
│                                                           │&lt;br&gt;
│  ┌─────────────────────────────────────────────────────┐  │&lt;br&gt;
│  │ data                                                │  │&lt;br&gt;
│  │   object: { id: "ord_999", total: 4999, ... }       │  │&lt;br&gt;
│  └─────────────────────────────────────────────────────┘  │&lt;br&gt;
└───────────────────────────────────────────────────────────┘&lt;br&gt;
Envelope vs. bare payload&lt;br&gt;
Stripe wraps every resource in an event envelope. A trimmed example:&lt;/p&gt;

&lt;p&gt;Code example&lt;br&gt;
Copy code&lt;br&gt;
{&lt;br&gt;
  "id": "evt_1N3k452eZvKYlo2C0XzY9XYZ",&lt;br&gt;
  "object": "event",&lt;br&gt;
  "type": "payment_intent.succeeded",&lt;br&gt;
  "created": 1773921600,&lt;br&gt;
  "api_version": "2026-03-25.dahlia",&lt;br&gt;
  "data": {&lt;br&gt;
    "object": {&lt;br&gt;
      "id": "pi_3MtwBw2eZvKYlo2C1Gq12345",&lt;br&gt;
      "object": "payment_intent",&lt;br&gt;
      "amount": 2000,&lt;br&gt;
      "currency": "usd",&lt;br&gt;
      "status": "succeeded"&lt;br&gt;
    }&lt;br&gt;
  },&lt;br&gt;
  "livemode": true&lt;br&gt;
}&lt;br&gt;
GitHub takes a different route. The event name travels in the X-GitHub-Event header, the delivery ID in X-GitHub-Delivery, and the body is the resource plus a top-level action key (for example "action": "opened" on an issues event). GitHub tells consumers to check both the header and the action before processing, because it keeps adding new event types and new actions to existing types.&lt;/p&gt;

&lt;p&gt;The envelope approach has a practical advantage for you as the provider: metadata lives in one predictable place regardless of resource type, so customers can write generic ingestion middleware once, and the body is self-describing when it's stored, queued or replayed later. Header-based metadata works too (GitHub proves it), but then the headers are part of your contract and every consumer has to keep them alongside the body.&lt;/p&gt;

&lt;p&gt;Taxonomy rules worth copying&lt;br&gt;
Name events hierarchically. Use dot-delimited names such as invoice.paid or user.created. Standard Webhooks recommends dot-delimited, hierarchical types limited to letters, digits and underscores, and says a given type should always carry the same payload schema.&lt;br&gt;
Give every event a unique ID that stays the same across retries. This is what lets consumers deduplicate. Stripe recommends logging processed event IDs, and notes that in some cases two separate Event objects are generated for the same underlying change, so consumers should also compare data.object.id plus type.&lt;br&gt;
Include an event timestamp, but don't promise ordering. Stripe doesn't guarantee that events arrive in the order they were generated, and its snapshot events record created in whole seconds, so distinct events can share a timestamp. Stripe's guidance is to avoid using created for ordering or deduplication, and to re-fetch the object from the API when you need the latest state. If ordering matters for your domain, consider adding a per-resource version or sequence number and documenting it.&lt;br&gt;
Pick one timestamp format and document it. Stripe uses Unix seconds. Standard Webhooks recommends ISO 8601 for the payload timestamp (and Unix seconds for the signed webhook-timestamp header). Either is fine; mixing them silently is not.&lt;br&gt;
Let customers filter by event type. Stripe and Standard Webhooks both recommend letting consumers subscribe only to what they need, and filtering on your side. Stripe also caps an account at 16 webhook endpoints.&lt;br&gt;
Thin vs. thick payloads&lt;br&gt;
Thick (full) payloads carry the whole object. Consumers get context immediately, but payloads are bigger and can go stale if the resource changes quickly.&lt;br&gt;
Thin payloads carry the event type and an ID, and the consumer fetches the rest. Standard Webhooks lists real advantages: less data to generate and send, easier to produce from any code path, easier to evolve (you can make a thin payload fuller later, but not the reverse), and better access control, since every read goes through your API and can be audited.&lt;br&gt;
Stripe supports both models. Its classic API v1 events are snapshot events that include a copy of the object at the time of the event. Its newer API v2 events are thin events that contain the event type and object ID, and Stripe's SDKs provide helpers to fetch the related object or the full event. Thin and snapshot events use separate webhook endpoints.&lt;/p&gt;

&lt;p&gt;Size limits. The numbers vary a lot by provider. GitHub caps payloads at 25 MB and simply won't deliver an event that would exceed it, for example when a huge number of branches or tags are created at once. Standard Webhooks takes the opposite stance and recommends keeping payloads small, usually under about 20 kB, and passing a link when you need to send something large. A sensible default: aim for small, and fall back to a reference (a signed download URL, or a resource URL to query) for anything big.&lt;/p&gt;

&lt;p&gt;Versioning&lt;br&gt;
Payload shapes will change. Stripe's approach is worth understanding in detail:&lt;/p&gt;

&lt;p&gt;Each account (and each event destination) has an API version, and the version in effect when the event occurs determines the structure of the event sent to you.&lt;br&gt;
Events are immutable. Upgrading your API version later doesn't rewrite events that already exist, and fetching an old event through a newer API version doesn't change its structure.&lt;br&gt;
Versions are named by date plus a release name, for example 2026-03-25.dahlia. Since the 2024-09-30 acacia release, Stripe ships monthly versions with no breaking changes, plus a major release with breaking changes twice a year.&lt;br&gt;
For your own provider, two patterns work well together: let customers pin a version per endpoint and run outgoing payloads through a compatibility transformer, and put the version in the envelope so consumers can branch on it. Make additive changes (new fields, new event types) the default, and tell consumers to ignore fields and event types they don't recognize.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Security: signatures, replay protection and secrets
Webhooks go to public URLs, so receivers need two guarantees: the request really came from you (authenticity), and it wasn't altered or replayed (integrity and freshness).&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Code example&lt;br&gt;
Copy code&lt;br&gt;
Your server                                    Customer server&lt;br&gt;
    │                                                │&lt;br&gt;
    │ 1. Serialize the exact body you will send      │&lt;br&gt;
    │ 2. Compute HMAC-SHA256 over timestamp + body   │&lt;br&gt;
    │ 3. Set the signature header                    │&lt;br&gt;
    ├───────────────────────────────────────────────►│&lt;br&gt;
    │            POST /webhook                       │ 4. Recompute HMAC with the shared secret&lt;br&gt;
    │                                                │ 5. Compare in constant time&lt;br&gt;
    │                                                │ 6. Check the timestamp is recent&lt;br&gt;
Three real signature schemes&lt;br&gt;
GitHub sends X-Hub-Signature-256: sha256=, an HMAC-SHA256 hex digest of the raw request body, keyed with your secret. It still sends the older SHA-1 X-Hub-Signature header for compatibility but recommends the SHA-256 one. There is no timestamp in the signed content, so a captured request can be replayed and still verify. GitHub's advice for replay protection is to track the X-GitHub-Delivery ID and reject repeats. Its docs also give consumers a fixed test vector to check their implementation. You can reproduce it in a terminal:&lt;/p&gt;

&lt;p&gt;Code example&lt;br&gt;
Copy code&lt;br&gt;
printf "Hello, World!" | openssl dgst -sha256 -hmac "It's a Secret to Everybody"&lt;/p&gt;

&lt;h1&gt;
  
  
  757107ea0eb2509fc211221cce984b8a37570b6d7586c22c46f4379c8b043e17
&lt;/h1&gt;

&lt;p&gt;Stripe sends Stripe-Signature: t=,v1=. The signed string is the timestamp, a literal period, and the raw JSON body. Because the timestamp is inside the signed content, an attacker can't change it without invalidating the signature, so a receiver can safely reject old requests. Stripe's libraries default to a 5-minute tolerance, and Stripe warns against a tolerance of 0, which disables the recency check. Receivers should ignore any scheme other than v1 (test events also carry a fake v0 signature) to avoid downgrade attacks.&lt;/p&gt;

&lt;p&gt;Standard Webhooks goes one step further and signs the message ID as well as the timestamp and body: msg_id.timestamp.payload. It uses three headers (webhook-id, webhook-timestamp, webhook-signature). The signature is base64-encoded and prefixed with v1,, and the header is a space-delimited list so several signatures can be sent at once. Symmetric secrets are 24 to 64 random bytes, base64-encoded with a whsec_ prefix. The spec also defines an asymmetric option (ed25519, signature prefix v1a,), and recommends preferring it when you don't control both the producer and the consumer, since receivers then hold only a public key.&lt;/p&gt;

&lt;p&gt;Sign every attempt, not just every event&lt;br&gt;
Stripe generates a fresh timestamp and signature each time it delivers an event, including retries. Standard Webhooks draws the same line: the attempt timestamp changes on every retry, while the message ID and the event's own timestamp stay the same. If you signed once at event creation and reused the header, a retry a few hours later would fail every receiver's tolerance check. Build the signature inside your delivery worker, right before the HTTP request.&lt;/p&gt;

&lt;p&gt;Node.js: sign and verify&lt;br&gt;
This Stripe-style implementation supports secret rotation by emitting one v1 signature per active secret:&lt;/p&gt;

&lt;p&gt;Code example&lt;br&gt;
Copy code&lt;br&gt;
import crypto from 'node:crypto';&lt;/p&gt;

&lt;p&gt;/**&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Builds a Stripe-style signature header.&lt;/li&gt;
&lt;li&gt;
&lt;a class="mentioned-user" href="https://dev.to/param"&gt;@param&lt;/a&gt; {string} rawBody  Exact UTF-8 string you will send as the request body.&lt;/li&gt;
&lt;li&gt;
&lt;a class="mentioned-user" href="https://dev.to/param"&gt;@param&lt;/a&gt; {string|string[]} secrets  One secret, or two during a rotation window.&lt;/li&gt;
&lt;li&gt;@returns {string} e.g. "t=1773921600,v1=,v1="
*/
export function signStripeStyle(rawBody, secrets, timestamp = Math.floor(Date.now() / 1000)) {
const signedPayload = &lt;code&gt;${timestamp}.${rawBody}&lt;/code&gt;;
const sigs = [].concat(secrets).map((secret) =&amp;gt;
crypto.createHmac('sha256', secret).update(signedPayload, 'utf8').digest('hex')
);
return &lt;code&gt;t=${timestamp},${sigs.map((s) =&amp;gt;&lt;/code&gt;v1=${s}&lt;code&gt;).join(',')}&lt;/code&gt;;
}
And the receiving side, which you should publish in your docs, in every language your customers use:&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Code example&lt;br&gt;
Copy code&lt;br&gt;
export function verifyStripeStyle(rawBody, header, secret, toleranceSec = 300) {&lt;br&gt;
  const parts = header.split(',').map((p) =&amp;gt; p.split('='));&lt;br&gt;
  const t = parts.find(([k]) =&amp;gt; k === 't')?.[1];&lt;br&gt;
  const candidates = parts.filter(([k]) =&amp;gt; k === 'v1').map(([, v]) =&amp;gt; v);&lt;br&gt;
  if (!t || candidates.length === 0) return false;&lt;/p&gt;

&lt;p&gt;// Reject stale (or far-future) timestamps.&lt;br&gt;
  if (Math.abs(Math.floor(Date.now() / 1000) - Number(t)) &amp;gt; toleranceSec) return false;&lt;/p&gt;

&lt;p&gt;const expected = crypto.createHmac('sha256', secret).update(&lt;code&gt;${t}.${rawBody}&lt;/code&gt;, 'utf8').digest();&lt;/p&gt;

&lt;p&gt;// timingSafeEqual throws if lengths differ, so check length first.&lt;br&gt;
  return candidates.some((hex) =&amp;gt; {&lt;br&gt;
    const got = Buffer.from(hex, 'hex');&lt;br&gt;
    return got.length === expected.length &amp;amp;&amp;amp; crypto.timingSafeEqual(got, expected);&lt;br&gt;
  });&lt;br&gt;
}&lt;br&gt;
Two implementation notes apply to every language:&lt;/p&gt;

&lt;p&gt;Sign and verify the raw body. Stripe and Standard Webhooks both warn that parsing the JSON and re-serializing it changes whitespace or key order and breaks the signature. Frameworks that parse the body before your handler runs are the most common cause of "signature verification failed" tickets.&lt;br&gt;
Use a constant-time comparison (crypto.timingSafeEqual, hmac.compare_digest, hash_equals). GitHub and Standard Webhooks both call this out, and GitHub specifically warns against a plain ==.&lt;br&gt;
If you'd rather follow the open standard, the Standard Webhooks variant is only a few lines different:&lt;/p&gt;

&lt;p&gt;Code example&lt;br&gt;
Copy code&lt;br&gt;
export function signStandardWebhooks(rawBody, msgId, secret, timestamp = Math.floor(Date.now() / 1000)) {&lt;br&gt;
  const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64');&lt;br&gt;
  const sig = crypto.createHmac('sha256', key)&lt;br&gt;
    .update(&lt;code&gt;${msgId}.${timestamp}.${rawBody}&lt;/code&gt;, 'utf8')&lt;br&gt;
    .digest('base64');&lt;/p&gt;

&lt;p&gt;return {&lt;br&gt;
    'webhook-id': msgId,               // stable across retries&lt;br&gt;
    'webhook-timestamp': String(timestamp), // changes on every attempt&lt;br&gt;
    'webhook-signature': &lt;code&gt;v1,${sig}&lt;/code&gt;,&lt;br&gt;
  };&lt;br&gt;
}&lt;br&gt;
Secrets: format, uniqueness and rotation&lt;br&gt;
Use a recognizable prefix. Both Stripe and Standard Webhooks use whsec_. A recognizable prefix makes secrets easier to spot in code reviews and secret-scanning tools.&lt;br&gt;
One secret per endpoint. Stripe generates a unique signing secret for each endpoint, and Standard Webhooks warns that reusing keys across customers creates security problems.&lt;br&gt;
Rotate with an overlap window. When a customer rolls a secret, Stripe lets them keep the old one alive for up to 24 hours, and during that time it sends one signature per active secret. Standard Webhooks solves the same problem by allowing several space-separated signatures in one header. Receivers accept the request if any one signature verifies.&lt;br&gt;
Transport and network safeguards&lt;br&gt;
HTTPS only. Stripe requires HTTPS for live-mode endpoints and supports TLS 1.2 and 1.3 only.&lt;br&gt;
Publish your source IPs. Stripe recommends receivers allowlist its published IP addresses in addition to verifying signatures, and GitHub exposes its list via the GET /meta endpoint. Standard Webhooks notes that enterprise customers behind firewalls often require static source IPs.&lt;br&gt;
Protect yourself from SSRF. Customers give you arbitrary URLs, and your workers will call them from inside your network. Standard Webhooks recommends routing webhook traffic through a filtering proxy (it names Stripe's open-source Smokescreen) and running the workers in a private subnet that can't reach internal services. Don't follow redirects either: Stripe, Svix and Standard Webhooks all treat a 3xx as a failed delivery, and redirect-following is a classic way to slip past URL validation.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Dispatch architecture: build for async resiliency
The cardinal rule: never send a webhook inside the request that caused it. If POST /api/v1/users waits on a customer's slow endpoint, your own API hangs. Stripe gives consumers the mirror-image advice: process events through an asynchronous queue and return a 2xx before doing heavy work, because spikes (such as monthly subscription renewals) can overwhelm synchronous handlers.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Code example&lt;br&gt;
Copy code&lt;br&gt;
┌────────────┐   ┌────────────────┐   ┌─────────────┐   ┌────────────────┐&lt;br&gt;
│ App / API  │──►│ Outbox table   │──►│ Queue       │──►│ Dispatch       │&lt;br&gt;
│ (business  │   │ (same DB tx)   │   │ (per-tenant │   │ workers        │&lt;br&gt;
│  change)   │   │                │   │  fairness)  │   │                │&lt;br&gt;
└────────────┘   └────────────────┘   └─────────────┘   └───────┬────────┘&lt;br&gt;
                                                                │&lt;br&gt;
                              ┌─────────────────────────────────┤&lt;br&gt;
                              ▼                                 ▼&lt;br&gt;
                    ┌────────────────────┐          ┌───────────────────────┐&lt;br&gt;
                    │ Rate limit +       │          │ Circuit breaker       │&lt;br&gt;
                    │ concurrency cap    │          │ (per endpoint)        │&lt;br&gt;
                    └─────────┬──────────┘          └───────────┬───────────┘&lt;br&gt;
                              └──────────────┬──────────────────┘&lt;br&gt;
                                             ▼&lt;br&gt;
                                ┌─────────────────────────┐&lt;br&gt;
                                │ Egress proxy (SSRF      │&lt;br&gt;
                                │ filter) → customer URL  │&lt;br&gt;
                                └─────────────────────────┘&lt;br&gt;
Don't lose events: the transactional outbox&lt;br&gt;
The classic failure mode is a dual write. If you publish to the queue inside the database transaction, the transaction might roll back after the event is already out. If you publish after commit, your process might crash before sending. The transactional outbox pattern avoids both: write the event to an outbox table in the same database transaction as the business change, and let a separate relay (a polling publisher, or change-data-capture) move rows to the broker. Chris Richardson's microservices.io catalog documents the pattern in detail.&lt;/p&gt;

&lt;p&gt;Workers and timeouts&lt;br&gt;
Workers pull tasks from the queue, look up the customer's subscriptions, build and sign the payload, and issue the HTTP request. Choose your request timeout deliberately. GitHub gives receivers 10 seconds, Svix 15, and Standard Webhooks suggests 15 to 30. Anything much shorter than 10 seconds punishes receivers that do modest work before responding, while very long timeouts tie up worker capacity when an endpoint hangs. Whatever you pick, document it.&lt;/p&gt;

&lt;p&gt;Noisy neighbors and concurrency limits&lt;br&gt;
If customer A triggers 100,000 events and customer B triggers one, a single FIFO queue makes B wait behind A. Partition or shard queues per tenant (or per endpoint), and cap in-flight requests per endpoint so a slow customer can't consume your whole worker pool. Numbers such as "10 to 20 concurrent requests per endpoint" are reasonable starting points, but tune them from real traffic. Also respect 429 Too Many Requests and Retry-After responses from customers, which Standard Webhooks specifically recommends.&lt;/p&gt;

&lt;p&gt;Circuit breakers&lt;br&gt;
The Circuit Breaker pattern, as documented in Microsoft's Azure Architecture Center, has three states. Closed means requests flow normally while failures are counted. Open means requests are rejected immediately, without waiting for a timeout. Half-open lets a limited number of trial requests through, and closes the circuit again after enough consecutive successes.&lt;/p&gt;

&lt;p&gt;Applied per endpoint, this stops you from spending worker capacity on a dead URL. When the breaker is open, hold that endpoint's events in a delayed state rather than discarding them, then let a probe delivery decide when to resume. The thresholds (for example, opening after a run of consecutive failures or a high error rate within a window) are yours to tune.&lt;/p&gt;

&lt;p&gt;A breaker is a short-term, automatic protection measured in minutes. Don't confuse it with endpoint disabling (section 5), which is a days-long policy decision that also notifies the customer.&lt;/p&gt;

&lt;p&gt;Dead-letter queues and operational events&lt;br&gt;
When a message has exhausted its retry schedule, mark it as failed and keep it, so it can be inspected and replayed. Svix, for example, marks the message Failed for that endpoint and emits an operational webhook of type message.attempt.exhausted to the sender's account. Emitting events about your own delivery health, such as "this endpoint was disabled", through the same webhook channel is a neat pattern, because your customers already know how to consume it.&lt;/p&gt;

&lt;p&gt;Delivery semantics: at-least-once, not exactly-once&lt;br&gt;
A retry-based system delivers a message at least once. Duplicates happen, and Stripe says so plainly. Make that a documented contract, keep the event ID stable across retries, and tell consumers to deduplicate on it. Standard Webhooks suggests using webhook-id as an idempotency key (for example, remembering IDs in Redis for a few minutes).&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Retries and fault tolerance
Even well-run customers have deploys, restarts and blips. Your retry policy decides whether a 20-minute outage becomes a lost event or a non-event.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;What the reference points do&lt;br&gt;
System  Automatic retries   When it gives up&lt;br&gt;
Stripe (live mode)  Up to 3 days with exponential backoff. The exact schedule isn't published, but the Dashboard shows the next retry time. Sandbox: 3 retries over a few hours Stripe emails you about failing webhooks; an independent review by Svix reports that endpoints are disabled after about 3 days of continuous failure&lt;br&gt;
GitHub  None. A failed delivery stays failed until someone redelivers it    Manual redelivery is possible for 3 days&lt;br&gt;
Svix    Immediately, then after 5 s, 5 min, 30 min, 2 h, 5 h, 10 h and 10 h (about 27.5 hours in total) Marks the message failed; disables the endpoint after 5 days of failures&lt;br&gt;
Standard Webhooks (example) A ten-step schedule stretching to a 24-hour gap, about 75.5 hours from the first attempt    Notify the customer through another channel (for example email) and disable the endpoint&lt;br&gt;
Two takeaways. First, there's no universal standard: the Standard Webhooks spec recommends a multi-day schedule with exponential backoff and jitter, but every provider picks its own numbers. Second, the retry window is a product promise. It tells your customers how long they can be down before data is at risk, so publish it.&lt;/p&gt;

&lt;p&gt;Also note that Stripe runs a much shorter retry policy in test mode than in live mode. That's a useful pattern for your own sandbox environment: fast feedback for developers, without letting test endpoints consume production capacity for days.&lt;/p&gt;

&lt;p&gt;Exponential backoff with full jitter&lt;br&gt;
Fixed-interval retries are dangerous. If an outage takes down hundreds of customer endpoints and every failed delivery retries on the same clock, they all hit at once when the endpoint recovers. Jitter breaks up that synchronization. The AWS Architecture Blog's "Exponential Backoff and Jitter" describes three variants; "full jitter" picks a random delay between zero and the capped exponential value:&lt;/p&gt;

&lt;p&gt;Code example&lt;br&gt;
Copy code&lt;br&gt;
sleep = random(0, min(cap, base × 2^attempt))&lt;br&gt;
Code example&lt;br&gt;
Copy code&lt;br&gt;
import random&lt;/p&gt;

&lt;h1&gt;
  
  
  Delay before each attempt. Index 0 is the initial delivery.
&lt;/h1&gt;

&lt;h1&gt;
  
  
  This is Svix's published schedule, about 27.5 hours end to end.
&lt;/h1&gt;

&lt;p&gt;SCHEDULE_SECONDS = [0, 5, 5 * 60, 30 * 60, 2 * 3600, 5 * 3600, 10 * 3600, 10 * 3600]&lt;/p&gt;

&lt;p&gt;def full_jitter_delay(attempt: int, base: float = 5.0, cap: float = 6 * 3600) -&amp;gt; float:&lt;br&gt;
    """AWS 'full jitter': random(0, min(cap, base * 2*&lt;em&gt;attempt)). attempt=0 is the first retry."""&lt;br&gt;
    return random.uniform(0, min(cap, base * (2 *&lt;/em&gt; attempt)))&lt;/p&gt;

&lt;p&gt;def scheduled_delay(attempt_index: int, jitter: float = 0.2) -&amp;gt; float | None:&lt;br&gt;
    """Fixed schedule with proportional randomisation.&lt;br&gt;
    Returns None when the schedule is exhausted: mark the message failed."""&lt;br&gt;
    if attempt_index &amp;gt;= len(SCHEDULE_SECONDS):&lt;br&gt;
        return None&lt;br&gt;
    return SCHEDULE_SECONDS[attempt_index] * random.uniform(1 - jitter, 1 + jitter)&lt;/p&gt;

&lt;p&gt;def next_delay(attempt_index: int, retry_after: float | None = None, max_wait: float = 6 * 3600):&lt;br&gt;
    """Honour a customer's Retry-After header (capped) when present."""&lt;br&gt;
    if retry_after is not None:&lt;br&gt;
        return min(retry_after, max_wait)&lt;br&gt;
    return scheduled_delay(attempt_index)&lt;br&gt;
Full jitter is the simplest to reason about, but it can produce very short delays. A published schedule with a modest random spread (as in scheduled_delay) gives you predictable retry windows to document, plus enough randomness to avoid herds. Pick one and be explicit about it.&lt;/p&gt;

&lt;p&gt;HTTP status codes: what counts as failure&lt;br&gt;
The rule Stripe, Svix and Standard Webhooks share is simple: only a 2xx is a success; everything else is a failure. Standard Webhooks adds a few refinements:&lt;/p&gt;

&lt;p&gt;Response    Treat as    What to do&lt;br&gt;
2xx Success Mark delivered&lt;br&gt;
3xx Failure Don't follow the redirect. Ask the customer to update the endpoint URL&lt;br&gt;
410 Gone    "Stop sending"  Disable the endpoint. This is the receiver's way of unsubscribing&lt;br&gt;
429 Rate limited    Throttle that endpoint and honour Retry-After&lt;br&gt;
502, 504 (and 503 with Retry-After) Receiver under load Retry, and slow down for this endpoint&lt;br&gt;
Other 4xx, other 5xx, timeouts, connection and TLS errors   Failure Retry on schedule&lt;br&gt;
Notice what's missing: a rule that says "don't retry 4xx". It's tempting to treat 400, 401, 403 and 404 as terminal, since the customer's endpoint is obviously misconfigured. But Stripe's status-code guidance lists these as ordinary failures, and there's a good reason. A very common cause of 4xx responses is a bad deploy on the customer's side, such as a wrong signing secret making verification fail and return 400. If you give up immediately, a fixable mistake becomes permanently lost events. Retrying for days gives them time to notice and recover, and an eventual 410 or endpoint disabling handles the truly dead ones.&lt;/p&gt;

&lt;p&gt;Automatic endpoint disabling&lt;br&gt;
A dead endpoint shouldn't get a fresh multi-day retry schedule for every event forever. Standard Webhooks recommends that when delivery fails consistently over a long period, you both notify the customer through another channel and disable the endpoint. Svix's rule is instructive: an endpoint is disabled when all attempts fail for 5 days, and the clock only starts after multiple failures within a 24-hour span with at least 12 hours between the first and last failure, so a short outage never counts. Stripe stops retries for events destined to a disabled or deleted endpoint.&lt;/p&gt;

&lt;p&gt;Whatever thresholds you choose, make re-enabling easy, and make the notification specific (which endpoint, what error, since when).&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Developer experience and observability
Backend reliability is only half the job. If customers can't see what happened, your support inbox fills up with "why didn't I get my webhook?" tickets.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Code example&lt;br&gt;
Copy code&lt;br&gt;
┌────────────────────────────────────────────────────────────────────────┐&lt;br&gt;
│ WEBHOOK DELIVERIES                                                     │&lt;br&gt;
│                                                                        │&lt;br&gt;
│ EVENT ID     TYPE             STATUS     HTTP     DURATION   AGE       │&lt;br&gt;
│ evt_90112    order.created    Delivered  200      124 ms     2m        │&lt;br&gt;
│ evt_90111    invoice.failed   Pending    500      5002 ms    10m       │&lt;br&gt;
│              └─ next retry in 28m                                      │&lt;br&gt;
│                                                                        │&lt;br&gt;
│ ▸ evt_90111   Request headers · Payload · Response body (first 2 KB)   │&lt;br&gt;
│                                                                        │&lt;br&gt;
│  [ Resend ]     [ Send test event ]     [ Disable endpoint ]           │&lt;br&gt;
└────────────────────────────────────────────────────────────────────────┘&lt;br&gt;
Delivery logs&lt;br&gt;
The bar is set by what Stripe and GitHub already expose:&lt;/p&gt;

&lt;p&gt;Stripe lists each event's delivery status (Delivered, Pending or Failed) per endpoint, with the HTTP status of each attempt and the time of the next scheduled retry. It also maps common failure modes (connection errors, redirects, 4xx, 5xx, TLS errors, timeouts) to concrete fixes.&lt;br&gt;
GitHub keeps a "Recent deliveries" view for each webhook, where you can inspect a delivery's request and response and redeliver it. Since October 2023, that history is limited to the last 3 days (7 days on GitHub Enterprise Server).&lt;br&gt;
At minimum, show the exact request body and headers that were sent, the response status and latency, the start of the response body (the first couple of kilobytes is usually enough to see an error message), and the state of the delivery: delivered, pending a retry, or failed.&lt;/p&gt;

&lt;p&gt;Manual replay&lt;br&gt;
Customers will fix a bug on their side and want the missed events back. Provide replay for a single event and for a time range. Stripe's Dashboard can resend an event for up to 15 days after it was created, and its CLI (stripe events resend) works up to 30 days. One subtle detail: Stripe says manually resending an event that already had failed deliveries doesn't cancel its automatic retries, even if the manual attempt succeeds, so consumers need idempotent handlers regardless. GitHub allows redelivery within 3 days, and because X-GitHub-Delivery stays the same on redelivery, receivers can recognize the repeat. GitHub's docs also suggest scripting a periodic job that finds failed deliveries through the REST API and redelivers them, which is a useful reminder that replay should be available through your API as well as your UI.&lt;/p&gt;

&lt;p&gt;Test events and local development&lt;br&gt;
Send a ping when an endpoint is created. GitHub sends a ping event containing a random "zen" string when you create a webhook, so developers can confirm reachability and signature handling before real data flows. Offer the same, plus a "send test event" button for any event type.&lt;br&gt;
Give people a way to receive webhooks locally. Stripe's CLI can forward events to a local server (stripe listen) and trigger sample events (stripe trigger), so developers don't need a public URL to start.&lt;br&gt;
Publish signature test vectors and per-language verification snippets. GitHub's docs include a known secret, payload and expected signature so implementers can check their code. It's a small addition that prevents a large class of support questions.&lt;br&gt;
Also worth offering&lt;br&gt;
Standard Webhooks lists a few "nice to have" features that turn out to matter in practice: multiple endpoints per customer (fan-out), so one event can reach several systems, and an endpoint-management API so customers and third-party tools can create, list and remove endpoints programmatically.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Build vs. buy: what it takes to run this in production
Everything above is a lot of surface area. A production-grade sender needs:&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;a durable queue and storage for events and delivery attempts, plus an outbox to keep them consistent with your database;&lt;br&gt;
per-tenant fairness, concurrency limits and an SSRF-safe egress path;&lt;br&gt;
signing, secret storage and zero-downtime rotation;&lt;br&gt;
a retry scheduler, circuit breakers, endpoint disabling and dead-letter handling;&lt;br&gt;
delivery logs, replay, test events and (ideally) an embeddable customer-facing UI;&lt;br&gt;
monitoring, alerting and on-call for all of the above.&lt;br&gt;
That's a real product. Teams typically choose one of three paths:&lt;/p&gt;

&lt;p&gt;Build in-house. Maximum control, and it makes sense when webhooks are core to your business or you have unusual compliance needs. Budget for ongoing operations, not just the initial build.&lt;br&gt;
Self-host open-source infrastructure. Options include Hookdeck's Outpost (an open-source, Apache-2.0 project for outbound webhooks and event destinations, which needs Redis or Redis Cluster, PostgreSQL and a supported message queue), and Svix's open-source, self-hostable server. You keep control of the data and run the infrastructure yourself.&lt;br&gt;
Use a managed service. You trade some control for a much smaller operational footprint.&lt;br&gt;
Where InstaWebhook fits&lt;br&gt;
InstaWebhook focuses on the reliability layer that sits between an event and its destination. According to its feature documentation, it provides:&lt;/p&gt;

&lt;p&gt;Durable endpoints: it validates the endpoint token, enforces limits, stores the event and queues delivery before it responds.&lt;br&gt;
Delivery timelines: each event shows when it was received, queued, attempted, retried, delivered or dead-lettered.&lt;br&gt;
Configurable retries: default backoff schedules, or a schedule per endpoint, with durable job leasing and crash recovery.&lt;br&gt;
Replay and a dead-letter queue: exhausted events go to a dedicated queue for investigation, bulk retry, replay or resolution.&lt;br&gt;
Signing: timestamped HMAC signatures on outgoing deliveries, with published verification examples.&lt;br&gt;
Audit logs and optional BYO database storage: payloads can live in your own PostgreSQL schema.&lt;br&gt;
It's also a practical fit when your customers need a sandboxed endpoint to receive, inspect and test events while integrating with your API: create an endpoint, send a test event, and read the delivery timeline before production traffic depends on it.&lt;/p&gt;

&lt;p&gt;Whichever route you take, evaluate it against the checklist below rather than the marketing page.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Conclusion: the webhook provider checklist
Stripe and GitHub took different routes to the same goal: events that arrive reliably, that receivers can trust, and that developers can debug. Standard Webhooks packages much of that experience into a spec you can adopt today. Use this checklist before you ship.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Payload&lt;/p&gt;

&lt;p&gt;Are events wrapped in a consistent envelope with a unique id (stable across retries), a type, a timestamp and a data object?&lt;br&gt;
 Do you document the API version, and can customers pin one per endpoint?&lt;br&gt;
 Is your payload size policy explicit, with a reference fallback for large data?&lt;br&gt;
 Have you told consumers that ordering isn't guaranteed and duplicates can happen?&lt;br&gt;
Security&lt;/p&gt;

&lt;p&gt;Are requests signed with HMAC-SHA256 over a string that includes the timestamp (and ideally the message ID) and the raw body?&lt;br&gt;
 Do you generate a fresh timestamp and signature on every delivery attempt?&lt;br&gt;
 Is there one secret per endpoint, with a recognizable prefix and an overlapping rotation window?&lt;br&gt;
 Do you require HTTPS, refuse to follow redirects, and route traffic through an SSRF-safe egress path?&lt;br&gt;
 Do you publish your source IPs, a recommended timestamp tolerance, and verification snippets with test vectors?&lt;br&gt;
Architecture&lt;/p&gt;

&lt;p&gt;Is delivery fully decoupled from your API request path, with an outbox so events aren't lost?&lt;br&gt;
 Do you have per-tenant fairness and per-endpoint concurrency limits?&lt;br&gt;
 Do you have per-endpoint circuit breakers and a dead-letter state?&lt;br&gt;
Retries&lt;/p&gt;

&lt;p&gt;Do you use a documented multi-day schedule with exponential backoff and jitter?&lt;br&gt;
 Do you treat only 2xx as success, honour Retry-After and 410 Gone, and avoid discarding events on 4xx?&lt;br&gt;
 Do you notify customers and disable endpoints that fail for days, and make re-enabling easy?&lt;br&gt;
Developer experience&lt;/p&gt;

&lt;p&gt;Can customers see every attempt (request, response, latency, next retry), send test events, and replay one event or a range from the UI and the API?&lt;br&gt;
 Have you honestly compared build, self-host and managed options against the operational cost?&lt;br&gt;
Sources&lt;br&gt;
Stripe: Receive Stripe events in your webhook endpoint (retries, signatures, replay protection, secret rolling, event ordering, thin and snapshot events, resend windows)&lt;br&gt;
Stripe: Versioning and support policy&lt;br&gt;
GitHub Docs: Webhook events and payloads (headers, 25 MB cap)&lt;br&gt;
GitHub Docs: Best practices for using webhooks&lt;br&gt;
GitHub Docs: Validating webhook deliveries&lt;br&gt;
GitHub Docs: Redelivering webhooks and Handling failed webhook deliveries&lt;br&gt;
GitHub Changelog: Webhook delivery logs will only be retained for 3 days&lt;br&gt;
Standard Webhooks specification, v1.0.0&lt;br&gt;
Svix Docs: Retry schedule and Svix's Stripe webhooks review&lt;br&gt;
AWS Architecture Blog: Exponential Backoff and Jitter&lt;br&gt;
Microsoft Azure Architecture Center: Circuit Breaker pattern&lt;br&gt;
microservices.io: Transactional outbox&lt;br&gt;
Hookdeck: Outpost&lt;br&gt;
InstaWebhook: Features&lt;/p&gt;

</description>
    </item>
    <item>
      <title>Redis Streams vs. AWS SQS vs. RabbitMQ: Choosing a Webhook Ingestion Buffer</title>
      <dc:creator>InstaWebhook</dc:creator>
      <pubDate>Fri, 18 Sep 2026 05:57:33 +0000</pubDate>
      <link>https://dev.to/instawebhook/redis-streams-vs-aws-sqs-vs-rabbitmq-choosing-a-webhook-ingestion-buffer-o7k</link>
      <guid>https://dev.to/instawebhook/redis-streams-vs-aws-sqs-vs-rabbitmq-choosing-a-webhook-ingestion-buffer-o7k</guid>
      <description>&lt;p&gt;asynchronous webhook processing&lt;br&gt;
at-least-once message delivery&lt;br&gt;
automated webhook retries&lt;br&gt;
AWS SQS dead-letter queue&lt;br&gt;
AWS SQS webhooks&lt;br&gt;
cloud message queues&lt;br&gt;
dead-letter queue webhooks&lt;br&gt;
decoupling webhook processing&lt;br&gt;
distributed system buffering&lt;br&gt;
DLQ configuration&lt;br&gt;
event-driven architecture&lt;br&gt;
exactly-once webhook processing&lt;br&gt;
GitHub webhook handler&lt;br&gt;
high-volume webhook ingestion&lt;br&gt;
HTTP webhook handler&lt;br&gt;
InstaWebhook&lt;br&gt;
managed queue infrastructure&lt;br&gt;
managed webhook queue&lt;br&gt;
message broker comparison&lt;br&gt;
message queue throughput&lt;br&gt;
open source vs managed queue&lt;br&gt;
payload ingestion queue&lt;br&gt;
RabbitMQ exchange binding&lt;br&gt;
RabbitMQ webhook ingestion&lt;br&gt;
real-time event queue&lt;br&gt;
Redis Streams consumer groups&lt;br&gt;
Redis Streams vs SQS&lt;br&gt;
Redis Streams webhook buffer&lt;br&gt;
Redis vs RabbitMQ webhooks&lt;br&gt;
scaling webhook ingestion&lt;br&gt;
serverless webhook queue&lt;br&gt;
SQS vs RabbitMQ&lt;br&gt;
Stripe webhook queue&lt;br&gt;
webhook backpressure&lt;br&gt;
webhook buffer queue&lt;br&gt;
webhook buffer vs message queue&lt;br&gt;
webhook delivery guarantee&lt;br&gt;
webhook failover queue&lt;br&gt;
webhook fault tolerance&lt;br&gt;
webhook gateway architecture&lt;br&gt;
webhook infrastructure&lt;br&gt;
webhook ingestion architecture&lt;br&gt;
webhook ingestion buffer&lt;br&gt;
webhook message queue&lt;br&gt;
webhook observability&lt;br&gt;
webhook persistence guarantees&lt;br&gt;
webhook proxy service&lt;br&gt;
webhook queue architecture&lt;br&gt;
webhook rate limiting&lt;br&gt;
webhook reliability&lt;br&gt;
webhook retry policy&lt;br&gt;
webhook spike absorber&lt;br&gt;
webhook throughput comparison&lt;br&gt;
webhook traffic spikes&lt;br&gt;
Redis Streams Vs AWS SQS Vs Rabbit MQ Choosing A Webhook Ingestion Buffer&lt;br&gt;
Redis Streams vs. AWS SQS vs. RabbitMQ: Choosing a Webhook Ingestion Buffer&lt;br&gt;
In modern cloud architectures, incoming webhooks represent an uncontrollable influx of external data. Whether you're processing payment notifications from Stripe, e-commerce orders from Shopify, or event streams from GitHub, webhook senders operate on their own timelines. During sudden traffic spikes — a flash sale, a viral post, a batch job on the sender's side — your endpoint can be hit with hundreds or thousands of concurrent HTTP POST requests with no warning.&lt;/p&gt;

&lt;p&gt;If your backend tries to synchronously run business logic, write to a database, and call external APIs on every incoming webhook, a few things go wrong at once:&lt;/p&gt;

&lt;p&gt;Timeouts. Most webhook providers expect a fast 200 OK, often within a few seconds. Slow processing causes timeouts.&lt;br&gt;
Retry storms. When a provider times out, it retries — often with the same event — compounding the spike.&lt;br&gt;
Dropped data. Unhandled concurrency spikes exhaust database connections and memory, and payloads get lost.&lt;br&gt;
The standard fix is an ingestion buffer: something that accepts the raw webhook, immediately acknowledges the request with a 200/202, stores the payload durably, and lets background workers process events at a rate your downstream systems can actually handle.&lt;/p&gt;

&lt;p&gt;Code example&lt;br&gt;
Copy code&lt;br&gt;
+------------------+         +--------------------------+         +--------------------+         +-------------------+&lt;br&gt;
| Third-Party API  | ------&amp;gt; |  Webhook Ingestion Buffer | ------&amp;gt; | Background Worker  | ------&amp;gt; | Primary Database  |&lt;br&gt;
| (Stripe/Shopify) |  POST   | (Redis / SQS / RabbitMQ) |  Pull   |  (Rate-Controlled) |  Write  |  (Postgres/Mongo) |&lt;br&gt;
+------------------+         +--------------------------+         +--------------------+         +-------------------+&lt;br&gt;
                                (Instantly returns 200 OK)&lt;br&gt;
Three technologies dominate this decision: Redis Streams, AWS SQS, and RabbitMQ. Below is a deep-dive comparison of how each works, what's changed recently, and how to pick between them.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Architectural Deep Dives
Redis Streams: The In-Memory Speed Demon
Redis Streams, introduced in Redis 5.0, is an append-only log data structure that turns Redis into a lightweight messaging engine alongside its usual key-value duties.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;How it works for webhooks&lt;br&gt;
Your API server issues an XADD to append the raw payload to a stream. Workers join a Consumer Group and pull batches with XREADGROUP. Once a worker finishes processing, it calls XACK to acknowledge the message.&lt;/p&gt;

&lt;p&gt;Code example&lt;br&gt;
Copy code&lt;/p&gt;

&lt;h1&gt;
  
  
  Appending an incoming webhook to a Redis Stream
&lt;/h1&gt;

&lt;p&gt;XADD webhooks:stripe * event_type "charge.succeeded" payload "{\"id\":\"ch_123\", ...}"&lt;br&gt;
Key technical characteristics&lt;br&gt;
Very low latency. Because Redis operates in RAM, ingestion and reads are typically sub-millisecond. Single-node throughput is commonly cited in the tens of thousands to 100k+ operations per second, but this varies a lot with payload size, persistence settings, and hardware — treat any specific number as a starting point for your own benchmark, not a guarantee.&lt;br&gt;
Consumer groups. Multiple workers can split load while Redis tracks which worker has claimed which unacknowledged message.&lt;br&gt;
Memory-bound. Unconsumed data lives in RAM, so you need to actively cap growth with XADD ... MAXLEN ~  or run periodic trimming, or you risk out-of-memory failures.&lt;br&gt;
Persistence trade-offs. Redis relies on RDB snapshots and an AOF (append-only file). With appendfsync everysec (the common setting), a hard crash can lose up to roughly one second of recent writes. Stricter fsync=always avoids this at a latency cost.&lt;br&gt;
The Redis licensing story you should know about&lt;br&gt;
This part of the decision changed materially in the last two years and most older comparisons don't mention it:&lt;/p&gt;

&lt;p&gt;Through Redis 7.2, Redis was BSD-licensed, permissive open source.&lt;br&gt;
March 2024: Redis Inc. switched new releases to a dual SSPL/RSAL license — no longer OSI-approved open source. Within days, the Linux Foundation and a group of cloud vendors (AWS, Google Cloud, Oracle, Ericsson, Snap, and others) announced Valkey, a BSD-licensed fork of Redis 7.2.4.&lt;br&gt;
May 2025: Redis Inc. reversed course. Redis 8.0 shipped back under the OSI-approved AGPLv3 license, with Redis creator Salvatore "antirez" Sanfilippo back at the company.&lt;br&gt;
Practically, this means: Redis Streams' commands and semantics (XADD, XREADGROUP, XACK, XPENDING, XCLAIM) are also implemented in Valkey, and AWS now offers both ElastiCache for Valkey and MemoryDB for Valkey alongside its Redis OSS-compatible offerings. If you're choosing "Redis Streams" as a category today, you're really choosing between Redis (AGPLv3, Redis Inc.-controlled) and Valkey (BSD, community/vendor-governed) as the underlying engine — the streams functionality itself is essentially the same either way.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;AWS SQS: The Serverless Cloud Workhorse
Amazon Simple Queue Service (SQS) is a fully managed queuing service. For webhook ingestion, it's typically paired with API Gateway or an Application Load Balancer so incoming HTTP POSTs land directly in a queue without a permanent web server in the hot path.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;How it works for webhooks&lt;br&gt;
SQS offers two queue types:&lt;/p&gt;

&lt;p&gt;Standard queues — near-unlimited throughput, at-least-once delivery, best-effort ordering.&lt;br&gt;
FIFO queues — strict ordering and exactly-once processing per MessageGroupId, with throughput limits (see below).&lt;br&gt;
When a worker calls ReceiveMessage, the message enters a visibility timeout window. If the worker crashes or doesn't delete the message before the timeout expires, the message becomes visible again for another worker to pick up.&lt;/p&gt;

&lt;p&gt;Code example&lt;br&gt;
Copy code&lt;br&gt;
+---------------+     HTTP POST     +-----------------+     Enqueue     +-----------------+&lt;br&gt;
| Webhook Sender | ----------------&amp;gt; | AWS API Gateway | -------------&amp;gt; |  Amazon SQS     |&lt;br&gt;
+---------------+                   +-----------------+                 +-----------------+&lt;br&gt;
                                                                                 |&lt;br&gt;
                                                                           Poll  | (Visibility Timeout)&lt;br&gt;
                                                                                 v&lt;br&gt;
                                                                        +-----------------+&lt;br&gt;
                                                                        | AWS Lambda / ECS |&lt;br&gt;
                                                                        +-----------------+&lt;br&gt;
Key technical characteristics (current, as of 2026)&lt;br&gt;
Zero infrastructure ops. No servers to provision or patch. SQS scales automatically.&lt;br&gt;
Message size: 1 MiB, not 256 KB. This is the biggest thing older comparisons get wrong: AWS raised the maximum SQS payload from 256 KiB to 1 MiB in August 2025, across standard and FIFO queues, in all commercial regions. For anything larger, you still need the SQS Extended Client Library, which offloads the payload to S3 and passes a reference through the queue.&lt;br&gt;
FIFO throughput is regional and tiered. Standard (non-high-throughput) FIFO is limited to 300 transactions per second (TPS) per API action, or 3,000 messages/sec with batching. High-throughput mode raises this considerably, but the ceiling depends on region — as of the current published quotas, US East (N. Virginia), US West (Oregon), and Europe (Ireland) support up to 70,000 TPS per API action (700,000 msgs/sec batched); other regions range from about 4,500 TPS down to a 2,400 TPS default elsewhere. Always check AWS's current quota table for your region before designing around a number.&lt;br&gt;
In-flight message limit. SQS raised the FIFO in-flight message cap from 20,000 to 120,000 in November 2024, which matters if your webhook backlog processing was previously bottlenecked on that ceiling.&lt;br&gt;
Built-in dead-letter queues. A maxReceiveCount redrive policy moves a message to a linked DLQ after N failed processing attempts — no custom code required.&lt;br&gt;
Cost model. You pay per request (in increments of, effectively, per-million API calls), which is cost-effective at low-to-moderate volume; continuous aggressive polling adds up, so long polling is worth using deliberately.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;RabbitMQ: The Flexible AMQP Routing Engine
RabbitMQ is an open-source message broker built primarily on AMQP 0-9-1, with AMQP 1.0, MQTT, and STOMP also supported. It's strongest where incoming webhooks need to be dynamically routed to different backend services based on headers or topics.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;How it works for webhooks&lt;br&gt;
Producers publish to exchanges, not directly to queues. An exchange uses routing keys and bindings to deliver each message to one or more bound queues.&lt;/p&gt;

&lt;p&gt;Code example&lt;br&gt;
Copy code&lt;br&gt;
                                    +-------------------+     Routing Key     +----------------------+&lt;br&gt;
                                    | Direct / Topic    | ------------------&amp;gt; | Billing Queue        |&lt;br&gt;
+----------------+     Publish      | Exchange          |                     +----------------------+&lt;br&gt;
| Webhook Server | ----------------&amp;gt; |                   |                     +----------------------+&lt;br&gt;
+----------------+                  |                   | ------------------&amp;gt; | Analytics Queue      |&lt;br&gt;
                                    +-------------------+                     +----------------------+&lt;br&gt;
For durability, modern RabbitMQ deployments use quorum queues — a replicated, Raft-based queue type — rather than the deprecated mirrored classic queues.&lt;/p&gt;

&lt;p&gt;Key technical characteristics (RabbitMQ 4.x, current line as of 2026)&lt;br&gt;
Rich routing. Route stripe.charge.succeeded to a billing queue and stripe.customer.created to a CRM queue using topic exchanges, without application-level filtering.&lt;br&gt;
Quorum queues by default for durability. They replicate via Raft and confirm a write only after a majority of nodes persist it. As of RabbitMQ 4.0, classic queue mirroring was removed entirely — classic queues are now single-replica only, and quorum queues (or streams) are the supported path for replicated, durable data.&lt;br&gt;
Dead Letter Exchanges (DLX). Native and flexible. Quorum queues also ship with a default redelivery limit of 20 (configurable via a policy), after which a message is dead-lettered automatically — this replaced the older manual TTL/requeue=false pattern as the default behavior.&lt;br&gt;
A second replicated option: Streams. Separately from quorum queues, RabbitMQ has a Streams queue type (its own log-based structure with a dedicated binary protocol, distinct from Kafka-style "streaming platforms" but conceptually similar to Redis Streams). Streams are heavily disk-I/O bound and benefit a lot from SSD/NVMe storage, but offer strong throughput for append-only, replay-capable workloads and support non-destructive, repeatable consumption.&lt;br&gt;
Khepri replaces Mnesia. RabbitMQ 4.0 made Khepri (a Raft-based metadata store) the fully supported default, and by 4.3 it's the only supported metadata store — Mnesia was removed. Operationally this means a cluster now needs a majority of nodes online at all times for the metadata layer, the same requirement quorum queues and streams already had.&lt;br&gt;
AMQP 1.0 is now core. As of 4.0, AMQP 1.0 is a built-in protocol (not a plugin) with more than double the peak throughput of the 3.13.x implementation on some workloads.&lt;br&gt;
Message size. Configurable via max_message_size. The default changed from 128 MiB (RabbitMQ ≤3.13) to 16 MiB as of RabbitMQ 4.0+ — you can raise it, but very large messages hurt broker performance regardless of the ceiling.&lt;br&gt;
Operational overhead. Running a highly available cluster means managing Erlang upgrades, network partitions, disk-alarm thresholds, and (until recently) two possible metadata stores — now consolidated to one.&lt;br&gt;
Technical Comparison Matrix&lt;br&gt;
Feature / Criteria  Redis Streams (Redis or Valkey) AWS SQS RabbitMQ (Quorum Queues / Streams)&lt;br&gt;
Primary paradigm    In-memory append-only log   Fully managed serverless queue  Replicated AMQP broker (+ separate log-based Streams type)&lt;br&gt;
Ingestion throughput    High, single-node (workload-dependent; benchmark before relying on a number)    Standard: near-unlimited. FIFO: 300–70,000 TPS per action depending on region and mode    High; exact ceiling depends on message size, disk speed, and cluster config&lt;br&gt;
Ingestion latency   Sub-millisecond typical Tens of milliseconds (HTTP-based API)   Low single-digit milliseconds typical&lt;br&gt;
Persistence guarantee   In-memory first; optional AOF/RDB async disk sync   Multi-AZ durable by default Raft-based write-ahead log (quorum queues); disk-log (streams)&lt;br&gt;
Dead-letter handling    Manual — via XPENDING/XCLAIM and your own logic   Built-in — maxReceiveCount + native DLQ redrive   Built-in — native DLX; quorum queues default to a 20-attempt redelivery limit&lt;br&gt;
Message ordering    Strict per-stream   Best-effort (standard) / strict per MessageGroupId (FIFO)   Strict FIFO per queue&lt;br&gt;
Routing flexibility Basic (stream key naming)   Basic (1 queue = 1 target; SNS fan-out needed for more) Advanced (direct, fanout, topic, headers exchanges)&lt;br&gt;
Max payload size    Bounded by available RAM — keep payloads small    1 MiB (raised from 256 KiB in August 2025); larger via S3-backed Extended Client    Configurable; default 16 MiB as of RabbitMQ 4.0 (was 128 MiB)&lt;br&gt;
Operational complexity  Medium (self-managed) to low (managed Redis/Valkey) Zero (fully managed)    High (Erlang runtime, Raft-based cluster state, Khepri)&lt;br&gt;
Licensing   Redis: AGPLv3 (as of Redis 8.0, May 2025). Valkey: BSD (Linux Foundation)   Proprietary AWS service Mozilla Public License 2.0&lt;br&gt;
Head-to-Head Evaluation for Webhook Workloads&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Throughput and latency during sudden spikes
When a payment provider fires tens of thousands of webhooks in a short window, your buffer has to absorb them without dropping connections.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Redis Streams wins on raw speed — in-RAM writes mean your edge layer can return 200 OK in low single-digit milliseconds.&lt;br&gt;
SQS Standard wins on elasticity — no pre-provisioning, and it absorbs spikes automatically, at the cost of tens of milliseconds of per-call network latency versus Redis.&lt;br&gt;
RabbitMQ performs well under load, but if backlogs grow into the millions of unconsumed messages, memory pressure can trigger RabbitMQ's high-watermark alarms, which deliberately block publishers to protect the node.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Persistence and data-loss protection
Webhooks often carry financial or state-changing events — losing one can mean a missed sale or an inconsistent record.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;SQS offers the strongest out-of-the-box durability: messages are stored across multiple Availability Zones before the write is acknowledged.&lt;br&gt;
RabbitMQ quorum queues confirm a write only once a majority of Raft cluster members have persisted it.&lt;br&gt;
Redis Streams is in-memory first. Even with AOF enabled at everysec, a hard crash can lose up to about a second of recent writes. Synchronous replication or a more conservative fsync policy narrows this gap at a throughput cost.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Poison-payload and dead-letter handling
A "poison" webhook is one that reliably crashes your worker on every attempt.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;SQS handles this cleanly: set maxReceiveCount, and after that many failed attempts SQS moves the message to a linked DLQ and can trigger a CloudWatch alarm, with a console UI for inspecting and redriving.&lt;br&gt;
RabbitMQ dead-letters via DLX, and quorum queues now default to a 20-attempt redelivery limit before that happens automatically — no custom TTL logic required.&lt;br&gt;
Redis Streams still has no built-in DLQ. You need to poll XPENDING for messages that have been claimed but not acknowledged for too long, track delivery attempts yourself, and move stuck entries to a separate stream with XCLAIM.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Operational complexity and maintenance
SQS requires close to zero infrastructure maintenance.
Redis Streams / Valkey needs memory monitoring, eviction/trim policy, and replication management to avoid out-of-memory crashes.
RabbitMQ is the heaviest to run yourself: Erlang upgrades, network-partition handling, disk-alarm monitoring, and — as of 4.x — a single Khepri-based metadata store that requires a quorum of nodes to be online.
Decision Framework
Choose Redis Streams (or Valkey Streams) if:
You already run Redis or Valkey and want the lowest possible ingestion latency without adding new infrastructure.
Your webhook volume is high but transient, and you have processes in place to trim streams and monitor memory.
You're comfortable writing your own dead-letter and retry logic around XPENDING/XCLAIM.
You've made a deliberate choice on licensing — Redis (AGPLv3) vs. Valkey (BSD) — rather than assuming "Redis" still means what it did before 2024.
Choose AWS SQS if:
You're already on AWS and want a serverless, pay-per-use model with no cluster to manage.
You want native dead-letter queues and don't want to build that logic yourself.
Your payloads fit in 1 MiB (true for the vast majority of webhook providers) and tens-of-milliseconds latency is acceptable.
You need FIFO guarantees at meaningful scale — just check your region's current high-throughput quota rather than assuming the older 3,000 msg/sec figure still applies everywhere.
Choose RabbitMQ if:
You need complex, multi-tenant routing — e.g., dynamically routing webhooks to many internal queues by header or tenant ID.
You're on multi-cloud or on-prem infrastructure where AWS SQS isn't an option.
You have the operational capacity to run an Erlang-based, Raft-backed cluster (Khepri, quorum queues) and keep it patched.
The Hidden Engineering Tax of Building Your Own Webhook Queue
Comparing these three engines highlights something worth stating plainly: a message queue is only one piece of a complete webhook processing pipeline. Around whichever engine you pick, you still need to build and maintain:&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Code example&lt;br&gt;
Copy code&lt;br&gt;
+-----------------------------------------------------------------------------------+&lt;br&gt;
|                        Your Custom Webhook Infrastructure                        |&lt;br&gt;
|                                                                                   |&lt;br&gt;
|  +--------------------+   +-----------------------+   +------------------------+  |&lt;br&gt;
|  | Webhook Receiver   |   | Ingestion Buffer      |   | Processing &amp;amp; Security  |  |&lt;br&gt;
|  | (API Endpoints)    |   | (Redis/SQS/RabbitMQ)  |   | (Signature Validation) |  |&lt;br&gt;
|  +--------------------+   +-----------------------+   +------------------------+  |&lt;br&gt;
|                                                                                   |&lt;br&gt;
|  +--------------------+   +-----------------------+   +------------------------+  |&lt;br&gt;
|  | Retry &amp;amp; Backoff    |   | Poison Message / DLQ  |   | Debugging UI &amp;amp; Logs    |  |&lt;br&gt;
|  | Engines            |   | Management            |   | &amp;amp; Observability        |  |&lt;br&gt;
|  +--------------------+   +-----------------------+   +------------------------+  |&lt;br&gt;
+-----------------------------------------------------------------------------------+&lt;br&gt;
Signature verification. You need middleware to check cryptographic signatures (Stripe's Stripe-Signature, Shopify's X-Shopify-Hmac-SHA256, and so on) before trusting a payload.&lt;br&gt;
Idempotency and deduplication. Providers routinely deliver duplicate events. You need a dedup layer — a lock key in Redis, a unique constraint in your database — to keep processing idempotent.&lt;br&gt;
Backoff and jitter. When a downstream write fails (a lock, a third-party outage), your consumer pipeline needs exponential backoff with jitter so it doesn't hammer your own systems.&lt;br&gt;
Visibility and replay. When a critical webhook fails, someone needs to inspect the raw payload, see the error trace, and re-drive it. None of the three engines above gives you a webhook-specific inspection UI out of the box — SQS's DLQ console comes closest, but it's generic to SQS, not webhook-aware.&lt;br&gt;
If that surrounding tooling is more work than your team wants to own, that's the actual trade-off to weigh against build-vs-buy for a dedicated webhook ingestion/delivery platform — evaluate any such vendor on the same criteria above (throughput, durability, DLQ handling, and what it actually costs at your volume) rather than on marketing copy.&lt;/p&gt;

&lt;p&gt;Conclusion&lt;br&gt;
The choice comes down to your priorities:&lt;/p&gt;

&lt;p&gt;Redis Streams (on Redis or Valkey) for the lowest latency, if you're equipped to handle memory management and build your own DLQ logic — and you've made a conscious call on which license/engine you're running.&lt;br&gt;
AWS SQS for a serverless, low-maintenance queue with native dead-lettering, now with a much less restrictive 1 MiB message size than it had before August 2025.&lt;br&gt;
RabbitMQ for advanced routing topologies and multi-cloud or on-prem independence, on a now-simplified (Khepri-only) operational model as of 4.x.&lt;br&gt;
Whichever you pick, budget for the signature verification, deduplication, retry, and observability layer around it — that's usually the larger and more error-prone part of the build, not the queue itself.&lt;/p&gt;

&lt;p&gt;Sources&lt;br&gt;
AWS SQS message quotas — official docs&lt;br&gt;
AWS: SQS increases maximum message payload size to 1 MiB (Aug 2025)&lt;br&gt;
AWS: SQS increases FIFO in-flight limit from 20K to 120K (Nov 2024)&lt;br&gt;
AWS: SQS FIFO high-throughput quota increases, 2022–2023 whats-new posts&lt;br&gt;
RabbitMQ 4.0 release notes&lt;br&gt;
RabbitMQ persistence configuration guide&lt;br&gt;
CloudAMQP: RabbitMQ message size limits by version&lt;br&gt;
InfoQ: Redis returns to open source under AGPL (May 2025)&lt;br&gt;
Valkey GLIDE client — engine version support&lt;br&gt;
This is a sensitive-to-change area — throughput quotas, licenses, and version defaults shift over time. Treat the specific numbers above as accurate at time of writing and re-check the linked official docs before making an architecture decision.&lt;/p&gt;

</description>
    </item>
    <item>
      <title>Handling Out-of-Order Webhooks: Event Sequencing in Distributed Systems</title>
      <dc:creator>InstaWebhook</dc:creator>
      <pubDate>Thu, 17 Sep 2026 04:56:47 +0000</pubDate>
      <link>https://dev.to/instawebhook/handling-out-of-order-webhooks-event-sequencing-in-distributed-systems-2k7d</link>
      <guid>https://dev.to/instawebhook/handling-out-of-order-webhooks-event-sequencing-in-distributed-systems-2k7d</guid>
      <description>&lt;p&gt;API webhook event handling&lt;br&gt;
asynchronous event handling&lt;br&gt;
asynchronous messaging race conditions&lt;br&gt;
asynchronous webhook race condition solutions&lt;br&gt;
at least once webhook delivery&lt;br&gt;
debugging out of order webhooks&lt;br&gt;
distributed state management webhooks&lt;br&gt;
distributed systems event sequencing&lt;br&gt;
distributed systems race conditions&lt;br&gt;
event driven architecture webhooks&lt;br&gt;
event ordering in distributed systems&lt;br&gt;
eventual consistency webhooks&lt;br&gt;
handling asynchronous race conditions&lt;br&gt;
handling order canceled before created&lt;br&gt;
handling out of order webhook payload&lt;br&gt;
InstaWebhook delivery timelines&lt;br&gt;
InstaWebhook event delivery&lt;br&gt;
lamport timestamps webhooks&lt;br&gt;
logical clocks webhooks&lt;br&gt;
message queue event ordering&lt;br&gt;
microservices event sequencing&lt;br&gt;
optimistic locking webhooks&lt;br&gt;
out of order event processing&lt;br&gt;
out of order events distributed systems&lt;br&gt;
out of order webhooks&lt;br&gt;
resolving webhook race conditions&lt;br&gt;
secure webhook delivery&lt;br&gt;
state machines for webhooks&lt;br&gt;
version vectors webhooks&lt;br&gt;
webhook architecture best practices&lt;br&gt;
webhook concurrency issues&lt;br&gt;
webhook data corruption prevention&lt;br&gt;
webhook debugging strategies&lt;br&gt;
webhook deduplication&lt;br&gt;
webhook delivery order&lt;br&gt;
webhook delivery retries&lt;br&gt;
webhook event delivery guarantees&lt;br&gt;
webhook event order guarantees&lt;br&gt;
webhook event ordering&lt;br&gt;
webhook event sequencing&lt;br&gt;
webhook idempotency&lt;br&gt;
webhook integration patterns&lt;br&gt;
webhook listener architecture&lt;br&gt;
webhook payload sequencing&lt;br&gt;
webhook processing pipeline&lt;br&gt;
webhook race conditions&lt;br&gt;
webhook reliability engineering&lt;br&gt;
webhook retry logic&lt;br&gt;
webhooks network latency&lt;br&gt;
webhook state machine pattern&lt;br&gt;
webhook system architecture&lt;br&gt;
webhook timestamp ordering&lt;br&gt;
webhook versioning pattern&lt;br&gt;
Handling Out Of Order Webhooks Event Sequencing In Distributed Systems&lt;br&gt;
Handling Out-of-Order Webhooks: Event Sequencing in Distributed Systems&lt;br&gt;
In event-driven integrations, webhooks are the standard way platforms propagate state changes across service boundaries. When a customer buys a product, updates a subscription, or cancels an invoice, the upstream platform fires an HTTP POST to every downstream consumer that's listening.&lt;/p&gt;

&lt;p&gt;Under ideal network conditions, webhooks would arrive in the exact order the underlying events occurred. Production systems don't run under ideal conditions. Retry queues, parallel delivery workers, and multi-region infrastructure all mean events can and do arrive out of sequence — and this isn't an edge case, it's the documented, expected behavior of essentially every major webhook provider.&lt;/p&gt;

&lt;p&gt;Consider a common e-commerce scenario: a customer creates an order and cancels it a second later. If network jitter delays the order.created webhook while order.canceled sails through, your consumer processes the cancellation first. When order.created finally lands, a naive handler overwrites the record and resurrects a dead order.&lt;/p&gt;

&lt;p&gt;This guide covers why webhooks lose their ordering in transit, why the instinctive fixes don't hold up, and the architectural patterns — sequence numbers, finite state machines, full-state events, and canonical re-fetching — that make a consumer correct regardless of arrival order.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Why webhooks arrive out of order
Code example
Copy code
   Upstream Provider                           Downstream Consumer
+-----------------------+                    +------------------------+
|  Event 1: Created     |                    |                        |
|  (Timestamp: 10:00:00)|---[ Retried / ]---&amp;gt;|                        |
|                       |   [ Delayed   ]    |                        |
|                       |                    | (10:00:02) Receives:   |
|  Event 2: Canceled    |                    | "Order Canceled"       |
|  (Timestamp: 10:00:01)|-------------------&amp;gt;| Status set to CANCELED |
|                       |                    |                        |
|                       |                    | (10:00:05) Receives:   |
|                       |-------------------&amp;gt;| "Order Created"        |
|                       |   (Delayed Event)  | Status set to CREATED  |
+-----------------------+                    | BAD STATE: Resurrected!|
                                            +------------------------+
Three mechanisms account for most reordering in practice:&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Retries with exponential backoff. If a provider gets a transient error (a 503, a timeout) sending Event 1, it queues that event for retry. Event 2, which happened afterward, gets sent immediately and succeeds. When Event 1's retry finally lands, it arrives after Event 2.&lt;/p&gt;

&lt;p&gt;Parallel delivery workers. High-throughput providers dispatch outgoing webhooks across many concurrent workers. If Event 1's worker hits a slow network path and Event 2's worker doesn't, Event 2 arrives first even though it happened second.&lt;/p&gt;

&lt;p&gt;Multi-region replication lag. When events originate from geographically distributed databases, replication delays between regions can push an earlier event onto the outbound queue after a later one generated in a faster-replicating region.&lt;/p&gt;

&lt;p&gt;This isn't theoretical — it's explicitly documented behavior. Stripe's webhook documentation states plainly that it doesn't guarantee events are delivered in the order they're generated, and instructs integrators to design endpoints that don't depend on a specific order. Shopify's webhook documentation says the same: ordering isn't guaranteed within a topic or across topics for the same resource, and gives the exact example this article opened with — a products/update webhook can arrive before the products/create webhook for the same product.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Why the instinctive fixes don't work
Anti-pattern 1: Sorting by the payload's created_at timestamp
Code example
Copy code
-- DANGEROUS: susceptible to clock skew and same-millisecond collisions
UPDATE orders
SET status = 'created', updated_at = '2026-09-17T10:00:00Z'
WHERE id = 'ord_123'
AND updated_at &amp;lt; '2026-09-17T10:00:00Z';
Stripe's own guidance is direct on this point: snapshot events are timestamped to the second, so distinct events can share a created value, and the docs explicitly warn against using created to determine order or to detect duplicates — event IDs are what should be tracked instead. Two problems drive this:&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Clock and granularity limits. If two events land within the same timestamp resolution window, their timestamps are identical and give you no ordering information at all.&lt;br&gt;
The timestamp reflects when the event was generated, not when it's safe to apply. A delayed retry keeps its original generation time, so sorting by it doesn't fix the resurrection problem — the delayed order.created event still looks "older" than the cancellation on paper, but it's arriving and would be applied later in wall-clock time if you don't guard against it.&lt;br&gt;
Anti-pattern 2: In-memory locks across instances&lt;br&gt;
Using a local mutex to serialize processing by order_id only works if exactly one instance of your receiver is running. The moment you scale horizontally — multiple pods, multiple serverless invocations — an in-memory lock on one instance offers zero protection against a concurrent event landing on another.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Core patterns for webhook event sequencing
Pattern A: Idempotency keys (table stakes, not optional)
Before ordering, solve for duplicates — at-least-once delivery is the default guarantee for almost every major provider (Stripe, GitHub, Shopify among them), which means your endpoint will see the same event more than once. The fix is a stable, per-event identifier: Stripe sends event.id, Shopify sends an X-Shopify-Webhook-Id header, and a growing number of providers follow the open Standard Webhooks specification, which defines a webhook-id header specifically as an idempotency key that stays constant across retries. Reporting suggests adopters of that spec now include Zapier, Twilio, Supabase, PagerDuty, and several AI providers.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The pattern is simple: record the ID in a unique-constrained table (or a short-lived Redis key) before or during processing, and skip anything you've already recorded.&lt;/p&gt;

&lt;p&gt;Code example&lt;br&gt;
Copy code&lt;br&gt;
CREATE TABLE processed_webhooks (&lt;br&gt;
  event_id    TEXT PRIMARY KEY,&lt;br&gt;
  received_at TIMESTAMPTZ NOT NULL DEFAULT now()&lt;br&gt;
);&lt;br&gt;
-- INSERT fails on a duplicate event_id -&amp;gt; you know it's already been handled&lt;br&gt;
Idempotency solves duplication. It does nothing for ordering on its own — that's what the remaining patterns address.&lt;/p&gt;

&lt;p&gt;Pattern B: Monotonic sequence numbers with optimistic concurrency control&lt;br&gt;
Where the upstream system exposes a per-resource sequence or version number, the consumer can reject any event that doesn't move the version forward.&lt;/p&gt;

&lt;p&gt;Code example&lt;br&gt;
Copy code&lt;br&gt;
{&lt;br&gt;
  "id": "evt_987654321",&lt;br&gt;
  "type": "order.updated",&lt;br&gt;
  "resource_id": "ord_1001",&lt;br&gt;
  "sequence": 3,&lt;br&gt;
  "timestamp": "2026-09-17T10:00:01.452Z",&lt;br&gt;
  "data": { "status": "processing", "total_cents": 4999 }&lt;br&gt;
}&lt;br&gt;
Code example&lt;br&gt;
Copy code&lt;br&gt;
-- PostgreSQL atomic update, guarded by version&lt;br&gt;
UPDATE orders&lt;br&gt;
SET status = $1, version = $2, updated_at = NOW()&lt;br&gt;
WHERE id = $3&lt;br&gt;
  AND version &amp;lt; $2;&lt;br&gt;
A 0 row count means one of two things: the event is stale (a higher sequence was already applied), or there's a gap (you received sequence 4 while sitting at sequence 1). Distinguishing the two determines whether you silently drop the event or escalate to a recovery strategy (Section 4).&lt;/p&gt;

&lt;p&gt;This pattern only works if the upstream provider actually issues sequence numbers — most mainstream SaaS webhook systems (Stripe, Shopify, GitHub) don't expose one on standard events, which is why the next two patterns exist for providers that don't.&lt;/p&gt;

&lt;p&gt;Pattern C: Finite state machines&lt;br&gt;
Without a sequence number, you can still enforce domain-level invariants by restricting which state transitions are valid, independent of what order events arrive in.&lt;/p&gt;

&lt;p&gt;Code example&lt;br&gt;
Copy code&lt;br&gt;
                  +----------------------------------+&lt;br&gt;
                  |                                  |&lt;br&gt;
                  v                                  |&lt;br&gt;
            +-----------+      +------------+      +-+----------+&lt;br&gt;
[Start] ---&amp;gt;|  CREATED  |-----&amp;gt;| PROCESSING |-----&amp;gt;| COMPLETED  |&lt;br&gt;
            +-----------+      +------------+      +------------+&lt;br&gt;
                  |                                  ^&lt;br&gt;
                  |                                  |&lt;br&gt;
                  +----------------------------------+&lt;br&gt;
                  |&lt;br&gt;
                  v&lt;br&gt;
            +-----------+&lt;br&gt;
            | CANCELED  | (Terminal State)&lt;br&gt;
            +-----------+&lt;br&gt;
Current state   Incoming event  Valid?  Action&lt;br&gt;
NONE    order.created   Yes Create record as CREATED&lt;br&gt;
NONE    order.canceled  Yes Create record as terminal CANCELED&lt;br&gt;
CANCELED    order.created   No  Reject — prevents resurrection&lt;br&gt;
CREATED order.canceled  Yes Transition to CANCELED&lt;br&gt;
COMPLETED   order.updated   No  Ignore — terminal state reached&lt;br&gt;
Code example&lt;br&gt;
Copy code&lt;br&gt;
import { Pool } from 'pg';&lt;/p&gt;

&lt;p&gt;const dbPool = new Pool({ connectionString: process.env.DATABASE_URL });&lt;/p&gt;

&lt;p&gt;const VALID_TRANSITIONS: Record = {&lt;br&gt;
  NONE: ['CREATED', 'CANCELED'],&lt;br&gt;
  CREATED: ['PROCESSING', 'CANCELED', 'COMPLETED'],&lt;br&gt;
  PROCESSING: ['COMPLETED', 'CANCELED'],&lt;br&gt;
  COMPLETED: [],&lt;br&gt;
  CANCELED: [],&lt;br&gt;
};&lt;/p&gt;

&lt;p&gt;interface WebhookPayload {&lt;br&gt;
  eventId: string;&lt;br&gt;
  orderId: string;&lt;br&gt;
  targetState: string;&lt;br&gt;
  sequence: number;&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;export async function handleOrderWebhook(payload: WebhookPayload): Promise {&lt;br&gt;
  const client = await dbPool.connect();&lt;br&gt;
  try {&lt;br&gt;
    await client.query('BEGIN');&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;const res = await client.query(
  `SELECT status, version FROM orders WHERE id = $1 FOR UPDATE`,
  [payload.orderId]
);
const existing = res.rows[0];
const currentState = existing ? existing.status : 'NONE';
const currentVersion = existing ? existing.version : 0;

if (existing &amp;amp;&amp;amp; payload.sequence &amp;lt;= currentVersion) {
  await client.query('ROLLBACK');
  return; // stale/duplicate
}

const allowed = VALID_TRANSITIONS[currentState] || [];
if (!allowed.includes(payload.targetState)) {
  // Special case: cancellation arriving before creation is still valid data
  if (currentState === 'NONE' &amp;amp;&amp;amp; payload.targetState === 'CANCELED') {
    await client.query(
      `INSERT INTO orders (id, status, version, created_at) VALUES ($1, $2, $3, NOW())`,
      [payload.orderId, 'CANCELED', payload.sequence]
    );
    await client.query('COMMIT');
    return;
  }
  await client.query('ROLLBACK'); // invalid transition, reject
  return;
}

if (!existing) {
  await client.query(
    `INSERT INTO orders (id, status, version, created_at) VALUES ($1, $2, $3, NOW())`,
    [payload.orderId, payload.targetState, payload.sequence]
  );
} else {
  await client.query(
    `UPDATE orders SET status = $1, version = $2, updated_at = NOW() WHERE id = $3`,
    [payload.targetState, payload.sequence, payload.orderId]
  );
}
await client.query('COMMIT');
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;} catch (error) {&lt;br&gt;
    await client.query('ROLLBACK');&lt;br&gt;
    throw error;&lt;br&gt;
  } finally {&lt;br&gt;
    client.release();&lt;br&gt;
  }&lt;br&gt;
}&lt;br&gt;
Pattern D: Emit full-state or terminal events instead of deltas&lt;br&gt;
The most effective fix is often on the producer side, and it's the approach several providers have converged on rather than trying to guarantee delivery order at all. Convoy's guidance for webhook providers makes the case directly: requesting strict ordering from a webhook system is an anti-pattern because it adds significant complexity, and the better fix is to design payloads so ordering doesn't matter. One technique is to emit a distinct event per state reached (invoice.paid, invoice.voided) rather than a generic invoice.updated delta — a paid invoice can't become unpaid, so a consumer applying a terminal event doesn't need to know what came before it.&lt;/p&gt;

&lt;p&gt;Shopify's newer order-webhook design for agent integrations takes this further: every delivery carries the full current state of the order, identical to what a direct API read would return, and the documentation is explicit that consumers should "treat the latest payload as the source of truth" rather than replaying deltas to reconstruct state. If you can influence the payload shape (an internal event bus, a partner API you also control) or if the upstream API already works this way, this pattern removes the ordering problem for the receiver almost entirely — the newest payload always wins, no version tracking required.&lt;/p&gt;

&lt;p&gt;Pattern E: Canonical state re-fetch (the "claim check" pattern)&lt;br&gt;
Where you don't control the payload shape, use the webhook only as a notification and treat the API as the source of truth:&lt;/p&gt;

&lt;p&gt;Receive the webhook as a signal that something changed.&lt;br&gt;
Make a synchronous GET back to the canonical resource endpoint.&lt;br&gt;
Overwrite local state with whatever the API returns right now.&lt;br&gt;
This is exactly what both Stripe and Shopify recommend when payload data might be stale or incomplete — Stripe's docs point out you can retrieve missing objects via the API, and Shopify explicitly recommends periodic reconciliation jobs against its API because delivery (and by extension, order) isn't guaranteed. The tradeoff is an extra HTTP round trip and exposure to the upstream API's rate limits, but it's self-healing: even a dropped event gets corrected on the next re-fetch.&lt;/p&gt;

&lt;p&gt;Pattern F: Buffering with a TTL&lt;br&gt;
When you must apply deltas and can't fall back to an API re-fetch, hold out-of-sequence events in a short-lived buffer keyed by sequence number:&lt;/p&gt;

&lt;p&gt;Code example&lt;br&gt;
Copy code&lt;br&gt;
async function processOrBufferEvent(event: WebhookPayload) {&lt;br&gt;
  const currentVer = await getCurrentDbVersion(event.orderId);&lt;/p&gt;

&lt;p&gt;if (event.sequence === currentVer + 1) {&lt;br&gt;
    await applyEventToDatabase(event);&lt;br&gt;
    await drainBufferedEvents(event.orderId, event.sequence + 1);&lt;br&gt;
  } else if (event.sequence &amp;gt; currentVer + 1) {&lt;br&gt;
    // Gap: park it and set a safety TTL in case the missing event never arrives&lt;br&gt;
    await redis.zadd(&lt;code&gt;buffer:${event.orderId}&lt;/code&gt;, event.sequence, JSON.stringify(event));&lt;br&gt;
    await redis.expire(&lt;code&gt;buffer:${event.orderId}&lt;/code&gt;, 300);&lt;br&gt;
  } else {&lt;br&gt;
    // Stale/duplicate&lt;br&gt;
  }&lt;br&gt;
}&lt;br&gt;
If the upstream provider itself offers ordered delivery, that's worth using instead of building this yourself. Svix, an open-source webhook-sending platform, publishes a good explanation of why it doesn't guarantee order on regular endpoints, but it does offer opt-in FIFO endpoints for consumers who need strict ordering — at the cost of throughput, since each delivery blocks until the previous one is acknowledged.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;What major providers actually do (a quick reference)&lt;br&gt;
Provider    Guarantees order?   Dedup key   Retry window    Recommended fallback&lt;br&gt;
Stripe  No  event.id    Up to 3 days, exponential backoff   Fetch object via API&lt;br&gt;
Shopify No  X-Shopify-Webhook-Id    8 attempts over 4 hours Periodic API reconciliation&lt;br&gt;
GitHub  No explicit guarantee   Delivery ID No auto-retry; manual/API redelivery within a retention window  Poll the REST API&lt;br&gt;
Standard Webhooks–compliant providers Spec-dependent  webhook-id header   Implementation-defined  Spec recommends ID-based dedup&lt;br&gt;
The pattern across all of them is consistent: no mainstream provider promises ordered delivery by default, and all of them point integrators toward the same two tools — a stable ID for deduplication, and either a state machine/version check or a live API call for correctness.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Comparison of consumer-side patterns&lt;br&gt;
Pattern Complexity  Extra network cost  Fixes ordering  Handles gap/loss&lt;br&gt;
Idempotency keys    Low None    No (solves dupes, not order)    No&lt;br&gt;
Monotonic sequence + OCC    Low None    Yes Needs a fallback&lt;br&gt;
Finite state machine    Medium  None    Yes Prevents invalid states, doesn't recover missing ones&lt;br&gt;
Full-state / terminal events    Low (if you control payloads)   None    Yes, by design  Yes — newest payload always wins&lt;br&gt;
Canonical API re-fetch  Low One extra HTTP call Yes Yes — self-healing&lt;br&gt;
Event buffering with TTL    High (needs Redis/DLQ)  Low Yes Needs a timeout/DLQ path&lt;br&gt;
Provider-side FIFO/ordered delivery Depends on provider support Lower throughput    Yes Depends on provider&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Debugging and testing tools&lt;br&gt;
A few widely used, genuinely independent options for inspecting and replaying webhook traffic during development:&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Provider-native tooling — the Stripe CLI can trigger and forward test events locally, and GitHub lets you redeliver any webhook from the past retention window straight from the repository settings UI or the REST API.&lt;br&gt;
ngrok / webhook.site — quick, low-friction ways to expose a local endpoint or inspect raw payloads without deploying anything.&lt;br&gt;
Hookdeck and Svix — hosted webhook gateways that sit between the provider and your app, adding queuing, delivery logs, and replay so you can see exactly what was sent and when, independent of your own server logs.&lt;br&gt;
None of these solve ordering for you — that logic still lives in your consumer, per the patterns above — but they make it far easier to see when out-of-order delivery is actually happening versus a bug in your own handler.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Resilience checklist
Idempotency keys — store the provider's event/delivery ID and skip anything already processed.
No timestamp-based ordering — providers explicitly warn that created/updated_at fields can collide or reflect generation time, not safe-to-apply time.
Sequence numbers where available — enforce WHERE version &amp;lt; incoming_version on writes.
State machine validation — block transitions like CANCELED → CREATED regardless of arrival order.
Prefer full-state or terminal-event payloads where you control the producer, so the newest delivery is always correct on its own.
Canonical re-fetch fallback — call the source API when a payload might be stale or a sequence gap is detected.
Reconciliation jobs — periodically re-sync against the API, since delivery itself (not just order) usually isn't guaranteed either.
Dead-letter queue — route events that fail validation after retries to a DLQ for manual inspection rather than silently dropping them.
Conclusion
Out-of-order delivery isn't a bug in any particular webhook provider — it's a documented, structural property of asynchronous HTTP delivery, and every major platform tells integrators so directly. Trying to force strict ordering at the transport layer is fragile; the durable fix is a consumer (and, where possible, a producer) designed so that arrival order simply doesn't matter — through idempotency keys, version-checked writes, state machine validation, full-state payloads, or a canonical re-fetch when in doubt.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A note on this piece: an earlier draft of this article contained a section promoting a specific "webhook debugging" product as though it were an established, neutral tool. That product's own content turned out to be SEO-style marketing material rather than documentation, so it's been replaced above with real, independently verifiable tools and vendor documentation — the tone throughout is intentionally point at primary sources you can check yourself.&lt;/p&gt;

</description>
    </item>
    <item>
      <title>Measuring Webhook Health: Tracking First-Attempt Success Rate (FASR)</title>
      <dc:creator>InstaWebhook</dc:creator>
      <pubDate>Wed, 16 Sep 2026 06:42:46 +0000</pubDate>
      <link>https://dev.to/instawebhook/measuring-webhook-health-tracking-first-attempt-success-rate-fasr-525m</link>
      <guid>https://dev.to/instawebhook/measuring-webhook-health-tracking-first-attempt-success-rate-fasr-525m</guid>
      <description>&lt;p&gt;API delivery reliability&lt;br&gt;
API health metrics&lt;br&gt;
api observability&lt;br&gt;
API webhook success rate&lt;br&gt;
Datadog log export&lt;br&gt;
Datadog webhook exporter&lt;br&gt;
downstream database pressure&lt;br&gt;
event delivery metrics&lt;br&gt;
event-driven observability&lt;br&gt;
FASR&lt;br&gt;
first-attempt success rate&lt;br&gt;
First-Attempt Success Rate API&lt;br&gt;
InstaWebhook delivery metrics&lt;br&gt;
InstaWebhook logs&lt;br&gt;
microservices webhook health&lt;br&gt;
Prometheus log exporter&lt;br&gt;
Prometheus webhook metrics&lt;br&gt;
real-time webhook tracking&lt;br&gt;
webhook alerts&lt;br&gt;
webhook analytics&lt;br&gt;
webhook debugging&lt;br&gt;
webhook delivery metrics&lt;br&gt;
webhook delivery tracking&lt;br&gt;
webhook error handling&lt;br&gt;
webhook error rate&lt;br&gt;
webhook failure alerts&lt;br&gt;
webhook failure rate&lt;br&gt;
webhook health&lt;br&gt;
webhook infrastructure monitoring&lt;br&gt;
webhook integration monitoring&lt;br&gt;
webhook latency tracking&lt;br&gt;
webhook logging&lt;br&gt;
webhook log streaming&lt;br&gt;
webhook monitoring&lt;br&gt;
webhook observability&lt;br&gt;
webhook operational metrics&lt;br&gt;
webhook performance metrics&lt;br&gt;
webhook queue bottleneck&lt;br&gt;
webhook queue depth&lt;br&gt;
webhook queue pressure&lt;br&gt;
webhook reliability&lt;br&gt;
webhook response time&lt;br&gt;
webhook retries&lt;br&gt;
webhook retries optimization&lt;br&gt;
webhook retry logic&lt;br&gt;
webhook retry volume&lt;br&gt;
webhook SLA monitoring&lt;br&gt;
webhook status codes&lt;br&gt;
webhook system health&lt;br&gt;
webhook telemetry&lt;br&gt;
webhook throughput&lt;br&gt;
webhook traffic monitoring&lt;br&gt;
Measuring Webhook Health Tracking First Attempt Success Rate FASR&lt;br&gt;
Measuring Webhook Health: Tracking First-Attempt Success Rate (FASR)&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The Illusion of "Eventual Delivery" in Webhook Systems
In modern event-driven architectures, webhooks act as the nervous system connecting decoupled microservices, payment gateways, e-commerce stores, and third-party SaaS applications. When evaluating system health, engineering teams frequently rely on a single top-level Service Level Objective (SLO): Eventual Success Rate (ESR).&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A status dashboard displaying "99.99% Eventual Webhook Delivery Success" creates a comfortable sense of security. However, for Site Reliability Engineers (SREs) and platform leaders, this single metric often masks critical underlying architectural failures.&lt;/p&gt;

&lt;p&gt;Code example&lt;br&gt;
Copy code&lt;br&gt;
                  THE EVENTUAL DELIVERY TRAP&lt;/p&gt;

&lt;p&gt;+------------------+     Attempt #1: 504 Timeout     +---------------------+&lt;br&gt;
| Webhook Producer | ------------------------------&amp;gt; | Downstream Database |&lt;br&gt;
+------------------+                                 | (Lock Contention)   |&lt;br&gt;
        |                                            +---------------------+&lt;br&gt;
        | Retry #1 (T + 10s): 504 Timeout                       ^&lt;br&gt;
        | Retry #2 (T + 60s): 504 Timeout                       |&lt;br&gt;
        | Retry #3 (T + 300s): 200 OK  -------------------------+&lt;br&gt;
        v&lt;br&gt;
Result: Marked as "SUCCESSFUL" in Eventual Metrics&lt;br&gt;
Reality: 5-minute processing delay, queue backlog, worker thread exhaustion&lt;br&gt;
If a webhook succeeds on its fourth retry attempt after 15 minutes of exponential backoff, traditional logging marks the payload as delivered. But what was the operational cost of that eventual delivery?&lt;/p&gt;

&lt;p&gt;Queue Pressure and Worker Exhaustion: Retrying millions of payloads forces event brokers (e.g., RabbitMQ, Apache Kafka, or AWS SQS) to retain messages longer, increasing memory usage and thread contention.&lt;br&gt;
Hidden Downstream Bottlenecks: A spike in retry volume usually indicates that a downstream consumer endpoint is struggling — often due to slow database queries, connection pool exhaustion, or unoptimized synchronous business logic.&lt;br&gt;
Out-of-Order Execution: Delayed retries can break chronological ordering. If an order.created webhook is retried for 10 minutes, an order.cancelled webhook emitted shortly after might be processed first by the receiver, creating data corruption and inconsistent state. This is precisely why most providers issue an at-least-once (not exactly-once, not strictly-ordered) delivery guarantee — more on this in the idempotency section below.&lt;br&gt;
To build resilient, high-throughput integration ecosystems, engineering leaders must shift their focus from eventual delivery to First-Attempt Success Rate (FASR) — a practical North Star metric for webhook observability. It's worth noting up front that "FASR" isn't a formally standardized industry acronym; you won't find it defined in an RFC. But the underlying measurement — the share of events that succeed without needing a retry — is exactly what mature webhook infrastructure providers already track. Hookdeck, for example, publishes a "delivery success rate" metric for this purpose and recommends keeping it above 99%, while explicitly calling out that a declining rate (even while eventual delivery stays high) signals that destinations are slow, erroring, or down.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Defining First-Attempt Success Rate (FASR)
First-Attempt Success Rate (FASR) measures the percentage of HTTP webhook payloads successfully delivered and acknowledged with an acceptable status code (typically 200 OK through 299) on the initial transmission attempt, without requiring retry mechanisms.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;2.1 The FASR Formula&lt;br&gt;
$$FASR = \left( \frac{N_{\text{success, attempt}=1}}{N_{\text{total, attempt}=1}} \right) \times 100$$&lt;/p&gt;

&lt;p&gt;Where:&lt;/p&gt;

&lt;p&gt;$N_{\text{success, attempt}=1}$ is the count of webhook HTTP requests that return an HTTP 2xx status code on attempt index 1 within the observation window.&lt;br&gt;
$N_{\text{total, attempt}=1}$ is the total volume of distinct webhook events emitted for their initial delivery attempt during the same window.&lt;br&gt;
2.2 Comparative Metric Matrix&lt;br&gt;
To get a complete view of webhook delivery health, FASR should be evaluated alongside supplementary indicators:&lt;/p&gt;

&lt;p&gt;Webhook Metric  Calculation Suggested Target    Operational Focus&lt;br&gt;
First-Attempt Success Rate (FASR)   $\frac{\text{Successes}&lt;em&gt;{\text{Attempt 1}}}{\text{Total Initial Ingress}} \times 100$   $&amp;gt; 98%$ Endpoint responsiveness, zero-queue overhead&lt;br&gt;
Eventual Success Rate (ESR) $\frac{\text{Total Unique Delivered Events}}{\text{Total Unique Ingested Events}} \times 100$   $&amp;gt; 99.9%$   Data integrity, overall pipeline durability&lt;br&gt;
Mean Time to Deliver (MTTD) $\frac{\sum (T&lt;/em&gt;{\text{delivered}} - T_{\text{emitted}})}{N_{\text{delivered}}}$ $&amp;lt; 500\text{ ms}$   Pipeline latency, event freshness&lt;br&gt;
Retry-to-Ingress Ratio  $\frac{\text{Total Retried Attempts}}{\text{Total Initial Ingress}}$    $&amp;lt; 0.05$    Infrastructure inefficiency, downstream stress&lt;br&gt;
A caveat worth stating plainly: the specific target numbers above (98%, 99.9%, 500ms) are reasonable engineering benchmarks, not universally published SLAs — no major provider we could find guarantees a specific first-attempt percentage. Treat them as a sane starting point to tune against your own traffic and endpoint mix, not as a certification you can point to. One data point worth knowing: at least one webhook infrastructure vendor (Kanopy) has published an estimate that roughly 15% of webhook deliveries fail on the first attempt across typical SaaS integrations — a useful sanity check, though it's a vendor estimate rather than an audited industry-wide figure.&lt;/p&gt;

&lt;p&gt;When your FASR drops while your ESR remains flat, your system is consuming extra compute and memory to force payloads through a congested receiver — the retries are doing the work that a healthy first attempt should have done.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;What Real Webhook Providers Actually Guarantee
Here's something that surprises a lot of teams: "webhooks" is not one contract. Every provider defines its own timeout window, retry count, and backoff schedule, and some don't retry at all. If you're building alerting thresholds or an SLA around FASR, you need to know what your actual upstream and downstream partners promise — not what you assume "a webhook" does.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Provider    Response timeout    Retry behavior  Notes&lt;br&gt;
Stripe  ~20 seconds Exponential backoff for up to 3 days in live mode; only 3 retries over a few hours in test mode Disables the endpoint and emails you if it keeps failing; exact backoff steps aren't published, but community-observed schedules run roughly immediate → 5 min → 30 min → 2 hr → 5 hr → 10 hr → every 12 hr&lt;br&gt;
GitHub  10 seconds  None, automatically. GitHub does not retry a failed delivery on its own You must manually redeliver via the UI/API, or build a scheduled script that polls the deliveries API and redelivers failures yourself&lt;br&gt;
Shopify ~5 seconds  Retries with backoff (shortest window among major providers)    Tight timeout makes the "acknowledge fast, process later" pattern essentially mandatory&lt;br&gt;
Svix (webhook-sending infrastructure)   15 seconds  Published 8-attempt schedule: immediate, +5s, +5m, +30m, +2h, +5h, +10h, +10h (last attempt ~27h35m after the first)    One of the few providers that documents its exact schedule publicly&lt;br&gt;
Recurly — Up to 10 retries, delay approximated by 10 + x·2^(x+5) seconds where x is the retry index  Early retries are fast, later ones space out substantially&lt;br&gt;
Alloy / many "Standard Webhooks"-style APIs ~10 seconds Multiple attempts, then endpoint marked failed after a sustained failure window (e.g., 72 hours of &amp;gt;95% non-2xx)    Illustrates a common convention across smaller platforms&lt;br&gt;
The takeaway for FASR tracking: don't build a single global timeout assumption into your instrumentation. Tag every delivery-attempt log with the source provider (or, for outbound webhooks you send yourself, with the destination endpoint) so you can evaluate FASR per-contract rather than against one blended number that hides which relationships are actually degrading.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Anatomy of First-Attempt Webhook Failures
Analyzing the HTTP status codes generated during first-attempt failures reveals the health of downstream infrastructure. When building a first-attempt monitoring workflow, group status codes into failure archetypes:&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Code example&lt;br&gt;
Copy code&lt;br&gt;
                  First-Attempt Failure Breakdown (illustrative example)&lt;/p&gt;

&lt;p&gt;HTTP 429 Too Many Requests   [====================] 45% (Rate Limit Exceeded)&lt;br&gt;
   HTTP 504 Gateway Timeout     [==============      ] 30% (DB Connection Lock)&lt;br&gt;
   HTTP 500 Internal Error      [========            ] 15% (Unhandled Exceptions)&lt;br&gt;
   HTTP 502/503 Service Unavailable [====          ] 10% (Pod Restarts/OOM)&lt;br&gt;
(The exact proportions above are illustrative — your own breakdown will depend heavily on your consumers' architecture. The point is to break the aggregate failure count down by status code rather than treat "not 2xx" as one bucket.)&lt;/p&gt;

&lt;p&gt;4.1 HTTP 429: Downstream Rate Limit Exceeded&lt;br&gt;
When a receiver responds with 429 Too Many Requests on attempt #1, the producer is overwhelming the consumer's ingress. HTTP 429, along with 503 Service Unavailable, is the standard mechanism (defined in the HTTP semantics RFC, RFC 9110) for a server to signal "back off" — and the accompanying Retry-After header, when a provider sends one, tells the client exactly how long to wait. A producer that ignores Retry-After and keeps retrying on its own fixed schedule is a common, avoidable cause of retry storms.&lt;/p&gt;

&lt;p&gt;4.2 HTTP 504 / 502: The Downstream Database Squeeze&lt;br&gt;
A sudden drop in FASR characterized by 504 Gateway Timeout or 502 Bad Gateway points to synchronous blocking operations on the consumer side. Consider this typical antipattern inside a webhook processing endpoint:&lt;/p&gt;

&lt;p&gt;Code example&lt;br&gt;
Copy code&lt;br&gt;
[ POST /webhooks/stripe ]&lt;br&gt;
         │&lt;br&gt;
         ├──&amp;gt; 1. Parse JSON Payload&lt;br&gt;
         ├──&amp;gt; 2. Open PostgreSQL Connection (Pool: 20 max)&lt;br&gt;
         ├──&amp;gt; 3. Execute SELECT ... FOR UPDATE (Blocking Query: 1200ms)&lt;br&gt;
         ├──&amp;gt; 4. Call Third-Party API (Blocking HTTP: 800ms)&lt;br&gt;
         └──&amp;gt; 5. Return HTTP 200 OK (Total Wall Time: 2000ms+)&lt;br&gt;
If the producer sends 50 concurrent webhooks, the receiver's connection pool fills up immediately. Subsequent requests queue up at the ingress layer (NGINX, an API gateway, or a cloud load balancer) until the proxy's own idle timeout is reached. This is a good place to fact-check a common assumption: AWS's Application Load Balancer does not default to a 30-second idle timeout — its default is 60 seconds (configurable from 1 to 4,000 seconds), and AWS Network Load Balancers default to 350 seconds. Whatever the number, once it's hit, the proxy returns its own timeout error, the event broker queues a retry, and the traffic spike escalates into a retry storm.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Measuring Queue Pressure and Retry Depth
Monitoring FASR alone tells you that a problem exists; tracking queue pressure tells you how long until your delivery infrastructure fails outright.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;5.1 Quantifying Queue Pressure&lt;br&gt;
Queue Pressure ($P_{\text{queue}}$) measures the imbalance between the incoming webhook generation rate ($\lambda_{\text{ingress}}$), the retry generation rate ($\lambda_{\text{retry}}$), and the underlying processing capacity ($\mu_{\text{workers}}$):&lt;/p&gt;

&lt;p&gt;$$P_{\text{queue}} = \frac{\lambda_{\text{ingress}} + \lambda_{\text{retry}}}{\mu_{\text{workers}}}$$&lt;/p&gt;

&lt;p&gt;This isn't a novel formula specific to webhooks — it's the same utilization factor (ρ = λ / μ) used throughout classical queueing theory, and it connects directly to Little's Law (L = λW: the average number of items in a system equals the arrival rate times the average time an item spends in the system). The standard result from that theory holds here too: when $P_{\text{queue}} &amp;gt; 1.0$ (i.e., arrivals outpace service capacity), queue depth grows without bound and latency for every event — not just the retried ones — increases.&lt;/p&gt;

&lt;p&gt;Code example&lt;br&gt;
Copy code&lt;br&gt;
                       QUEUE PRESSURE DYNAMICS&lt;/p&gt;

&lt;p&gt;Ingress Rate (λ_ingress = 1,000 req/s) ──┐&lt;br&gt;
                                           ├──&amp;gt; [ Ingestion Queue ] ──&amp;gt; Workers (μ = 1,200 req/s)&lt;br&gt;
  Retry Rate   (λ_retry   = 500 req/s) ────┘        (P_queue = 1.25)&lt;br&gt;
                                                    &lt;em&gt;SYSTEM UNSTABLE&lt;/em&gt;&lt;br&gt;
5.2 Calculating Retry-Induced Latency Overhead&lt;br&gt;
To isolate the latency overhead caused by retries, compute:&lt;/p&gt;

&lt;p&gt;$$RILO = \text{Percentile}{99}(T{\text{delivered}}) - \text{Percentile}{99}(T{\text{first_attempt}})$$&lt;/p&gt;

&lt;p&gt;A healthy pipeline should maintain RILO near 0 ms. If it rises to several minutes, events are spending significant time sitting in exponential backoff queues rather than processing in real time.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Exporting Webhook Delivery Telemetry to Datadog and Prometheus
Whether you run your own delivery workers or use a managed relay service (examples in this category include Svix, Hookdeck, Convoy, and InstaWebhook), the instrumentation pattern is the same: capture a structured record for every delivery attempt — not just every event — and export it to your metrics stack.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;6.1 Representative Telemetry Schema&lt;br&gt;
The exact field names will differ by vendor, but a typical per-attempt log looks like this:&lt;/p&gt;

&lt;p&gt;Code example&lt;br&gt;
Copy code&lt;br&gt;
{&lt;br&gt;
  "timestamp": "2026-09-16T09:45:00.123Z",&lt;br&gt;
  "event_id": "evt_99f823a10bc",&lt;br&gt;
  "tenant_id": "org_acme_corp",&lt;br&gt;
  "endpoint_id": "ep_7721b0a9",&lt;br&gt;
  "destination_url": "&lt;a href="https://api.acme.com/v1/orders/webhook" rel="noopener noreferrer"&gt;https://api.acme.com/v1/orders/webhook&lt;/a&gt;",&lt;br&gt;
  "attempt_number": 1,&lt;br&gt;
  "max_attempts": 5,&lt;br&gt;
  "status": "failed",&lt;br&gt;
  "http_status_code": 504,&lt;br&gt;
  "execution_duration_ms": 30002,&lt;br&gt;
  "retry_reason": "GATEWAY_TIMEOUT",&lt;br&gt;
  "next_retry_at": "2026-09-16T09:45:30.123Z"&lt;br&gt;
}&lt;br&gt;
InstaWebhook, as one example of a managed delivery relay, publicly documents that it tracks each event through received, queued, attempted, retried, delivered, and dead-lettered states, signs outgoing deliveries with timestamped HMAC signatures, and offers a "bring your own database" mode for teams that need to keep payloads under their own storage controls. If you're evaluating a relay service rather than building your own, those are the kinds of capabilities worth checking for — regardless of which vendor you choose.&lt;/p&gt;

&lt;p&gt;6.2 Exporting Metrics to Datadog&lt;br&gt;
To stream metrics into Datadog, use DogStatsD (via the official datadog Python package, still the current, actively maintained client for this) with standard tags (tenant_id, endpoint_id, status, attempt_number).&lt;/p&gt;

&lt;p&gt;Code example&lt;br&gt;
Copy code&lt;br&gt;
import os&lt;br&gt;
from datadog import initialize, statsd&lt;/p&gt;

&lt;h1&gt;
  
  
  Initialize Datadog client
&lt;/h1&gt;

&lt;p&gt;initialize(statsd_host=os.getenv("DOGSTATSD_HOST", "localhost"), statsd_port=8125)&lt;/p&gt;

&lt;p&gt;def process_webhook_telemetry(log_event: dict) -&amp;gt; None:&lt;br&gt;
    """&lt;br&gt;
    Parses incoming delivery-attempt logs and converts them&lt;br&gt;
    into Datadog metrics for FASR and latency tracking.&lt;br&gt;
    """&lt;br&gt;
    tenant_id = log_event.get("tenant_id", "unknown")&lt;br&gt;
    endpoint_id = log_event.get("endpoint_id", "unknown")&lt;br&gt;
    attempt = log_event.get("attempt_number", 1)&lt;br&gt;
    status_code = str(log_event.get("http_status_code", 0))&lt;br&gt;
    duration_ms = log_event.get("execution_duration_ms", 0)&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;tags = [
    f"tenant:{tenant_id}",
    f"endpoint:{endpoint_id}",
    f"attempt:{attempt}",
    f"status_code:{status_code}"
]

# Increment overall attempt counter
statsd.increment("webhook.delivery.attempts.total", tags=tags)

# Track first-attempt metrics specifically
if attempt == 1:
    is_success = 200 &amp;lt;= log_event.get("http_status_code", 0) &amp;lt; 300
    if is_success:
        statsd.increment("webhook.delivery.first_attempt.success", tags=tags)
    else:
        statsd.increment("webhook.delivery.first_attempt.failure", tags=tags)

    statsd.histogram("webhook.delivery.first_attempt.latency_ms", duration_ms, tags=tags)
else:
    # Increment retry attempt counter
    statsd.increment("webhook.delivery.retry.attempts.total", tags=tags)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;Datadog FASR Metric Queries&lt;br&gt;
First-Attempt Success Rate (%):&lt;/p&gt;

&lt;p&gt;Code example&lt;br&gt;
Copy code&lt;br&gt;
( sum:webhook.delivery.first_attempt.success{*}.as_count() / sum:webhook.delivery.attempts.total{attempt:1}.as_count() ) * 100&lt;br&gt;
Retry Volume Ratio:&lt;/p&gt;

&lt;p&gt;Code example&lt;br&gt;
Copy code&lt;br&gt;
sum:webhook.delivery.retry.attempts.total{&lt;em&gt;}.as_count() / sum:webhook.delivery.attempts.total{&lt;/em&gt;}.as_count()&lt;br&gt;
6.3 Exporting Metrics to Prometheus&lt;br&gt;
Prometheus follows a pull-based model. Below is a Python exporter using the standard prometheus_client library to expose a /metrics endpoint for delivery telemetry:&lt;/p&gt;

&lt;p&gt;Code example&lt;br&gt;
Copy code&lt;br&gt;
import time&lt;br&gt;
from prometheus_client import start_http_server, Counter, Histogram&lt;/p&gt;

&lt;p&gt;WEBHOOK_ATTEMPTS_TOTAL = Counter(&lt;br&gt;
    'webhook_delivery_attempts_total',&lt;br&gt;
    'Total count of webhook delivery attempts executed',&lt;br&gt;
    ['tenant_id', 'endpoint_id', 'attempt', 'status_code']&lt;br&gt;
)&lt;/p&gt;

&lt;p&gt;WEBHOOK_FIRST_ATTEMPT_SUCCESS = Counter(&lt;br&gt;
    'webhook_first_attempt_success_total',&lt;br&gt;
    'Total count of webhooks delivered successfully on attempt 1',&lt;br&gt;
    ['tenant_id', 'endpoint_id']&lt;br&gt;
)&lt;/p&gt;

&lt;p&gt;WEBHOOK_DELIVERY_DURATION = Histogram(&lt;br&gt;
    'webhook_delivery_duration_seconds',&lt;br&gt;
    'Latency histogram of webhook execution in seconds',&lt;br&gt;
    ['tenant_id', 'attempt'],&lt;br&gt;
    buckets=(0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0, 30.0)&lt;br&gt;
)&lt;/p&gt;

&lt;p&gt;def record_prometheus_telemetry(tenant_id: str, endpoint_id: str, attempt: int, status_code: int, duration_sec: float):&lt;br&gt;
    """&lt;br&gt;
    Records delivery-attempt metadata into Prometheus Counter and Histogram vectors.&lt;br&gt;
    """&lt;br&gt;
    str_attempt = str(attempt)&lt;br&gt;
    str_status = str(status_code)&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;WEBHOOK_ATTEMPTS_TOTAL.labels(
    tenant_id=tenant_id,
    endpoint_id=endpoint_id,
    attempt=str_attempt,
    status_code=str_status
).inc()

WEBHOOK_DELIVERY_DURATION.labels(
    tenant_id=tenant_id,
    attempt=str_attempt
).observe(duration_sec)

if attempt == 1 and 200 &amp;lt;= status_code &amp;lt; 300:
    WEBHOOK_FIRST_ATTEMPT_SUCCESS.labels(
        tenant_id=tenant_id,
        endpoint_id=endpoint_id
    ).inc()
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;if &lt;strong&gt;name&lt;/strong&gt; == '&lt;strong&gt;main&lt;/strong&gt;':&lt;br&gt;
    start_http_server(9102)&lt;br&gt;
    print("Webhook Telemetry Prometheus Exporter running on port :9102/metrics")&lt;br&gt;
    while True:&lt;br&gt;
        time.sleep(1)&lt;br&gt;
PromQL Queries for Prometheus &amp;amp; Grafana&lt;br&gt;
Global FASR over a 5-minute window:&lt;/p&gt;

&lt;p&gt;Code example&lt;br&gt;
Copy code&lt;br&gt;
(&lt;br&gt;
  sum(rate(webhook_first_attempt_success_total[5m]))&lt;br&gt;
  /&lt;br&gt;
  sum(rate(webhook_delivery_attempts_total{attempt="1"}[5m]))&lt;br&gt;
) * 100&lt;br&gt;
Retry pressure (retries vs. first attempts):&lt;/p&gt;

&lt;p&gt;Code example&lt;br&gt;
Copy code&lt;br&gt;
sum(rate(webhook_delivery_attempts_total{attempt!="1"}[5m]))&lt;br&gt;
/&lt;br&gt;
sum(rate(webhook_delivery_attempts_total{attempt="1"}[5m]))&lt;br&gt;
P99 latency for first attempts:&lt;/p&gt;

&lt;p&gt;Code example&lt;br&gt;
Copy code&lt;br&gt;
histogram_quantile(0.99, sum(rate(webhook_delivery_duration_seconds_bucket{attempt="1"}[5m])) by (le))&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;Setting Up Production Alerts for Queue Pressure and FASR Degradation&lt;br&gt;
Code example&lt;br&gt;
Copy code&lt;br&gt;
                       ALERTING FLOWCHART&lt;/p&gt;

&lt;p&gt;+-------------------------------------------------------+&lt;br&gt;
| FASR Drops Below 95% over 5-min Window                |&lt;br&gt;
+-------------------------------------------------------+&lt;br&gt;
                            |&lt;br&gt;
                Is Queue Pressure &amp;gt; 1.5?&lt;br&gt;
              /                        \&lt;br&gt;
            YES                         NO&lt;br&gt;
            /                             \&lt;br&gt;
+-------------------------+   +-------------------------+&lt;br&gt;
| P1 Critical Alert:      |   | P2 Warning Alert:       |&lt;br&gt;
| "Downstream Collapse /  |   | "Degraded Endpoint /    |&lt;br&gt;
| Retry Queue Backlog"    |   | Rate Limit Exceeded"    |&lt;br&gt;
+-------------------------+   +-------------------------+&lt;br&gt;
7.1 Alerting Rule 1: FASR Critical Drop (P1)&lt;br&gt;
Code example&lt;br&gt;
Copy code&lt;br&gt;
groups:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;name: webhook_health_alerts
rules:

&lt;ul&gt;
&lt;li&gt;alert: WebhookLowFASR
expr: |
(
sum(rate(webhook_first_attempt_success_total[5m]))
/
sum(rate(webhook_delivery_attempts_total{attempt="1"}[5m]))
) * 100 &amp;lt; 92.0
for: 5m
labels:
severity: critical
team: platform-integrations
annotations:
summary: "Webhook First-Attempt Success Rate (FASR) dropped below 92%"
description: "Current FASR is {{ $value | printf \"%.2f\" }}%. Downstream endpoints are failing initial delivery, causing queue buildup."
7.2 Alerting Rule 2: High Retry-to-Ingress Ratio (P2)
Code example
Copy code&lt;/li&gt;
&lt;li&gt;alert: HighWebhookRetryRatio
expr: |
sum(rate(webhook_delivery_attempts_total{attempt!="1"}[10m]))
/
sum(rate(webhook_delivery_attempts_total{attempt="1"}[10m])) &amp;gt; 0.15
for: 10m
labels:
severity: warning
team: platform-integrations
annotations:
summary: "Excessive Webhook Retry Volume Detected"
description: "Retry attempts currently represent {{ $value | mul 100 | printf \"%.2f\" }}% of all webhook traffic."&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Architectural Strategies to Improve FASR&lt;br&gt;
Code example&lt;br&gt;
Copy code&lt;br&gt;
+-----------------------------------------------------------------------+&lt;br&gt;
|                 RECOMMENDED CONSUMER ARCHITECTURE                     |&lt;br&gt;
|                                                                       |&lt;br&gt;
|  [ Ingress Gateway ] ──&amp;gt; [ Lightweight Receiver ]                     |&lt;br&gt;
|                                |                                      |&lt;br&gt;
|                                v  (Acknowledge HTTP 202 in &amp;lt; 20ms)     |&lt;br&gt;
|                      [ Internal Queue (Redis/Kafka) ]                 |&lt;br&gt;
|                                |                                      |&lt;br&gt;
|                                v                                      |&lt;br&gt;
|                      [ Async Worker Pool ] ──&amp;gt; [ Database ]           |&lt;br&gt;
+-----------------------------------------------------------------------+&lt;br&gt;
Strategy 1: Async Queue Ingestion&lt;br&gt;
The most common cause of low FASR is synchronous processing on the consumer side. Receivers should verify the signature, write the payload to an internal queue (Redis Stream, RabbitMQ, SQS), and immediately respond with HTTP 202 Accepted. Heavy database writes happen asynchronously downstream. This is the exact pattern Stripe's own integration guidance and third-party guides converge on: return 2xx before doing anything that could take more than a couple of seconds, and treat "receipt" and "processing" as two separate steps.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Strategy 2: Idempotent Processing (don't skip this)&lt;br&gt;
Because virtually every provider guarantees at-least-once delivery (never exactly-once), duplicate deliveries are expected, normal behavior — not a bug in the provider or your pipeline. Stripe, for instance, explicitly documents that manually resending an event doesn't cancel its own automatic retries, so the same event can legitimately arrive more than once. Build your handlers to be idempotent: persist the provider's event ID, check it before applying side effects, and treat a repeat delivery of an already-processed event ID as a no-op success. Skipping this step is one of the most common causes of double-charged customers or duplicated records in webhook-driven systems.&lt;/p&gt;

&lt;p&gt;Strategy 3: Dynamic Rate-Limiting with Backpressure Negotiation&lt;br&gt;
Producers should respect 429/503 responses and the Retry-After header, and dynamically throttle outbound workers for specific subscriber endpoints rather than hammering a struggling receiver on a fixed schedule.&lt;/p&gt;

&lt;p&gt;Strategy 4: Circuit Breaking&lt;br&gt;
When a destination fails 100% of its first attempts over a short window (e.g., during a subscriber outage), a circuit breaker trips the endpoint to a PAUSED state, preventing wasted retry traffic from flooding the pipeline. The circuit breaker pattern itself was popularized in distributed systems largely through Netflix's open-source Hystrix library — but it's worth knowing that Hystrix has been in official maintenance mode since 2018 per Netflix's own repository notice, with no new feature development. For new projects, Resilience4j (Java) or your platform's equivalent (many API gateways and service meshes implement circuit breaking natively) are the actively maintained choices; Spring Cloud's own circuit-breaker starter now points to Resilience4j rather than Hystrix.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Conclusion &amp;amp; Webhook Health Checklist
Relying on Eventual Success Rate alone creates a false sense of pipeline reliability. First-Attempt Success Rate is a useful early indicator of downstream strain, worker bottlenecks, and hidden infrastructure cost — and while it's not a formally standardized metric name, the concept is already load-bearing in the monitoring dashboards of real webhook infrastructure providers.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;By exporting per-attempt telemetry into tools like Datadog and Prometheus, teams can get real operational visibility into webhook performance, alert on queue pressure before it becomes an outage, and know which downstream relationships are actually degrading.&lt;/p&gt;

&lt;p&gt;Webhook Observability Checklist&lt;br&gt;
 Track FASR as an operational metric, alongside ESR — not instead of it.&lt;br&gt;
 Separate first-attempt and retry logs so aggregators can distinguish initial transmissions from retries.&lt;br&gt;
 Break down failures by HTTP status code (429, 500, 502, 503, 504) to locate the actual bottleneck.&lt;br&gt;
 Alert on queue pressure (retry-to-ingress ratio) before queues start backing up, not just on the success-rate threshold alone.&lt;br&gt;
 Know your specific provider's contract. Check whether it auto-retries at all (some, like GitHub, don't), how long its timeout window is, and how long its retry period lasts, instead of assuming a generic "webhook" behavior.&lt;br&gt;
 Build idempotent handlers. At-least-once delivery means duplicates are a certainty, not an edge case.&lt;br&gt;
Sources &amp;amp; Further Reading&lt;br&gt;
Stripe — Receive Stripe events in your webhook endpoint (retry window, timeout, best practices)&lt;br&gt;
GitHub Docs — About webhook delivery failures and Redelivering webhooks&lt;br&gt;
Svix Docs — Retry Schedule and Webhook timeout best practices&lt;br&gt;
Recurly Docs — Automatic retries&lt;br&gt;
Hookdeck Docs — Metrics and Building a Reliable Service for Sending Webhooks&lt;br&gt;
AWS Docs — Application Load Balancers: idle timeout attribute (default 60s)&lt;br&gt;
Wikipedia — Little's Law (queueing utilization factor ρ = λ/μ)&lt;br&gt;
Netflix — Hystrix GitHub repository notice on maintenance mode; Resilience4j project docs&lt;br&gt;
Datadog — datadogpy client library and DogStatsD documentation&lt;br&gt;
InstaWebhook — instawebhook.com (product feature descriptions)&lt;br&gt;
Note: a few of the numeric targets in this article (e.g., &amp;gt;98% FASR, specific failure-code proportions) are presented as illustrative engineering benchmarks rather than figures published by any single authority — no such universal SLA exists publicly for first-attempt webhook success. Calibrate them against your own traffic before using them as alert thresholds.&lt;/p&gt;

</description>
    </item>
    <item>
      <title>Ephemeral Webhook Tokens: Moving Beyond Long-Lived Static Secrets</title>
      <dc:creator>InstaWebhook</dc:creator>
      <pubDate>Tue, 15 Sep 2026 05:11:00 +0000</pubDate>
      <link>https://dev.to/instawebhook/ephemeral-webhook-tokens-moving-beyond-long-lived-static-secrets-bbk</link>
      <guid>https://dev.to/instawebhook/ephemeral-webhook-tokens-moving-beyond-long-lived-static-secrets-bbk</guid>
      <description>&lt;p&gt;API gateway secret management&lt;br&gt;
API ingress architecture&lt;br&gt;
API ingress proxy webhooks&lt;br&gt;
API secret management&lt;br&gt;
automated secret rotation&lt;br&gt;
cloud native API security&lt;br&gt;
cryptography webhooks&lt;br&gt;
derived root secrets&lt;br&gt;
developer API security&lt;br&gt;
devsecops webhook security&lt;br&gt;
dynamic signing keys&lt;br&gt;
dynamic token derivation&lt;br&gt;
dynamic webhook authentication&lt;br&gt;
enterprise webhook security&lt;br&gt;
ephemeral HMAC keys&lt;br&gt;
ephemeral keys API&lt;br&gt;
ephemeral signing tokens webhooks&lt;br&gt;
ephemeral token architecture&lt;br&gt;
ephemeral tokens&lt;br&gt;
event delivery security&lt;br&gt;
HMAC key management&lt;br&gt;
HMAC secret rotation&lt;br&gt;
HMAC SHA256 webhooks&lt;br&gt;
infrastructure security webhooks&lt;br&gt;
key rotation webhooks&lt;br&gt;
microservices webhook security&lt;br&gt;
modern API security trends&lt;br&gt;
modern webhook authentication&lt;br&gt;
per batch tokens webhooks&lt;br&gt;
root secret key derivation&lt;br&gt;
rotating HMAC secrets&lt;br&gt;
safe webhook payload verification&lt;br&gt;
secure event driven architecture&lt;br&gt;
secure webhook integration&lt;br&gt;
short lived webhook tokens&lt;br&gt;
signature replay prevention&lt;br&gt;
stateless webhook authentication&lt;br&gt;
static secret liabilities&lt;br&gt;
webhook API security&lt;br&gt;
webhook authentication best practices&lt;br&gt;
webhook authorization mechanisms&lt;br&gt;
webhook encryption trends&lt;br&gt;
webhook gateway security&lt;br&gt;
webhook ingress security&lt;br&gt;
webhook payload signing&lt;br&gt;
webhooks dynamic secrets&lt;br&gt;
webhook secret leakage prevention&lt;br&gt;
webhook security&lt;br&gt;
webhook security architecture&lt;br&gt;
webhook security trends&lt;br&gt;
webhook signature verification&lt;br&gt;
webhook threat mitigation&lt;br&gt;
webhook token expiry&lt;br&gt;
webhook validation modern methods&lt;br&gt;
zero trust webhooks&lt;br&gt;
Ephemeral Webhook Tokens Moving Beyond Long Lived Static Secrets&lt;br&gt;
Ephemeral Webhook Tokens: Moving Beyond Long-Lived Static Secrets&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Introduction: The Fragile State of Webhook Security
Webhooks are the connective tissue of modern event-driven architecture. A payment succeeds in Stripe, a commit lands in GitHub, an order ships in Shopify — each fires an HTTP callback that keeps decoupled systems in sync without anyone polling for updates.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;For over a decade, the way those callbacks prove they're legitimate has barely changed: a provider and a consumer share a static secret, and every request is signed with an HMAC over that secret. It works, but a secret that never expires is also a secret that never stops being a liability. As organizations move toward Zero Trust architectures and just-in-time credentials for machine-to-machine traffic, that static-forever model looks increasingly out of step with how the rest of the identity stack is evolving.&lt;/p&gt;

&lt;p&gt;This article covers where webhook authentication actually stands heading into the second half of 2026: the industry-standard signing scheme most providers now share, the IETF standard trying to generalize it further, and the short-lived-key techniques — including HKDF-derived ephemeral tokens — that a smaller but growing set of teams are adopting at the edge.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The Vulnerabilities of Long-Lived Static Secrets
2.1 The Traditional Static HMAC Model
In a standard webhook setup, the sender signs the request body with a shared secret key, $K_{\text{static}}$:&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;$$\text{Signature} = \text{HMAC-SHA256}(K_{\text{static}}, \text{Payload})$$&lt;/p&gt;

&lt;p&gt;The receiver recomputes the same HMAC locally and compares it, in constant time, against the signature header.&lt;/p&gt;

&lt;p&gt;Code example&lt;br&gt;
Copy code&lt;br&gt;
Sender                                                          Receiver&lt;br&gt;
  |                                                                 |&lt;br&gt;
  |-- 1. Compute HMAC(K_static, Body) ----------------------------&amp;gt;|&lt;br&gt;
  |-- 2. Send HTTP POST with signature header ---------------------&amp;gt;|&lt;br&gt;
  |                                                                 |-- 3. Look up K_static&lt;br&gt;
  |                                                                 |-- 4. Recompute HMAC(K_static, Body)&lt;br&gt;
  |                                                                 |-- 5. Compare, constant-time&lt;br&gt;
2.2 Where This Breaks Down&lt;br&gt;
Blast radius. If $K_{\text{static}}$ leaks — via logs, a misconfigured secret store, or a checked-in .env file — an attacker can forge valid events indefinitely, for every endpoint that shares the secret.&lt;br&gt;
Secret sprawl. As a company's webhook consumers multiply across services and lambdas, the same secret ends up copied into more places than anyone can easily audit.&lt;br&gt;
Rotation friction. Rotating a shared secret needs coordinated deploys on both sides. Because a bad rotation can break production traffic, it's routinely delayed — sometimes indefinitely.&lt;br&gt;
Replay risk. A signature alone doesn't say when it was valid. Without an enforced timestamp check, a captured request can be replayed long after the fact.&lt;br&gt;
None of this is new — it's the reason the industry has spent the last few years converging on shared standards rather than each provider inventing its own scheme from scratch.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Where the Industry Actually Stands in 2026
3.1 Standard Webhooks: the de facto baseline
The most consequential real-world change isn't ephemeral cryptography — it's standardization. Standard Webhooks, an open specification authored by Svix along with Twilio, Kong, Supabase, Mux, ngrok, and Lob, has become the closest thing the industry has to a shared webhook-signing convention, and has reportedly been adopted by companies including OpenAI, Anthropic, and Google.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The spec fixes three headers and a signing scheme:&lt;/p&gt;

&lt;p&gt;Code example&lt;br&gt;
Copy code&lt;br&gt;
webhook-id:        msg_2b1c...             # unique message id&lt;br&gt;
webhook-timestamp: 1614265330              # unix seconds&lt;br&gt;
webhook-signature: v1,g0hM9SsE...          # space-delimited "v1," entries&lt;br&gt;
The signed content is {webhook-id}.{webhook-timestamp}.{raw body}, HMAC-SHA256'd with the base64-decoded bytes of a whsec_-prefixed secret (24–64 random bytes), then base64-encoded. Two details matter more than they look:&lt;/p&gt;

&lt;p&gt;Rotation is built into the header, not bolted on. During a secret rotation, the sender signs with both the old and new secret and sends both space-delimited signatures. A receiver holding either one verifies successfully, so the two sides never have to cut over in the same instant.&lt;br&gt;
The recommended replay tolerance is 300 seconds. Stripe's own libraries independently reject events more than 300 seconds outside server time — that five-minute window has effectively become an industry default, and it lines up with what security guides now cite as OWASP's recommended maximum.&lt;br&gt;
3.2 RFC 9421: the IETF's answer for HTTP signatures generally&lt;br&gt;
In February 2024, the IETF published RFC 9421, HTTP Message Signatures, a general-purpose standard for signing components of an HTTP message (not just webhooks), designed to survive intermediaries and proxies that might otherwise mangle a naive signature. It supports both asymmetric signatures and keyed MACs.&lt;/p&gt;

&lt;p&gt;RFC 9421 already has real, if narrow, adoption: it underpins server-to-server authentication in ActivityPub implementations like Mastodon, and the Open Payments standard for payment interoperability requires it for every API request. That said, as of early 2026, mainstream webhook adoption is still limited — a widely used platform like GitHub still runs its own ad hoc signature scheme rather than RFC 9421, and framework-level support (e.g., in Spring Security) is still an open request rather than a shipped feature. It's a serious standards-track effort, but it hasn't yet displaced HMAC-based schemes for webhooks specifically.&lt;/p&gt;

&lt;p&gt;3.3 Short-lived signing keys: the emerging edge&lt;br&gt;
Underneath both of the above, a narrower trend is visible in 2025–2026 security guidance: replacing a single long-lived signing secret with short-lived signing keys — typically valid from fifteen minutes to twenty-four hours — published through a signed, JWKS-style endpoint that receivers poll and cache. This shrinks the blast radius of a leaked key without requiring a full protocol change, and it's showing up in webhook security guides as a 2026 trend alongside CloudEvents adoption as a common payload format and built-in exponential backoff in major platforms.&lt;/p&gt;

&lt;p&gt;The most aggressive version of this idea — deriving a fresh key per time window, on demand, from a root secret via HKDF, with no key ever transmitted or stored — is a legitimate cryptographic pattern, and one with precedent in adjacent areas (for example, several AI API providers now issue short-lived, scoped tokens so a long-lived server key never has to touch an untrusted client). But it's worth being precise about where it stands: as of 2026 this is an advanced pattern discussed in engineering blogs and implemented by individual teams at their own ingress layer, not something baked into a ratified webhook standard or offered by name as a feature by major providers like Stripe or GitHub. If you build it, you're building ahead of the pack, not catching up to it.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The Cryptography Behind HKDF-Derived Ephemeral Tokens
For teams that do want to go this route, here's the mechanism. Rather than transmitting or storing a key directly, an ephemeral-key architecture derives temporary keys ($K_{\text{ephemeral}}$) from a root secret using HKDF (RFC 5869), which operates in two phases.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Code example&lt;br&gt;
Copy code&lt;br&gt;
                   +------------------------+&lt;br&gt;
                   |  Root Secret (K_root)  |&lt;br&gt;
                   +------------------------+&lt;br&gt;
                                |&lt;br&gt;
                                v&lt;br&gt;
+----------+          +-------------------+&lt;br&gt;
|   Salt   | -------&amp;gt; |    HKDF-Extract   |&lt;br&gt;
+----------+          +-------------------+&lt;br&gt;
                                |&lt;br&gt;
                                v&lt;br&gt;
                    Pseudo-Random Key (PRK)&lt;br&gt;
                                |&lt;br&gt;
                                v&lt;br&gt;
+----------+          +-------------------+&lt;br&gt;
|   Info   | -------&amp;gt; |    HKDF-Expand    |&lt;br&gt;
+----------+          +-------------------+&lt;br&gt;
                                |&lt;br&gt;
                                v&lt;br&gt;
                    +-----------------------+&lt;br&gt;
                    | Ephemeral Signing Key |&lt;br&gt;
                    |     (K_ephemeral)     |&lt;br&gt;
                    +-----------------------+&lt;br&gt;
HKDF-Extract condenses the root key material into a fixed-length pseudorandom key using an optional salt:&lt;/p&gt;

&lt;p&gt;$$PRK = \text{HMAC-Hash}(\text{Salt}, K_{\text{root}})$$&lt;/p&gt;

&lt;p&gt;HKDF-Expand stretches that into an output key of the desired length, bound to an application-specific context string:&lt;/p&gt;

&lt;p&gt;$$K_{\text{ephemeral}} = \text{HKDF-Expand}(PRK, \text{Info}, L)$$&lt;/p&gt;

&lt;p&gt;4.1 Binding keys to a time window&lt;br&gt;
To make $K_{\text{ephemeral}}$ genuinely short-lived, the Info context string includes a time-bucketed epoch, computed by dividing Unix time by an interval $\Delta t$ (e.g., 300 seconds):&lt;/p&gt;

&lt;p&gt;$$T_{\text{epoch}} = \left\lfloor \frac{\text{Unix Timestamp}}{\Delta t} \right\rfloor$$&lt;/p&gt;

&lt;p&gt;$$\text{Info} = \text{"webhook-v1:"} \parallel \text{TenantID} \parallel T_{\text{epoch}}$$&lt;/p&gt;

&lt;p&gt;Because both sides share $K_{\text{root}}$ and agree on $\Delta t$, each independently derives the identical $K_{\text{ephemeral}}$ for the current window — no key ever crosses the wire.&lt;/p&gt;

&lt;p&gt;Code example&lt;br&gt;
Copy code&lt;br&gt;
Current Time: 10:04:15 AM (Unix: 1776247455)&lt;br&gt;
Time Window (Δt): 300 seconds (5 minutes)&lt;/p&gt;

&lt;p&gt;Epoch = floor(1776247455 / 300) = 5920824&lt;br&gt;
Info Context String = "webhook-v1:tenant_99:5920824"&lt;br&gt;
4.2 Signing the payload&lt;br&gt;
The sender computes the signature over the payload combined with a batch ID and timestamp:&lt;/p&gt;

&lt;p&gt;$$S = \text{HMAC-SHA256}(K_{\text{ephemeral}}, \text{BatchID} \parallel \text{Timestamp} \parallel P)$$&lt;/p&gt;

&lt;p&gt;If $S$ and the payload are captured in transit, the exposure is bounded: the key expires automatically at the next epoch boundary, and $K_{\text{root}}$ itself is never exposed.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Architecture: Pushing the Complexity to the Ingress Layer
Implementing epoch math, clock-skew tolerance, and constant-time comparison in every microservice that consumes webhooks is a recipe for subtle bugs. A cleaner pattern offloads verification to a single ingress layer (an API gateway or a dedicated sidecar), so backend services never see raw signing headers at all.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Code example&lt;br&gt;
Copy code&lt;br&gt;
[ External Webhook Provider ]&lt;br&gt;
             |&lt;br&gt;
             | 1. HTTP POST — X-Signature, X-Batch-ID, X-Timestamp&lt;br&gt;
             v&lt;br&gt;
+-------------------------------------------------------------------+&lt;br&gt;
| INGRESS LAYER / API GATEWAY                                       |&lt;br&gt;
|   a. Check timestamp drift                                        |&lt;br&gt;
|   b. Derive K_ephemeral = HKDF(K_root, epoch)                     |&lt;br&gt;
|   c. Recompute and compare the HMAC                                |&lt;br&gt;
|   d. Strip external headers, mint a short-lived internal token    |&lt;br&gt;
+-------------------------------------------------------------------+&lt;br&gt;
             |&lt;br&gt;
             | 2. Authorization: Bearer &lt;br&gt;
             v&lt;br&gt;
+-------------------------------------------------------------------+&lt;br&gt;
| CORE API / INTERNAL MICROSERVICES                                 |&lt;br&gt;
|   Handles business logic — never touches raw HMAC verification    |&lt;br&gt;
+-------------------------------------------------------------------+&lt;br&gt;
Egress side: the outbound proxy checks the clock, derives $K_{\text{ephemeral}}$, signs, and attaches the epoch, batch ID, and signature as headers.&lt;/p&gt;

&lt;p&gt;Ingress side: the gateway checks the timestamp against a tolerance window, derives the same key (checking both the current epoch and the previous one, to tolerate clock boundary drift), verifies in constant time, then mints an internal credential and forwards the request — the backend service never handles the external signature at all.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Reference Implementation
6.1 Headers
Code example
Copy code
POST /api/v1/webhooks/payments HTTP/1.1
Host: api.subscriber.com
Content-Type: application/json
X-Webhook-Timestamp: 1776247455
X-Webhook-Batch-ID: bch_987654321
X-Webhook-Epoch: 5920824
X-Webhook-Signature: v1=a8f5f167f123456789abcdef0123456789abcdef0123456789abcdef01234567
Content-Length: 142&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;{&lt;br&gt;
  "event": "payment_intent.succeeded",&lt;br&gt;
  "amount": 4900,&lt;br&gt;
  "currency": "usd"&lt;br&gt;
}&lt;br&gt;
6.2 Python: HKDF signer and verifier&lt;br&gt;
Code example&lt;br&gt;
Copy code&lt;br&gt;
import hashlib&lt;br&gt;
import hmac&lt;br&gt;
import math&lt;br&gt;
import time&lt;br&gt;
from cryptography.hazmat.primitives.kdf.hkdf import HKDF&lt;br&gt;
from cryptography.hazmat.primitives import hashes&lt;/p&gt;

&lt;p&gt;class EphemeralWebhookSigner:&lt;br&gt;
    def &lt;strong&gt;init&lt;/strong&gt;(self, root_secret: bytes, window_seconds: int = 300):&lt;br&gt;
        self.root_secret = root_secret&lt;br&gt;
        self.window_seconds = window_seconds&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;def _get_epoch(self, timestamp: float) -&amp;gt; int:
    return math.floor(timestamp / self.window_seconds)

def derive_ephemeral_key(self, tenant_id: str, epoch: int) -&amp;gt; bytes:
    """Derive an ephemeral key for a tenant and time epoch via HKDF."""
    info = f"webhook-v1:{tenant_id}:{epoch}".encode("utf-8")
    hkdf = HKDF(algorithm=hashes.SHA256(), length=32, salt=None, info=info)
    return hkdf.derive(self.root_secret)

def generate_signature(self, tenant_id: str, timestamp: float,
                        batch_id: str, payload: bytes) -&amp;gt; tuple[str, int]:
    epoch = self._get_epoch(timestamp)
    ephemeral_key = self.derive_ephemeral_key(tenant_id, epoch)
    canonical = f"{batch_id}.{int(timestamp)}.".encode("utf-8") + payload
    sig = hmac.new(ephemeral_key, canonical, hashlib.sha256).hexdigest()
    return f"v1={sig}", epoch

def verify_signature(self, tenant_id: str, timestamp: float, batch_id: str,
                      payload: bytes, incoming_signature: str) -&amp;gt; bool:
    # Reject signatures outside the tolerance window
    if abs(time.time() - timestamp) &amp;gt; self.window_seconds:
        return False

    current_epoch = self._get_epoch(timestamp)
    # Check current and previous epoch to tolerate boundary drift
    for epoch in (current_epoch, current_epoch - 1):
        ephemeral_key = self.derive_ephemeral_key(tenant_id, epoch)
        canonical = f"{batch_id}.{int(timestamp)}.".encode("utf-8") + payload
        expected = "v1=" + hmac.new(ephemeral_key, canonical, hashlib.sha256).hexdigest()
        if hmac.compare_digest(expected, incoming_signature):
            return True
    return False
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;if &lt;strong&gt;name&lt;/strong&gt; == "&lt;strong&gt;main&lt;/strong&gt;":&lt;br&gt;
    ROOT_SECRET = b"super-secret-root-key-known-only-to-gateways"&lt;br&gt;
    TENANT_ID = "org_acme_corp"&lt;br&gt;
    PAYLOAD = b'{"event":"order.created","id":"ord_123"}'&lt;br&gt;
    BATCH_ID = "bch_000112233"&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;signer = EphemeralWebhookSigner(root_secret=ROOT_SECRET, window_seconds=300)

now = time.time()
sig_header, epoch = signer.generate_signature(TENANT_ID, now, BATCH_ID, PAYLOAD)
print(f"Signature: {sig_header} (epoch {epoch})")
print("Valid now?", signer.verify_signature(TENANT_ID, now, BATCH_ID, PAYLOAD, sig_header))

# A replay 10 minutes later, after the window has expired
later = now + 600
print("Valid after 10 min?", signer.verify_signature(TENANT_ID, later, BATCH_ID, PAYLOAD, sig_header))
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;ol&gt;
&lt;li&gt;Comparing the Options in 2026
Dimension   Static HMAC (raw)   Standard Webhooks   RFC 9421 (asymmetric)   HKDF ephemeral tokens
Real-world adoption Legacy, still common    Wide — Svix, Twilio, Kong, and reportedly OpenAI, Anthropic, Google   Narrow but growing (ActivityPub, Open Payments) Individual teams, not a named provider feature yet
Credential lifetime Months–years  Same secret, but rotation is built into the header format   Certificate/key lifetime, automated via JWKS    Minutes, derived on demand
Blast radius if leaked  High    Bounded by rotation speed   Low (public keys only)  Minimal — limited to one epoch
Verification cost   Very low    Very low    Higher (asymmetric signature checks)    Low (HKDF + HMAC)
Replay protection   Depends entirely on app-level checks    Enforced timestamp, ~300s default tolerance Depends on implementation   Hard expiration at epoch boundary&lt;/li&gt;
&lt;li&gt;Rotating the Root Secret Without Downtime
Even with ephemeral derivation, the underlying root secret still needs periodic rotation to satisfy compliance requirements. The pattern that both Standard Webhooks and most rotation-aware implementations converge on is the same: run two secrets in parallel for a bounded window.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Code example&lt;br&gt;
Copy code&lt;br&gt;
  Phase 1: Normal          Phase 2: Dual-Active         Phase 3: Complete&lt;br&gt;
  Primary: K_root_V1       Primary: K_root_V2           Primary: K_root_V2&lt;br&gt;
  Fallback: none           Fallback: K_root_V1          Fallback: none&lt;br&gt;
Sign every outbound message with the new key, but keep verifying against the old one for a defined overlap (commonly 24–48 hours) so in-flight retries don't fail.&lt;br&gt;
Track usage of the old key so you know when it's safe to retire.&lt;br&gt;
Delete the old key once telemetry confirms nothing is still relying on it.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A Practical Checklist for 2026
Whatever scheme you land on, the fundamentals that security guidance converges on haven't changed much, and they matter more than which cryptographic primitive you pick:&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Enforce a timestamp tolerance, commonly cited at a five-minute (300-second) maximum, and reject anything older.&lt;br&gt;
Verify against the raw request body, not a re-parsed and re-serialized copy — whitespace and key ordering changes will silently break otherwise-correct signatures.&lt;br&gt;
Deduplicate on the message ID, not just the timestamp, since legitimate retries can arrive more than once.&lt;br&gt;
Treat webhook payloads as untrusted input — validate and sanitize before acting on any URL or value they contain, particularly if your handler makes outbound requests based on payload contents.&lt;br&gt;
Where a provider publishes source IP ranges, allowlist them as a defense-in-depth layer, not a substitute for signature verification.&lt;br&gt;
Don't over-index on post-quantum urgency for HMAC. NIST's current transition guidance (deprecating classical asymmetric algorithms like RSA and ECDSA after 2030, disallowing them after 2035) targets asymmetric cryptography threatened by Shor's algorithm. Symmetric HMAC-SHA256 isn't in that category — it only needs adequate key length against Grover's algorithm — so if you're using RFC 9421 with asymmetric signatures, plan for that migration; if you're on HMAC-based Standard Webhooks, it's a lower near-term priority.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Conclusion &amp;amp; Outlook
The picture in 2026 is less "static secrets are dead" and more "static secrets got a standard, and something better is being built on top." Standard Webhooks has done the unglamorous, high-leverage work of getting most of the industry to sign the same three headers the same way, with rotation designed in from the start. RFC 9421 offers a more general, IETF-backed path for HTTP-level signatures, though it's still early in webhook-specific adoption. And HKDF-derived, time-boxed ephemeral tokens represent a genuinely stronger security posture — automated rotation, minimal blast radius, hard cryptographic expiration — that a growing number of security-conscious teams are building at their ingress layer, even though no major provider ships it as a named feature yet.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you're building a webhook receiver today, adopting Standard Webhooks conventions and enforcing the checklist above will cover the overwhelming majority of real-world risk. If you're building the sending side for a security-sensitive product, the ephemeral-token pattern is a legitimate next step worth evaluating — just go in knowing you're ahead of the standard, not implementing one.&lt;/p&gt;

&lt;p&gt;Sources&lt;br&gt;
Standard Webhooks specification&lt;br&gt;
Svix: Announcing Standard Webhooks&lt;br&gt;
Svix: What is a webhook signature?&lt;br&gt;
Svix: What is a webhook secret?&lt;br&gt;
RFC 9421 — HTTP Message Signatures (IETF)&lt;br&gt;
Spring Security issue tracking RFC 9421 support&lt;br&gt;
Hooklistener: Webhooks Fundamentals (2026)&lt;br&gt;
Hooklistener: Webhook Security Fundamentals&lt;br&gt;
Obsidian Security: What is Webhook Security (2026)&lt;br&gt;
APIsec: Securing Webhook Endpoints&lt;br&gt;
NIST IR 8547 (Initial Public Draft): Transition to Post-Quantum Cryptography Standards&lt;br&gt;
Zapier docs: Verifying webhook signatures&lt;/p&gt;

</description>
    </item>
  </channel>
</rss>
