Build a Webhook System from Scratch

Build a webhook provider and consumer with HMAC-SHA256 signature verification.

Introduction

30 Second Summary

When you buy something online, the store doesn't make you refresh the tracking page every five minutes. Instead, it sends you a notification the moment your package ships. Software systems work the same way using webhooks: one system automatically pings another the instant something happens.

In this project, you will build a complete webhook system from scratch. You'll create both the sender and the receiver, sign payloads cryptographically, verify signatures on the receiving end, and prove the security layer works by forging a tampered payload and watching it get rejected.

What You'll Build

A two-part webhook system: a provider server that signs and delivers events, and a consumer server that verifies signatures and handles events securely, connected via ngrok to simulate real-world deliveries.

By the end of this project, you'll have:

  • A webhook provider that signs payloads with HMAC-SHA256 and delivers events to registered endpoints.
  • A webhook consumer that verifies signatures using timing-safe comparison, rejects tampered payloads, and deduplicates events.
  • A test script that sends legitimate events, forges a tampered payload, and attempts a replay attack to prove the security layer works.
  • Secret Mission: Add exponential backoff retry logic so failed deliveries are automatically retried with increasing delays.

Are there any prerequisites?

You'll need basic JavaScript knowledge and familiarity with HTTP requests and JSON. No prior experience with webhooks or cryptography is required.

Set Up Your Development Environment

To build a webhook system, you need two separate servers talking to each other. One sends signed payloads, the other verifies and processes them.

In this step, you will install Node.js, create your project folder, and set up the two-file structure that the provider and consumer servers will live in.

In this step, get ready to:
  • Install Node.js and verify it's working.
  • Create the project directory and initialize with npm.
  • Install Express.js and create the two-server project structure.
Install Node.js and verify it's working

Node.js is the JavaScript runtime that powers both your provider and consumer servers. This project requires version 18 or higher because it uses the built-in fetch API and crypto.randomUUID().

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Terminal and press Enter to open it.
  • Check your Node.js version by running this command:
node --version

โœ”๏ธ I see version 18 or higher

If you see v18.x.x or higher (like v24.16.0), you are ready to go. Move on to the next substep.

โ“ง I see a version lower than 18

You have Node.js installed, but it is too old for this project. You need to upgrade to version 18 or higher.

  • If you use nvm (macOS/Linux), upgrade by running:
  • If you do not use nvm, visit nodejs.org/en/download and download the LTS installer for your operating system.
nvm install 18
nvm use 18
  • After upgrading, close and reopen your terminal.
  • Run node --version again to confirm you see v18 or higher.

โ“ง Command not found

Node.js is not installed on your machine yet. You need to install it.

  • Visit nodejs.org/en/download and download the LTS installer for your operating system.
  • Run the installer and accept the default settings.
  • Close and reopen your terminal after installation finishes.
  • Run node --version again to confirm you see v18 or higher.
Create the project directory and initialize with npm

Every Node.js project starts with a package.json file that tracks its dependencies. You will create a dedicated folder for this project and initialize it.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir webhook-system
cd webhook-system
  • Initialize the project with npm by running this command:
npm init -y

What does this command do?

The -y flag tells npm to accept all defaults without prompting you. It creates a package.json file that stores your project's name, version, and dependencies.

  • Confirm the file was created by running:
ls

You should see package.json listed in the output.

Don't see package.json?

Make sure you ran npm init -y from inside the webhook-system folder. Run pwd to check your current directory. If you are somewhere else, run cd ~/Desktop/webhook-system and try again.

help me troubleshoot why npm init isn't creating a package.json file

Install Express.js and set up the two-server project structure

Express.js is the web framework that handles HTTP routing for both your provider and consumer servers. You will also create the two files where your server code will live.

  • Install Express.js as a dependency by running this command:
npm install express

What does this command do?

This downloads the Express package from the npm registry and adds it to your package.json under dependencies. It also creates a node_modules folder containing Express and everything it needs to run.

  • Confirm the installation succeeded by running:
ls

You should see node_modules, package.json, and package-lock.json in the output.

Seeing permission errors?

On macOS or Linux, you may need to fix npm permissions. Try running the install command with sudo in front, or follow the npm docs to fix global permissions. On Windows, try running your terminal as Administrator.

help me fix npm permission errors when installing packages

  • Create the two placeholder files for your servers by running:

macOS

touch provider.js consumer.js

Windows

New-Item provider.js, consumer.js
  • Confirm the files were created by running:
ls

You should see provider.js and consumer.js alongside package.json and node_modules.

Why two separate files?

In a real webhook integration, the provider and consumer are entirely separate applications, often owned by different companies. Running them as two separate Node.js servers on different ports simulates this real-world separation while keeping everything on your machine.

Your project structure is ready. Next up, you will build the webhook provider that signs and delivers event payloads.

Build the Webhook Provider

Your project is set up and ready for code. Now it is time to build the first half of your webhook system: the provider.

The provider is the server that sends webhooks. When something happens (an order is placed, a payment succeeds), the provider signs a payload with a secret key and delivers it to any registered endpoint.

In this step, get ready to:
  • Build the Express server that accepts endpoint registrations.
  • Implement HMAC-SHA256 payload signing.
  • Create the delivery function that signs and sends webhooks to consumers.
Create the provider server that accepts endpoint registrations

Before the provider can deliver webhooks, it needs to know where to send them. You will create routes that let consumers register their URLs and list the registered endpoints.

  • Open provider.js in your editor.
  • Add the server setup, constants, and in-memory storage by pasting this code:
const express = require('express');
const crypto = require('node:crypto');

