Skip to main content
Your upstream URL is reachable from the internet; the zc-signature header is the gate. Verification proves a request came through ZeroClick (payment handled, buyer resolved) and that nothing changed in transit. Every SDK guard verifies before it does anything else. A request that fails must get 401 {"error":"invalid_zeroclick_signature"}, never your handler.

What the SDKs check

Verification runs entirely inside your process, with no network call:
  1. Parse zc-signature (t=<unix seconds>,kid=<hsec_…>,v1=<64 lowercase hex>). Ignore unknown fields: ZeroClick appends fields additively, such as ,sb=1 on sandbox traffic.
  2. Reject timestamps outside the clock tolerance: 300 seconds by default, in either direction.
  3. Look up the signing secret by the signature’s kid. An unknown kid is a refusal, so rotation means holding both kids.
  4. Recompute the HMAC-SHA256 over the canonical string and compare in constant time. The canonical string is the timestamp, the uppercased method, the raw path and query, the SHA-256 of the raw body bytes, zc-request-id, and zc-agent-id (empty string when absent).
The signature spec specifies the exact bytes. Failures are decisions, not exceptions: the guard returns a deny carrying the 401, with a machine-readable reason (missing_signature, malformed_signature, stale_timestamp, missing_request_id, unknown_kid, or invalid_signature) for your logs. The 401 body itself says nothing about why the request failed.

Signed anonymous probes are valid

A verified request with no zc-agent-id (or an empty one) is a signed anonymous probe, and it is how pay-as-you-go pricing works. For an unpaid call, ZeroClick forwards a probe to your API. Your guard denies it with the 402 payment_required usage body. ZeroClick re-prices that refusal into the one priced challenge the agent pays. Probes are expected traffic, not an attack, and only the paid retry carries the agent id.

The signature covers the raw path

The canonical string contains the raw, percent-encoded path and query exactly as ZeroClick sent it. Every web framework hands you a decoded path, and a decoded path hashes differently. For GET /v1/items/a%2Fb%20c: The SDKs handle this at the boundary. The TypeScript SDK reads the web-standard Request URL, which preserves encoding. The Python adapters (zc_request_from_asgi_scope, zc_request_from_wsgi_environ) read the raw target and handle the difference: ASGI’s raw_path excludes the query string, while WSGI’s RAW_URI includes it. Go’s FromHTTP reads r.RequestURI, which Go leaves untouched.
A reverse proxy, ingress controller, or managed load balancer in front of your server may normalize the path (%2F becoming /) before your server sees it. If that happens, every request with an encoded path segment fails verification. If you route through nginx, an ingress, or a load balancer, confirm it passes the request target through unmodified.
On WSGI, if the server sets neither RAW_URI nor REQUEST_URI, an encoded separator cannot be recovered: the server decodes %2F before the SDK runs. gunicorn, werkzeug, uWSGI, and nginx all set one of them.

Verify the raw body bytes

The body digest covers the bytes exactly as they arrived, before any parsing or transformation. Adapt framework requests without re-encoding the body. If your service opts into encrypted bodies, verify first: the signature covers the ciphertext, so decryption comes after the guard. In Go, the Meter and Identify middleware read the body for verification and then restore r.Body, so your handler reads it normally.

Lower-level verification

guard is the right entry point for a billable route. When verification and allowance checking live in separate middleware layers (verify once at the edge, check allowances per route), use the verify-only entry point. In TypeScript, it reads a clone of the Request, so the body stays available to your handler. In Python and Go, you hand it the raw bytes you already hold.
The verified context carries zcRequestId and zcAgentId: everything the allowance check and usage settlement need.

Who called, and who they belong to

zcAgentId is always the agent that made this call. When ZeroClick knows the owner behind that agent, the request also carries zc-buyer-id (byr_…), exposed by the TypeScript and Python SDKs as zcBuyerId / zc_buyer_id. One buyer can hold several agents, and they all share the buyer’s entitlements, so the buyer id is what stays stable when a customer rotates or replaces an agent. Key durable per-customer records on the buyer id when it is present and per-caller state on the agent id. An absent buyer id means an anonymous agent: still identified and billable, just not yet attached to an owner. Note that the signature covers zc-agent-id and not zc-buyer-id; the headers reference has the full matrix.