Skip to main content

Why verify?

Your webhook URL is a public endpoint, so anyone could attempt to send requests to it. Every request from Zivio is signed with your webhook’s signing secret. By verifying the signature before acting on a request, you can be confident it genuinely came from Zivio and was not tampered with in transit.
Always verify the signature before trusting or processing a webhook payload.
Zivio signs requests following the Standard Webhooks specification. The easiest and safest way to verify is with one of the official open-source libraries, available for many languages. Pass your signing secret and the request headers, and the library handles verification — including protection against replay attacks — for you.
import { Webhook } from "standardwebhooks";

const wh = new Webhook(process.env.ZIVIO_WEBHOOK_SECRET); // "whsec_..."

// `body` must be the raw request body string, not a parsed object
const payload = wh.verify(body, {
  "webhook-id": req.headers["webhook-id"],
  "webhook-timestamp": req.headers["webhook-timestamp"],
  "webhook-signature": req.headers["webhook-signature"],
});

// `payload` is now the verified, parsed event

How the signature works

If you prefer to verify manually, the scheme is straightforward:
1

Build the signed content

Concatenate the webhook-id, the webhook-timestamp, and the raw request body, separated by full stops:
{webhook-id}.{webhook-timestamp}.{raw-body}
2

Compute an HMAC

Your signing secret is the part after the whsec_ prefix, which is Base64-encoded. Base64-decode it to get the key, then compute an HMAC-SHA256 of the signed content using that key. Base64-encode the result.
3

Compare against the header

The webhook-signature header contains a space-separated list of signatures, each prefixed with a version, e.g. v1,<base64-signature>. Compare your computed value against the v1 signature using a constant-time comparison.
4

Check the timestamp

Reject requests whose webhook-timestamp is too far from the current time (a tolerance of around five minutes is typical) to defend against replay attacks.
You must use the raw request body exactly as received to compute the signature. Re-serializing a parsed JSON object can change whitespace or key order and will cause verification to fail.

Manual verification example

Node.js
import crypto from "crypto";

function verify(rawBody, headers, signingSecret) {
  const id = headers["webhook-id"];
  const timestamp = headers["webhook-timestamp"];
  const signatureHeader = headers["webhook-signature"];

  // 1. Build the signed content
  const signedContent = `${id}.${timestamp}.${rawBody}`;

  // 2. Compute the expected signature
  const key = Buffer.from(signingSecret.replace(/^whsec_/, ""), "base64");
  const expected = crypto
    .createHmac("sha256", key)
    .update(signedContent)
    .digest("base64");

  // 3. Compare against each signature in the header (scheme: "v1,<sig>")
  const passed = signatureHeader.split(" ").some((part) => {
    const [, sig] = part.split(",");
    return (
      sig &&
      crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))
    );
  });

  if (!passed) throw new Error("Invalid webhook signature");
}

Rotating your secret

If your secret is ever exposed, rotate it from the Webhooks admin. Rotation takes effect immediately: the old secret stops working at once and a new one is shown to you a single time. Update the stored secret in your endpoint as soon as you rotate, or verification will fail in the interim.