<?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: EventDock</title>
    <description>The latest articles on DEV Community by EventDock (@eventdock).</description>
    <link>https://dev.to/eventdock</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.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3660548%2Fc81bc6e6-bc07-4db9-af25-2ada184b662b.png</url>
      <title>DEV Community: EventDock</title>
      <link>https://dev.to/eventdock</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/eventdock"/>
    <language>en</language>
    <item>
      <title>What is the worst production bug you have hit on Cloudflare Workers?</title>
      <dc:creator>EventDock</dc:creator>
      <pubDate>Tue, 31 Mar 2026 17:02:41 +0000</pubDate>
      <link>https://dev.to/eventdock/what-is-the-worst-production-bug-you-have-hit-on-cloudflare-workers-3aja</link>
      <guid>https://dev.to/eventdock/what-is-the-worst-production-bug-you-have-hit-on-cloudflare-workers-3aja</guid>
      <description>&lt;p&gt;I recently shipped a webhook relay service on Cloudflare Workers and hit some genuinely painful production bugs — the kind where everything &lt;em&gt;looks&lt;/em&gt; fine but data is silently disappearing.&lt;/p&gt;

&lt;p&gt;The worst one: an &lt;code&gt;async&lt;/code&gt; function that was not &lt;code&gt;await&lt;/code&gt;ed in a queue consumer. In Node.js, the fire-and-forget promise would probably complete. On Cloudflare Workers, the runtime kills the execution context once the handler returns, so the promise just... vanishes. Events that needed retries silently stopped existing.&lt;/p&gt;

&lt;p&gt;Another fun one: a cron-based recovery system that was supposed to catch exactly these failures — except the cron trigger in &lt;code&gt;wrangler.toml&lt;/code&gt; was commented out. The safety net did not exist for months.&lt;/p&gt;

&lt;p&gt;I wrote up all four bugs in detail here: &lt;a href="https://dev.to/eventdock/i-built-a-webhook-relay-on-cloudflare-workers-here-is-every-bug-that-almost-killed-it-22dc"&gt;I Built a Webhook Relay on Cloudflare Workers. Here Are the Bugs That Killed It&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What is the worst production bug you have hit on Cloudflare Workers (or any edge/serverless platform)?&lt;/strong&gt; The "distributed systems fail in ways that look like success" category especially — where metrics look fine but data is quietly being lost.&lt;/p&gt;

</description>
      <category>discuss</category>
      <category>cloudflare</category>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Best Webhook Monitoring Tools in 2026: Complete Comparison</title>
      <dc:creator>EventDock</dc:creator>
      <pubDate>Wed, 25 Mar 2026 11:20:16 +0000</pubDate>
      <link>https://dev.to/eventdock/best-webhook-monitoring-tools-in-2026-complete-comparison-2cm2</link>
      <guid>https://dev.to/eventdock/best-webhook-monitoring-tools-in-2026-complete-comparison-2cm2</guid>
      <description>&lt;p&gt;The webhook tooling landscape in 2026 ranges from simple inspection tools to full infrastructure platforms. Choosing the right tool depends on what you're trying to do: debug during development, monitor in production, or guarantee delivery at scale. This guide compares seven popular tools across these dimensions.&lt;/p&gt;

&lt;p&gt;I'll be honest upfront: I built &lt;a href="https://eventdock.app" rel="noopener noreferrer"&gt;EventDock&lt;/a&gt;, so I have a bias. I'll try to be fair about where each tool shines and where it falls short — including EventDock's limitations.&lt;/p&gt;