const app = express();
app.use(express.json());

const WEBHOOK_SECRET = 'whsec_my-super-secret-key-change-me';
const PORT = 3001;

// In-memory storage
const registeredEndpoints = [];
const deliveryLog = [];

What does this code do?

This block imports Express for the HTTP server and Node.js crypto for signing payloads later.

The WEBHOOK_SECRET is the shared key both provider and consumer will use to verify messages. The two arrays act as a simple in-memory database for registered endpoints and delivery history.

  • You won't see output yet since the server has no routes or listener. The next code block adds the registration routes.
  • Below the storage arrays, add the endpoint registration and listing routes:
// Register a webhook endpoint
app.post('/register', (req, res) => {
  const { url, events } = req.body;
  if (!url) {
    return res.status(400).json({ error: 'url is required' });
  }
  const endpoint = {
    id: crypto.randomUUID(),
    url,
    events: events || ['*'],
    createdAt: new Date().toISOString()
  };
  registeredEndpoints.push(endpoint);
  res.status(201).json({ endpoint, secret: WEBHOOK_SECRET });
});

// List registered endpoints
app.get('/endpoints', (req, res) => {
  res.json({ endpoints: registeredEndpoints });
});

What does this code do?

POST /register lets a consumer sign up by providing its URL and an optional list of event types it wants to receive. If no events are specified, the wildcard '*' subscribes to everything.

GET /endpoints returns all registered endpoints. This is useful for debugging and confirming that registration worked.

  • You won't see a change yet. The next substep adds the signing function that makes deliveries secure.
Implement HMAC-SHA256 payload signing using Node.js crypto

Every webhook delivery needs a signature so the consumer can prove the payload came from a trusted source. HMAC-SHA256 creates a unique fingerprint by combining the payload content with the shared secret.

  • Below the GET /endpoints route, add the signing function:
// Sign a payload
function signPayload(payload, timestamp, eventId) {
  const signedContent = `${eventId}.${timestamp}.${JSON.stringify(payload)}`;
  const signature = crypto
    .createHmac('sha256', WEBHOOK_SECRET)
    .update(signedContent)
    .digest('hex');
  return signature;
}

What does this code do?

The signPayload function builds a canonical string by joining the event ID, timestamp, and JSON body with dots. It then runs that string through HMAC-SHA256 using the shared secret.

This format follows the Standard Webhooks convention used by services like Stripe and GitHub. Including the timestamp in the signed content is what makes replay protection possible on the consumer side.

Why include the timestamp in the signature?

If an attacker captures a valid webhook and replays it later, the consumer can reject it because the timestamp will be too old. But that only works if the timestamp is part of the signed content. Otherwise, an attacker could swap in a fresh timestamp without invalidating the signature.

  • You won't see output yet. The next substep uses this function inside the delivery logic.
Build the delivery endpoint that signs and sends webhooks to registered consumers

The delivery function is the core of the provider. It takes a registered endpoint and an event, signs the payload, and sends it as an HTTP POST with special headers the consumer will verify.

  • Below the signPayload function, add the delivery function:
// Deliver a webhook event
async function deliverWebhook(endpoint, event) {
  const eventId = crypto.randomUUID();
  const timestamp = Math.floor(Date.now() / 1000).toString();
  const payload = {
    id: eventId,
    type: event.type,
    timestamp,
    data: event.data
  };

  const signature = signPayload(payload, timestamp, eventId);

  const delivery = {
    id: crypto.randomUUID(),
    endpointId: endpoint.id,
    eventId,
    status: 'pending',
    attempts: 0,
    lastAttemptAt: null,
    response: null
  };
  deliveryLog.push(delivery);

  try {
    const response = await fetch(endpoint.url, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'webhook-id': eventId,
        'webhook-timestamp': timestamp,
        'webhook-signature': `v1=${signature}`
      },
      body: JSON.stringify(payload)
    });

    delivery.attempts = 1;
    delivery.lastAttemptAt = new Date().toISOString();
    delivery.response = response.status;

    if (response.ok) {
      delivery.status = 'delivered';
      console.log(`\u2705 Delivered to ${endpoint.url} - Status: ${response.status}`);
    } else {
      delivery.status = 'failed';
      console.log(`\u274C Failed delivery to ${endpoint.url} - Status: ${response.status}`);
    }
  } catch (error) {
    delivery.attempts = 1;
    delivery.lastAttemptAt = new Date().toISOString();
    delivery.status = 'failed';
    delivery.response = error.message;
    console.log(`\u274C Failed delivery to ${endpoint.url} - Error: ${error.message}`);
  }

  return delivery;
}

What does this code do?

The deliverWebhook function generates a unique event ID and Unix timestamp, builds the payload, and signs it. It then fires an HTTP POST to the consumer's URL with three special headers:

  • webhook-id identifies the event for deduplication.
  • webhook-timestamp tells the consumer when the event was created.
  • webhook-signature carries the HMAC signature prefixed with v1= for version identification.

The delivery record tracks whether the request succeeded or failed, and logs the HTTP status or error message.

  • Now add the event trigger route, the delivery log route, and the server listener below deliverWebhook:
// Trigger a webhook event
app.post('/events', async (req, res) => {
  const { type, data } = req.body;
  if (!type) {
    return res.status(400).json({ error: 'type is required' });
  }

  const event = { type, data: data || {} };
  const deliveries = [];

  for (const endpoint of registeredEndpoints) {
    if (endpoint.events.includes('*') || endpoint.events.includes(type)) {
      const delivery = await deliverWebhook(endpoint, event);
      deliveries.push(delivery);
    }
  }

  res.json({ event, deliveries });
});

