Every webhook delivery is signed with the secret stored on that webhook endpoint. Verifying the signature proves the request came from Zquence and that the payload was not modified in transit.

Headers

Current webhook deliveries include these security-related headers:
HeaderFormatPurpose
X-Webhook-TimestampUnix seconds, e.g. 1745311982Replay protection input
X-Webhook-Signaturesha256=<hex>HMAC SHA-256 signature
X-Webhook-Signature-Versionv1Signature version marker
X-Zquence-Evente.g. kyc.provider.completedConvenience routing header
X-Zquence-Webhook-Ide.g. evt_...Event identifier for idempotency
There is no secondary or legacy signature header in the current implementation.

Signing algorithm

signedPayload = timestamp + "." + rawRequestBody
signature     = HMAC_SHA256(webhookSecret, signedPayload)
headerValue   = "sha256=" + hex(signature)

Verification steps

1

Read the raw body

Do not parse JSON before verifying. Signature verification must use the exact raw bytes that were sent.
2

Read timestamp and signature headers

Use X-Webhook-Timestamp and X-Webhook-Signature.
3

Reject stale timestamps

A 5-minute tolerance window is a sensible default to reduce replay risk.
4

Compute the expected HMAC

Build timestamp + "." + body, then HMAC it with your webhook secret.
5

Use constant-time comparison

Always compare the received signature and expected signature with a timing-safe comparison.

Reference implementation

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);
}

Secret usage

Each endpoint has one secret, and only that secret should be used to verify deliveries for that endpoint. If you rotate the secret by updating the endpoint configuration, deploy the new secret to your backend before expecting new deliveries to verify successfully.
Keep webhook secrets separate from your API keys. Webhook verification uses the endpoint secret, not x-api-key / x-api-secret.