How to Create a Webhook Endpoint in Node.js, Python and PHP

To create a webhook endpoint, expose an HTTPS POST route that checks the signature on the raw body, records the event ID and returns a 2xx before doing slow work: GitHub waits 10 seconds, Shopify 5 and Slack 3. Test it against a free bin first.

A webhook endpoint (also called a webhook receiver, webhook listener or webhook server) is a public HTTPS route that accepts the sender's POST, verifies the signature on the raw body, records the event ID so a retry isn't processed twice, and answers 2xx within the sender's time limit before doing slow work. The three samples below are minimal, tested, and verify GitHub's X-Hub-Signature-256 header, though other senders use different headers with the same steps. Before writing code, point the sender at a free bin to see the real payload and headers.

What a webhook endpoint has to do

  1. Accept a POST request over HTTPS.
  2. Verify the webhook signature against the raw request bytes, because JSON that was parsed and serialized again no longer matches the signature.
  3. Look up the event or delivery ID and stop if you've already seen it.
  4. Reply 2xx right away and hand slow work off to a queue.

Senders give you little time, as the table below shows, and a timeout counts as a failure even if your code finished later, so the same event may arrive again (see the webhook retries guide).

SenderTime to respondIf you miss it
Slack Events API3 secondsUp to 3 retries
Shopify1 second to connect, 5 seconds in total8 retries over the next 4 hours
GitHub10 secondsMarked failed; no automatic retry
Zendesk12 secondsRetried up to 5 times
StripeNo number published; reply 2xx before any complex logicRetried for up to 3 days in live mode

A minimal webhook receiver in Node.js (Express)

Mount express.raw() directly on the webhook route so req.body stays an untouched Buffer; if express.json() parses the body first, the exact bytes the sender signed are gone. Compute the HMAC over that Buffer, compare the two digests with crypto.timingSafeEqual (it throws when buffer lengths differ, so the length check comes first), and answer 401 on a mismatch.

The Set of X-GitHub-Delivery IDs in the sample stands in for a database table with a unique key, and the 200 goes out before the work starts. Note that express.raw() caps request bodies at 100kb by default, so raise its limit option if your sender posts bigger payloads.

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

const app = express();
const secret = process.env.WEBHOOK_SECRET;
const seen = new Set();

app.post("/webhooks", express.raw({ type: "*/*" }), (req, res) => {
  const mac = crypto.createHmac("sha256", secret)
    .update(req.body).digest("hex");
  const expected = Buffer.from(`sha256=${mac}`);
  const given = Buffer.from(req.get("X-Hub-Signature-256") || "");
  if (given.length !== expected.length ||
      !crypto.timingSafeEqual(given, expected)) {
    return res.sendStatus(401);
  }

  const id = req.get("X-GitHub-Delivery");
  if (seen.has(id)) return res.sendStatus(200);
  seen.add(id);

  res.sendStatus(200);
  setImmediate(() => handle(JSON.parse(req.body)));
});

function handle(event) {
  console.log("processing", event.action);
}

app.listen(3000);

The same receiver in Python (Flask)

In Flask, request.get_data() returns the raw request bytes before the framework parses anything. Python's hmac.new computes the SHA-256 digest and hmac.compare_digest compares them in constant time; comparing bytes instead of strings means an unusual header value can't crash the check.

A queue.Queue with one worker thread lets the route return 200 at once, though in production you'll want to swap in a real job queue and a database table for the seen set.

import hashlib
import hmac
import os
import queue
import threading

from flask import Flask, abort, request

app = Flask(__name__)
secret = os.environ["WEBHOOK_SECRET"].encode()
seen = set()
jobs = queue.Queue()


@app.post("/webhooks")
def receive():
    body = request.get_data()
    mac = hmac.new(secret, body, hashlib.sha256).hexdigest()
    expected = f"sha256={mac}".encode()
    given = request.headers.get("X-Hub-Signature-256", "").encode()
    if not hmac.compare_digest(expected, given):
        abort(401)

    delivery = request.headers.get("X-GitHub-Delivery")
    if delivery not in seen:
        seen.add(delivery)
        jobs.put(body)
    return "", 200


def worker():
    while True:
        body = jobs.get()
        print("processing", len(body), "bytes")


threading.Thread(target=worker, daemon=True).start()

The same receiver in PHP

In PHP, file_get_contents('php://input') reads the raw request body, and the server exposes the X-Hub-Signature-256 header as $_SERVER['HTTP_X_HUB_SIGNATURE_256']. hash_hmac returns a hex digest and hash_equals compares the two strings in constant time, with the known digest passed first.