// View delivery log
app.get('/deliveries', (req, res) => {
  res.json({ deliveries: deliveryLog });
});

app.listen(PORT, () => {
  console.log(`\ud83d\ude80 Webhook Provider running on http://localhost:${PORT}`);
  console.log(`   POST /register  - Register a webhook endpoint`);
  console.log(`   POST /events    - Trigger a webhook event`);
  console.log(`   GET  /endpoints - List registered endpoints`);
  console.log(`   GET  /deliveries - View delivery log`);
});

What does this code do?

POST /events is the trigger. When called with an event type, it loops through all registered endpoints and delivers the event to any whose subscription matches. The wildcard '*' matches all event types.

GET /deliveries exposes the delivery log so you can inspect successes and failures. The app.listen call starts the server on port 3001 and prints all available routes.

  • Save provider.js.
  • Start the provider by running:
node provider.js
  • You should see this output in your terminal:
๐Ÿš€ Webhook Provider running on http://localhost:3001
   POST /register  - Register a webhook endpoint
   POST /events    - Trigger a webhook event
   GET  /endpoints - List registered endpoints
   GET  /deliveries - View delivery log
  • Stop the server with Ctrl+C. You will start it again later when both servers are ready.

Don't see the startup message?

  • Check that your file is saved. Unsaved changes mean Node.js reads the old (empty) file.
  • Look for syntax errors in the terminal output. A missing comma or bracket often causes an unexpected token error.
  • Make sure you are in the webhook-system folder when you run the command.

Help me debug my provider.js server startup error.

โœ”๏ธ Awesome, I've got everything!

Great. Double check you have saved provider.js before moving on.

โ“ง I'd like to double check the full code

Here is the complete provider.js as it should look at the end of this step:

const express = require('express');
const crypto = require('node:crypto');

const app = express();
app.use(express.json());

const WEBHOOK_SECRET = 'whsec_my-super-secret-key-change-me';
const PORT = 3001;

// In-memory storage
const registeredEndpoints = [];
const deliveryLog = [];

// Register a webhook endpoint
app.post('/register', (req, res) => {
  const { url, events } = req.body;
  if (!url) {
    return res.status(400).json({ error: 'url is required' });
  }
  const endpoint = {
    id: crypto.randomUUID(),
    url,
    events: events || ['*'],
    createdAt: new Date().toISOString()
  };
  registeredEndpoints.push(endpoint);
  res.status(201).json({ endpoint, secret: WEBHOOK_SECRET });
});

// List registered endpoints
app.get('/endpoints', (req, res) => {
  res.json({ endpoints: registeredEndpoints });
});

// Sign a payload
function signPayload(payload, timestamp, eventId) {
  const signedContent = `${eventId}.${timestamp}.${JSON.stringify(payload)}`;
  const signature = crypto
    .createHmac('sha256', WEBHOOK_SECRET)
    .update(signedContent)
    .digest('hex');
  return signature;
}

// Deliver a webhook event
async function deliverWebhook(endpoint, event) {
  const eventId = crypto.randomUUID();
  const timestamp = Math.floor(Date.now() / 1000).toString();
  const payload = {
    id: eventId,
    type: event.type,
    timestamp,
    data: event.data
  };

  const signature = signPayload(payload, timestamp, eventId);

  const delivery = {
    id: crypto.randomUUID(),
    endpointId: endpoint.id,
    eventId,
    status: 'pending',
    attempts: 0,
    lastAttemptAt: null,
    response: null
  };
  deliveryLog.push(delivery);

  try {
    const response = await fetch(endpoint.url, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'webhook-id': eventId,
        'webhook-timestamp': timestamp,
        'webhook-signature': `v1=${signature}`
      },
      body: JSON.stringify(payload)
    });

    delivery.attempts = 1;
    delivery.lastAttemptAt = new Date().toISOString();
    delivery.response = response.status;

    if (response.ok) {
      delivery.status = 'delivered';
      console.log(`\u2705 Delivered to ${endpoint.url} - Status: ${response.status}`);
    } else {
      delivery.status = 'failed';
      console.log(`\u274C Failed delivery to ${endpoint.url} - Status: ${response.status}`);
    }
  } catch (error) {
    delivery.attempts = 1;
    delivery.lastAttemptAt = new Date().toISOString();
    delivery.status = 'failed';
    delivery.response = error.message;
    console.log(`\u274C Failed delivery to ${endpoint.url} - Error: ${error.message}`);
  }

  return delivery;
}

// Trigger a webhook event
app.post('/events', async (req, res) => {
  const { type, data } = req.body;
  if (!type) {
    return res.status(400).json({ error: 'type is required' });
  }

  const event = { type, data: data || {} };
  const deliveries = [];

  for (const endpoint of registeredEndpoints) {
    if (endpoint.events.includes('*') || endpoint.events.includes(type)) {
      const delivery = await deliverWebhook(endpoint, event);
      deliveries.push(delivery);
    }
  }

  res.json({ event, deliveries });
});

// View delivery log
app.get('/deliveries', (req, res) => {
  res.json({ deliveries: deliveryLog });
});

app.listen(PORT, () => {
  console.log(`\ud83d\ude80 Webhook Provider running on http://localhost:${PORT}`);
  console.log(`   POST /register  - Register a webhook endpoint`);
  console.log(`   POST /events    - Trigger a webhook event`);
  console.log(`   GET  /endpoints - List registered endpoints`);
  console.log(`   GET  /deliveries - View delivery log`);
});

Your provider is complete. It can register endpoints, sign payloads, and deliver events. Next up, you will build the consumer that receives these deliveries and verifies the signatures.

