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-Timestamp is older than about 5 minutes. This blocks replay attacks with captured requests.
  • Recompute the HMAC over ${timestamp}.${rawBody} with your secret and prefix it with sha256=.
  • Compare timing-safe — use crypto.timingSafeEqual / hmac.compare_digest, never ==, to avoid leaking the signature through response timing.
  • Then parse and handle — respond 2xx quickly 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

Test it end to end