Signature verification

Anyone who knows your webhook URL can send a POST to it. To prove a request actually came from Distribu, every delivery includes an X-Webhook-Signature header — an HMAC-SHA256 hex digest of the raw request body, computed with your webhook's signing secret.

Your endpoint should:

  1. Read the raw body bytes (not a parsed JSON object).
  2. Compute HMAC-SHA256(raw_body, secret) and hex-encode the result.
  3. Compare the result, in constant time, against the X-Webhook-Signature header.
  4. Respond with 401 and stop processing if they don't match.

The signing algorithm

Conceptually:

signature = hex(hmac_sha256(secret, request.body))

Where:

  • secret is the whsec_... string shown when you created the webhook.
  • request.body is the raw bytes of the POST body, before any framework parses them as JSON.

Why raw body matters

Most web frameworks auto-parse the request body as JSON and give you an object. If you re-serialize that object to compute the signature, you'll get different bytes — different whitespace, different key order, maybe different number formatting — and the HMAC won't match.

Every snippet below explicitly captures raw bytes first, then parses JSON after verifying the signature.

Node.js (Express)

import express from "express";
import crypto from "crypto";

const app = express();
const SECRET = process.env.DISTRIBU_WEBHOOK_SECRET; // whsec_...

// IMPORTANT: use express.raw, not express.json, on this route
app.post(
  "/webhooks/distribu",
  express.raw({ type: "application/json" }),
  (req, res) => {
    const signature = req.headers["x-webhook-signature"];
    if (typeof signature !== "string") {
      return res.status(401).send("Missing signature");
    }

    const expected = crypto
      .createHmac("sha256", SECRET)
      .update(req.body)
      .digest("hex");

    // Constant-time comparison — never use === for signatures
    const sigBuf = Buffer.from(signature, "hex");
    const expBuf = Buffer.from(expected, "hex");
    if (
      sigBuf.length !== expBuf.length ||
      !crypto.timingSafeEqual(sigBuf, expBuf)
    ) {
      return res.status(401).send("Invalid signature");
    }

    // Safe to parse now
    const { event, timestamp, data } = JSON.parse(req.body.toString());
    console.log(`[${timestamp}] ${event}`, data);

    res.sendStatus(200);
  }
);

Node.js (Next.js App Router)

// app/api/webhooks/distribu/route.ts
import crypto from "crypto";

const SECRET = process.env.DISTRIBU_WEBHOOK_SECRET!;

export async function POST(req: Request) {
  const signature = req.headers.get("x-webhook-signature");
  if (!signature) return new Response("Missing signature", { status: 401 });

  const body = await req.text(); // raw string
  const expected = crypto
    .createHmac("sha256", SECRET)
    .update(body)
    .digest("hex");

  const sigBuf = Buffer.from(signature, "hex");
  const expBuf = Buffer.from(expected, "hex");
  if (
    sigBuf.length !== expBuf.length ||
    !crypto.timingSafeEqual(sigBuf, expBuf)
  ) {
    return new Response("Invalid signature", { status: 401 });
  }

  const payload = JSON.parse(body);
  // …handle event…

  return new Response("OK");
}

Python (Flask)

import hmac, hashlib, os
from flask import Flask, request, abort

app = Flask(__name__)
SECRET = os.environ["DISTRIBU_WEBHOOK_SECRET"].encode()

@app.post("/webhooks/distribu")
def distribu_webhook():
    signature = request.headers.get("X-Webhook-Signature", "")
    raw = request.get_data()  # raw bytes, NOT request.json
    expected = hmac.new(SECRET, raw, hashlib.sha256).hexdigest()

    if not hmac.compare_digest(signature, expected):
        abort(401)

    payload = request.get_json()
    event = payload["event"]
    data = payload["data"]
    # …handle event…
    return "", 200

Python (FastAPI)

import hmac, hashlib, os
from fastapi import FastAPI, Request, HTTPException

app = FastAPI()
SECRET = os.environ["DISTRIBU_WEBHOOK_SECRET"].encode()

@app.post("/webhooks/distribu")
async def distribu_webhook(request: Request):
    signature = request.headers.get("x-webhook-signature", "")
    raw = await request.body()
    expected = hmac.new(SECRET, raw, hashlib.sha256).hexdigest()

    if not hmac.compare_digest(signature, expected):
        raise HTTPException(status_code=401, detail="Invalid signature")

    payload = await request.json()
    # …handle payload["event"] and payload["data"]…
    return {"ok": True}

Ruby (Rack / Rails)

require "openssl"

post "/webhooks/distribu" do
  secret = ENV["DISTRIBU_WEBHOOK_SECRET"]
  signature = request.env["HTTP_X_WEBHOOK_SIGNATURE"].to_s
  raw = request.body.read

  expected = OpenSSL::HMAC.hexdigest("sha256", secret, raw)

  unless Rack::Utils.secure_compare(signature, expected)
    halt 401, "Invalid signature"
  end

  payload = JSON.parse(raw)
  # …handle payload["event"] and payload["data"]…
  status 200
end

Go (net/http)

package main

import (
    "crypto/hmac"
    "crypto/sha256"
    "encoding/hex"
    "encoding/json"
    "io"
    "net/http"
    "os"
)

var secret = []byte(os.Getenv("DISTRIBU_WEBHOOK_SECRET"))