&lt;h2&gt;
  
  
  Quick Comparison
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;Primary Use Case&lt;/th&gt;
&lt;th&gt;Retries&lt;/th&gt;
&lt;th&gt;DLQ&lt;/th&gt;
&lt;th&gt;Local Dev&lt;/th&gt;
&lt;th&gt;Free Tier&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;EventDock&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Production reliability&lt;/td&gt;
&lt;td&gt;7 retries, exp. backoff&lt;/td&gt;
&lt;td&gt;Yes + replay&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;5K events/mo&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Hookdeck&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Event gateway + routing&lt;/td&gt;
&lt;td&gt;Configurable&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;CLI tunnel&lt;/td&gt;
&lt;td&gt;100K events/mo&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Svix&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Sending webhooks (provider-side)&lt;/td&gt;
&lt;td&gt;Yes (outbound)&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Self-host option&lt;/td&gt;
&lt;td&gt;Limited&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;RequestBin&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Inspection &amp;amp; debugging&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes (web)&lt;/td&gt;
&lt;td&gt;Free (public)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;webhook.site&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Quick inspection&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes (web)&lt;/td&gt;
&lt;td&gt;Free (limited)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;ngrok&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Local tunnel for dev&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes (primary use)&lt;/td&gt;
&lt;td&gt;Free (1 tunnel)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Stripe CLI&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Stripe webhook testing&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes (Stripe only)&lt;/td&gt;
&lt;td&gt;Free&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  EventDock — Production Webhook Reliability
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What it does:&lt;/strong&gt; EventDock is a webhook proxy that sits between your webhook providers (Stripe, Shopify, GitHub, etc.) and your application. It receives webhooks at the edge, stores them durably, and delivers to your endpoint with automatic retries, dead letter queue, and a real-time dashboard.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Best for:&lt;/strong&gt; Teams that need production webhook reliability without building the infrastructure themselves. If you've lost webhook events due to deploys, timeouts, or server outages, EventDock prevents that.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Strengths:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Sub-50ms acknowledgment from the nearest edge (Cloudflare network)&lt;/li&gt;
&lt;li&gt;7 retries with exponential backoff over 2+ hours&lt;/li&gt;
&lt;li&gt;Dead letter queue with one-click replay&lt;/li&gt;
&lt;li&gt;Real-time delivery dashboard with full payload inspection&lt;/li&gt;
&lt;li&gt;Simple setup: change the webhook URL, done&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Limitations:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;No local development tunnel (use ngrok or Hookdeck CLI for local dev)&lt;/li&gt;
&lt;li&gt;Newer product with a smaller community compared to Hookdeck or Svix&lt;/li&gt;
&lt;li&gt;No webhook transformation or routing rules (it's a relay, not a gateway)&lt;/li&gt;
&lt;li&gt;Free tier is 5K events/month (lower than Hookdeck's 100K)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Pricing:&lt;/strong&gt; Free tier with 5,000 events/month and 3 endpoints. Paid plans for higher volume.&lt;/p&gt;

&lt;h2&gt;
  
  
  Hookdeck — Event Gateway and Routing
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What it does:&lt;/strong&gt; Hookdeck is a full-featured webhook event gateway. It receives webhooks, applies transformations, routes them to destinations based on rules, and provides monitoring and replay. It's the most feature-rich tool in this comparison.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Best for:&lt;/strong&gt; Teams with complex webhook routing needs — multiple sources going to multiple destinations, event filtering, payload transformation, fan-out patterns.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Strengths:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Powerful routing and transformation rules&lt;/li&gt;
&lt;li&gt;Generous free tier (100K events/month)&lt;/li&gt;
&lt;li&gt;CLI tool for local development tunneling&lt;/li&gt;
&lt;li&gt;Excellent dashboard with filtering and search&lt;/li&gt;
&lt;li&gt;Event replay and bulk retry&lt;/li&gt;
&lt;li&gt;Well-documented, active community&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Limitations:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;More complex setup for simple use cases (sources, connections, destinations)&lt;/li&gt;
&lt;li&gt;Pricing scales with features — advanced routing on paid plans&lt;/li&gt;
&lt;li&gt;Can be overkill if you just need "receive webhook, deliver reliably"&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Pricing:&lt;/strong&gt; Free tier with 100K events/month. Team plan starts at $75/month.&lt;/p&gt;

&lt;h2&gt;
  
  
  Svix — Webhook Sending Infrastructure
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What it does:&lt;/strong&gt; Svix solves the &lt;em&gt;opposite&lt;/em&gt; problem from the other tools here. Instead of receiving webhooks, Svix helps you &lt;em&gt;send&lt;/em&gt; them. If you're building an API or platform that needs to notify customers via webhooks, Svix provides the delivery infrastructure, retry logic, and management portal.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Best for:&lt;/strong&gt; SaaS companies and API providers that need to send webhooks to their customers reliably. Think of it as "Stripe's webhook infrastructure, as a service."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Strengths:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Purpose-built for webhook sending (the provider side)&lt;/li&gt;
&lt;li&gt;Customer-facing webhook management portal&lt;/li&gt;
&lt;li&gt;Open-source core (self-hostable)&lt;/li&gt;
&lt;li&gt;SDKs for multiple languages&lt;/li&gt;
&lt;li&gt;Handles signature generation, retries, and endpoint management&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Limitations:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Doesn't help with receiving webhooks — it's for the sending side&lt;/li&gt;
&lt;li&gt;Not a monitoring or debugging tool&lt;/li&gt;
&lt;li&gt;Self-hosting requires significant infrastructure&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Pricing:&lt;/strong&gt; Free tier with limited messages. Paid plans from $75/month. Open-source version available for self-hosting.&lt;/p&gt;

&lt;h2&gt;
  
  
  RequestBin — Quick Webhook Inspection
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What it does:&lt;/strong&gt; RequestBin (now part of Pipedream) gives you a temporary URL that captures and displays any HTTP request sent to it. You point your webhook at the RequestBin URL, trigger an event, and see exactly what was sent — headers, body, method, everything.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Best for:&lt;/strong&gt; Development and debugging. When you're integrating a new webhook provider and want to see the exact payload format before writing handler code.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Strengths:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Instant setup — no account required for public bins&lt;/li&gt;
&lt;li&gt;Clear, readable display of requests&lt;/li&gt;
&lt;li&gt;Part of Pipedream ecosystem (can chain into workflows)&lt;/li&gt;
&lt;li&gt;Good for one-off debugging&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Limitations:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;No retries, no delivery guarantees&lt;/li&gt;
&lt;li&gt;Public bins are publicly accessible (don't send sensitive data)&lt;/li&gt;
&lt;li&gt;Not designed for production use&lt;/li&gt;
&lt;li&gt;Bins expire after a period of inactivity&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Pricing:&lt;/strong&gt; Free for public bins. Pipedream paid plans for private bins and workflows.&lt;/p&gt;

&lt;h2&gt;
  
  
  webhook.site — Instant Webhook Testing
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What it does:&lt;/strong&gt; Similar to RequestBin — gives you a unique URL that captures and displays incoming requests in real-time. Slightly simpler UX, focused purely on inspection.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Best for:&lt;/strong&gt; Quick testing and debugging during development. The simplest possible way to see what a webhook provider is sending.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Strengths:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Extremely simple — visit the site, get a URL, done&lt;/li&gt;
&lt;li&gt;Real-time updates in the browser&lt;/li&gt;
&lt;li&gt;Supports custom responses (return specific status codes)&lt;/li&gt;
&lt;li&gt;No account needed&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Limitations:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Development tool only — not for production&lt;/li&gt;
&lt;li&gt;No retries, forwarding, or reliability features&lt;/li&gt;
&lt;li&gt;Limited history on the free tier&lt;/li&gt;
&lt;li&gt;URLs are temporary&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Pricing:&lt;/strong&gt; Free tier with limited requests. Pro plan at $9/month for more features.&lt;/p&gt;

&lt;h2&gt;
  
  
  ngrok — Local Development Tunnels
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What it does:&lt;/strong&gt; ngrok creates a public URL that tunnels traffic to your local development machine. You run your webhook handler on localhost:3000, ngrok gives you a public URL like &lt;code&gt;https://abc123.ngrok.io&lt;/code&gt;, and you point your webhook provider at that URL.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Best for:&lt;/strong&gt; Local development. When you want to test webhook handlers on your laptop without deploying to a server.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Strengths:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Essential for local webhook development&lt;/li&gt;
&lt;li&gt;Works with any webhook provider&lt;/li&gt;
&lt;li&gt;Request inspection in the ngrok dashboard&lt;/li&gt;
&lt;li&gt;Replay requests from the dashboard&lt;/li&gt;
&lt;li&gt;Mature, reliable product&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Limitations:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Development tool — not for production&lt;/li&gt;
&lt;li&gt;Free tier URLs change every session (need paid plan for stable URLs)&lt;/li&gt;
&lt;li&gt;No retry logic or reliability features&lt;/li&gt;
&lt;li&gt;Adds latency (traffic routes through ngrok's servers)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Pricing:&lt;/strong&gt; Free tier with 1 tunnel. Paid plans from $8/month for stable domains and more tunnels.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stripe CLI — Stripe-Specific Webhook Testing
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What it does:&lt;/strong&gt; The Stripe CLI's &lt;code&gt;stripe listen&lt;/code&gt; command forwards Stripe webhook events to your local development server. It can also trigger test events (&lt;code&gt;stripe trigger payment_intent.succeeded&lt;/code&gt;) so you don't need to create real transactions during development.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Best for:&lt;/strong&gt; Stripe-specific development. If you're building a Stripe integration and need to test webhook handlers locally.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Strengths:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Official Stripe tool — always up to date with Stripe's API&lt;/li&gt;
&lt;li&gt;Can trigger specific event types on demand&lt;/li&gt;
&lt;li&gt;Handles signature verification automatically&lt;/li&gt;
&lt;li&gt;Free, no account setup beyond Stripe&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Limitations:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Stripe only — doesn't work with Shopify, GitHub, etc.&lt;/li&gt;
&lt;li&gt;Local development only — not for production monitoring&lt;/li&gt;
&lt;li&gt;No retry logic, DLQ, or reliability features&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Pricing:&lt;/strong&gt; Free (part of Stripe's developer tools).&lt;/p&gt;

&lt;h2&gt;
  
  
  Which Tool Should You Use?
&lt;/h2&gt;

&lt;p&gt;Most teams need &lt;strong&gt;different tools for different stages&lt;/strong&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Stage&lt;/th&gt;
&lt;th&gt;Recommended Tool&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Local development&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;ngrok or Stripe CLI&lt;/td&gt;
&lt;td&gt;Tunnel webhooks to localhost for testing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Debugging payloads&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;webhook.site or RequestBin&lt;/td&gt;
&lt;td&gt;See exactly what the provider sends&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Production reliability&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;EventDock or Hookdeck&lt;/td&gt;
&lt;td&gt;Retries, DLQ, monitoring, guaranteed delivery&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Sending webhooks&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Svix&lt;/td&gt;
&lt;td&gt;If YOU are the webhook provider&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Complex routing&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Hookdeck&lt;/td&gt;
&lt;td&gt;Multiple sources/destinations, transformations&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Simple reliability&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;EventDock&lt;/td&gt;
&lt;td&gt;Proxy setup, no routing complexity needed&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  EventDock vs. Hookdeck: The Main Decision
&lt;/h3&gt;

&lt;p&gt;For production webhook reliability, EventDock and Hookdeck are the two primary options. The choice comes down to complexity vs. simplicity:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Choose Hookdeck&lt;/strong&gt; if you need routing rules, payload transformations, fan-out to multiple destinations, or if the generous free tier matters (100K vs. 5K events/month).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Choose EventDock&lt;/strong&gt; if you want the simplest possible setup (change one URL), don't need routing or transformations, and want edge-based delivery with sub-50ms acknowledgment.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both tools solve the core problem: making sure webhook events don't get lost. The difference is in how much additional functionality you need around that core.&lt;/p&gt;

&lt;h2&gt;
  
  
  Frequently Asked Questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  What is the best webhook monitoring tool in 2026?
&lt;/h3&gt;

&lt;p&gt;It depends on your stage: ngrok for local dev, webhook.site for quick inspection, EventDock or Hookdeck for production reliability, Svix for sending webhooks. Most teams use 2-3 tools across different stages.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do I need a webhook monitoring tool for production?
&lt;/h3&gt;

&lt;p&gt;If webhooks drive critical functionality (payments, orders, notifications), yes. Without monitoring, failures are invisible until customers complain. A reliability layer adds retries, DLQ, and visibility.&lt;/p&gt;

&lt;h3&gt;
  
  
  What is the difference between EventDock and Hookdeck?
&lt;/h3&gt;

&lt;p&gt;Both provide webhook reliability. Hookdeck is a full event gateway with routing, transformations, and a larger free tier (100K events/month). EventDock is a simpler relay focused on fast edge delivery and zero-config setup (5K events/month free). Choose based on whether you need routing complexity.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is webhook.site safe for production webhooks?
&lt;/h3&gt;

&lt;p&gt;No. It's a development tool with no retries, no delivery guarantees, and temporary URLs. Never use it for production webhooks.&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  ---
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;
&lt;h3&gt;
  
  
  Try EventDock free
&lt;/h3&gt;

&lt;p&gt;Webhook reliability in 2 minutes. Point your provider at EventDock, point EventDock at your server. Automatic retries, dead letter queue, and a real-time dashboard.&lt;/p&gt;

&lt;p&gt;5,000 events/month, 3 endpoints, no credit card.&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;    [Start Free](https://dashboard.eventdock.app/login)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

</description>
      <category>webdev</category>
      <category>devops</category>
      <category>tools</category>
      <category>webhooks</category>
    </item>
    <item>
      <title>Exactly-Once Webhook Processing: The Pattern Every Developer Gets Wrong</title>
      <dc:creator>EventDock</dc:creator>
      <pubDate>Wed, 25 Mar 2026 11:19:21 +0000</pubDate>
      <link>https://dev.to/eventdock/exactly-once-webhook-processing-the-pattern-every-developer-gets-wrong-35ca</link>
      <guid>https://dev.to/eventdock/exactly-once-webhook-processing-the-pattern-every-developer-gets-wrong-35ca</guid>
      <description>&lt;p&gt;Every major webhook provider — Stripe, Shopify, GitHub, Twilio — delivers webhooks with &lt;strong&gt;at-least-once&lt;/strong&gt; semantics. That means duplicates aren't a bug; they're a guarantee. Your system will receive the same event two, three, or more times. If you process each delivery independently, you'll charge customers twice, send duplicate emails, or create duplicate records.&lt;/p&gt;

&lt;p&gt;Most developers know they need idempotency. The problem is that the most common idempotency pattern has a race condition that fails under real production load. This post covers the wrong way, the right way, and the battle-tested patterns for exactly-once webhook processing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Webhook Duplicates Are Inevitable
&lt;/h2&gt;

&lt;p&gt;Duplicates happen for multiple reasons, and you can't prevent any of them:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Provider retries:&lt;/strong&gt; Your server returned 200 but the response was lost in transit (network blip, load balancer timeout). The provider sees no response and retries.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;At-least-once queues:&lt;/strong&gt; If the provider uses a message queue internally (most do), the queue may deliver the same message twice. This is a fundamental property of distributed message queues.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Webhook replay:&lt;/strong&gt; Someone clicks "resend" in the Stripe dashboard, or an automated recovery system replays events.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Multiple delivery paths:&lt;/strong&gt; Some systems have both real-time delivery and a recovery/catch-up mechanism that can send the same event through different paths.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You cannot make duplicates stop happening. You can only make your system handle them correctly.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Pattern Everyone Uses (And Why It Breaks)
&lt;/h2&gt;

&lt;p&gt;Here's the "obvious" idempotency pattern — check if you've seen the event, skip if you have:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// THE WRONG WAY: check-then-act (has a race condition)&lt;/span&gt;
&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/webhook&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;eventId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// e.g., evt_abc123&lt;/span&gt;

  &lt;span class="c1"&gt;// Step 1: Check if already processed&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;existing&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;processedEvents&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findUnique&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;where&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;eventId&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;existing&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;duplicate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt; &lt;span class="c1"&gt;// Already handled&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;// Step 2: Process the event&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;processEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c1"&gt;// Step 3: Mark as processed&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;processedEvents&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;eventId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;processedAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;received&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This looks correct. It checks for duplicates, processes the event, records that it was processed. What could go wrong?&lt;/p&gt;

&lt;h3&gt;
  
  
  The Race Condition
&lt;/h3&gt;

&lt;p&gt;Imagine two deliveries of the same event arrive 50ms apart (this happens regularly under load):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Timeline:
  T=0ms   Request A: SELECT * FROM processed_events WHERE event_id = 'evt_abc123'
  T=5ms   Request A: Result: no rows → not a duplicate
  T=10ms  Request B: SELECT * FROM processed_events WHERE event_id = 'evt_abc123'
  T=15ms  Request B: Result: no rows → not a duplicate  (!!!)
  T=20ms  Request A: processEvent(event)  → charges customer
  T=25ms  Request B: processEvent(event)  → charges customer AGAIN
  T=50ms  Request A: INSERT INTO processed_events (event_id, ...)
  T=55ms  Request B: INSERT INTO processed_events → duplicate key error (too late)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both requests pass the duplicate check because neither has written to the database yet when the other checks. The customer gets charged twice. The second INSERT might fail on a unique constraint, but by then the damage is done.&lt;/p&gt;

&lt;p&gt;This is the classic &lt;strong&gt;check-then-act&lt;/strong&gt; race condition (also called TOCTOU — Time Of Check to Time Of Use). It's the same bug that causes double-spend in payment systems, double-voting in election software, and overselling in e-commerce. In webhook processing, it's everywhere.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Correct Patterns
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Pattern 1: Database Constraint as the Lock (Recommended)
&lt;/h3&gt;

&lt;p&gt;Instead of checking then acting, use the database's unique constraint as an atomic lock. Insert first, process only if the insert succeeds:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// THE RIGHT WAY: insert-first with unique constraint&lt;/span&gt;
&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/webhook&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;eventId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="c1"&gt;// Attempt to claim this event atomically&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;processedEvents&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;eventId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;processing&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;receivedAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;isDuplicateKeyError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="c1"&gt;// Another request already claimed this event&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;duplicate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// Some other database error&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;// If we get here, we "own" this event — no race condition&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;processEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;processedEvents&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;where&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;eventId&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;processed&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;processedAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;processedEvents&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;where&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;eventId&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;failed&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="c1"&gt;// Don't rethrow — we still return 200 so the provider doesn't retry&lt;/span&gt;
    &lt;span class="c1"&gt;// The failed event needs manual investigation or a retry job&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;received&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;isDuplicateKeyError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;any&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Prisma&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;P2002&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="c1"&gt;// PostgreSQL&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;23505&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="c1"&gt;// MySQL&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;errno&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;1062&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="c1"&gt;// SQLite&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;SQLITE_CONSTRAINT_UNIQUE&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The key insight: the &lt;code&gt;INSERT&lt;/code&gt; with a unique constraint on &lt;code&gt;eventId&lt;/code&gt; is atomic at the database level. Two concurrent requests can't both succeed. One will insert, the other will get a duplicate key error. No race condition possible.&lt;/p&gt;

&lt;h3&gt;
  
  
  Pattern 2: SELECT ... FOR UPDATE (Pessimistic Locking)
&lt;/h3&gt;

&lt;p&gt;If you need more flexibility (e.g., the idempotency key isn't the primary key), use row-level locking:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Pessimistic locking with a transaction&lt;/span&gt;
&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/webhook&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;eventId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;$transaction&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Lock the row (or lock nothing if it doesn't exist yet)&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;existing&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;$queryRaw&lt;/span&gt;&lt;span class="s2"&gt;`
      SELECT * FROM processed_events
      WHERE event_id = &lt;/span&gt;&lt;span class="se"&gt;\$&lt;/span&gt;&lt;span class="s2"&gt;{eventId}
      FOR UPDATE
    `&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;existing&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// Already processed, skip&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;processedEvents&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;eventId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;processing&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;

    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;processEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;processedEvents&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;where&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;eventId&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;processed&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;received&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Tradeoff:&lt;/strong&gt; This holds a database lock for the entire duration of processing. Fine for fast operations, but problematic if &lt;code&gt;processEvent()&lt;/code&gt; takes seconds or calls external APIs. The lock blocks all other requests for the same event.&lt;/p&gt;

&lt;h3&gt;
  
  
  Pattern 3: Idempotency Key in a Separate Store (Redis/KV)
&lt;/h3&gt;

&lt;p&gt;For high-throughput systems where database transactions are expensive, use an atomic set-if-not-exists operation in a fast key-value store:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Redis-based idempotency with atomic SETNX&lt;/span&gt;
&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/webhook&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;eventId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;lockKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`webhook:lock:&lt;/span&gt;&lt;span class="se"&gt;\$&lt;/span&gt;&lt;span class="s2"&gt;{eventId}`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="c1"&gt;// SETNX: set only if key doesn't exist (atomic)&lt;/span&gt;
  &lt;span class="c1"&gt;// EX 86400: expire after 24 hours (cleanup)&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;acquired&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;lockKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;processing&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;EX&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;86400&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;NX&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;acquired&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Another process already claimed this event&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;duplicate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;processEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;lockKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;processed&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;EX&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;86400&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Release the lock so a retry can attempt processing&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;del&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;lockKey&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;received&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Tradeoff:&lt;/strong&gt; Redis is fast but not as durable as a database. If Redis restarts between the lock acquisition and processing completion, you might process the event twice. For most webhook use cases, this is acceptable — Redis rarely restarts, and the 24-hour TTL keeps the keyspace clean.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which Pattern Should You Use?
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Pattern&lt;/th&gt;
&lt;th&gt;Best For&lt;/th&gt;
&lt;th&gt;Tradeoff&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Unique constraint (Pattern 1)&lt;/td&gt;
&lt;td&gt;Most applications&lt;/td&gt;
&lt;td&gt;Requires database write before processing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SELECT FOR UPDATE (Pattern 2)&lt;/td&gt;
&lt;td&gt;Complex idempotency keys&lt;/td&gt;
&lt;td&gt;Holds lock during processing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Redis SETNX (Pattern 3)&lt;/td&gt;
&lt;td&gt;High-throughput systems&lt;/td&gt;
&lt;td&gt;Less durable than database&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;For 90% of webhook handlers, &lt;strong&gt;Pattern 1 (unique constraint)&lt;/strong&gt; is the right choice. It's simple, correct, and uses infrastructure you already have.&lt;/p&gt;

&lt;h2&gt;
  
  
  Edge Cases That Still Bite You
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Processing Succeeds But Status Update Fails
&lt;/h3&gt;

&lt;p&gt;You process the event (charge the customer), but the database update to mark it as "processed" fails (network error, database full). On retry, you see the event in "processing" state. Is it safe to reprocess? You need a way to check the actual side effect (was the charge created?) rather than just the status field.&lt;/p&gt;

&lt;h3&gt;
  
  
  Non-Deterministic Processing
&lt;/h3&gt;

&lt;p&gt;If &lt;code&gt;processEvent()&lt;/code&gt; creates resources with random IDs, processing the same event twice creates two different resources. True idempotency means the same input always produces the same output. Use deterministic IDs derived from the event ID where possible.&lt;/p&gt;

&lt;h3&gt;
  
  
  Multi-Step Processing
&lt;/h3&gt;

&lt;p&gt;If processing involves multiple steps (update DB, send email, call API), partial failure means some steps completed and some didn't. On retry, you need to skip completed steps. This is the &lt;a href="https://microservices.io/patterns/data/saga.html" rel="noopener noreferrer"&gt;saga pattern&lt;/a&gt;, and it's significantly more complex than single-step idempotency.&lt;/p&gt;

&lt;h2&gt;
  
  
  How EventDock Handles Deduplication
&lt;/h2&gt;

&lt;p&gt;EventDock deduplicates at the infrastructure level before events reach your handler. Each event gets a unique ID, and the delivery pipeline uses KV-based atomic dedup to ensure your endpoint receives each event exactly once — even if the webhook provider delivers it multiple times.&lt;/p&gt;

&lt;p&gt;This means your webhook handler doesn't need any of the patterns above. You write simple processing logic, and EventDock guarantees you won't see the same event twice. If you want defense-in-depth, you can still add application-level idempotency using Pattern 1 — but you won't need it for correctness.&lt;/p&gt;

&lt;h2&gt;
  
  
  Frequently Asked Questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Why do webhooks get delivered more than once?
&lt;/h3&gt;

&lt;p&gt;At-least-once delivery is a fundamental property of distributed systems. If the provider doesn't receive your 200 response (network blip, load balancer timeout, process crash), it retries. Some providers also use internal queues that may deliver the same message twice. You cannot prevent duplicates — only handle them.&lt;/p&gt;

&lt;h3&gt;
  
  
  What is the check-then-act race condition?
&lt;/h3&gt;

&lt;p&gt;It's when you check if an event was processed (SELECT), then process it, then mark it done (INSERT). Two concurrent requests can both pass the check before either writes, causing double processing. Fix it by inserting first with a unique constraint — the database enforces atomicity.&lt;/p&gt;

&lt;h3&gt;
  
  
  What is an idempotency key for webhooks?
&lt;/h3&gt;

&lt;p&gt;A unique identifier for a webhook event used to detect duplicates. Stripe provides &lt;code&gt;event.id&lt;/code&gt;, Shopify provides &lt;code&gt;X-Shopify-Webhook-Id&lt;/code&gt;, GitHub provides &lt;code&gt;X-GitHub-Delivery&lt;/code&gt;. Store these with a unique database constraint.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I make my webhook handler idempotent?
&lt;/h3&gt;

&lt;p&gt;Use the insert-first pattern: INSERT the idempotency key with a unique constraint. If the insert succeeds, process the event. If it fails with a duplicate key error, skip it. This is the only pattern that's both correct and race-condition-free without requiring explicit locks.&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  ---
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;
&lt;h3&gt;
  
  
  Skip the dedup complexity
&lt;/h3&gt;

&lt;p&gt;EventDock deduplicates webhook events at the infrastructure level. Your handler receives each event exactly once — no idempotency keys, no race conditions, no duplicate processing.&lt;/p&gt;

&lt;p&gt;5,000 events/month free. No credit card required.&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;    [Start Free](https://dashboard.eventdock.app/login)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

</description>
      <category>webdev</category>
      <category>database</category>
      <category>javascript</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Shopify Webhook Reliability: Why Orders Go Missing and How to Fix It</title>
      <dc:creator>EventDock</dc:creator>
      <pubDate>Wed, 25 Mar 2026 11:19:20 +0000</pubDate>
      <link>https://dev.to/eventdock/shopify-webhook-reliability-why-orders-go-missing-and-how-to-fix-it-3o08</link>
      <guid>https://dev.to/eventdock/shopify-webhook-reliability-why-orders-go-missing-and-how-to-fix-it-3o08</guid>
      <description>&lt;p&gt;If you run a Shopify store with custom integrations, you've probably hit this: an order comes in, but your fulfillment system, inventory tracker, or CRM never gets notified. The order exists in Shopify, but it's invisible to the rest of your stack. The culprit is almost always a failed webhook.&lt;/p&gt;

&lt;p&gt;Shopify's webhook system has specific constraints that make it surprisingly easy to lose events. This guide covers the exact failure modes, how to verify webhook signatures properly, and how to guarantee you never miss an order again.&lt;/p&gt;

&lt;h2&gt;
  
  
  Shopify's Webhook Constraints (The Ones That Bite You)
&lt;/h2&gt;

&lt;p&gt;Shopify's webhook delivery system has three properties that combine to create reliability problems:&lt;/p&gt;

&lt;h3&gt;
  
  
  1. The 5-Second Timeout
&lt;/h3&gt;

&lt;p&gt;Shopify gives your endpoint &lt;strong&gt;5 seconds&lt;/strong&gt; to respond with a 2xx status code. If your server takes longer — due to a slow database query, a cold start, or downstream API calls — Shopify marks the delivery as failed.&lt;/p&gt;

&lt;p&gt;Five seconds sounds generous until you realize what happens during real production conditions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Your server is restarting during a deploy (10-30 second gap)&lt;/li&gt;
&lt;li&gt;Your database connection pool is exhausted during a flash sale&lt;/li&gt;
&lt;li&gt;You're calling a third-party API (shipping provider, ERP) synchronously in the handler&lt;/li&gt;
&lt;li&gt;Lambda/serverless cold start adds 2-3 seconds, leaving you 2 seconds for actual work&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  2. Limited Retry Window
&lt;/h3&gt;

&lt;p&gt;When delivery fails, Shopify retries up to &lt;strong&gt;19 times over 48 hours&lt;/strong&gt; with increasing delays. After that, the webhook subscription is automatically marked as degraded. If failures continue, Shopify may &lt;strong&gt;delete your webhook subscription entirely&lt;/strong&gt; — meaning you stop receiving ALL events of that type, not just the one that failed.&lt;/p&gt;

&lt;p&gt;This is the most dangerous behavior: a temporary outage on your end can permanently break your integration if you don't catch it quickly.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Mandatory Webhook Topics
&lt;/h3&gt;

&lt;p&gt;Starting in 2025, Shopify requires apps to subscribe to certain webhook topics (like &lt;code&gt;app/uninstalled&lt;/code&gt; and compliance-related webhooks). If your app doesn't handle these properly, it can fail app review or lose API access. You can't afford to miss these events.&lt;/p&gt;

&lt;h2&gt;
  
  
  Verifying Shopify Webhook Signatures (HMAC-SHA256)
&lt;/h2&gt;

&lt;p&gt;Every Shopify webhook includes an &lt;code&gt;X-Shopify-Hmac-SHA256&lt;/code&gt; header — a Base64-encoded HMAC digest of the request body, signed with your app's shared secret. You &lt;strong&gt;must&lt;/strong&gt; verify this before processing any webhook.&lt;/p&gt;

&lt;p&gt;Here's the correct implementation in Node.js:&lt;br&gt;
&lt;/p&gt;

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

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;verifyShopifyWebhook&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;rawBody&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Buffer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;hmacHeader&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;secret&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;string&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;generatedHash&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createHmac&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;sha256&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;secret&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rawBody&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;base64&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c1"&gt;// Use timingSafeEqual to prevent timing attacks&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;timingSafeEqual&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="nx"&gt;Buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;generatedHash&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
      &lt;span class="nx"&gt;Buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;hmacHeader&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// Lengths don't match&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Express middleware&lt;/span&gt;
&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/webhooks/shopify&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;express&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;hmac&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;x-shopify-hmac-sha256&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nf"&gt;verifyShopifyWebhook&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;hmac&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;SHOPIFY_WEBHOOK_SECRET&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Shopify webhook signature verification failed&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;401&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Unauthorized&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;topic&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;x-shopify-topic&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="c1"&gt;// ACK immediately — process async&lt;/span&gt;
    &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;OK&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="c1"&gt;// Process in background&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;processShopifyEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;topic&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Failed to process Shopify webhook:&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;topic&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="c1"&gt;// Without a reliability layer, this event may be lost&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Critical detail:&lt;/strong&gt; You must use the &lt;em&gt;raw&lt;/em&gt; request body for HMAC verification, not a parsed-and-re-serialized version. If you use &lt;code&gt;express.json()&lt;/code&gt; middleware and then &lt;code&gt;JSON.stringify(req.body)&lt;/code&gt;, the signature won't match because JSON serialization isn't deterministic (key ordering, whitespace).&lt;/p&gt;

&lt;h2&gt;
  
  
  The 5 Ways Shopify Webhooks Go Missing
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Deploy Windows
&lt;/h3&gt;

&lt;p&gt;Every time you deploy, there's a window where your server is unavailable. If Shopify sends an &lt;code&gt;orders/create&lt;/code&gt; webhook during that window, it fails. With blue-green deployments you can minimize this, but you can't eliminate it entirely — especially with database migrations.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Synchronous Processing
&lt;/h3&gt;

&lt;p&gt;The most common mistake: doing all your work inside the webhook handler before returning 200. If you're updating inventory, notifying a warehouse, and sending a customer email — all before responding — you'll blow past the 5-second timeout.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// BAD: synchronous processing&lt;/span&gt;
&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/webhooks/shopify/orders&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;order&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;updateInventory&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;order&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;        &lt;span class="c1"&gt;// 1.5s&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;notifyWarehouse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;order&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;        &lt;span class="c1"&gt;// 2s (external API)&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;sendConfirmationEmail&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;order&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;  &lt;span class="c1"&gt;// 1s&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;updateCRM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;order&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;             &lt;span class="c1"&gt;// 1.5s&lt;/span&gt;
  &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;OK&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;         &lt;span class="c1"&gt;// Total: 6s — TIMEOUT&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// GOOD: ACK first, process async&lt;/span&gt;
&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/webhooks/shopify/orders&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;order&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;webhookQueue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;insert&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;topic&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;orders/create&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;order&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;OK&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;  &lt;span class="c1"&gt;// &amp;lt;100ms&lt;/span&gt;

  &lt;span class="c1"&gt;// Process via background job/queue worker&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  3. Unhandled Errors Crashing the Process
&lt;/h3&gt;

&lt;p&gt;If your handler throws an unhandled exception, the HTTP connection drops without a response. Shopify sees this as a failure. If your error is deterministic (like a missing field in a new webhook format), every retry will fail the same way.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Scaling Issues During Flash Sales
&lt;/h3&gt;

&lt;p&gt;Shopify can send hundreds of &lt;code&gt;orders/create&lt;/code&gt; webhooks per minute during a flash sale. If your server can't keep up — connection pool exhaustion, memory pressure, CPU saturation — webhooks start timing out. Shopify's retry mechanism doesn't back off fast enough to help you recover, and the retries add even more load.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Webhook Subscription Deletion
&lt;/h3&gt;

&lt;p&gt;This is the silent killer. If your endpoint fails consistently, Shopify doesn't just stop retrying the individual events — it removes the webhook subscription. New events aren't even attempted. You have to re-register the webhook, and the events that occurred during the gap are permanently lost.&lt;/p&gt;

&lt;h2&gt;
  
  
  Making Shopify Webhooks Bulletproof
&lt;/h2&gt;

&lt;p&gt;There are two approaches: build it yourself or use a reliability layer.&lt;/p&gt;

&lt;h3&gt;
  
  
  DIY Approach
&lt;/h3&gt;

&lt;p&gt;If you're building it yourself, you need:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;ACK immediately:&lt;/strong&gt; Return 200 within 1 second, process asynchronously via a job queue (BullMQ, SQS, etc.)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Idempotency:&lt;/strong&gt; Use the &lt;code&gt;X-Shopify-Webhook-Id&lt;/code&gt; header as a dedup key — Shopify may deliver the same event multiple times&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reconciliation job:&lt;/strong&gt; Periodically poll the Shopify Orders API to find orders that your system missed, comparing Shopify's records against your database&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Subscription monitoring:&lt;/strong&gt; Check your webhook subscriptions daily using the Admin API — if one disappeared, re-register it and backfill from the API&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dead letter queue:&lt;/strong&gt; Events that fail processing after N retries need a place to go where you can inspect and replay them manually&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That's a solid approach, but it's easily 2-3 weeks of engineering work to build and maintain properly.&lt;/p&gt;

&lt;h3&gt;
  
  
  Reliability Layer Approach
&lt;/h3&gt;

&lt;p&gt;Put a webhook proxy between Shopify and your server. Instead of pointing Shopify at your endpoint directly, point it at a reliability layer that:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Responds to Shopify within milliseconds (never times out)&lt;/li&gt;
&lt;li&gt;Stores every event durably before forwarding&lt;/li&gt;
&lt;li&gt;Retries delivery to your server with exponential backoff&lt;/li&gt;
&lt;li&gt;Provides a dead letter queue for manual inspection and replay&lt;/li&gt;
&lt;li&gt;Never lets Shopify see your endpoint's failures&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is what &lt;a href="https://eventdock.app" rel="noopener noreferrer"&gt;EventDock&lt;/a&gt; does. Because EventDock responds to Shopify in under 50ms from the nearest edge node, Shopify never sees a timeout. Your webhook subscription stays healthy even if your server has a bad day.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Setup:&lt;/span&gt;
&lt;span class="c"&gt;# 1. Create endpoint in EventDock dashboard&lt;/span&gt;
&lt;span class="c"&gt;#    Provider: Shopify&lt;/span&gt;
&lt;span class="c"&gt;#    Destination: https://yourapp.com/webhooks/shopify&lt;/span&gt;
&lt;span class="c"&gt;#    You get: https://in.eventdock.app/ep_xyz789&lt;/span&gt;
&lt;span class="c"&gt;#&lt;/span&gt;
&lt;span class="c"&gt;# 2. In Shopify Admin → Settings → Notifications → Webhooks:&lt;/span&gt;
&lt;span class="c"&gt;#    Point all webhook topics at: https://in.eventdock.app/ep_xyz789&lt;/span&gt;
&lt;span class="c"&gt;#&lt;/span&gt;
&lt;span class="c"&gt;# Now: Shopify → EventDock (instant ACK, durable storage)&lt;/span&gt;
&lt;span class="c"&gt;#       → Your server (retries, DLQ, monitoring)&lt;/span&gt;
&lt;span class="c"&gt;#&lt;/span&gt;
&lt;span class="c"&gt;# Your server can be slow, restart, or even go down.&lt;/span&gt;
&lt;span class="c"&gt;# EventDock holds the events and delivers when you're ready.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Frequently Asked Questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  How long does Shopify retry failed webhooks?
&lt;/h3&gt;

&lt;p&gt;Shopify retries up to 19 times over 48 hours with increasing delays. After all retries fail, the event is permanently lost. Worse, persistent failures can cause Shopify to &lt;strong&gt;delete your webhook subscription entirely&lt;/strong&gt;, meaning you stop receiving all future events of that type.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why is my Shopify webhook subscription disappearing?
&lt;/h3&gt;

&lt;p&gt;Shopify automatically removes webhook subscriptions that consistently fail. If your endpoint returns errors or times out repeatedly, the subscription gets marked as degraded and eventually deleted. Using a webhook reliability layer prevents this by always responding to Shopify instantly, keeping your subscription healthy.&lt;/p&gt;

&lt;h3&gt;
  
  
  What is the Shopify webhook timeout limit?
&lt;/h3&gt;

&lt;p&gt;5 seconds, strictly enforced and not configurable. Your endpoint must respond with a 2xx status within 5 seconds or the delivery is marked as failed. This is why synchronous processing in webhook handlers is dangerous.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I verify Shopify webhook signatures in Node.js?
&lt;/h3&gt;

&lt;p&gt;Compute an HMAC-SHA256 digest of the &lt;em&gt;raw&lt;/em&gt; request body using your app's shared secret, Base64-encode it, and compare it against the &lt;code&gt;X-Shopify-Hmac-SHA256&lt;/code&gt; header using &lt;code&gt;crypto.timingSafeEqual()&lt;/code&gt;. The key detail is using the raw body — not a parsed and re-serialized version.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I recover missed Shopify webhooks?
&lt;/h3&gt;

&lt;p&gt;You can poll Shopify's Admin API for resources (orders, products, etc.) and reconcile against your database. But this is a workaround, not a solution. It only works for resources with API endpoints, adds API rate limit pressure, and requires building a custom reconciliation system. Better to prevent loss in the first place with a reliability layer.&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  ---
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;
&lt;h3&gt;
  
  
  Never miss a Shopify order webhook again
&lt;/h3&gt;

&lt;p&gt;EventDock sits between Shopify and your server, storing every event durably and delivering with automatic retries. Your Shopify webhook subscriptions stay healthy, even when your server doesn't.&lt;/p&gt;

&lt;p&gt;5,000 events/month free. Set up in 2 minutes.&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;    [Start Free](https://dashboard.eventdock.app/login)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

</description>
      <category>shopify</category>
      <category>webdev</category>
      <category>javascript</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>I Built a Webhook Relay on Cloudflare Workers. Here Are the Bugs That Killed It</title>
      <dc:creator>EventDock</dc:creator>
      <pubDate>Mon, 23 Mar 2026 20:16:52 +0000</pubDate>
      <link>https://dev.to/eventdock/i-built-a-webhook-relay-on-cloudflare-workers-here-is-every-bug-that-almost-killed-it-22dc</link>
      <guid>https://dev.to/eventdock/i-built-a-webhook-relay-on-cloudflare-workers-here-is-every-bug-that-almost-killed-it-22dc</guid>
      <description>&lt;p&gt;Four production bugs. All invisible in staging. Each one silently dropping data while every dashboard metric looked perfectly healthy. Here is what I learned building a webhook relay on Cloudflare Workers — and why distributed systems fail in ways that look like success.&lt;/p&gt;

&lt;p&gt;I built &lt;a href="https://eventdock.app" rel="noopener noreferrer"&gt;EventDock&lt;/a&gt;, a webhook reliability layer that sits between webhook providers (Stripe, GitHub, etc.) and your application. The idea is simple: accept the webhook instantly, store it durably, and deliver it to your endpoint with retries, logging, and a dead letter queue.&lt;/p&gt;

&lt;p&gt;I chose Cloudflare Workers as the platform. Edge compute seemed like the perfect fit — webhook providers have short timeouts (Stripe gives you ~20 seconds), so you want to ACK as fast as possible. A Worker can respond in under 50ms from the nearest edge node. No cold starts, no servers to manage, global by default.&lt;/p&gt;

&lt;p&gt;The architecture works beautifully &lt;em&gt;on paper&lt;/em&gt;. Getting it to work reliably in production required finding and fixing bugs that were invisible in development and staging. Here are the four that almost killed the project.&lt;/p&gt;

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

&lt;p&gt;Before diving into the bugs, here's the flow:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Provider (Stripe, GitHub, etc.)
  → CF Worker (ingest) — validates, stores to D1, enqueues
    → CF Queue — at-least-once delivery semantics
      → CF Worker (delivery) — fetches payload, delivers to customer endpoint
        → Customer's app
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The supporting cast:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;D1&lt;/strong&gt; (SQLite at the edge) — stores event metadata, delivery state, and retry counts&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;KV&lt;/strong&gt; — idempotency keys and deduplication&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;R2&lt;/strong&gt; — payload storage for large webhook bodies&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cron triggers&lt;/strong&gt; — a recovery mechanism that finds stuck events and requeues them&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The key design decision: the ingest worker does minimal work. Accept the webhook, write to D1 and the queue, return 200. Everything else happens asynchronously. This keeps the p99 response time under 100ms, which matters when Stripe is waiting for your response.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug #1: The Unwaited Retry
&lt;/h2&gt;

&lt;p&gt;This one was subtle and devastating. In the queue consumer (the delivery worker), when a delivery attempt failed and needed to be retried, the code looked like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// The queue consumer processes batches of messages&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;queue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;batch&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;MessageBatch&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Env&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;msg&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;batch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;deliverWebhook&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="c1"&gt;// Schedule retry with exponential backoff&lt;/span&gt;
      &lt;span class="nf"&gt;handleRetry&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;  &lt;span class="c1"&gt;// ← THE BUG&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nx"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ack&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;handleRetry()&lt;/code&gt; is an async function. It writes to D1 to update retry count and schedules the next attempt. But it wasn't being &lt;code&gt;await&lt;/code&gt;ed.&lt;/p&gt;

&lt;p&gt;In Node.js, this would be a fire-and-forget — the retry would probably still complete in the background. In Cloudflare Workers, it's a death sentence. Workers are request-scoped. Once you call &lt;code&gt;msg.ack()&lt;/code&gt; and the queue batch handler returns, the runtime can (and will) terminate the execution context. The unresolved promise from &lt;code&gt;handleRetry()&lt;/code&gt; just... disappears.&lt;/p&gt;

&lt;p&gt;The fix was one word:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Before (broken)&lt;/span&gt;
&lt;span class="nf"&gt;handleRetry&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ack&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="c1"&gt;// After (fixed)&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;handleRetry&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ack&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Why it was hard to find:&lt;/strong&gt; Events appeared to be processing. The first delivery attempt worked fine. It was only events that needed retries that silently vanished. And in development, you're usually testing against endpoints that succeed. The failure mode only appeared under real production conditions — intermittent endpoint failures, timeouts, 500s.&lt;/p&gt;

&lt;p&gt;The monitoring showed events being consumed from the queue (good!) but some never reaching a final delivered/failed state. They just sat in "delivering" forever. Classic fire-and-forget symptom, but only obvious in retrospect.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug #2: Queue Send Without Try-Catch
&lt;/h2&gt;

&lt;p&gt;The ingest handler had a straightforward flow: validate the webhook, store the event in D1, then send it to the queue for delivery.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Ingest handler (simplified)&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;handleWebhook&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Env&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c1"&gt;// Step 1: Save to D1 (durable state)&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;DB&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;prepare&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;INSERT INTO events (id, endpoint_id, payload, status) VALUES (?, ?, ?, ?)&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
  &lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;bind&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;endpointId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;pending&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="c1"&gt;// Step 2: Enqueue for delivery&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;QUEUE&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;eventId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;endpointId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;endpointId&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="c1"&gt;// ↑ No try-catch. If this throws, the event is saved but never queued.&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;OK&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The problem: &lt;code&gt;QUEUE.send()&lt;/code&gt; can fail. Cloudflare Queues are generally reliable, but they're a distributed system — transient errors happen. When the queue send failed, the error propagated up and the handler returned a 500 to the webhook provider. But the D1 write had already committed.&lt;/p&gt;

&lt;p&gt;So now you have an event in the database with status "pending" that will never be picked up by the queue consumer. The customer sees it in their dashboard as "received" but it never gets delivered. It's a zombie event.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// After: catch queue failures, mark for cron recovery&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;DB&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;prepare&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;INSERT INTO events (id, endpoint_id, payload, status) VALUES (?, ?, ?, ?)&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;bind&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;endpointId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;pending&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;QUEUE&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;eventId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;endpointId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;endpointId&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Event is in D1 — the cron recovery job will find it&lt;/span&gt;
  &lt;span class="c1"&gt;// and requeue it. Log the error but return 200 to the provider.&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Queue send failed, event will be recovered by cron&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Always return 200 — the event is durably stored regardless&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;OK&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The insight: the D1 write IS the source of truth. If the event is in the database, the system should eventually deliver it. The queue is an optimization for fast delivery, not the guarantee. The guarantee comes from the cron recovery job that scans for stuck events.&lt;/p&gt;

&lt;p&gt;Which brings us to bug #3.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug #3: The Safety Net That Never Ran
&lt;/h2&gt;

&lt;p&gt;From the very beginning, I built a cron-triggered recovery system. Every few minutes, a Worker runs, queries D1 for events stuck in "pending" or "delivering" for too long, and requeues them. This was supposed to be the safety net for exactly the kind of failure in Bug #2.&lt;/p&gt;

&lt;p&gt;The code was solid. Tested in development. Ready to go.&lt;/p&gt;

&lt;p&gt;One problem: the cron trigger in &lt;code&gt;wrangler.toml&lt;/code&gt; was commented out.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight toml"&gt;&lt;code&gt;&lt;span class="c"&gt;# wrangler.toml (production env)&lt;/span&gt;

&lt;span class="nn"&gt;[env.production]&lt;/span&gt;
&lt;span class="py"&gt;name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"eventdock-worker-prod"&lt;/span&gt;
&lt;span class="c"&gt;# ... other config ...&lt;/span&gt;

&lt;span class="c"&gt;# [env.production.triggers]&lt;/span&gt;
&lt;span class="c"&gt;# crons = ["*/5 * * * *"]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I had commented it out during early development (probably debugging something) and never uncommented it. The deployment pipeline didn't warn about missing triggers. There's no "expected crons" health check. The recovery system silently didn't exist for months.&lt;/p&gt;

&lt;p&gt;During those months, any event that hit Bug #2's failure mode (or any other edge case where the queue send didn't fire) was just... gone. Saved in D1 but never delivered and never recovered.&lt;/p&gt;

&lt;p&gt;The fix was uncommenting two lines. The lesson was harder: &lt;strong&gt;your safety nets need their own monitoring&lt;/strong&gt;. If the recovery cron hasn't run in the last 10 minutes, that itself should be an alert.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight toml"&gt;&lt;code&gt;&lt;span class="nn"&gt;[env.production.triggers]&lt;/span&gt;
&lt;span class="py"&gt;crons&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"*/5 * * * *"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A note on Wrangler v3 syntax: cron triggers for specific environments need the &lt;code&gt;[env.production.triggers]&lt;/code&gt; block with a &lt;code&gt;crons = [...]&lt;/code&gt; array. Not &lt;code&gt;[triggers]&lt;/code&gt; at the top level (that's the default env), and not &lt;code&gt;cron =&lt;/code&gt; (singular). This particular config syntax isn't well-documented, and getting it wrong means your crons silently don't deploy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug #4: The Ghost Consumer
&lt;/h2&gt;

&lt;p&gt;This one was the most disorienting to debug. Events were being enqueued correctly (I could see the queue depth increasing and then dropping back to zero), but some events were never delivered. The delivery worker's logs showed nothing — no attempts, no errors, nothing. Events just vanished from the queue without a trace.&lt;/p&gt;

&lt;p&gt;The cause: my &lt;code&gt;wrangler.toml&lt;/code&gt; had a queue consumer binding in the default (top-level) environment, not just in &lt;code&gt;[env.production]&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight toml"&gt;&lt;code&gt;&lt;span class="c"&gt;# wrangler.toml (the problem)&lt;/span&gt;

&lt;span class="c"&gt;# Default env — leftover from initial setup&lt;/span&gt;
&lt;span class="nn"&gt;[[queues.consumers]]&lt;/span&gt;
&lt;span class="py"&gt;queue&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"eventdock-deliveries"&lt;/span&gt;
&lt;span class="py"&gt;max_batch_size&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;

&lt;span class="c"&gt;# Production env — the "real" consumer&lt;/span&gt;
&lt;span class="nn"&gt;[env.production]&lt;/span&gt;
&lt;span class="nn"&gt;[[env.production.queues.consumers]]&lt;/span&gt;
&lt;span class="py"&gt;queue&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"eventdock-deliveries"&lt;/span&gt;
&lt;span class="py"&gt;max_batch_size&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When I deployed with &lt;code&gt;wrangler deploy --env production&lt;/code&gt;, it deployed the production worker. But the default environment's consumer config meant there was a &lt;em&gt;previous&lt;/em&gt; worker deployment (from running &lt;code&gt;wrangler deploy&lt;/code&gt; without &lt;code&gt;--env&lt;/code&gt; during early development) that was ALSO registered as a consumer on the same queue.&lt;/p&gt;

&lt;p&gt;Cloudflare Queues distributes messages across all registered consumers. So roughly half the events went to the production worker (which delivered them correctly) and half went to a stale, unmonitored worker that consumed them and did nothing useful.&lt;/p&gt;

&lt;p&gt;The fix was removing the default environment's consumer config and deleting the stale worker deployment. But finding it required going to the Cloudflare dashboard, looking at the queue's consumer list, and realizing there were &lt;em&gt;two&lt;/em&gt; workers consuming from the same queue.&lt;/p&gt;

&lt;p&gt;The debugging process took hours because every metric looked "kind of right." Queue depth went up, queue depth went down, delivery rate was non-zero. It was only when I compared "events enqueued" to "events delivered" over a 24-hour window that the ~50% loss rate became obvious.&lt;/p&gt;

&lt;h2&gt;
  
  
  Edge Compute Gotchas (Things I Wish I'd Known)
&lt;/h2&gt;

&lt;p&gt;Beyond the specific bugs, here are the platform-level lessons from running a production system on Cloudflare Workers:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;D1 is SQLite, and that matters.&lt;/strong&gt; It's excellent for reads and great for the kind of workload a webhook relay generates (mostly inserts and point lookups). But SQLite has write contention characteristics you need to understand. Concurrent writes to the same database serialize. Under high throughput, your D1 write latency can spike. Batch your writes where possible.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;CF Queues are at-least-once, not exactly-once.&lt;/strong&gt; This is documented, but you need to internalize what it means: your queue consumer MUST be idempotent. The same message can be delivered multiple times. If your delivery handler isn't idempotent, you'll send duplicate webhooks to your customers. We use KV-based deduplication keyed on event ID.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No &lt;code&gt;setTimeout&lt;/code&gt;, no background work.&lt;/strong&gt; Workers are request-scoped. When the request handler (or queue batch handler) returns, your execution context can be terminated. Any work you haven't &lt;code&gt;await&lt;/code&gt;ed is at risk. This is fundamentally different from a long-running Node.js server where fire-and-forget async calls will "probably" complete. On Workers, they probably won't.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Wrangler config is your infrastructure-as-code.&lt;/strong&gt; Unlike traditional IaC tools (Terraform, Pulumi), Wrangler doesn't have a plan/apply cycle. &lt;code&gt;wrangler deploy&lt;/code&gt; just does it. There's no diff, no confirmation, and misconfigured environments are silent failures. Treat &lt;code&gt;wrangler.toml&lt;/code&gt; with the same rigor you'd treat a Terraform file — code review every change.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd Do Differently
&lt;/h2&gt;

&lt;p&gt;If I were starting over, I'd invert the delivery architecture. Instead of queue-based delivery as the primary path with cron recovery as a safety net, I'd make the cron the primary mechanism.&lt;/p&gt;

&lt;p&gt;Here's why: the cron recovery pattern is inherently reliable. It scans D1 for undelivered events, attempts delivery, and updates state. It doesn't depend on queue health, consumer registration, or message acking semantics. It's a simple polling loop backed by a durable database.&lt;/p&gt;

&lt;p&gt;The queue would become a performance optimization — a fast path that delivers events within seconds instead of waiting for the next cron tick. But the system would be correct and complete with ONLY the cron. The queue just makes it faster.&lt;/p&gt;

&lt;p&gt;This is the same insight behind patterns like the &lt;a href="https://microservices.io/patterns/data/transactional-outbox.html" rel="noopener noreferrer"&gt;transactional outbox pattern&lt;/a&gt;: write to the database first, process asynchronously second. The database is the source of truth, and the async mechanism is an optimization.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where It Stands Now
&lt;/h2&gt;

&lt;p&gt;After fixing all four bugs, the system works well:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Ingest latency:&lt;/strong&gt; sub-100ms p99 (usually under 50ms)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Delivery:&lt;/strong&gt; 7 retries with exponential backoff over 2+ hours&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Recovery:&lt;/strong&gt; cron scans every 5 minutes for stuck events&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dead letter queue:&lt;/strong&gt; events that exhaust all retries go to DLQ for manual inspection&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Idempotency:&lt;/strong&gt; KV-based dedup prevents duplicate deliveries&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The common thread through all four bugs: &lt;strong&gt;distributed systems fail in ways that look like success.&lt;/strong&gt; Events appeared to be processing. Queue depth looked healthy. The dashboard showed events being received. But under the surface, promises weren't being awaited, queues were silently failing, safety nets weren't running, and ghost workers were eating messages.&lt;/p&gt;

&lt;p&gt;The only way to find these bugs was to compare inputs to outputs at every stage: events received vs. events enqueued vs. events delivered. Any discrepancy means something is silently dropping data. If you're building on Cloudflare Workers (or any edge compute platform), build that end-to-end observability from day one. You'll need it.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;EventDock is a webhook reliability layer for teams that can't afford to lose events. If you've hit these kinds of problems and don't want to build the infrastructure yourself, check out &lt;a href="https://eventdock.app" rel="noopener noreferrer"&gt;eventdock.app&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>cloudflare</category>
      <category>programming</category>
    </item>
    <item>
      <title>EventDock vs Svix: They Solve Different Problems</title>
      <dc:creator>EventDock</dc:creator>
      <pubDate>Mon, 23 Mar 2026 20:08:14 +0000</pubDate>
      <link>https://dev.to/eventdock/eventdock-vs-svix-they-solve-different-problems-5co0</link>
      <guid>https://dev.to/eventdock/eventdock-vs-svix-they-solve-different-problems-5co0</guid>
      <description>&lt;p&gt;If you're researching webhook infrastructure, you've probably seen both Svix and EventDock. They sound similar — both deal with webhooks, both handle retries, both have APIs. But they solve fundamentally different problems.&lt;/p&gt;

&lt;p&gt;This is one of the most common confusions in the webhook space, so let's clear it up.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Key Difference: Sending vs. Receiving
&lt;/h2&gt;

&lt;p&gt;The entire difference comes down to one question: &lt;strong&gt;are you sending webhooks or receiving them?&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Svix&lt;/th&gt;
&lt;th&gt;EventDock&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Core function&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Send webhooks to your users&lt;/td&gt;
&lt;td&gt;Receive webhooks from providers&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;You are...&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;A SaaS building webhook delivery&lt;/td&gt;
&lt;td&gt;A developer consuming Stripe/Shopify/GitHub&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Direction&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Your app → Customer servers&lt;/td&gt;
&lt;td&gt;Provider servers → Your app&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Problem solved&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;How to reliably notify your users&lt;/td&gt;
&lt;td&gt;How to reliably receive notifications&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  When You Need Svix
&lt;/h2&gt;

&lt;p&gt;Svix is the right choice when &lt;strong&gt;you are the webhook sender&lt;/strong&gt;. Specifically:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You're building a SaaS product and need to notify customers when things happen (new order, payment processed, status change)&lt;/li&gt;
&lt;li&gt;Your customers expect webhook integrations — they want to receive events from your platform in their own systems&lt;/li&gt;
&lt;li&gt;You need a management portal where your customers can configure their webhook endpoints, see delivery logs, and retry failures&lt;/li&gt;
&lt;li&gt;You want to offer webhook delivery as a feature without building the infrastructure yourself&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Svix handles the hard parts of webhook sending:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Using Svix to SEND webhooks to your customers&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Svix&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;svix&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;svix&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Svix&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;your-auth-token&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// When something happens in your app, send a webhook&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;svix&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;app_customer123&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;eventType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;order.completed&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;orderId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ord_456&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;9900&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;currency&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;usd&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// Svix handles:&lt;/span&gt;
&lt;span class="c1"&gt;// - Delivery to the customer's endpoint&lt;/span&gt;
&lt;span class="c1"&gt;// - Retries if their server is down&lt;/span&gt;
&lt;span class="c1"&gt;// - Signature generation for verification&lt;/span&gt;
&lt;span class="c1"&gt;// - A portal for customers to manage their endpoints&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is a real engineering problem. Sending webhooks reliably to thousands of customer endpoints with different uptimes, response times, and configurations is complex. Svix is good at it.&lt;/p&gt;

&lt;h2&gt;
  
  
  When You Need EventDock
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://eventdock.app" rel="noopener noreferrer"&gt;EventDock&lt;/a&gt; is the right choice when &lt;strong&gt;you are the webhook receiver&lt;/strong&gt;. Specifically:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You integrate with Stripe, Shopify, GitHub, or any provider that sends you webhooks&lt;/li&gt;
&lt;li&gt;You've had webhooks fail silently during deploys, outages, or bugs&lt;/li&gt;
&lt;li&gt;You need retries, dead letter queues, and monitoring for incoming webhooks&lt;/li&gt;
&lt;li&gt;You want automatic signature verification for major providers&lt;/li&gt;
&lt;li&gt;You want to stop worrying about whether your server was up when Stripe sent that payment event&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;EventDock sits between the provider and your server:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# Without EventDock:
Stripe --webhook--&amp;gt; Your Server
                    (down during deploy? event lost.)

# With EventDock:
Stripe --webhook--&amp;gt; EventDock (edge, always up)
                        |
                        +--&amp;gt; Your Server (retries, DLQ, monitoring)

# Setup:
# 1. Create endpoint at dashboard.eventdock.app
# 2. Get URL: https://in.eventdock.app/ep_abc123
# 3. Point Stripe at that URL
# 4. Set your server as the destination
# Done. 2 minutes.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Can You Use Both?
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Yes — and some teams do.&lt;/strong&gt; They're complementary, not competing.&lt;/p&gt;

&lt;p&gt;If you're building a SaaS that both receives webhooks from providers (Stripe for payments, GitHub for CI) AND sends webhooks to your own customers (notifying them of events in your platform), you might use:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;EventDock&lt;/strong&gt; on the receiving side — making sure you never miss a Stripe payment event or a GitHub push notification&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Svix&lt;/strong&gt; on the sending side — reliably delivering your own webhooks to your customers' endpoints&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;They operate on opposite sides of the webhook flow and don't overlap.&lt;/p&gt;

&lt;h2&gt;
  
  
  Detailed Feature Comparison
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Feature&lt;/th&gt;
&lt;th&gt;Svix (Sending)&lt;/th&gt;
&lt;th&gt;EventDock (Receiving)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Retries with backoff&lt;/td&gt;
&lt;td&gt;Yes (for outgoing deliveries)&lt;/td&gt;
&lt;td&gt;Yes (for incoming deliveries to your server)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dead letter queue&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes, with one-click replay&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Signature handling&lt;/td&gt;
&lt;td&gt;Signs outgoing webhooks&lt;/td&gt;
&lt;td&gt;Verifies incoming signatures automatically&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dashboard&lt;/td&gt;
&lt;td&gt;Customer-facing management portal&lt;/td&gt;
&lt;td&gt;Developer-facing delivery monitoring&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Provider integrations&lt;/td&gt;
&lt;td&gt;N/A (you are the provider)&lt;/td&gt;
&lt;td&gt;Stripe, Shopify, GitHub, and more&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Edge-native&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes (Cloudflare Workers, global PoPs)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Self-hostable&lt;/td&gt;
&lt;td&gt;Yes (open source)&lt;/td&gt;
&lt;td&gt;No (managed service)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;API-first&lt;/td&gt;
&lt;td&gt;Yes (SDK for sending)&lt;/td&gt;
&lt;td&gt;Yes (API for management, URL-based for receiving)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Idempotency&lt;/td&gt;
&lt;td&gt;Event-type-based dedup&lt;/td&gt;
&lt;td&gt;Built-in 24h deduplication window&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Setup time&lt;/td&gt;
&lt;td&gt;Hours (SDK integration in your codebase)&lt;/td&gt;
&lt;td&gt;~2 minutes (URL swap)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Why People Confuse Them
&lt;/h2&gt;

&lt;p&gt;The confusion makes sense. Both services:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Use the word "webhook" in their marketing&lt;/li&gt;
&lt;li&gt;Offer retries and reliability&lt;/li&gt;
&lt;li&gt;Have APIs and dashboards&lt;/li&gt;
&lt;li&gt;Solve real infrastructure problems&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;But the direction is opposite. A simple test:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;"I need to send webhooks to my customers"&lt;/strong&gt; → Svix&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"I need to receive webhooks from Stripe/Shopify/GitHub"&lt;/strong&gt; → EventDock&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"I need both"&lt;/strong&gt; → Use both&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What About Other Options?
&lt;/h2&gt;

&lt;h3&gt;
  
  
  For Sending (Svix alternatives)
&lt;/h3&gt;

&lt;p&gt;If you're looking at Svix for sending webhooks, other options include building it yourself (risky), using a message queue like SQS/RabbitMQ with custom delivery code (complex), or services like Hookdeck's outbound features.&lt;/p&gt;

&lt;h3&gt;
  
  
  For Receiving (EventDock alternatives)
&lt;/h3&gt;

&lt;p&gt;If you're looking at EventDock for receiving webhooks, other options include Hookdeck (more features, more complex, usage-based pricing) or building your own reliability layer with queues and retry logic (weeks of work).&lt;/p&gt;

&lt;h2&gt;
  
  
  Frequently Asked Questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Is EventDock a Svix alternative?
&lt;/h3&gt;

&lt;p&gt;Not exactly. They solve different problems. Svix helps you send webhooks to your customers. EventDock helps you receive webhooks reliably. They're complementary, not competitors.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I use Svix and EventDock together?
&lt;/h3&gt;

&lt;p&gt;Yes. Use EventDock on the receiving side (Stripe, Shopify, GitHub webhooks coming into your app) and Svix on the sending side (your app notifying your customers).&lt;/p&gt;

&lt;h3&gt;
  
  
  What's the difference between sending and receiving webhooks?
&lt;/h3&gt;

&lt;p&gt;Sending: your app notifies other systems when events happen. Receiving: your app listens for events from other services. Different infrastructure is needed for each direction.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is Svix open source?
&lt;/h3&gt;

&lt;p&gt;Yes, Svix offers an open-source webhook server you can self-host. EventDock is a managed service running on Cloudflare's edge network.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do I need a webhook service if I only receive from one provider?
&lt;/h3&gt;

&lt;p&gt;Even with one provider, webhooks fail during deploys and outages. EventDock's free tier (5,000 events/month) covers most single-provider setups.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Receive webhooks reliably. &lt;a href="https://eventdock.app" rel="noopener noreferrer"&gt;EventDock&lt;/a&gt; adds retries, DLQ, signature verification, and monitoring to your incoming webhooks. Set up in 2 minutes, free for 5,000 events/month.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>webhooks</category>
      <category>saas</category>
      <category>devops</category>
    </item>
    <item>
      <title>I Vibe-Coded a SaaS and My Webhooks Were Silently Failing — Here's What I Missed</title>
      <dc:creator>EventDock</dc:creator>
      <pubDate>Mon, 23 Mar 2026 20:07:28 +0000</pubDate>
      <link>https://dev.to/eventdock/why-your-ai-built-app-is-silently-losing-webhooks-ao4</link>
      <guid>https://dev.to/eventdock/why-your-ai-built-app-is-silently-losing-webhooks-ao4</guid>
      <description>&lt;p&gt;You built an entire SaaS in a weekend. Cursor, Copilot, or Claude wrote most of the code. It works, it's deployed, users are signing up. That's genuinely impressive. But there's one thing AI code generators almost always get wrong: webhooks.&lt;/p&gt;

&lt;p&gt;This isn't about AI-generated code being bad. It's about a specific blind spot. When you ask an AI to "add a Stripe webhook endpoint" or "handle Shopify order notifications," you get code that works in the happy path. But webhooks aren't a happy-path problem — they're an infrastructure reliability problem. And AI tools don't think about infrastructure reliability unless you explicitly ask.&lt;/p&gt;

&lt;h2&gt;
  
  
  What AI Actually Generates for Webhooks
&lt;/h2&gt;

&lt;p&gt;Here's what you typically get when you ask Cursor or Copilot to add a webhook handler:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// AI-generated webhook handler (typical output)&lt;/span&gt;
&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/webhooks/stripe&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;checkout.session.completed&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;session&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;object&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;users&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;where&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;customer_email&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;plan&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;pro&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;stripeId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;customer&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;received&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This code &lt;em&gt;works&lt;/em&gt;. It handles the event, updates the database, returns 200. Ship it.&lt;/p&gt;

&lt;p&gt;But here's what's missing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No signature verification.&lt;/strong&gt; Anyone can POST to your webhook URL and fake a payment event. AI tools rarely add &lt;code&gt;stripe.webhooks.constructEvent()&lt;/code&gt; unless you specifically ask.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No retry handling.&lt;/strong&gt; If your database is slow or your server restarts during a deploy, the webhook fails. There's no retry logic.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No idempotency.&lt;/strong&gt; Stripe may deliver the same event twice. Without deduplication, you'll grant access twice, send two confirmation emails, or double-charge.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No error handling for the webhook itself.&lt;/strong&gt; If &lt;code&gt;db.users.update()&lt;/code&gt; throws, the entire handler crashes. Stripe gets a 500 and retries, but your code will crash again the same way.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No dead letter queue.&lt;/strong&gt; After Stripe gives up retrying (3 days), the event is gone. Your customer paid but never got access. You have no record of the failure.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No monitoring or alerts.&lt;/strong&gt; You won't know webhooks are failing until a customer emails you.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why AI Code Generators Miss This
&lt;/h2&gt;

&lt;p&gt;It's not a bug in the AI — it's a natural limitation of how code generation works:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;AI optimizes for the prompt.&lt;/strong&gt; "Add a Stripe webhook" means: create an endpoint that handles Stripe events. The AI delivers exactly that. It doesn't add infrastructure it wasn't asked for.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Training data bias.&lt;/strong&gt; Most webhook tutorials and Stack Overflow answers show the happy path. The AI learned from code that doesn't include retry logic because most example code doesn't either.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reliability is cross-cutting.&lt;/strong&gt; Retry logic, DLQ, monitoring — these aren't features of your webhook handler. They're infrastructure concerns that span your entire system. AI generates code file-by-file, not architecture-by-architecture.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The failure mode is silent.&lt;/strong&gt; A missing button is obvious. A webhook that fails during a deploy at 3 AM is invisible. AI can't warn you about problems it can't detect.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Real Failure Scenarios in Vibe-Coded Apps
&lt;/h2&gt;

&lt;p&gt;These are the patterns we see repeatedly in apps built with AI assistance:&lt;/p&gt;

&lt;h3&gt;
  
  
  The Deploy Gap
&lt;/h3&gt;

&lt;p&gt;You push a new version. Your server restarts. During those 10-30 seconds, Stripe sends a &lt;code&gt;payment_intent.succeeded&lt;/code&gt; event. Your server is down. Stripe retries a few times, then gives up after 3 days. The customer paid $99 but never got access to your product.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Unhandled Exception
&lt;/h3&gt;

&lt;p&gt;Your AI-generated handler works for &lt;code&gt;checkout.session.completed&lt;/code&gt; but crashes on &lt;code&gt;customer.subscription.updated&lt;/code&gt; because the payload structure is slightly different. Every subscription update webhook returns a 500. Stripe retries 16 times over 3 days, then stops. You don't find out until users report they can't cancel.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Spoofed Payment
&lt;/h3&gt;

&lt;p&gt;Without signature verification, someone discovers your webhook URL (it's usually predictable — &lt;code&gt;/webhooks/stripe&lt;/code&gt;) and sends a fake &lt;code&gt;checkout.session.completed&lt;/code&gt; event. Your code grants them Pro access without payment.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Silent Drift
&lt;/h3&gt;

&lt;p&gt;Your app works fine for weeks. Then you add a feature that makes the webhook handler 200ms slower. Then another. Eventually it times out under load. Stripe starts retrying. You don't notice because there are no alerts. By the time a customer reports a problem, dozens of events have been lost.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Proper Webhook Handling Actually Looks Like
&lt;/h2&gt;

&lt;p&gt;Here's the full version of what your webhook code should do:&lt;br&gt;
&lt;/p&gt;

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

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;stripe&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Stripe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;STRIPE_SECRET_KEY&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/webhooks/stripe&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;express&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;  &lt;span class="c1"&gt;// Raw body for signature verification&lt;/span&gt;
  &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// 1. Verify signature (reject spoofed events)&lt;/span&gt;
    &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;event&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;webhooks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;constructEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;stripe-signature&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;STRIPE_WEBHOOK_SECRET&lt;/span&gt;
      &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Signature verification failed:&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Invalid signature&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;// 2. Check idempotency (skip duplicates)&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;alreadyProcessed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;webhookEvents&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findUnique&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;where&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;eventId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;alreadyProcessed&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;received&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;duplicate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;// 3. Store the event (so you have a record even if processing fails)&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;webhookEvents&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;eventId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;

    &lt;span class="c1"&gt;// 4. Acknowledge fast — return 200 BEFORE processing&lt;/span&gt;
    &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;received&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

    &lt;span class="c1"&gt;// 5. Process asynchronously (failures won't affect the response)&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;processWebhookEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;webhookEvents&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
        &lt;span class="na"&gt;where&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;eventId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;processed&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Processing failed:&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;webhookEvents&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
        &lt;span class="na"&gt;where&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;eventId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;failed&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="p"&gt;});&lt;/span&gt;
      &lt;span class="c1"&gt;// TODO: alert, retry, or add to DLQ&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's a lot more code. And it still doesn't include retries, a dead letter queue, monitoring dashboards, or alerting. For a solo developer or small team shipping fast, building all of this is a week of work that pulls you away from your actual product.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Shortcut: Add a Reliability Layer in 2 Minutes
&lt;/h2&gt;

&lt;p&gt;Instead of building all that infrastructure yourself, you can put a reliability layer between the webhook provider and your server. This is exactly what &lt;a href="https://eventdock.app" rel="noopener noreferrer"&gt;EventDock&lt;/a&gt; does.&lt;/p&gt;

&lt;p&gt;Here's the setup:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 1. Sign up at dashboard.eventdock.app (free, no credit card)&lt;/span&gt;

&lt;span class="c"&gt;# 2. Create an endpoint:&lt;/span&gt;
&lt;span class="c"&gt;#    Provider: Stripe (or Shopify, GitHub, etc.)&lt;/span&gt;
&lt;span class="c"&gt;#    Destination: https://yourapp.com/webhooks/stripe&lt;/span&gt;
&lt;span class="c"&gt;#    → You get: https://in.eventdock.app/ep_abc123&lt;/span&gt;

&lt;span class="c"&gt;# 3. Point Stripe at your EventDock URL instead of your server:&lt;/span&gt;
&lt;span class="c"&gt;#    Stripe Dashboard → Webhooks → Add endpoint&lt;/span&gt;
&lt;span class="c"&gt;#    URL: https://in.eventdock.app/ep_abc123&lt;/span&gt;

&lt;span class="c"&gt;# Done. Now you get:&lt;/span&gt;
&lt;span class="c"&gt;# ✓ Automatic signature verification&lt;/span&gt;
&lt;span class="c"&gt;# ✓ 7 retries with exponential backoff&lt;/span&gt;
&lt;span class="c"&gt;# ✓ Dead letter queue with one-click replay&lt;/span&gt;
&lt;span class="c"&gt;# ✓ Real-time delivery dashboard&lt;/span&gt;
&lt;span class="c"&gt;# ✓ Slack/email alerts on failures&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Your AI-generated webhook handler stays simple — because EventDock handles the reliability part. You focus on business logic; EventDock makes sure the events actually arrive.&lt;/p&gt;

&lt;h2&gt;
  
  
  Frequently Asked Questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Do AI code generators add webhook retry logic?
&lt;/h3&gt;

&lt;p&gt;No. AI code generators like Cursor, Copilot, and ChatGPT typically generate webhook handlers that handle the happy path. They rarely add retry logic, dead letter queues, or idempotency handling unless explicitly prompted.&lt;/p&gt;

&lt;h3&gt;
  
  
  What happens when my server is down and a webhook is sent?
&lt;/h3&gt;

&lt;p&gt;The webhook provider (Stripe, Shopify, etc.) receives an error and retries with exponential backoff. Stripe retries for about 3 days. If your server is still failing after all retries, the event is permanently lost. A reliability layer like EventDock stores events durably and retries independently.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is vibe coding bad for production apps?
&lt;/h3&gt;

&lt;p&gt;No. AI-assisted development is excellent for building features fast. The gap is in infrastructure reliability — retries, queues, monitoring — which AI tools don't add automatically. The fix isn't to stop using AI; it's to add a reliability layer for critical points like webhooks.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I add webhook reliability without rewriting my code?
&lt;/h3&gt;

&lt;p&gt;Use a webhook reliability layer like EventDock. Point your provider at EventDock instead of your server. EventDock handles retries, DLQ, and signature verification. Your existing code doesn't change.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does Cursor or Copilot add webhook signature verification?
&lt;/h3&gt;

&lt;p&gt;Sometimes, but inconsistently. Specific prompts like "secure Stripe webhook handling" may include it. Default prompts usually don't, leaving your app vulnerable to spoofed events.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Ship fast, don't lose webhooks. &lt;a href="https://eventdock.app" rel="noopener noreferrer"&gt;EventDock&lt;/a&gt; adds retries, DLQ, and monitoring in 2 minutes. Free for 5,000 events/month.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>ai</category>
      <category>beginners</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Why Your Stripe Webhooks Are Failing (And How to Fix It)</title>
      <dc:creator>EventDock</dc:creator>
      <pubDate>Fri, 20 Mar 2026 19:35:02 +0000</pubDate>
      <link>https://dev.to/eventdock/why-your-stripe-webhooks-are-failing-and-how-to-fix-it-7hb</link>
      <guid>https://dev.to/eventdock/why-your-stripe-webhooks-are-failing-and-how-to-fix-it-7hb</guid>
      <description>&lt;p&gt;You're losing Stripe webhooks and you don't even know it.&lt;/p&gt;

&lt;p&gt;Stripe's webhook system is solid, but your &lt;em&gt;receiving&lt;/em&gt; end isn't. Here are the 5 most common failure scenarios and exactly how to fix them.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Your Server Is Down During Deploys
&lt;/h2&gt;

&lt;p&gt;The most common cause. You deploy, your server restarts, and during that 10-30 second window, Stripe sends a webhook. Your server returns a 502. Stripe will retry, but if your deploys are frequent or your downtime is longer, you're in trouble.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Stripe's retry behavior:&lt;/strong&gt; 16 attempts over approximately 3 days, with exponential backoff.&lt;/p&gt;

&lt;p&gt;After that? The event is gone. You'd need to manually poll the Events API to recover it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; Use a webhook proxy that accepts webhooks even when your server is down, then delivers them when it's back up.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Your Handler Times Out
&lt;/h2&gt;

&lt;p&gt;Stripe expects a response within &lt;strong&gt;20 seconds&lt;/strong&gt;. If your webhook handler does heavy processing (database writes, external API calls, email sends), you might exceed this.&lt;/p&gt;

&lt;p&gt;Stripe sees a timeout as a failure and retries. Now you're processing the same event multiple times.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Bad: Process everything synchronously&lt;/span&gt;
&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/webhook&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;processPayment&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;    &lt;span class="c1"&gt;// 5s&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;updateDatabase&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;     &lt;span class="c1"&gt;// 3s  &lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;sendEmail&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;          &lt;span class="c1"&gt;// 8s&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;notifySlack&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;        &lt;span class="c1"&gt;// 4s&lt;/span&gt;
  &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sendStatus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;                &lt;span class="c1"&gt;// Total: 20s - too slow!&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// Good: Acknowledge immediately, process async&lt;/span&gt;
&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/webhook&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sendStatus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;  &lt;span class="c1"&gt;// Respond in &amp;lt;100ms&lt;/span&gt;

  &lt;span class="c1"&gt;// Process in background&lt;/span&gt;
  &lt;span class="nx"&gt;queue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;process-webhook&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  3. Wrong Response Codes
&lt;/h2&gt;

&lt;p&gt;Your server returns &lt;code&gt;301 Moved Permanently&lt;/code&gt; because you have HTTP→HTTPS redirects. Or it returns &lt;code&gt;401 Unauthorized&lt;/code&gt; because your auth middleware intercepts the webhook endpoint.&lt;/p&gt;

&lt;p&gt;Stripe only counts &lt;code&gt;2xx&lt;/code&gt; as success. Everything else triggers a retry.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; Ensure your webhook endpoint:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Is served over HTTPS directly (no redirects)&lt;/li&gt;
&lt;li&gt;Is excluded from auth middleware&lt;/li&gt;
&lt;li&gt;Returns &lt;code&gt;200&lt;/code&gt; or &lt;code&gt;204&lt;/code&gt; explicitly&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  4. Signature Verification Fails
&lt;/h2&gt;

&lt;p&gt;You're comparing the wrong payload. Common mistakes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Wrong: Using parsed JSON body&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;webhooks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;constructEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;  &lt;span class="c1"&gt;// This re-serializes differently!&lt;/span&gt;
  &lt;span class="nx"&gt;sig&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;secret&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// Right: Using the raw body&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;webhooks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;constructEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rawBody&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;// Exact bytes Stripe sent&lt;/span&gt;
  &lt;span class="nx"&gt;sig&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;secret&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Express, Next.js, and most frameworks parse the body before your handler sees it. You need the &lt;strong&gt;raw body&lt;/strong&gt; for signature verification.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Firewall or WAF Blocks
&lt;/h2&gt;

&lt;p&gt;Your CDN or WAF (Cloudflare, AWS WAF) blocks Stripe's webhook requests because they look suspicious — high volume POST requests from unknown IPs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; Whitelist Stripe's webhook IPs or, better yet, use signature verification instead of IP whitelisting (IPs can change).&lt;/p&gt;

&lt;h2&gt;
  
  
  What Happens After Stripe Stops Retrying?
&lt;/h2&gt;

&lt;p&gt;After 16 failed attempts over 3 days, Stripe marks the event as failed and &lt;strong&gt;stops trying&lt;/strong&gt;. Your options:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Manual recovery:&lt;/strong&gt; Use the Stripe Dashboard → Events to find and manually resend&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;API polling:&lt;/strong&gt; Periodically call &lt;code&gt;stripe.events.list()&lt;/code&gt; to find events you missed&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Webhook proxy:&lt;/strong&gt; Use a service that sits between Stripe and your server, guaranteeing delivery&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Option 3 is the only one that doesn't require ongoing manual work.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Reliability Layer Approach
&lt;/h2&gt;

&lt;p&gt;Instead of pointing Stripe directly at your server, point it at a webhook proxy:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Stripe → EventDock (always up) → Your Server (might be down)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;EventDock accepts the webhook immediately (sub-100ms, runs on Cloudflare's edge), stores it, verifies the Stripe signature, and then delivers it to your server. If your server is down, EventDock retries with exponential backoff for hours.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Setup takes 2 minutes:&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Create an account at &lt;a href="https://eventdock.app" rel="noopener noreferrer"&gt;eventdock.app&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Create an endpoint pointing to your server&lt;/li&gt;
&lt;li&gt;Copy the EventDock URL into Stripe's webhook settings&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;You get:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Automatic retries (7 attempts over 2+ hours)&lt;/li&gt;
&lt;li&gt;Dead letter queue for permanently failed webhooks&lt;/li&gt;
&lt;li&gt;One-click replay from the dashboard&lt;/li&gt;
&lt;li&gt;Full request/response logging&lt;/li&gt;
&lt;li&gt;Real-time failure alerts&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Prevention Checklist
&lt;/h2&gt;

&lt;p&gt;Before your next deploy, verify:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;[ ] Webhook endpoint returns &lt;code&gt;200&lt;/code&gt; in &amp;lt;5 seconds&lt;/li&gt;
&lt;li&gt;[ ] Raw body is preserved for signature verification&lt;/li&gt;
&lt;li&gt;[ ] Endpoint is excluded from auth middleware&lt;/li&gt;
&lt;li&gt;[ ] No HTTP→HTTPS redirects on the webhook path&lt;/li&gt;
&lt;li&gt;[ ] Firewall allows Stripe's IPs&lt;/li&gt;
&lt;li&gt;[ ] Idempotency keys prevent duplicate processing&lt;/li&gt;
&lt;li&gt;[ ] You have monitoring/alerts on webhook failures&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://eventdock.app/blog/why-stripe-webhooks-failing-how-to-fix" rel="noopener noreferrer"&gt;eventdock.app/blog&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;What's the worst webhook failure you've experienced? Drop a comment below.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>stripe</category>
      <category>tutorial</category>
      <category>devops</category>
    </item>
  </channel>
</rss>