Build the Webhook Consumer

Your provider can sign and deliver webhooks. Now you need something on the receiving end to verify those signatures and reject anything suspicious.

The consumer is the other half of the system. It receives incoming webhook deliveries, checks that the payload hasn't been tampered with, and only processes events that pass every security check.

In this step, get ready to:
  • Create the consumer server with raw body handling for signature verification.
  • Implement HMAC-SHA256 signature verification with timing-safe comparison.
  • Add replay protection via timestamp validation and event deduplication via event IDs.
Create the consumer server with raw body handling

Webhook signature verification requires access to the exact bytes that were sent over the network. If Express parses the JSON body first, the original byte sequence is lost and your HMAC computation will produce a different result.

Why raw body handling matters

The provider signs the raw JSON string before sending it. If the consumer parses that JSON into an object and re-serializes it, even tiny differences (like key ordering or whitespace) change the bytes. The HMAC would no longer match.

Using express.raw({ type: 'application/json' }) on the webhook route gives you the original Buffer of bytes, preserving the exact content for signature verification.

  • Open consumer.js in your editor and add the server setup with imports, constants, and in-memory storage:
const express = require('express');
const crypto = require('node:crypto');

const app = express();
const PORT = 3002;

const WEBHOOK_SECRET = 'whsec_my-super-secret-key-change-me';
const TIMESTAMP_TOLERANCE_SECONDS = 300; // 5 minutes

// In-memory storage for deduplication
const processedEvents = new Set();
const receivedEvents = [];

What does this code do?

  • PORT 3002 keeps the consumer on a separate port from the provider (which uses 3001).
  • WEBHOOK_SECRET must match the provider's secret exactly. Both sides need the same key to compute matching HMACs.
  • TIMESTAMP_TOLERANCE_SECONDS sets a 5-minute window for accepting deliveries. Anything older is rejected as a potential replay attack.
  • processedEvents is a Set that tracks event IDs already handled, preventing duplicate processing.
  • Below the storage declarations, add the webhook route with express.raw middleware and the header existence check:
// Use express.raw to capture the exact bytes for signature verification
app.post(
  '/webhook',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const rawBody = req.body; // Buffer with raw bytes
    const eventId = req.headers['webhook-id'];
    const timestamp = req.headers['webhook-timestamp'];
    const signature = req.headers['webhook-signature'];

    // Step 1: Check required headers exist
    if (!eventId || !timestamp || !signature) {
      console.log('โŒ Missing required webhook headers');
      return res.status(401).json({ error: 'Missing required headers' });
    }

What does this code do?

  • express.raw({ type: 'application/json' }) is applied as route-level middleware. It intercepts the request before the handler runs and populates req.body with a raw Buffer instead of a parsed JavaScript object.
  • The three headers (webhook-id, webhook-timestamp, webhook-signature) follow the Standard Webhooks convention used by providers like Svix and OpenAI.
  • If any header is missing, the request is immediately rejected with a 401 status. No point computing an HMAC if the data is incomplete.
Implement HMAC-SHA256 signature verification with timing-safe comparison

The signature check is the core security gate. It proves the payload was created by someone who knows the shared secret and that no byte was changed in transit.

  • Directly after the header check (still inside the route handler), add the timestamp validation and signature verification:
    // Step 2: Replay protection - check timestamp freshness
    const currentTime = Math.floor(Date.now() / 1000);
    const webhookTime = parseInt(timestamp, 10);
    if (Math.abs(currentTime - webhookTime) > TIMESTAMP_TOLERANCE_SECONDS) {
      console.log('โŒ Webhook timestamp too old - possible replay attack');
      return res.status(401).json({ error: 'Timestamp too old' });
    }

    // Step 3: Verify signature
    const payload = JSON.parse(rawBody.toString());
    const signedContent = `${eventId}.${timestamp}.${rawBody.toString()}`;
    const expectedSignature = crypto
      .createHmac('sha256', WEBHOOK_SECRET)
      .update(signedContent)
      .digest('hex');

    const receivedSig = signature.replace('v1=', '');

    const isValid = crypto.timingSafeEqual(
      Buffer.from(expectedSignature, 'hex'),
      Buffer.from(receivedSig, 'hex')
    );

    if (!isValid) {
      console.log('โŒ Invalid webhook signature - payload may be tampered');
      return res.status(401).json({ error: 'Invalid signature' });
    }

What does this code do?

The timestamp check rejects deliveries older than 5 minutes. An attacker who intercepts a valid webhook can't replay it hours later because the timestamp will be stale.

The signature verification reconstructs the same signed content format the provider used: id.timestamp.body. It computes an HMAC-SHA256 digest with the shared secret and compares it to what the provider sent.

crypto.timingSafeEqual prevents timing attacks. A regular === comparison returns faster when early characters don't match, leaking information about which bytes are correct. The timing-safe version always takes the same amount of time regardless of where the mismatch occurs.

Why check the timestamp before verifying the signature?

The timestamp check is intentionally placed first because it's a cheaper operation than computing an HMAC. If the timestamp is stale, there's no reason to spend CPU cycles on cryptographic verification.

Add event deduplication and process verified events

Webhook providers guarantee at-least-once delivery. This means your consumer might receive the same event more than once. The deduplication layer ensures each event is processed exactly once.

  • After the signature verification, add the deduplication check, event processing, and close the route handler:
    // Step 4: Deduplication - check if we already processed this event
    if (processedEvents.has(eventId)) {
      console.log(`โš ๏ธ  Duplicate event ${eventId} - skipping`);
      return res.status(200).json({ received: true, duplicate: true });
    }

    // Step 5: Process the event
    processedEvents.add(eventId);
    receivedEvents.push({
      id: eventId,
      type: payload.type,
      data: payload.data,
      receivedAt: new Date().toISOString()
    });

    console.log(`โœ… Verified and processed event: ${payload.type} (${eventId})`);
    res.status(200).json({ received: true });
  }
);

