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.

Node.js
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);
}
Python
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)
Three things that break signature checksRe-serializing the JSON before hashing (use the raw body); a plain === / == string compare (use a constant-time compare); and skipping the timestamp tolerance (which leaves you open to replays of an old, valid request).

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.

Idempotent handler
# 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 retried

3. 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.

Get a free sandbox keyBack to docs

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.

© 2026 Zanyara Ltd. All rights reserved.

Questions: hello@zanyara.com · Privacy: privacy@zanyara.com

We use analytics cookies to understand how visitors use Zanyara. Declining still lets you use the site — see our Privacy Policy.