Verifying webhooks: signatures & idempotency
Zanyara delivers every verification decision to your endpoint by webhook. Two things make that endpoint safe: verifying the HMAC signature so you only act on genuine, untampered events, and handling them idempotently so an at-least-once retry never processes the same decision twice. Copy-paste examples for both are below.
1. Verify the signature (HMAC-SHA256)
Each delivery carries an X-Zanyara-Signature header (a hex HMAC-SHA256 of {timestamp}.{raw_body}) and an X-Zanyara-Timestamp header. Recompute the HMAC with your endpoint’s signing secret (from the dashboard), compare it in constant time, and reject anything stale to defeat replays.
const crypto = require("crypto");
// Verify a Zanyara webhook. Pass the RAW request body (a Buffer/string),
// not the parsed JSON — re-serializing changes the bytes and breaks the HMAC.
function verifyZanyaraWebhook(rawBody, headers, signingSecret) {
const signature = headers["x-zanyara-signature"];
const timestamp = headers["x-zanyara-timestamp"];
if (!signature || !timestamp) return false;
// Replay defence: reject deliveries older than 5 minutes.
const age = Math.abs(Date.now() / 1000 - Number(timestamp));
if (Number.isNaN(age) || age > 300) return false;
const expected = crypto
.createHmac("sha256", signingSecret)
.update(`${timestamp}.${rawBody}`)
.digest("hex");
// Constant-time compare — never use === on secrets.
const a = Buffer.from(signature);
const b = Buffer.from(expected);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}import hmac, hashlib, time
def verify_zanyara_webhook(raw_body: bytes, headers, signing_secret: str) -> bool:
signature = headers.get("X-Zanyara-Signature")
timestamp = headers.get("X-Zanyara-Timestamp")
if not signature or not timestamp:
return False
# Replay defence: reject deliveries older than 5 minutes.
try:
if abs(time.time() - int(timestamp)) > 300:
return False
except ValueError:
return False
signed = f"{timestamp}.".encode() + raw_body
expected = hmac.new(signing_secret.encode(), signed, hashlib.sha256).hexdigest()
# Constant-time compare.
return hmac.compare_digest(signature, expected)2. Handle events idempotently
Delivery is at least once: after a timeout we retry with exponential backoff, and brief races can deliver a duplicate. Dedupe on the event id and keep a record of processed ids (a TTL of ~30 days comfortably covers the retry window). Order is not guaranteed — treat each event on its own merits rather than assuming it arrives after an earlier one.
# At-least-once delivery: the same event id may arrive more than once
# (retries, network races). Dedupe on event id before doing any work.
def handle(event, store):
if store.seen(event["id"]):
return 200 # already processed — ack and drop
store.mark_seen(event["id"], ttl_days=30)
process(event) # your business logic, made safe to re-run
return 200 # 2xx = delivered; anything else is retried3. Event payload
Every event shares an envelope: an id, a type, a created_at timestamp and a data object. A completed check looks like this:
{
"id": "evt_01J9ZQ8M4T",
"type": "check.completed",
"created_at": "2026-08-12T14:03:11Z",
"data": {
"check_id": "chk_01J9YR2C7P",
"result": "clear",
"breakdown": {
"document": "clear",
"face": "clear",
"watchlist": "consider"
}
}
}Common event types include a completed decision, a check routed to human review, and — if ongoing monitoring is enabled — a delta alert when a monitored subject’s screening status changes.
Header names and payload fields above are illustrative of the pattern; the exact contract for your account is in the API reference we share with your sandbox key.
Frequently asked questions
- Why verify webhook signatures at all?
- Your webhook endpoint is a public URL, so anyone can POST to it. The HMAC signature proves a request genuinely came from Zanyara and wasn’t altered in transit — without it, an attacker could forge a “clear” decision. Verify every request before acting on it.
- Why compute the HMAC over the raw body?
- The signature covers the exact bytes we sent. If you parse the JSON and re-serialize it, key order and whitespace can change, the recomputed hash won’t match, and every webhook will appear invalid. Capture the raw body before any JSON middleware runs.
- Why do I need idempotency?
- Webhooks are delivered at least once: retries after a timeout, or brief network races, can deliver the same event twice. Dedupe on the event id and make your handler safe to run more than once, so a duplicate never double-charges, double-onboards or double-alerts.
- What response should my endpoint return?
- Return a 2xx quickly to acknowledge receipt, then do slow work asynchronously. Any non-2xx (or a timeout) is treated as a failure and retried with backoff, which is exactly why the handler must be idempotent.