What does this code do?

  • The deduplication check uses the processedEvents Set to see if this event ID was already handled. If so, it returns a 200 (so the provider doesn't retry) but skips processing.
  • For new events, the ID is added to the Set and the event data is pushed to receivedEvents for later inspection.
  • Returning 200 for duplicates is important. If you returned 4xx, the provider would keep retrying the same event.
  • Below the closed route handler, add the events endpoint and server listener to complete the file:
// View received events
app.get('/events', (req, res) => {
  res.json({ events: receivedEvents, total: receivedEvents.length });
});

app.listen(PORT, () => {
  console.log(`๐Ÿ”’ Webhook Consumer running on http://localhost:${PORT}`);
  console.log(`   POST /webhook - Receive webhook deliveries`);
  console.log(`   GET  /events  - View received events`);
});

What does this code do?

  • The GET /events endpoint lets you inspect which events the consumer has successfully verified and processed.
  • The server listens on port 3002, separate from the provider on port 3001. Both servers run independently on your machine.
  • Save consumer.js.
  • Test that the consumer starts by running:
node consumer.js
  • You should see this output in your terminal:
๐Ÿ”’ Webhook Consumer running on http://localhost:3002
   POST /webhook - Receive webhook deliveries
   GET  /events  - View received events
  • Press Ctrl+C to stop the server for now.

Consumer not starting?

  • If you see a syntax error, check that all parentheses and braces are balanced. The route handler has nested function calls that are easy to mis-close.
  • If port 3002 is already in use, make sure you stopped any previously running server with Ctrl+C.
  • Compare your code against the full file in the tab below to spot any differences.

Help me debug why my webhook consumer server won't start.

โœ”๏ธ Awesome, I've got everything!

Great. Double check you've saved consumer.js before moving on.

โ“ง I'd like to double check the full code

const express = require('express');
const crypto = require('node:crypto');

const app = express();
const PORT = 3002;

const WEBHOOK_SECRET = 'whsec_my-super-secret-key-change-me';
const TIMESTAMP_TOLERANCE_SECONDS = 300; // 5 minutes

// In-memory storage for deduplication
const processedEvents = new Set();
const receivedEvents = [];

// Use express.raw to capture the exact bytes for signature verification
app.post(
  '/webhook',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const rawBody = req.body; // Buffer with raw bytes
    const eventId = req.headers['webhook-id'];
    const timestamp = req.headers['webhook-timestamp'];
    const signature = req.headers['webhook-signature'];

    // Step 1: Check required headers exist
    if (!eventId || !timestamp || !signature) {
      console.log('โŒ Missing required webhook headers');
      return res.status(401).json({ error: 'Missing required headers' });
    }

    // Step 2: Replay protection - check timestamp freshness
    const currentTime = Math.floor(Date.now() / 1000);
    const webhookTime = parseInt(timestamp, 10);
    if (Math.abs(currentTime - webhookTime) > TIMESTAMP_TOLERANCE_SECONDS) {
      console.log('โŒ Webhook timestamp too old - possible replay attack');
      return res.status(401).json({ error: 'Timestamp too old' });
    }

    // Step 3: Verify signature
    const payload = JSON.parse(rawBody.toString());
    const signedContent = `${eventId}.${timestamp}.${rawBody.toString()}`;
    const expectedSignature = crypto
      .createHmac('sha256', WEBHOOK_SECRET)
      .update(signedContent)
      .digest('hex');

    const receivedSig = signature.replace('v1=', '');

    const isValid = crypto.timingSafeEqual(
      Buffer.from(expectedSignature, 'hex'),
      Buffer.from(receivedSig, 'hex')
    );

    if (!isValid) {
      console.log('โŒ Invalid webhook signature - payload may be tampered');
      return res.status(401).json({ error: 'Invalid signature' });
    }

    // Step 4: Deduplication - check if we already processed this event
    if (processedEvents.has(eventId)) {
      console.log(`โš ๏ธ  Duplicate event ${eventId} - skipping`);
      return res.status(200).json({ received: true, duplicate: true });
    }

    // Step 5: Process the event
    processedEvents.add(eventId);
    receivedEvents.push({
      id: eventId,
      type: payload.type,
      data: payload.data,
      receivedAt: new Date().toISOString()
    });

    console.log(`โœ… Verified and processed event: ${payload.type} (${eventId})`);
    res.status(200).json({ received: true });
  }
);

// View received events
app.get('/events', (req, res) => {
  res.json({ events: receivedEvents, total: receivedEvents.length });
});

app.listen(PORT, () => {
  console.log(`๐Ÿ”’ Webhook Consumer running on http://localhost:${PORT}`);
  console.log(`   POST /webhook - Receive webhook deliveries`);
  console.log(`   GET  /events  - View received events`);
});

Your consumer is locked and loaded. In the next step, you'll connect both servers through ngrok and watch a real webhook delivery flow from provider to consumer, signature verification and all.

Connect with ngrok and Test

You now have a provider that signs payloads and a consumer that verifies them. But both servers are running on localhost, which means they can only talk to each other on your machine.

In the real world, webhooks travel over the public internet. A provider like Stripe needs a publicly reachable URL to deliver events to your server. In this step, you will use ngrok to expose your consumer to the internet, then run a test script that proves your entire security pipeline works end-to-end.

