Almost every serious full-stack project I've touched eventually needs webhook handling in Node.js. Payments, subscriptions, email delivery events, e-signature callbacks, CRM syncs: a third party calls your server when something happens, and your app has to react correctly. It looks like the easiest endpoint you'll ever write. In my experience, it's also one of the most common places for quiet, expensive bugs: duplicate orders, missed subscription cancellations, users charged but never upgraded.
After 8+ years building backends with Node.js, Laravel and Firebase, and a lot of freelance work where clients bring me integrations that "mostly work," I've settled on a pattern I reuse on almost every project. This post walks through it: verify the signature, store the event idempotently, acknowledge fast, process asynchronously, and make the side effects safe to repeat.
Why Webhook Handling in Node.js Breaks in Production
The naive version looks like this: receive a POST, parse JSON, update the database, return 200. It passes every local test. Then it meets reality.
Here's what reality looks like for webhooks:
- Providers retry. If your endpoint times out or returns a non-2xx status, most providers send the same event again, sometimes many times over hours or days.
- Delivery is at-least-once, not exactly-once. Even when you respond successfully, you can still receive duplicates. Your code has to assume it will see the same event more than once.
- Order isn't guaranteed. A "subscription updated" event can arrive before "subscription created."
- Anyone can hit your URL. Webhook endpoints are public. Without signature verification, someone can POST a fake "payment succeeded" event.
- Timeouts are short. Providers expect a quick response. If you're sending emails, generating PDFs and calling three APIs inline, you'll time out, trigger a retry and do all that work twice.
In my client work, I've seen every one of these. The fix isn't clever code. It's a boring, disciplined structure.
Verify Signatures With the Raw Body
Most providers sign each webhook with an HMAC of the request body using a shared secret, and send the signature in a header. You recompute the HMAC on your side and compare. The catch that bites almost everyone: you must hash the exact raw bytes you received, not a re-serialized JSON object. If express.json() runs first, the body has already been parsed, and re-stringifying it can change whitespace or key order. The signature will never match.
So mount a raw body parser on the webhook route only, before any global JSON middleware:
import express from 'express';
import crypto from 'node:crypto';
const app = express();
function verifySignature(rawBody, signatureHeader, secret) {
const expected = crypto
.createHmac('sha256', secret)
.update(rawBody)
.digest('hex');
const a = Buffer.from(signatureHeader, 'utf8');
const b = Buffer.from(expected, 'utf8');
// timingSafeEqual throws on length mismatch, so check first
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
// Raw body ONLY for this route, registered before express.json()
app.post(
'/webhooks/provider',
express.raw({ type: 'application/json' }),
async (req, res) => {
const signature = req.header('x-signature') ?? '';
if (!verifySignature(req.body, signature, process.env.WEBHOOK_SECRET)) {
return res.status(401).send('invalid signature');
}
const event = JSON.parse(req.body.toString('utf8'));
// ...store and enqueue (next section)
return res.status(200).send('ok');
}
);
app.use(express.json()); // everything else gets parsed JSONTwo details matter here. First, use crypto.timingSafeEqual instead of === so you don't leak information through comparison timing. Second, if the provider includes a timestamp in the signed payload, reject events older than a few minutes to block replay attacks.
Use the official SDK when it exists
Many payment providers ship a helper that verifies the signature and timestamp for you. Use it. The manual version above is for providers that only document the HMAC scheme, but the raw body rule still applies when you use an SDK.
Idempotent Webhook Handling in Node.js
Idempotency means processing the same event twice has the same effect as processing it once. This is the core of reliable webhook handling in Node.js, and the simplest way I know to get it is to let the database enforce it.
Every reputable provider sends a unique event ID. Store it in a table with a primary key on that ID:
CREATE TABLE webhook_events (
id TEXT PRIMARY KEY, -- provider's event id
type TEXT NOT NULL,
payload JSONB NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
attempts INT NOT NULL DEFAULT 0,
last_error TEXT,
received_at TIMESTAMPTZ NOT NULL DEFAULT now(),
processed_at TIMESTAMPTZ
);Then, inside the route, insert with ON CONFLICT DO NOTHING. If the insert affects zero rows, you've already seen this event, so you return 200 and stop:
const result = await pool.query(
`INSERT INTO webhook_events (id, type, payload)
VALUES ($1, $2, $3)
ON CONFLICT (id) DO NOTHING
RETURNING id`,
[event.id, event.type, event]
);
if (result.rowCount === 0) {
// Duplicate delivery: acknowledge so the provider stops retrying
return res.status(200).send('duplicate');
}
await webhookQueue.add('process', { eventId: event.id });
return res.status(200).send('ok');Why not check "does this ID exist?" with a SELECT first? Because two identical deliveries can arrive at the same moment, both see "not found," and both process. A unique constraint closes that race at the database level, where it belongs.
Make the side effects idempotent too
Deduplicating events isn't enough on its own. If your worker crashes halfway through, the job will retry, and whatever ran before the crash runs again. So write side effects as conditional updates:
async function markOrderPaid(orderId) {
// Only transitions once; re-running is a no-op
await pool.query(
`UPDATE orders
SET status = 'paid', paid_at = now()
WHERE id = $1 AND status <> 'paid'`,
[orderId]
);
}For things you can't make conditional, like sending an email, record that it was sent (for example, a receipt_sent_at column) and check it before sending again.
Handle out-of-order events
When order matters, don't blindly apply whatever the payload says. Either compare the event's timestamp against the last one you applied for that resource, or treat the webhook as a signal and fetch the current state from the provider's API. The second approach costs an API call but removes a whole class of bugs, and it's what I default to for subscription status.
Acknowledge Fast, Process Later
Your webhook endpoint should do three things: verify, store, enqueue. Then return 200. All real work happens in a background worker. This keeps response times short, avoids timeout-triggered retries and gives you your own retry logic that you control.
With BullMQ on Redis, the worker looks something like this:
import { Worker } from 'bullmq';
const handlers = {
'payment.succeeded': (p) => markOrderPaid(p.data.orderId),
'subscription.canceled': (p) => syncSubscription(p.data.subscriptionId),
};
new Worker(
'webhooks',
async (job) => {
const { rows } = await pool.query(
'SELECT * FROM webhook_events WHERE id = $1',
[job.data.eventId]
);
const evt = rows[0];
if (!evt || evt.status === 'processed') return;
const handler = handlers[evt.type];
try {
if (handler) await handler(evt.payload);
await pool.query(
`UPDATE webhook_events
SET status = 'processed', processed_at = now()
WHERE id = $1`,
[evt.id]
);
} catch (err) {
await pool.query(
`UPDATE webhook_events
SET attempts = attempts + 1, last_error = $2
WHERE id = $1`,
[evt.id, String(err)]
);
throw err; // let BullMQ retry with backoff
}
},
{ connection: redisConnection }
);Configure retries with exponential backoff when you add jobs, and route jobs that exhaust their attempts to a place you'll actually look. Because the full payload lives in webhook_events, you can replay any failed event after fixing the bug, without asking the provider to resend anything. That table has saved me more debugging time than any logging tool.
Don't return 500 for events you ignore
If you receive an event type you don't handle, store it and return 200. Returning an error just makes the provider retry something you'll never process, and some providers will disable endpoints that fail repeatedly.
Webhooks in Next.js Route Handlers
A lot of full-stack projects I see now are Next.js apps without a separate Node.js backend. Webhooks work fine in App Router route handlers, with two adjustments.
First, read the raw body with req.text() before parsing, and make sure the route runs on the Node.js runtime so node:crypto is available:
// app/api/webhooks/provider/route.ts
import crypto from 'node:crypto';
export const runtime = 'nodejs';
export async function POST(req: Request) {
const raw = await req.text();
const signature = req.headers.get('x-signature') ?? '';
const expected = crypto
.createHmac('sha256', process.env.WEBHOOK_SECRET!)
.update(raw)
.digest('hex');
const a = Buffer.from(signature);
const b = Buffer.from(expected);
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return new Response('invalid signature', { status: 401 });
}
const event = JSON.parse(raw);
const inserted = await storeEventIfNew(event); // same ON CONFLICT logic
if (inserted) await enqueue(event.id);
return new Response('ok', { status: 200 });
}Second, be careful with background work on serverless hosting. Once you return a response, the function may be frozen, so a fire-and-forget promise can silently never finish. For anything that matters, hand off to a real queue or a background job service rather than relying on work after the response. Recent Next.js versions provide an after() API for post-response tasks, which is fine for lightweight things like analytics, but I still wouldn't trust it with payment logic.
If the app has grown to the point where you need long-running workers, that's often a sign it's time for a dedicated Node.js service alongside Next.js. The webhook route can stay thin and simply store and enqueue.
Testing locally
Most providers offer a CLI or dashboard tool that forwards events to localhost or lets you resend a specific event. Use it to test the ugly cases deliberately: send the same event twice, send events out of order, send one with a tampered body. If your handler survives those, it'll survive production far better than one that only ever saw the happy path.
When a client hands me an integration that "randomly" double-charges or misses upgrades, the first thing I look at is the webhook endpoint. It's almost never a provider bug.
Key Takeaways
- Verify signatures against the raw request body using a timing-safe comparison, and reject stale timestamps to block replays.
- Let a unique constraint on the event ID enforce idempotency instead of a check-then-insert that can race.
- Keep the endpoint thin: verify, store, enqueue, return 200. Do the real work in a worker with retries and backoff.
- Make every side effect safe to repeat with conditional updates and "already sent" markers, and handle out-of-order events by checking timestamps or fetching current state.
- In Next.js, read
req.text(), use the Node.js runtime and don't trust post-response work on serverless for anything critical.