Skip to main content
Every webhook delivery carries an X-Zetta-Signature header. Your handler must verify it before trusting the payload. Without verification, anyone can POST a fake call.ended to your endpoint.

The contract

signed_bytes = f"{timestamp}.".encode() + raw_request_body
expected     = "sha256=" + hmac_sha256(signing_secret, signed_bytes).hexdigest()
Reject the request if:
  1. X-Zetta-Signature doesn’t match expected (timing-safe compare), OR
  2. |now - X-Zetta-Timestamp| > 300 seconds (replay protection)

Reference implementations

import hmac, hashlib, time
from fastapi import Request, HTTPException

SIGNING_SECRET = os.environ["YOTEL_WEBHOOK_SECRET"]

async def handle_webhook(request: Request):
    raw = await request.body()
    sig = request.headers.get("X-Zetta-Signature", "")
    ts = int(request.headers.get("X-Zetta-Timestamp", "0"))

    if abs(int(time.time()) - ts) > 300:
        raise HTTPException(401, "Replay window exceeded")

    signed = f"{ts}.".encode() + raw
    expected = "sha256=" + hmac.new(
        SIGNING_SECRET.encode(), signed, hashlib.sha256
    ).hexdigest()
    if not hmac.compare_digest(expected, sig):
        raise HTTPException(401, "Bad signature")

    # Safe to parse + process
    import json
    event = json.loads(raw)
    return {"ok": True}

Critical gotchas

Many frameworks (Express, FastAPI) auto-parse JSON bodies. If you sign the parsed-then-reserialized body, whitespace or key-order drift breaks the signature. Always sign the raw bytes.
The 5-minute replay window requires NTP sync on your host. A drift of more than ±5 min rejects every delivery. If you see 401: Replay window exceeded, check your server’s clock first.
Don’t use == to compare signatures — it short-circuits on the first different byte and leaks information to an attacker probing your endpoint. Use hmac.compare_digest / crypto.timingSafeEqual as shown above.
Retries use the same event_id. Your handler should key on that — store a seen_event_ids set (Redis + TTL matches our 7.5h retry window) and skip duplicates. Skipping is fine: respond 200 without reprocessing.