X-Webhook-Signature header against the raw request body using your endpoint secret.
Always verify against the raw request body before parsing JSON. Any body transformation can invalidate the signature.
Reusable verify function
import crypto from "crypto";
export function verifyWebhook(
rawBody: string | Buffer,
timestampHeader: string,
signatureHeader: string,
secret: string,
toleranceSeconds = 300,
) {
const timestamp = Number(timestampHeader);
if (!timestamp) throw new Error("Missing or invalid webhook timestamp");
const age = Math.floor(Date.now() / 1000) - timestamp;
if (age > toleranceSeconds) {
throw new Error("Timestamp outside tolerance");
}
const body = typeof rawBody === "string" ? rawBody : rawBody.toString("utf8");
const expected = crypto
.createHmac("sha256", secret)
.update(`${timestamp}.${body}`, "utf8")
.digest("hex");
const received = String(signatureHeader).replace(/^sha256=/i, "").trim();
const a = Buffer.from(expected, "utf8");
const b = Buffer.from(received, "utf8");
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
throw new Error("Invalid signature");
}
return JSON.parse(body);
}
import hmac, hashlib, json, time
def verify_webhook(raw_body: bytes, timestamp_header: str, signature_header: str, secret: str, tolerance=300):
timestamp = int(timestamp_header or 0)
if not timestamp:
raise ValueError("Missing or invalid webhook timestamp")
if time.time() - timestamp > tolerance:
raise ValueError("Timestamp outside tolerance")
signed = f"{timestamp}.{raw_body.decode()}".encode()
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
received = str(signature_header).replace("sha256=", "", 1)
if not hmac.compare_digest(expected, received):
raise ValueError("Invalid signature")
return json.loads(raw_body)
Express (Node.js)
import express from "express";
import { verifyWebhook } from "./verify";
const app = express();
app.post(
"/webhooks/zquence",
express.raw({ type: "application/json" }),
async (req, res) => {
let event;
try {
event = verifyWebhook(
req.body,
String(req.headers["x-webhook-timestamp"] || ""),
String(req.headers["x-webhook-signature"] || ""),
process.env.ZQUENCE_WEBHOOK_SECRET!,
);
} catch {
return res.status(400).send("Invalid webhook");
}
await enqueue(event);
return res.sendStatus(200);
},
);
Next.js (App Router)
import { NextResponse } from "next/server";
import { verifyWebhook } from "@/lib/verify";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function POST(req: Request) {
const body = await req.text();
const timestamp = req.headers.get("x-webhook-timestamp") || "";
const signature = req.headers.get("x-webhook-signature") || "";
let event;
try {
event = verifyWebhook(body, timestamp, signature, process.env.ZQUENCE_WEBHOOK_SECRET!);
} catch {
return new NextResponse("Invalid webhook", { status: 400 });
}
await enqueue(event);
return NextResponse.json({ received: true });
}
Flask (Python)
from flask import Flask, request
from verify import verify_webhook
import os
app = Flask(__name__)
@app.post("/webhooks/zquence")
def handle():
try:
event = verify_webhook(
request.data,
request.headers.get("X-Webhook-Timestamp", ""),
request.headers.get("X-Webhook-Signature", ""),
os.environ["ZQUENCE_WEBHOOK_SECRET"],
)
except ValueError:
return "Invalid webhook", 400
enqueue(event)
return "", 200
Response codes
Current delivery behavior is based only on HTTP success vs failure:2xx→ delivery succeeds and stops retrying.- Non-
2xx→ delivery is marked failed and retried if attempts remain. - Timeout after 10 seconds → delivery is marked failed and retried if attempts remain.
Keep your handler small: verify, enqueue, return
200. Do not do slow business logic inline.