In this step, get ready to:
  • Install ngrok and expose the consumer server to a public HTTPS URL.
  • Create a test script that registers the ngrok URL with the provider and triggers a webhook delivery.
  • Forge a tampered payload to prove the consumer rejects it with HTTP 401.
Install ngrok and expose the consumer server

Your consumer is listening on port 3002, but the provider needs a public URL to deliver webhooks to. ngrok creates a secure tunnel from a public HTTPS endpoint to your local port.

What is ngrok?

ngrok is a tool that gives your local server a public HTTPS URL. When a request hits that URL, ngrok forwards it through a secure tunnel to your machine. This is exactly how you would test webhooks in development before deploying to a cloud server.

  • Install ngrok by running the command for your operating system:

macOS

brew install ngrok

Windows

winget install ngrok -s msstore
  • Connect ngrok to your account by running this command, replacing the token with your authtoken from the ngrok dashboard:
ngrok config add-authtoken your-authtoken-here

Where do I find my authtoken?

After signing up, your authtoken is displayed on the ngrok dashboard. It is only shown once, so copy it immediately.

  • Now start both servers. Open a terminal in your webhook-system folder and start the provider by running:
node provider.js

You should see the provider startup message with its available routes.

  • Open a second terminal in the same webhook-system folder and start the consumer by running:
node consumer.js

You should see the consumer startup message confirming it is running on port 3002.

  • Open a third terminal and expose the consumer to the internet by running:
ngrok http 3002

ngrok displays a console UI with a Forwarding line showing your public HTTPS URL. It looks something like https://abc123.ngrok-free.app.

  • Copy the full HTTPS URL from the Forwarding line. You will need it in the next substep.

Seeing an authentication error?

Make sure you ran ngrok config add-authtoken with your correct token from the dashboard. If your token was not saved, re-run the command with the correct value.

help me troubleshoot ngrok authentication errors

Create the test script and trigger a webhook delivery

Rather than manually crafting HTTP requests, you will write a test script that automates the full workflow: register the consumer, send a legitimate event, forge a tampered payload, and attempt a replay attack.

  • Create a file called test.js in your webhook-system folder and add the following code:
const crypto = require('node:crypto');

const PROVIDER_URL = 'http://localhost:3001';
const CONSUMER_URL = 'http://localhost:3002';
const WEBHOOK_SECRET = 'whsec_my-super-secret-key-change-me';

async function runTests() {
  console.log('๐Ÿงช Webhook System Tests\n');

  // Test 1: Register the consumer with the provider
  console.log('--- Test 1: Register Consumer Endpoint ---');
  const registerRes = await fetch(`${PROVIDER_URL}/register`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      url: `${CONSUMER_URL}/webhook`,
      events: ['order.created', 'order.updated']
    })
  });
  const registerData = await registerRes.json();
  console.log('Registered:', registerData.endpoint.id);
  console.log('');

  // Test 2: Trigger a legitimate webhook event
  console.log('--- Test 2: Send Legitimate Event ---');
  const eventRes = await fetch(`${PROVIDER_URL}/events`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      type: 'order.created',
      data: { orderId: 'ORD-123', amount: 49.99, currency: 'USD' }
    })
  });
  const eventData = await eventRes.json();
  console.log('Event type:', eventData.event.type);
  console.log('Delivery status:', eventData.deliveries[0].status);
  console.log('');

  // Test 3: Forge a tampered payload (should be rejected)
  console.log('--- Test 3: Send Forged Payload (Should Be Rejected) ---');
  const eventId = crypto.randomUUID();
  const timestamp = Math.floor(Date.now() / 1000).toString();
  const tamperedPayload = JSON.stringify({
    id: eventId,
    type: 'order.created',
    timestamp,
    data: { orderId: 'ORD-FAKE', amount: 0.01, currency: 'USD' }
  });

  // Sign with wrong secret to simulate tampering
  const fakeSignature = crypto
    .createHmac('sha256', 'wrong-secret')
    .update(`${eventId}.${timestamp}.${tamperedPayload}`)
    .digest('hex');

  const forgedRes = await fetch(`${CONSUMER_URL}/webhook`, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'webhook-id': eventId,
      'webhook-timestamp': timestamp,
      'webhook-signature': `v1=${fakeSignature}`
    },
    body: tamperedPayload
  });
  const forgedData = await forgedRes.json();
  console.log('Response status:', forgedRes.status);
  console.log('Response:', forgedData);
  console.log(forgedRes.status === 401 ? 'โœ… Forged payload correctly rejected!' : 'โŒ Security issue!');
  console.log('');

  // Test 4: Replay protection (send expired timestamp)
  console.log('--- Test 4: Replay Attack (Expired Timestamp) ---');
  const oldTimestamp = (Math.floor(Date.now() / 1000) - 600).toString(); // 10 minutes ago
  const replayPayload = JSON.stringify({
    id: crypto.randomUUID(),
    type: 'order.created',
    timestamp: oldTimestamp,
    data: { orderId: 'ORD-OLD', amount: 99.99 }
  });
  const replayEventId = crypto.randomUUID();
  const replaySignature = crypto
    .createHmac('sha256', WEBHOOK_SECRET)
    .update(`${replayEventId}.${oldTimestamp}.${replayPayload}`)
    .digest('hex');

  const replayRes = await fetch(`${CONSUMER_URL}/webhook`, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'webhook-id': replayEventId,
      'webhook-timestamp': oldTimestamp,
      'webhook-signature': `v1=${replaySignature}`
    },
    body: replayPayload
  });
  const replayData = await replayRes.json();
  console.log('Response status:', replayRes.status);
  console.log('Response:', replayData);
  console.log(replayRes.status === 401 ? 'โœ… Replay attack correctly rejected!' : 'โŒ Security issue!');
  console.log('');

  // Test 5: Check received events on consumer
  console.log('--- Test 5: Verify Consumer State ---');
  const eventsRes = await fetch(`${CONSUMER_URL}/events`);
  const eventsData = await eventsRes.json();
  console.log(`Consumer received ${eventsData.total} legitimate event(s)`);
  eventsData.events.forEach(e => {
    console.log(`  - ${e.type} (${e.id}) at ${e.receivedAt}`);
  });

  console.log('\n๐ŸŽ‰ All tests complete!');
}

