Webhooks
Verifying signatures
Every webhook delivery is signed with your endpoint's secret. Verify the signature before trusting the payload — it proves the request came from Zyron and was not tampered with.
The scheme
Each delivery carries an X-Zyron-Timestamp header (unix seconds) and an X-Zyron-Signature header computed as:
text
signature = "sha256=" + hex( HMAC_SHA256( secret, "<X-Zyron-Timestamp>.<rawBody>" ) )secret is the whsec_... value returned once when you created the webhook. The signed payload is the timestamp, a literal dot, then the raw request body bytes.
Verification steps
- Read the raw body first — before any JSON parsing. Re-serialized JSON almost never matches the original bytes, so verification must run on the body exactly as received.
- Check the timestamp — reject deliveries whose
X-Zyron-Timestampis older than about 5 minutes. This blocks replay attacks with captured requests. - Recompute the HMAC over
${timestamp}.${rawBody}with your secret and prefix it withsha256=. - Compare timing-safe — use
crypto.timingSafeEqual/hmac.compare_digest, never==, to avoid leaking the signature through response timing. - Then parse and handle — respond
2xxquickly and do heavy work asynchronously.
Examples
import crypto from "node:crypto";
import express from "express";
const app = express();
const SECRET = process.env.ZYRON_WEBHOOK_SECRET; // whsec_...
app.post(
"/zyron/webhook",
// IMPORTANT: capture the RAW body — do not json-parse first.
express.raw({ type: "application/json" }),
(req, res) => {
const signature = req.get("X-Zyron-Signature") ?? "";
const timestamp = req.get("X-Zyron-Timestamp") ?? "";
// 1. Replay guard: reject deliveries older than ~5 minutes.
const age = Math.abs(Date.now() / 1000 - Number(timestamp));
if (!timestamp || age > 300) {
return res.status(400).send("stale timestamp");
}
// 2. Recompute: sha256= + HMAC_SHA256(secret, timestamp + "." + rawBody)
const expected =
"sha256=" +
crypto
.createHmac("sha256", SECRET)
.update(`${timestamp}.${req.body}`) // req.body is a Buffer here
.digest("hex");
// 3. Timing-safe compare.
const a = Buffer.from(signature);
const b = Buffer.from(expected);
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return res.status(401).send("bad signature");
}
// Verified — parse and handle.
const event = JSON.parse(req.body.toString("utf8"));
console.log(event.event, event.data);
res.sendStatus(204);
}
);
app.listen(3000);Common pitfalls
- Global JSON middleware — if
express.json()(or Flask's auto-parsing) consumes the body before your handler, the raw bytes are gone. Mount a raw body parser on the webhook route specifically. - Signing the parsed body — computing the HMAC over
JSON.stringify(req.body)fails whenever key order or whitespace differs. Always use the raw bytes. - Skipping the timestamp check — a valid signature on an old request is still an attack. Enforce the ~5-minute window.
Never expose an unverified endpoint
Treat any request that fails verification as hostile: return
401 and do not process it. Your webhook URL is public — the signature is the only thing proving a request is from Zyron.Test it end to end
Use
POST /webhooks/:webhookId/test to send yourself a signed zyron.ping and confirm your verification passes before going live.