Under PHP-FPM, fastcgi_finish_request() flushes the 200 response to the sender and lets the script keep running afterwards, while the function_exists check keeps the script working on servers without PHP-FPM. To skip duplicates in production, store the X-GitHub-Delivery ID in a database table with a unique key before running your logic.

<?php
$secret = getenv('WEBHOOK_SECRET');
$body = file_get_contents('php://input');
$given = $_SERVER['HTTP_X_HUB_SIGNATURE_256'] ?? '';
$expected = 'sha256=' . hash_hmac('sha256', $body, $secret);

if (!hash_equals($expected, $given)) {
    http_response_code(401);
    exit;
}

http_response_code(200);
if (function_exists('fastcgi_finish_request')) {
    fastcgi_finish_request();
}

$event = json_decode($body, true);
error_log('processing ' . ($event['action'] ?? 'unknown'));

What a webhook endpoint should send back

A webhook response is mostly just an HTTP status code, and any 2xx tells the sender the event was delivered. Don't put a redirect on a webhook URL: Shopify treats anything outside the 200 range (3xx included) as an error, Stripe counts 3xx redirects as failures, and Slack follows at most two redirects. Return 401 when the signature check fails (Discord requires a 401 error code), and return 2xx when you skip a duplicate event, or the sender will keep retrying an event you already have.

Some senders, like Slack with url_verification, check a new URL with a challenge first; free bins answer those challenges for Slack, Twitch EventSub, Meta, Strava, WebSub, Dropbox and Microsoft Graph and still capture the request.

Test the endpoint with a free bin, then relay to localhost

Step one needs no code at all: create a free bin, paste its ingest URL into the sender's webhook settings, and trigger an event. The browser viewer shows the real headers and body as they arrive, and signature headers like X-Hub-Signature-256 and Stripe-Signature stay visible so you can grab them. Save that captured body byte for byte and replay it against your local receiver with curl; as long as your local app uses the same secret, the signature check will pass.

A free bin always replies 200 with a fixed JSON body like {"ok":true,"delivery_id":"..."}, so it can't test your own response codes or business logic. Free bins last 48 hours and take up to 100 requests a day (up to 100KB per body), and you can claim a bin with a free Google sign-in to stop the 48-hour expiry and keep new requests for 30 days.

Step two, if you're on the Solo plan ($12 a month), is running wsh listen <endpoint> --to localhost:3000 to relay live deliveries from a persistent webhooks.host endpoint straight to your machine, and the same CLI can replay captured requests on demand. When you relay, the sender still gets its 200 from webhooks.sh rather than your local server's response.

Create a free bin

curl -X POST https://webhooks.sh/v1/bins

Save a captured body byte for byte

curl -s -H "x-view-key: <view key>" \
  https://view.webhooks.page/api/bins/<bin>/deliveries/<id> \
  | jq -r .delivery.body_base64 | base64 -d > payload.json

Replay it against your receiver

curl -X POST http://localhost:3000/webhooks \
  -H "Content-Type: application/json" \
  -H "X-GitHub-Delivery: <delivery id>" \
  -H "X-Hub-Signature-256: <signature from the bin>" \
  --data-binary @payload.json

Relay live deliveries to localhost (Solo)

npm install -g https://webhooks.sh/cli/wsh.tgz
wsh auth login
wsh listen <endpoint> --to localhost:3000

FAQ

How do I create a webhook endpoint?

Add an HTTPS POST route to your app that verifies the sender's signature against the raw request body, skips event IDs it has already recorded, returns a 2xx status fast, and pushes the real work to a background queue. Once it's up, paste the route's public URL into the sender's webhook settings.

What should a webhook endpoint return?

Return any 2xx status code within the sender's timeout: Slack gives you 3 seconds, Shopify gives you 5, and GitHub gives you 10. Return 401 for a failed signature check, return 2xx when ignoring a duplicate delivery, and never return a 3xx redirect.

Does a webhook endpoint need HTTPS?

Yes in practice, because Stripe requires an HTTPS URL for webhook endpoints in live mode and PayPal requires webhook listeners to run over HTTPS on port 443.

Why does my webhook signature check fail with the right secret?

Usually a middleware like express.json() parsed and altered the request body before you hashed it, or the secret in your environment belongs to a different endpoint or mode. Hash the raw bytes, and if you get stuck, paste the captured request into the HMAC signature verifier to check it in your browser.

Can I test a webhook endpoint without deploying it?

Yes, you can capture real deliveries in a free bin and replay them to localhost with curl, or stream live requests straight to your machine with wsh listen on the Solo plan. Our guide on how to test webhooks locally walks through local tunnels as well.

Last updated

Try it in 10 seconds.

Create a free bin at view.webhooks.page — no signup, no install. Point your sender at the URL and watch requests land live. Bins expire after 48 hours.