Getting Started with Webhooks: Part 1 - Webhook Servers

A comprehensive two-part guide to understanding webhooks; this part focuses on webhook servers.


Webhooks are one of the most common integration patterns in modern software - they allow one system to notify another when something happens, in real time, without polling. In this two-part series, we'll build a webhook server using Node.js and Express (Part 1) and a webhook client using Streamlit (Part 2) from scratch, and deploy both as a two-service template on Railway.

What is a Webhook?

Our world is more intertwined than ever - a digital experience on any given day is the (mostly) harmonious result of a complex web of interconnected systems. These systems are often built using different languages and frameworks, deployed in different locations and modes, billed using different consumption models, and yet, share a common trait - they need to communicate with each other and share data.

Data is typically shared across heterogenous systems in two ways, via either a push or a pull mechanism. Push notifications often take the form of webhooks. A webhook is an HTTP request (or, in the words of Wikipedia, "user-defined HTTP callbacks"), triggered by an event in a source system and sent to a destination system, often with a payload of data. According to Hookdeck, webhooks provide a way for one system (the source) to "speak" (HTTP request) to another system (the destination) when an event occurs, and share information (request payload) about the event that occurred.

Often used in SaaS platforms like GitHub, Shopify, Stripe, Twilio, and Slack, webhooks are a powerful tool for web developers to receive real-time notifications and data updates from other web applications. Webhooks are versatile and easy to use, and can be used to trigger a wide range of actions, such as sending an email, updating a database, or triggering further downstream actions. To receive webhook requests, you have to register for one or more of the events (also known as topics) for which the platform offers a webhook. Once the webhook registration for an event is complete, you will receive webhook requests at the destination URL you provided each time the event occurs.

The primary alternative to webhooks is polling-based integration, where a client repeatedly requests data from a server at fixed intervals. While this method can be simpler to set up, it can lead to increased latency and resource consumption.

Source: hookdeck.com
Source: hookdeck.com

What We're Building - The Webhook Server

The webhook server in this series:

  • Listens for POST requests on /webhook/:event — a single parameterised route that handles any event type
  • Optionally verifies an HMAC-SHA256 signature on incoming requests
  • Logs the event type, headers, and body
  • Returns the received payload as JSON

The server is built with Node.js and Express. Here's an excerpt from the server.js; you can find the complete source code here.

const express = require("express");
const cors = require("cors");
const crypto = require("crypto");
const app = express();
const port = process.env.PORT || 3000;

// Middleware
app.use(cors());
app.use(express.json());
app.use(express.urlencoded({ extended: true }));

// HMAC-SHA256 signature verification
function verifySignature(req, secret) {
  const signature = req.headers["x-webhook-signature"];
  if (!signature) return false;
  const expected = `sha256=${crypto
    .createHmac("sha256", secret)
    .update(JSON.stringify(req.body))
    .digest("hex")}`;
  try {
    return crypto.timingSafeEqual(
      Buffer.from(signature),
      Buffer.from(expected)
    );
  } catch {
    return false;
  }
}

// Routes
app.get("/", (req, res) => {
  res.json({ message: "Webhook server is running.", endpoints: ["/health", "/webhook/:event"] });
});
 
app.get("/health", (req, res) => {
  res.json({ status: "ok" });
});
 
app.post("/webhook/:event", (req, res) => {
  const { event } = req.params;
  const eventType = req.headers["x-event-type"] || event;
 
  // Verify signature if WEBHOOK_SECRET is set
  if (process.env.WEBHOOK_SECRET) {
    if (!verifySignature(req, process.env.WEBHOOK_SECRET)) {
      console.warn(`[${new Date().toISOString()}] Invalid signature for event: ${eventType}`);
      return res.status(401).json({ error: "Invalid signature." });
    }
  }
 
  console.log(`[${new Date().toISOString()}] Webhook received`);
  console.log("Event:", eventType);
  console.log("Headers:", req.headers);
  console.log("Body:", JSON.stringify(req.body, null, 2));
 
  res.json({
    message: `Webhook received successfully.`,
    event: eventType,
    receivedData: req.body,
  });
});
 
// Error handler (must be registered after routes)
app.use((err, req, res, next) => {
  console.error("Error:", err.stack);
  res.status(500).json({ error: "Internal Server Error" });
});
 
app.listen(port, () => {
  console.log(`Webhook server running at http://localhost:${port}/`);
});

Deploying the Webhook Server on Railway

You can deploy the webhook server with the one-click Railway template:

Deploy on Railway

Once the deployment completes, the webhook server will be available at a default xxx.up.railway.app domain - launch this URL and verify that the webhook server is running. If you are interested in setting up a custom domain, I covered it at length in a previous post - see the final section here.

Simple webhook server deployed on Railway
Simple webhook server deployed on Railway

The Railway template deploys the server with a railway.toml that sets the start command and health check path automatically:

[deploy]
startCommand = "node server.js"
healthcheckPath = "/health"

A Few Thoughts

Parameterised Routes

The server uses a single route /webhook/:event. The :event segment matches any value — user.created, payment.succeeded, deploy.finished — and is available as req.params.event. This mirrors how real webhook providers work and means you don't need to add a new route for every event type.

HMAC-SHA256 Signature Verification

This is the most important security concept in webhook handling. Without it, anyone who discovers your endpoint URL can send arbitrary payloads to it. The pattern works like this:

  1. Both sides share a secret string (set as WEBHOOK_SECRET on the server)
  2. The sender hashes the payload body with the secret using HMAC-SHA256
  3. The sender sends the hash as a header: X-Webhook-Signature: sha256=<hex_digest>
  4. The receiver independently computes the same hash and compares — if they match, the payload is authentic

The comparison uses crypto.timingSafeEqual rather than === to prevent timing attacks, where an attacker could infer the correct signature by measuring how long the comparison takes.

WEBHOOK_SECRET is optional in this implementation — if it's not set, all requests are accepted. This makes the server easy to test without secrets, while being production-ready when the variable is set.

Health Endpoint

The /health endpoint returns { "status": "ok" } and is used by Railway's health check system to confirm the service started correctly. Without it, Railway falls back to TCP port detection, which is less reliable.

In Part 2, we'll build the Streamlit client that signs and sends requests to this server, also deployed on Railway as part of the same template.

Subscribe to alphasec

Don’t miss out on the latest issues. Sign up now to get access to the library of members-only issues.
jamie@example.com
Subscribe