<?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: deadletter</title>
    <description>The latest articles on DEV Community by deadletter (@deadletter).</description>
    <link>https://dev.to/deadletter</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%2F3824446%2Fb11b9ae7-be2d-45fd-9344-e068990a3cd0.png</url>
      <title>DEV Community: deadletter</title>
      <link>https://dev.to/deadletter</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/deadletter"/>
    <language>en</language>
    <item>
      <title>Why Your Webhook Handler Is Silently Failing (And How to Fix It)</title>
      <dc:creator>deadletter</dc:creator>
      <pubDate>Sat, 14 Mar 2026 19:56:11 +0000</pubDate>
      <link>https://dev.to/deadletter/why-your-webhook-handler-is-silently-failing-and-how-to-fix-it-50ah</link>
      <guid>https://dev.to/deadletter/why-your-webhook-handler-is-silently-failing-and-how-to-fix-it-50ah</guid>
      <description>&lt;p&gt;You built the webhook endpoint. Stripe says it delivered. Your logs show nothing. The order never fulfilled.&lt;br&gt;
This is one of the most frustrating debugging experiences in backend development — and it almost always comes down to the same handful of mistakes. Here's what's actually going wrong and how to fix each one.&lt;/p&gt;

&lt;p&gt;Mistake 1: You're doing slow work before responding&lt;br&gt;
Stripe, GitHub, and most webhook providers have a timeout of 5–10 seconds. If your handler takes longer, they mark it as failed and retry — sometimes dozens of times.&lt;br&gt;
The fix is simple: respond 200 OK immediately, then process the event asynchronously.&lt;br&gt;
javascriptapp.post('/webhooks/stripe', express.raw({ type: '&lt;em&gt;/&lt;/em&gt;' }), (req, res) =&amp;gt; {&lt;br&gt;
  // Respond FIRST&lt;br&gt;
  res.status(200).send('ok');&lt;/p&gt;

&lt;p&gt;// Process AFTER&lt;br&gt;
  processEvent(JSON.parse(req.body));&lt;br&gt;
});&lt;br&gt;
Never do database writes, email sends, or third-party API calls before that res.send(). Push the work to a queue (BullMQ, SQS, even a simple setImmediate) and let a background worker handle it.&lt;/p&gt;

&lt;p&gt;Mistake 2: You're not verifying the signature&lt;br&gt;
Any server on the internet can POST to your webhook URL. If you're not verifying the HMAC signature on every request, you're trusting arbitrary payloads — a serious security hole.&lt;br&gt;
Every major provider gives you a secret and signs each request. Always verify it before touching the payload:&lt;br&gt;
javascriptconst sig = req.headers['stripe-signature'];&lt;br&gt;
const expected = 'sha256=' + crypto&lt;br&gt;
  .createHmac('sha256', process.env.WEBHOOK_SECRET)&lt;br&gt;
  .update(req.body)&lt;br&gt;
  .digest('hex');&lt;/p&gt;

&lt;p&gt;if (sig !== expected) {&lt;br&gt;
  return res.status(401).end();&lt;br&gt;
}&lt;br&gt;
One critical detail: use express.raw() not express.json() for the body parser on webhook routes. Stripe signs the raw bytes — if Express parses the body first, the signature will never match.&lt;/p&gt;

