Build the mental model
Every webhook receiver you build for production needs to answer three questions this project ties together: is this request really from the sender, have I already handled this exact event, and can I respond before my own processing logic slows things down.
Signature verification, from the webhook security lesson, uses HMAC to prove the payload was signed with a shared secret and has not been altered in transit -- without it, anyone who finds your endpoint URL could impersonate a real provider.
Idempotent event processing, from the retries-and-idempotent-handlers lesson, exists because senders retry on timeout or any non-2xx response -- reprocessing the same event ID twice can duplicate a charge or a notification.
Slow responses look like failures
Most webhook providers apply a short timeout, often just a few seconds, before treating your endpoint as failed and queuing a retry -- so slow processing invisibly causes duplicate deliveries even when nothing is actually wrong.
- Verify the signature first -- trusting an unverified payload could poison your duplicate-tracking store.
- Check the event ID second -- a legitimate retry should short-circuit before any real work happens.
- Respond quickly always -- whether the event was new, a duplicate, or invalid.
WEBHOOK RECEIVER FLOW
---------------------
Sender (e.g. Stripe, GitHub)
|
| POST /webhook (payload + X-Signature-256 header)
v
1. Verify Signature (HMAC-SHA256)
-- invalid --> 401 Unauthorized (stop, do not process)
-- valid --> continue
|
v
2. Check Event ID against processed-events store
-- new --> process the event, then record its ID
-- duplicate --> skip (do not reprocess), no side effects
|
v
3. Respond 200 OK
(always -- new, duplicate, or invalid all get a fast
response -- well before the sender's own timeout expires)Connect it to a real scenario
Write the signing helper
Compute an HMAC-SHA256 digest of the raw request body using the shared secret -- the exact same algorithm the sender used to produce the signature header.
Verify with timingSafeEqual
Use crypto.timingSafeEqual instead of a plain string comparison, since a regular equality check leaks timing information an attacker could exploit.
Build the duplicate-check store
Keep a store, keyed by the sender's event ID, that remembers which events have already been processed -- a Set here, a database or Redis in production.
Wire it into handleWebhook
Combine verify, then check, then process-or-skip, then always return a response object immediately, into one function.
Test three scenarios
Confirm a new event, a duplicate event, and an invalid signature each produce the correct, distinct result.
Test before wiring to a real server
Only once handleWebhook passes all three scenarios should you place it behind an actual Express route or Node http server.
Try the working example
const crypto = require('crypto');
// In-memory "database" of event IDs we have already processed.
// In a real service this would be a row in Postgres/Redis, not a Set.
const processedEvents = new Set();
function signPayload(payload, secret) {
return crypto.createHmac('sha256', secret).update(payload).digest('hex');
}
function handleWebhook(payload, signatureHeader, secret) {
// Step 1: verify the signature BEFORE touching any event data.
const expected = signPayload(payload, secret);
const provided = signatureHeader.replace('sha256=', '');
const validLength = provided.length === expected.length;
const isValid =
validLength &&
crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(provided));
if (!isValid) {
return { status: 401, body: { ok: false, reason: 'invalid_signature' } };
}
// Step 2: only after the signature is trusted, look at the event ID.
const event = JSON.parse(payload);
if (processedEvents.has(event.id)) {
return {
status: 200,
body: { ok: true, reason: 'duplicate_skipped', eventId: event.id },
};
}
// Step 3: process the new event, then record it as done.
processedEvents.add(event.id);
return {
status: 200,
body: { ok: true, reason: 'processed', eventId: event.id, type: event.type },
};
}
// --- Fixed, deterministic test data -----------------------------------
const secret = 'whsec_test_secret';
const payload = JSON.stringify({ id: 'evt_1001', type: 'payment.succeeded', amount: 4200 });
const goodSig = 'sha256=' + signPayload(payload, secret);
const badSig = 'sha256=' + '0'.repeat(64);
console.log('Scenario 1: valid new event');
console.log(handleWebhook(payload, goodSig, secret));
console.log('\nScenario 2: valid duplicate event (same payload again)');
console.log(handleWebhook(payload, goodSig, secret));
console.log('\nScenario 3: invalid signature');
console.log(handleWebhook(payload, badSig, secret));All three scenarios run correctly: a valid new event returns { status: 200, body: { ok: true, reason: 'processed', eventId: 'evt_1001', type: 'payment.succeeded' } }; the same event replayed returns { status: 200, body: { ok: true, reason: 'duplicate_skipped', eventId: 'evt_1001' } } with no reprocessing; and the invalid signature is rejected with { status: 401, body: { ok: false, reason: 'invalid_signature' } } before the event is ever parsed.5-minute try-it
Extend handleWebhook so that processedEvents also stores a timestamp, and add a cleanup step that only keeps entries from the last 24 hours (simulate the clock instead of using real time) -- this mirrors how a production system prevents its duplicate-tracking store from growing forever.
One important caution
Processing the event before verifying its signature -- even 'just to log it' -- means an attacker who forges a payload gets it recorded as if it were legitimate.
Using a plain === or string comparison to check the signature instead of crypto.timingSafeEqual, which can leak timing information an attacker can exploit.
Stripe Docs: Verify webhook signatures — API Integration & Webhooks