DEV Community

deadletter
deadletter

Posted on

Why Your Webhook Handler Is Silently Failing (And How to Fix It)

You built the webhook endpoint. Stripe says it delivered. Your logs show nothing. The order never fulfilled.
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.

Mistake 1: You're doing slow work before responding
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.
The fix is simple: respond 200 OK immediately, then process the event asynchronously.
javascriptapp.post('/webhooks/stripe', express.raw({ type: '/' }), (req, res) => {
// Respond FIRST
res.status(200).send('ok');

// Process AFTER
processEvent(JSON.parse(req.body));
});
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.

Mistake 2: You're not verifying the signature
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.
Every major provider gives you a secret and signs each request. Always verify it before touching the payload:
javascriptconst sig = req.headers['stripe-signature'];
const expected = 'sha256=' + crypto
.createHmac('sha256', process.env.WEBHOOK_SECRET)
.update(req.body)
.digest('hex');

if (sig !== expected) {
return res.status(401).end();
}
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.

Mistake 3: You're not handling duplicate deliveries
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.
If your handler charges a card, creates a database record, or sends an email, duplicate processing is a real problem.
The solution is idempotency — track which event IDs you've already processed:
javascriptasync function processEvent(event) {
// Check if already handled
const alreadyProcessed = await redis.get(event:${event.id});
if (alreadyProcessed) return;

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

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

Mistake 4: You're using express.json() globally
If your app has this at the top:
javascriptapp.use(express.json());
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:
javascript// Parse JSON everywhere EXCEPT webhooks
app.use((req, res, next) => {
if (req.originalUrl === '/webhooks/stripe') {
next();
} else {
express.json()(req, res, next);
}
});

// Raw body for webhooks only
app.use('/webhooks/stripe', express.raw({ type: '/' }));

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

Start your local server on port 3000

node server.js

In a second terminal, expose it publicly

ngrok http 3000
ngrok gives you a public HTTPS URL like https://abc123.ngrok.io. Paste that into your Stripe webhook settings, trigger a test event from the dashboard, and watch it hit your local server in real time.

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

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;
}
Enter fullscreen mode Exit fullscreen mode

}
);

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.
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: https://deadlletter.gumroad.com/l/APIIntegrationMastery

Top comments (0)