&lt;p&gt;Mistake 3: You're not handling duplicate deliveries&lt;br&gt;
Webhook providers guarantee at-least-once delivery, not exactly-once. Your handler will receive the same event multiple times — during retries, network blips, or provider-side issues.&lt;br&gt;
If your handler charges a card, creates a database record, or sends an email, duplicate processing is a real problem.&lt;br&gt;
The solution is idempotency — track which event IDs you've already processed:&lt;br&gt;
javascriptasync function processEvent(event) {&lt;br&gt;
  // Check if already handled&lt;br&gt;
  const alreadyProcessed = await redis.get(&lt;code&gt;event:${event.id}&lt;/code&gt;);&lt;br&gt;
  if (alreadyProcessed) return;&lt;/p&gt;

&lt;p&gt;// Mark as processed (with 24h expiry)&lt;br&gt;
  await redis.set(&lt;code&gt;event:${event.id}&lt;/code&gt;, '1', 'EX', 86400);&lt;/p&gt;

&lt;p&gt;// Now do the actual work&lt;br&gt;
  if (event.type === 'payment_intent.succeeded') {&lt;br&gt;
    await fulfillOrder(event.data.object);&lt;br&gt;
  }&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;Mistake 4: You're using express.json() globally&lt;br&gt;
If your app has this at the top:&lt;br&gt;
javascriptapp.use(express.json());&lt;br&gt;
Then your webhook route is already broken for signature verification — the body has been parsed and the raw bytes are gone. You need to exempt the webhook route:&lt;br&gt;
javascript// Parse JSON everywhere EXCEPT webhooks&lt;br&gt;
app.use((req, res, next) =&amp;gt; {&lt;br&gt;
  if (req.originalUrl === '/webhooks/stripe') {&lt;br&gt;
    next();&lt;br&gt;
  } else {&lt;br&gt;
    express.json()(req, res, next);&lt;br&gt;
  }&lt;br&gt;
});&lt;/p&gt;

&lt;p&gt;// Raw body for webhooks only&lt;br&gt;
app.use('/webhooks/stripe', express.raw({ type: '&lt;em&gt;/&lt;/em&gt;' }));&lt;/p&gt;

&lt;p&gt;Mistake 5: You have no way to test locally&lt;br&gt;
Webhook providers can't send POST requests to localhost. Most developers skip testing entirely and push to staging — which makes debugging a slow nightmare.&lt;br&gt;
The proper setup takes about 2 minutes:&lt;br&gt;
bash# Install ngrok&lt;br&gt;
npm install -g ngrok&lt;/p&gt;

&lt;h1&gt;
  
  
  Start your local server on port 3000
&lt;/h1&gt;

&lt;p&gt;node server.js&lt;/p&gt;

&lt;h1&gt;
  
  
  In a second terminal, expose it publicly
&lt;/h1&gt;

&lt;p&gt;ngrok http 3000&lt;br&gt;
ngrok gives you a public HTTPS URL like &lt;a href="https://abc123.ngrok.io" rel="noopener noreferrer"&gt;https://abc123.ngrok.io&lt;/a&gt;. Paste that into your Stripe webhook settings, trigger a test event from the dashboard, and watch it hit your local server in real time.&lt;/p&gt;

&lt;p&gt;The full pattern together&lt;br&gt;
Here's what a production-grade webhook handler looks like when all five mistakes are avoided:&lt;br&gt;
javascriptapp.post(&lt;br&gt;
  '/webhooks/stripe',&lt;br&gt;
  express.raw({ type: '&lt;em&gt;/&lt;/em&gt;' }),&lt;br&gt;
  async (req, res) =&amp;gt; {&lt;br&gt;
    // 1. Verify signature&lt;br&gt;
    const sig = req.headers['stripe-signature'];&lt;br&gt;
    const expected = 'sha256=' + crypto&lt;br&gt;
      .createHmac('sha256', process.env.WEBHOOK_SECRET)&lt;br&gt;
      .update(req.body)&lt;br&gt;
      .digest('hex');&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;if (sig !== expected) return res.status(401).end();

// 2. Respond immediately
res.status(200).send('ok');

// 3. Process async with idempotency
const event = JSON.parse(req.body);
const seen = await redis.get(`event:${event.id}`);
if (seen) return;

await redis.set(`event:${event.id}`, '1', 'EX', 86400);

// 4. Handle the event type
switch (event.type) {
  case 'payment_intent.succeeded':
    await fulfillOrder(event.data.object);
    break;
  case 'customer.subscription.deleted':
    await cancelSubscription(event.data.object);
    break;
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

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

&lt;p&gt;Webhooks are one of those things that look simple until something goes wrong in production at 2am. Getting the pattern right once means you never have to debug it again.&lt;br&gt;
If you want to go deeper — this is just one of 10 API integration patterns (REST, GraphQL, gRPC, WebSockets, message queues, auth flows, rate limiting, tRPC and more) I put together in a complete guide with code examples, quizzes, and hands-on exercises: &lt;a href="https://deadlletter.gumroad.com/l/APIIntegrationMastery" rel="noopener noreferrer"&gt;https://deadlletter.gumroad.com/l/APIIntegrationMastery&lt;/a&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>backend</category>
      <category>api</category>
    </item>
  </channel>
</rss>
