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.
Current webhook deliveries include these security-related headers:
| Header | Format | Purpose |
|---|
X-Webhook-Timestamp | Unix seconds, e.g. 1745311982 | Replay protection input |
X-Webhook-Signature | sha256=<hex> | HMAC SHA-256 signature |
X-Webhook-Signature-Version | v1 | Signature version marker |
X-Zquence-Event | e.g. kyc.provider.completed | Convenience routing header |
X-Zquence-Webhook-Id | e.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
Read the raw body
Do not parse JSON before verifying. Signature verification must use the exact raw bytes that were sent.
Read timestamp and signature headers
Use X-Webhook-Timestamp and X-Webhook-Signature.
Reject stale timestamps
A 5-minute tolerance window is a sensible default to reduce replay risk.
Compute the expected HMAC
Build timestamp + "." + body, then HMAC it with your webhook secret.
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.