Thuta Learning
ProjectsWeb Developmentintermediate

Project: A Safe Webhook Receiver

What you'll walk away with

  • Explain the core ideas behind Project: A Safe Webhook Receiver
  • Read the diagram and trace how a request, response, or event flows through the system
  • Explain how this applies to a real API integration you might build

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.
text
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

javascript
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));
You should see
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 signaturesAPI Integration & Webhooks

Easy traps

  • 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.
  • If you haven't taken the API Tutorial yet, it's worth finishing that first -- this course doesn't re-teach REST/HTTP/auth basics, it builds on top of them with webhooks, testing, reliability, and integration architecture.

Exercise

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.

You'll know it worked when: 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.