func webhookHandler(w http.ResponseWriter, r *http.Request) {
    signature := r.Header.Get("X-Webhook-Signature")
    body, err := io.ReadAll(r.Body)
    if err != nil {
        http.Error(w, "bad body", http.StatusBadRequest)
        return
    }
    defer r.Body.Close()

    mac := hmac.New(sha256.New, secret)
    mac.Write(body)
    expected := hex.EncodeToString(mac.Sum(nil))

    sigBytes, _ := hex.DecodeString(signature)
    expBytes, _ := hex.DecodeString(expected)
    if !hmac.Equal(sigBytes, expBytes) {
        http.Error(w, "invalid signature", http.StatusUnauthorized)
        return
    }

    var payload struct {
        Event     string                 `json:"event"`
        Timestamp string                 `json:"timestamp"`
        Data      map[string]interface{} `json:"data"`
    }
    if err := json.Unmarshal(body, &payload); err != nil {
        http.Error(w, "bad json", http.StatusBadRequest)
        return
    }
    // …handle payload…
    w.WriteHeader(http.StatusOK)
}

Common mistakes

Using JSON.parse(body) before hashing

If you parse the body as JSON, then serialize it back with JSON.stringify to hash, the bytes won't match. Always hash the raw bytes exactly as received.

Using === (or Ruby's ==) to compare

// ❌ Vulnerable to timing attacks
if (signature === expected) { ... }

Always use a constant-time comparison function:

  • Node: crypto.timingSafeEqual(a, b) — requires both buffers to be the same length, so compare lengths first.
  • Python: hmac.compare_digest(a, b).
  • Ruby: Rack::Utils.secure_compare(a, b) (or OpenSSL.fixed_length_secure_compare on 2.5+).
  • Go: hmac.Equal(a, b).

Accepting unsigned requests

If the header is missing, don't process the request. A signature-less POST to your webhook URL is almost certainly someone probing; respond 401 and move on.

Storing the secret in your source code

Keep it in an env var or secret manager. If the secret leaks, rotate it (see below) or delete the webhook and create a new one.

Ignoring X-Webhook-Event

The event name is also in the JSON payload, so using the header is optional, but it's convenient for routing without parsing:

const event = req.headers["x-webhook-event"]; // "order.created" etc.
if (event === "order.created") { /* … */ }

Secret rotation

When you suspect the signing secret has leaked — or on a periodic schedule — rotate it from Settings → Webhooks → Rotate secret on the webhook's detail page. Rotation is a zero-downtime cutover: Distribu issues a new secret and keeps the old one valid for a grace window (24 hours by default) so your receiver can migrate without dropping events.

Headers during the grace window

While rotation is in progress, every webhook delivery carries two signature headers:

HeaderSigns with
X-Webhook-SignatureThe new secret.
X-Webhook-Signature-OldThe old secret. Only present during the grace window.

Your receiver should accept a request if either signature validates. Once the grace window expires, X-Webhook-Signature-Old stops being sent and only the new secret is accepted.

Verifying against both secrets

// Next.js App Router example — adapt the same pattern to any framework
import crypto from "crypto";

const NEW_SECRET = process.env.DISTRIBU_WEBHOOK_SECRET!;
const OLD_SECRET = process.env.DISTRIBU_WEBHOOK_SECRET_OLD; // optional

function verify(body: string, header: string | null, secret: string) {
  if (!header) return false;
  const expected = crypto.createHmac("sha256", secret).update(body).digest("hex");
  const a = Buffer.from(header, "hex");
  const b = Buffer.from(expected, "hex");
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

export async function POST(req: Request) {
  const body = await req.text();
  const sig = req.headers.get("x-webhook-signature");
  const sigOld = req.headers.get("x-webhook-signature-old");

  const ok =
    verify(body, sig, NEW_SECRET) ||
    (OLD_SECRET ? verify(body, sigOld, OLD_SECRET) : false);

  if (!ok) return new Response("Invalid signature", { status: 401 });

  // …handle payload…
  return new Response("OK");
}

Recommended rotation flow

  1. Add the new secret to your environment as DISTRIBU_WEBHOOK_SECRET_NEW (keep the current one in DISTRIBU_WEBHOOK_SECRET).
  2. Deploy a receiver that accepts either secret.
  3. In Distribu, click Rotate secret on the webhook — Distribu will start signing with the new one and stop signing with the old one after the grace window.
  4. Once the grace window ends, swap env vars: DISTRIBU_WEBHOOK_SECRET ← NEW, delete …_OLD.
  5. Redeploy.

Note: Secret rotation is dashboard-only today — POST /api/v1/webhooks/{id}/rotate-secret is on the roadmap but not available yet. The API exposes secretRotationGraceExpiresAt on the webhook so you can read when the current rotation completes; see Webhooks endpoints.

Testing locally

A common local dev setup:

  1. Run your app on localhost:3000.
  2. Tunnel it with ngrok http 3000 (or cloudflared, localtunnel, etc.).
  3. Register the public tunnel URL in Settings → Webhooks.
  4. Place a test order from the storefront to fire order.created.
  5. Watch the delivery log under the webhook — you should see a 200 row within seconds.

If you see 401s, your signature check is wrong. If you see 500s, your handler is throwing. If you see blank rows (no statusCode), the tunnel is probably down or the request timed out.


Next: Retries & delivery.