runTests().catch(console.error);

What does this test script do?

The script runs five tests in sequence:

  • Test 1 registers the consumer's /webhook endpoint with the provider, subscribing to order.created and order.updated events.
  • Test 2 triggers a legitimate event through the provider, which signs and delivers it to the consumer.
  • Test 3 forges a payload signed with a wrong secret, simulating a man-in-the-middle attack. The consumer should reject it with HTTP 401.
  • Test 4 sends a correctly signed payload but with a timestamp from 10 minutes ago (beyond the 5-minute tolerance). This tests replay protection.
  • Test 5 queries the consumer's /events endpoint to confirm only the one legitimate event was processed.

Why does the test script use localhost instead of the ngrok URL?

The test script talks to both the provider and consumer directly via localhost. The provider itself uses the registered ngrok URL when it delivers webhooks. This means Test 2 travels the full path: test script hits provider on localhost, provider delivers to consumer via ngrok, and ngrok forwards to consumer on localhost.

Before running the test, you need to update the CONSUMER_URL in test.js to use your ngrok URL so the provider delivers webhooks through the public tunnel.

  • In test.js, find this line:
const CONSUMER_URL = 'http://localhost:3002';
  • Replace http://localhost:3002 with your ngrok HTTPS URL (the one from the Forwarding line), so it looks like:
const CONSUMER_URL = 'https://your-assigned-name.ngrok-free.app';

Keep localhost for local-only testing

If you want to test without ngrok (both servers on the same machine), you can leave CONSUMER_URL as http://localhost:3002. The webhook still travels through the provider's delivery function. Using the ngrok URL proves the system works over a real network hop.

  • Save test.js.
  • Open a fourth terminal in your webhook-system folder and run the test script:
node test.js

You should see output confirming Test 1 registers successfully, Test 2 delivers with status delivered, and both the provider and consumer terminals show activity.

Seeing a fetch failed or connection refused error?

Make sure both provider.js and consumer.js are running in separate terminals before you execute the test script. Check that ngrok is also still running and forwarding to port 3002.

help me debug connection errors when running my webhook test script

Verify the security layer rejects forged payloads

The most important part of the test output is Tests 3 and 4. These prove that your consumer's signature verification and replay protection are working correctly.

  • Look at the output from Test 3 in your terminal. You should see:
--- Test 3: Send Forged Payload (Should Be Rejected) ---
Response status: 401
Response: { error: 'Invalid signature' }
โœ… Forged payload correctly rejected!
  • Check the output from Test 4. You should see:
--- Test 4: Replay Attack (Expired Timestamp) ---
Response status: 401
Response: { error: 'Timestamp too old' }
โœ… Replay attack correctly rejected!
  • Check the output from Test 5. You should see exactly 1 legitimate event received:
--- Test 5: Verify Consumer State ---
Consumer received 1 legitimate event(s)

Why does this matter?

Test 3 simulates an attacker who intercepts a webhook and modifies the payload (changing the amount to 0.01). Without knowing the shared secret, they cannot produce a valid signature. Your consumer detects the mismatch and rejects it.

Test 4 simulates a replay attack where someone captures a legitimate webhook and re-sends it minutes later. Even though the signature is valid, the timestamp is stale. Your consumer's 5-minute tolerance window catches it.

  • Switch to your consumer's terminal. You should see log output showing the legitimate event was verified and processed, and the forged and replayed events were rejected.

Test 3 showing a 200 instead of 401?

Make sure your consumer.js has the signature verification logic using crypto.timingSafeEqual. If the consumer is not checking signatures, it will accept any payload. Double-check that the WEBHOOK_SECRET matches between provider.js and consumer.js.

help me figure out why my forged payload test is not being rejected

โœ”๏ธ Awesome, I've got everything!

Great. Double check you have saved test.js and that all five tests pass before moving on.

โ“ง I'd like to double check the full code

Here is the complete test.js file for reference:

const crypto = require('node:crypto');

const PROVIDER_URL = 'http://localhost:3001';
const CONSUMER_URL = 'http://localhost:3002';
const WEBHOOK_SECRET = 'whsec_my-super-secret-key-change-me';

