Skip to main content
Every webhook delivery includes a Zest-Signature header that lets you authenticate the request before processing it. The scheme is HMAC-SHA256 over the timestamped raw body, modelled on Stripe’s signing format.

Signature header format

Future signature versions will append additional vN=... tokens; partners should match on the version they support and ignore others.

Algorithm

  1. Read the Zest-Signature header. Parse out t and v1.
  2. Reject if |now_unix - t| > 300 (5 minute replay window).
  3. Compute expected = hmac_sha256(secret, f"{t}.{raw_body}").
  4. Compare v1 to expected in constant time. Reject on mismatch.
The signing secret is the plaintext value of the whsec_<hex> string Zest issued you. Zest stores it AES-256-GCM-encrypted at rest and never logs it.
Compare bytes, not strings. Use hmac.compare_digest in Python or crypto.timingSafeEqual in Node. A naive == comparison leaks length/timing information.

Python

Node.js

Common pitfalls

Most JSON middleware mutates whitespace and key order. The signature is computed over the exact bytes Zest sent — verify before any parsing. Use express.raw() in Node, await request.body() in FastAPI/Starlette.
The signed payload is <t>.<body>, not <t><body>. Drop the period and every signature mismatches.
== reveals timing-side-channel info. Always use hmac.compare_digest / crypto.timingSafeEqual.
Without a window check, a captured request can be replayed forever. Reject if |now - t| > 300s.

Rotating secrets

Email sara@zestholdco.com to rotate. Zest will issue a new whsec_* and accept signatures from both old and new for a short overlap window.