Skip to main content
Every request ZeroClick forwards to a seller’s API carries a zc-signature header: an HMAC-SHA256 over the request, keyed by the seller’s signing secret. This page is the normative specification for implementers writing their own verifier. If you use a seller SDK, it already implements every rule here. See verify requests. For the surrounding flow, see the REST walkthrough.

Header grammar

The value is a comma-separated list of key=value members:
Sandbox traffic appends sb=1:
Parsing rules:
  • Split the value on ,. Each member is key=value. Trim whitespace around a key or value.
  • Reject a member with no = or an empty key as malformed.
  • Reject duplicate member keys as malformed. Do not skip this rule.
  • Ignore unrecognized member keys. ZeroClick extends this header additively (sb=1 is one such member), so a parser that demands exactly three members fails all sandbox traffic.
  • After parsing, t, kid, and v1 must all be present and pass their validations; anything else is malformed.

Canonical string

The signed bytes are six fields joined by \n (LF, 0x0A), with no trailing newline:
  1. Timestamp: the header’s t value byte-for-byte as received. Never re-parse and re-format it.
  2. Method: the HTTP method, uppercased.
  3. Request target: the raw percent-encoded path plus query exactly as sent, with no scheme or host. /v1/items/a%2Fb%20c stays /v1/items/a%2Fb%20c. Never the decoded path: frameworks commonly hand you one, and it hashes to a different string. Preserve query order and repeated keys verbatim.
  4. Body digest: lowercase hex SHA-256 of the exact body bytes as received. An empty body hashes to e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855. Re-serialized JSON will not match: the digest covers bytes, not meaning. Hash an encrypted body as the ciphertext it arrived as. Verify before you decrypt.
  5. Request id: the zc-request-id header value.
  6. Agent id: the zc-agent-id header value, or the empty string when the header is absent. An absent header and a present-but-empty one sign identically; both mean a signed anonymous probe, which is valid.
The signature is HMAC-SHA256(secret, canonical string), lowercase hex, with the secret and canonical string both UTF-8 encoded. The secret is the one whose id equals kid.

Verification algorithm

  1. Read zc-signature. Absent → missing_signature.
  2. Parse it per the grammar above. Any violation → malformed_signature.
  3. Parse t as an integer. If |now − t| exceeds the tolerance (300 seconds by default, applied in both directions) → stale_timestamp.
  4. Read zc-request-id. Absent or empty → missing_request_id.
  5. Read zc-agent-id; treat absent as the empty string.
  6. Resolve the secret for kid. No secret with that id → unknown_kid. Run steps 1 to 5 first so an unauthenticated request never reaches your key store. A lookup that fails (a vault being unreachable) is an operational error, not a refusal.
  7. Compute the expected HMAC over the canonical string. Compare it to v1 in constant time (hmac.compare_digest, crypto.timingSafeEqual, hmac.Equal). Mismatch → invalid_signature.
  8. Otherwise the request is verified: trust zc-request-id and zc-agent-id.

Failure taxonomy

Every reason maps to the same seller response, one 401:
The body never says which check failed. Keep the reason in your own logs.

Worked test vector

This vector comes from the cross-SDK vector suite (the identified_buyer case), which every seller SDK executes. The full suite ships with the SDKs as signing-vectors.json. The ids and secret below are test fixtures. Production values look like hsec_…, zcreq_…, and agt_…. Signing secret:
Request:
Body (exactly these 36 bytes, no trailing newline):
Its SHA-256 is b07172b06310bf8dc76e9427aa33af6c61894b2a2abc1524a980c2b6bb7bd35e, giving the canonical string:
Expected result: with the verifier’s clock pinned to 1760000000 and tolerance 300, the HMAC-SHA256 of the canonical string with the secret equals the header’s v1. Verification succeeds with request id zcreq_vector and agent id zcagent_vector. Your implementation must also pass two checks. With zc-agent-id absent, the same request verifies only against v1=326b303be6b1ca89598b3b572961bce3b999c00cbf8333a71cc1c7f9f3d6566c (the empty-string sixth field). Your verifier must reject a header that carries v1 twice as malformed, even when both copies are correct.