Development
How to Implement Webhooks in Node.js: A Complete Guide
Learn how to implement webhooks in Node.js. Covers sending outgoing webhooks, receiving incoming webhooks, signature verification, retry logic with exponential backoff, queue-based delivery, and testing webhooks locally with ngrok.
What are Webhooks?
A webhook is an HTTP POST that your application sends to a URL registered by another system when an event occurs. Instead of the other system polling your API every minute asking if anything has changed, your application notifies it immediately when an event happens. Webhooks are used everywhere: Stripe sends a webhook when a payment succeeds, GitHub sends a webhook when a push happens, and Shopify sends a webhook when an order is placed.
Webhooks have two sides: sending (outgoing) and receiving (incoming). This guide covers both, including the critical details of signature verification and reliable delivery with retries.
Sending Outgoing Webhooks with Retry Logic
When your application needs to notify a subscriber URL of an event, you need to handle failures gracefully. The subscriber endpoint may be temporarily down, so you need exponential backoff retries:
import crypto from 'crypto';
async function sendWebhook(
url: string,
payload: Record<string, unknown>,
secret: string,
maxRetries = 5
) {
const body = JSON.stringify(payload);
const signature = crypto
.createHmac('sha256', secret)
.update(body)
.digest('hex');
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Webhook-Signature': signature,
'X-Webhook-Timestamp': Date.now().toString()
},
body,
signal: AbortSignal.timeout(10_000)
});
if (response.ok) return { success: true, attempt };
throw new Error('HTTP ' + response.status);
} catch (err) {
if (attempt === maxRetries - 1) throw err;
const delay = Math.pow(2, attempt) * 1000 + Math.random() * 500;
await new Promise(r => setTimeout(r, delay));
}
}
}Receiving and Verifying Incoming Webhooks
When receiving webhooks (e.g., from Stripe or GitHub), always verify the signature to ensure the request is authentic. Use the raw body for signature verification — JSON parsing changes whitespace and may alter the signature:
import express from 'express';
import crypto from 'crypto';
const app = express();
app.post(
'/webhooks/stripe',
express.raw({ type: 'application/json' }),
(req, res) => {
const signature = req.headers['stripe-signature'] as string;
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!;
const [timestamp, hash] = signature
.split(',')
.reduce<Record<string, string>>((acc, part) => {
const [key, val] = part.split('=');
acc[key] = val;
return acc;
}, {} as Record<string, string>)
|> (({ t, v1 }) => [t, v1]); // destructure
const expected = crypto
.createHmac('sha256', webhookSecret)
.update(timestamp + '.' + req.body.toString())
.digest('hex');
if (!crypto.timingSafeEqual(Buffer.from(hash), Buffer.from(expected))) {
return res.status(401).json({ error: 'Invalid signature' });
}
const event = JSON.parse(req.body.toString());
// Process event.type
res.status(200).json({ received: true });
}
);Testing Webhooks Locally with ngrok
Use ngrok to expose your local server to the internet so external services can send webhooks to it:
# Install ngrok
npm install -g ngrok
# Start your server
npm run dev
# In another terminal, expose port 3000
ngrok http 3000
# Copy the https://xxx.ngrok.io URL and configure it
# in Stripe dashboard > Webhooks > Add endpointQueue-Based Reliable Delivery
For production webhook delivery, use a job queue (Bull with Redis) so webhook sends happen asynchronously and can be retried without blocking the main request:
- Store webhook registrations in the database with the subscriber URL and shared secret
- When an event occurs, enqueue a job in Bull for each registered subscriber
- The Bull worker picks up each job, calls sendWebhook, and marks it complete or failed
- Bull retries failed jobs with exponential backoff automatically
- Log all delivery attempts (attempt number, HTTP status, response body) to a webhook_deliveries table for debugging
FAQ
How do I prevent replay attacks on webhooks?
Include a timestamp in the webhook payload and signature. When verifying, check that the timestamp is within 5 minutes of the current time. An attacker who captures a signed webhook cannot replay it after the tolerance window expires. Stripe uses this pattern: the stripe-signature header includes a timestamp and a HMAC of timestamp.body, and their SDK rejects webhooks with a timestamp older than 300 seconds.
Should my webhook endpoint respond before processing the event?
Yes. Return 200 immediately after verifying the signature, then process the event asynchronously. If your processing takes 10 seconds and the sender has a 5-second timeout, they will mark the delivery as failed and retry, causing duplicate processing. Acknowledge receipt immediately, enqueue the event for background processing, and handle idempotency by storing the event ID and skipping if already processed.
How do I make webhook processing idempotent?
Store the webhook event ID in a processed_events table with a unique constraint on the event ID. Before processing, check if the event ID already exists. If it does, return 200 without reprocessing. Insert the event ID after successful processing. This prevents duplicate processing when the sender retries a delivery that your server acknowledged but your application failed to process before responding.
What HTTP status code should I return from a webhook endpoint?
Return 200 for successful receipt. Any 2xx status counts as success. Return 400 for invalid payloads or signature verification failures (the sender should not retry these). Return 500 for server errors that the sender should retry. Never return a redirect (3xx) — most webhook senders do not follow redirects. Process asynchronously and always return 200 quickly to avoid timeout-triggered retries.
Related free tools
If you want to turn this topic into action, use one of ShortIQ's free tools for campaign planning, UTM structure, or QR distribution.
Continue Reading
Explore more guides on link shortener SaaS strategy, Bitly alternatives, and white label link management.
Free newsletter
Get new guides in your inbox
We publish practical guides on dev tooling, prompt engineering, marketing workflows, and deployment. No fluff — straight to the point.
No spam. Unsubscribe any time.
Was this article helpful?
Tell us if this guide solved the problem or what was still missing. We use this to improve the blog and only follow up if you explicitly allow it.