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:
- Read the raw body bytes (not a parsed JSON object).
- Compute
HMAC-SHA256(raw_body, secret)and hex-encode the result. - Compare the result, in constant time, against the
X-Webhook-Signatureheader. - Respond with
401and stop processing if they don't match.
The signing algorithm
Conceptually:
signature = hex(hmac_sha256(secret, request.body))
Where:
secretis thewhsec_...string shown when you created the webhook.request.bodyis 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)(orOpenSSL.fixed_length_secure_compareon 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:
| Header | Signs with |
|---|---|
X-Webhook-Signature | The new secret. |
X-Webhook-Signature-Old | The 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
- Add the new secret to your environment as
DISTRIBU_WEBHOOK_SECRET_NEW(keep the current one inDISTRIBU_WEBHOOK_SECRET). - Deploy a receiver that accepts either secret.
- 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.
- Once the grace window ends, swap env vars:
DISTRIBU_WEBHOOK_SECRET ← NEW, delete…_OLD. - Redeploy.
Note: Secret rotation is dashboard-only today —
POST /api/v1/webhooks/{id}/rotate-secretis on the roadmap but not available yet. The API exposessecretRotationGraceExpiresAton the webhook so you can read when the current rotation completes; see Webhooks endpoints.
Testing locally
A common local dev setup:
- Run your app on
localhost:3000. - Tunnel it with
ngrok http 3000(orcloudflared,localtunnel, etc.). - Register the public tunnel URL in Settings → Webhooks.
- Place a test order from the storefront to fire
order.created. - Watch the delivery log under the webhook — you should see a
200row 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.