async function runTests() {
  console.log('๐Ÿงช Webhook System Tests\n');

  // Test 1: Register the consumer with the provider
  console.log('--- Test 1: Register Consumer Endpoint ---');
  const registerRes = await fetch(`${PROVIDER_URL}/register`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      url: `${CONSUMER_URL}/webhook`,
      events: ['order.created', 'order.updated']
    })
  });
  const registerData = await registerRes.json();
  console.log('Registered:', registerData.endpoint.id);
  console.log('');

  // Test 2: Trigger a legitimate webhook event
  console.log('--- Test 2: Send Legitimate Event ---');
  const eventRes = await fetch(`${PROVIDER_URL}/events`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      type: 'order.created',
      data: { orderId: 'ORD-123', amount: 49.99, currency: 'USD' }
    })
  });
  const eventData = await eventRes.json();
  console.log('Event type:', eventData.event.type);
  console.log('Delivery status:', eventData.deliveries[0].status);
  console.log('');

  // Test 3: Forge a tampered payload (should be rejected)
  console.log('--- Test 3: Send Forged Payload (Should Be Rejected) ---');
  const eventId = crypto.randomUUID();
  const timestamp = Math.floor(Date.now() / 1000).toString();
  const tamperedPayload = JSON.stringify({
    id: eventId,
    type: 'order.created',
    timestamp,
    data: { orderId: 'ORD-FAKE', amount: 0.01, currency: 'USD' }
  });

  // Sign with wrong secret to simulate tampering
  const fakeSignature = crypto
    .createHmac('sha256', 'wrong-secret')
    .update(`${eventId}.${timestamp}.${tamperedPayload}`)
    .digest('hex');

  const forgedRes = await fetch(`${CONSUMER_URL}/webhook`, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'webhook-id': eventId,
      'webhook-timestamp': timestamp,
      'webhook-signature': `v1=${fakeSignature}`
    },
    body: tamperedPayload
  });
  const forgedData = await forgedRes.json();
  console.log('Response status:', forgedRes.status);
  console.log('Response:', forgedData);
  console.log(forgedRes.status === 401 ? 'โœ… Forged payload correctly rejected!' : 'โŒ Security issue!');
  console.log('');

  // Test 4: Replay protection (send expired timestamp)
  console.log('--- Test 4: Replay Attack (Expired Timestamp) ---');
  const oldTimestamp = (Math.floor(Date.now() / 1000) - 600).toString(); // 10 minutes ago
  const replayPayload = JSON.stringify({
    id: crypto.randomUUID(),
    type: 'order.created',
    timestamp: oldTimestamp,
    data: { orderId: 'ORD-OLD', amount: 99.99 }
  });
  const replayEventId = crypto.randomUUID();
  const replaySignature = crypto
    .createHmac('sha256', WEBHOOK_SECRET)
    .update(`${replayEventId}.${oldTimestamp}.${replayPayload}`)
    .digest('hex');

  const replayRes = await fetch(`${CONSUMER_URL}/webhook`, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'webhook-id': replayEventId,
      'webhook-timestamp': oldTimestamp,
      'webhook-signature': `v1=${replaySignature}`
    },
    body: replayPayload
  });
  const replayData = await replayRes.json();
  console.log('Response status:', replayRes.status);
  console.log('Response:', replayData);
  console.log(replayRes.status === 401 ? 'โœ… Replay attack correctly rejected!' : 'โŒ Security issue!');
  console.log('');

  // Test 5: Check received events on consumer
  console.log('--- Test 5: Verify Consumer State ---');
  const eventsRes = await fetch(`${CONSUMER_URL}/events`);
  const eventsData = await eventsRes.json();
  console.log(`Consumer received ${eventsData.total} legitimate event(s)`);
  eventsData.events.forEach(e => {
    console.log(`  - ${e.type} (${e.id}) at ${e.receivedAt}`);
  });

  console.log('\n๐ŸŽ‰ All tests complete!');
}

runTests().catch(console.error);

Your webhook system is battle-tested. You have proven that legitimate events flow through, forged payloads are rejected, and replay attacks are caught. Next up, the Secret Mission will challenge you to add automatic retry logic with exponential backoff.

Secret mission

Add Retry Logic with Exponential Backoff

Your webhook provider delivers once and gives up. Real providers like Stripe retry failed deliveries with increasing delays. Can you add exponential backoff so your provider automatically retries up to 3 times?

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally, so there are no ongoing costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • Webhook provider server running on port 3001.
  • Webhook consumer server running on port 3002.
  • ngrok tunnel exposing the consumer to the internet.
  • Project folder (webhook-system) on your Desktop.

Keep everything running

No action needed. Choose this if you're still actively building or testing your webhook system.

  • Both servers and the ngrok tunnel will keep running until you close their terminal windows.
  • The ngrok free tier allows up to 20,000 requests per month, so there is no risk of charges.

Pause - I'll come back to this later

Shut down running processes to free up terminal windows and memory. Your project files stay intact on disk.

  • In each terminal running a server or ngrok, press Ctrl+C to stop the process.
  • Restart anytime by opening new terminal windows and running node provider.js, node consumer.js, and ngrok http 3002.
  • You will need to re-register the new ngrok URL with the provider since the tunnel URL changes each time.

Delete - I don't want to use this again

Remove all project resources and start fresh. No cloud resources need to be cleaned up.

  • In each terminal running a server or ngrok, press Ctrl+C to stop the process.
  • Delete the project folder by running this command:

macOS

cd ~/Desktop
rm -rf webhook-system

Windows

cd ~/Desktop
rmdir /s /q webhook-system
  • Confirm the folder is gone by running:
ls ~/Desktop

You should no longer see webhook-system in the output.

That's a Wrap!

That's a Wrap!

Nice work! You just built a production-quality webhook system from scratch, covering both the provider and consumer sides of the integration.

You've learned how to:

  • Built a webhook provider that signs payloads with HMAC-SHA256 and delivers events to registered endpoints.
  • Built a webhook consumer that verifies signatures using timing-safe comparison, rejects replay attacks via timestamp validation, and deduplicates events.
  • Connected both systems via ngrok and proved the security layer works by forging a tampered payload that was correctly rejected.
  • Added exponential backoff retry logic to the provider so failed deliveries are automatically retried with increasing delays.

Ready to quiz yourself?