> ## Documentation Index
> Fetch the complete documentation index at: https://docs.zeroclick.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Account and key reference

> The complete wire contract for stateful sellers: request and response bodies, field meanings, handler return values, retry and timeout behavior, and the signature for stacks without an SDK.

Every detail behind [sell accounts and API keys](/integrate/stateful-sellers). Read that guide first — this page is for looking things up once you are building.

## Configuring the endpoint

ZeroClick derives the endpoint from the **origin** of your upstream base URL plus `/zeroclick/access`. Any path on the base URL is dropped: `https://api.example.com/v1` gives `https://api.example.com/zeroclick/access`.

To host it elsewhere, set an override:

```sh theme={null}
curl -X PATCH https://api.zeroclick.io/v1/sellers/{sellerId}/stateful-access \
  -H "authorization: Bearer $ZEROCLICK_API_KEY" \
  -H "content-type: application/json" \
  -d '{"accessEndpointUrl":"https://accounts.example.com/hooks/zeroclick"}'
```

`GET` the same path returns `stateful` (is this storefront designated to sell accounts) and the current `accessEndpointUrl`.

The mint route is always the write route plus `/{accessId}/keys`.

## Request headers

Both calls arrive as `POST` with:

| Header           | Meaning                                                                                                                                                                                                                                                                                                                                                                      |
| ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `zc-signature`   | `t=…,kid=…,v1=…`. A `t` more than 5 minutes old or in the future is rejected.                                                                                                                                                                                                                                                                                                |
| `zc-agent-id`    | The agent making this call — different calls to one account may carry different values. Sent again as `zc-anonymous-id` with the same value.                                                                                                                                                                                                                                 |
| `zc-buyer-id`    | The person or company behind the agent (`byr_…`), once the credential has been claimed; absent until then. Not covered by the signature — it mirrors the body's `buyerId`, which is, and the SDK rejects a disagreement with `400 buyer_mismatch`.                                                                                                                           |
| `zc-buyer-email` | The buyer's verified email, sent on account writes when the purchased plan's [verified email policy](/concepts/plans-and-pricing#the-verified-email-policy) is `requested` or `required` and the email exists; absent otherwise. Like `zc-buyer-id` it is an unsigned mirror of the body's `buyerEmail`, and the SDK rejects a disagreement with `400 buyer_email_mismatch`. |
| `zc-request-id`  | Stable across retries of the same call. For account writes it is `{accessId}:{stateVersion}`.                                                                                                                                                                                                                                                                                |

## The account write

```json POST /zeroclick/access theme={null}
{
  "accessId": "zacc_8h2m4x0q9k1f",
  "agentId": "agt_x7f2kq93bh0d",
  "buyerId": "byr_3n8v1c6t5j2w",
  "buyerEmail": "casey@example.com",
  "idempotencyKey": "zacc_8h2m4x0q9k1f:3",
  "stateVersion": 3,
  "plan": {
    "slug": "research-pro",
    "name": "Research Pro",
    "billingMode": "subscription_usage",
    "interval": "month",
    "basePriceUsd": "20.000000"
  },
  "state": {
    "period": { "start": "2026-08-01T00:00:00Z", "end": "2026-09-01T00:00:00Z" },
    "lifetimeCreditGrantedUsd": "45.000000",
    "lifetimeCreditReversedUsd": "0.000000",
    "status": "active"
  }
}
```

| Field                             | What it means                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              |
| --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `accessId`                        | The durable ZeroClick account handle, `zacc_…`. **Your account key**, stable for the life of the account.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  |
| `agentId`                         | The agent making this call, `agt_…` — may differ between writes to one account, so never key on it. Always equal to the signed `zc-agent-id`; the SDK rejects the request with `400` if they disagree.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                     |
| `buyerId`                         | The person or company behind the agent (`byr_…`), or `null` until the credential is claimed — it can appear partway through an account's life. Covered by the body signature; the same value proxied traffic carries as `zc-buyer-id`, so store it if you keep buyer-keyed customer records.                                                                                                                                                                                                                                                                                                                                                                                               |
| `buyerEmail`                      | The buyer's verified email, or `null` unless the purchased plan's [verified email policy](/concepts/plans-and-pricing#the-verified-email-policy) asks for it and the email exists. Under the `requested` policy it can appear **partway through an account's life**: when the human claims after the purchase, ZeroClick sends a fresh write, one `stateVersion` higher, whose only news is this field — apply it like any other write. Covered by the body signature and mirrored unsigned as `zc-buyer-email`. The recommended key for tying this account to a user account on your side — see [the integration guide](/integrate/stateful-sellers#tying-the-account-to-a-user-account). |
| `stateVersion`                    | A counter that goes up every time ZeroClick sends a newer picture of this account. **Apply a write only if this is higher than the last version you stored.** Equal or lower is a retry you have already handled.                                                                                                                                                                                                                                                                                                                                                                                                                                                                          |
| `idempotencyKey`                  | `{accessId}:{stateVersion}`, the same value as `zc-request-id`. Keep it for your audit log; `stateVersion` is what you deduplicate on.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                     |
| `plan.billingMode`                | `subscription`, `subscription_usage`, `credit`, or `free_trial` — see [plans and pricing](/concepts/plans-and-pricing#the-five-billing-modes). Key trial behavior off this mode, never off a slug convention.                                                                                                                                                                                                                                                                                                                                                                                                                                                                              |
| `plan.interval`                   | `none`, `day`, `month`, or `year`.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                         |
| `plan.basePriceUsd`               | What the plan charges per period.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          |
| `state.period`                    | The window this plan is paid for. `end` is `null` for plans with no expiry.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                |
| `state.lifetimeCreditGrantedUsd`  | **Not the remaining balance, and not the size of this top-up** — the cumulative total for the account. Each payment mints what its plan's billing mode says: a `subscription_usage` purchase mints its `includedCreditUsd`, not its price, and a `free_trial` claim mints the trial's `includedCreditUsd` with no payment. `null` if the plan has no credit component — on a `subscription` plan both credit totals are `null`.                                                                                                                                                                                                                                                            |
| `state.lifetimeCreditReversedUsd` | **What card refunds and chargebacks have pulled back** — cumulative like the grant total, not the size of one refund. `null` if the plan has no credit component, exactly as the grant is.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                 |
| `state.status`                    | `active`, `suspended`, or `closed`. `suspended` is recoverable: suspend service, delete nothing — a later write may return the account to `active`. `closed` is terminal, sent when the account is torn down on the ZeroClick side: revoke live API keys, stop serving, keep your own records — no higher `stateVersion` will ever follow.                                                                                                                                                                                                                                                                                                                                                 |

Every declared field is always present in the body — explicitly `null` when unset, never omitted. Every money field is a string with exactly six decimal places. `parseMoneyUsd` converts one to an integer count of millionths of a dollar ("micros") so you never do floating-point math on money; `formatMoneyUsd` converts back.

### Why the write is a complete picture

The write describes **how the account should look**, not what changed. Replay it, receive it twice, or receive an old one late: applying the newest version you have seen always leaves the account correct. That is why `lifetimeCreditGrantedUsd` and `lifetimeCreditReversedUsd` are running totals rather than deltas.

`deriveCreditDelta` nets the two totals into one purse movement, and returns one of four outcomes:

| Outcome               | What to do                                                                                                            |
| --------------------- | --------------------------------------------------------------------------------------------------------------------- |
| `credit`              | Add `deltaUsdMicros` to the balance, then store `next` as the new recorded totals.                                    |
| `debit`               | A refund or chargeback clawed money back. Subtract `deltaUsdMicros`, clamping the balance at zero, then store `next`. |
| `no_credit_dimension` | This plan has no credit component. Store `next`; leave the balance alone.                                             |
| `replay`              | You have already applied this version or a newer one. Do nothing.                                                     |

### When the period advances

A renewal arrives as an ordinary write whose `state.period.start` differs from the one you stored — `periodAdvanced` compares the two. That is your cue to reset any **per-period counters of your own** (a monthly quota, included usage you meter yourself). The credit totals are the asymmetry: they are lifetime running totals and never reset, which is exactly what lets `deriveCreditDelta` stay correct across renewals. Reset quotas on a new period; never reset the recorded grant and reversal totals.

### Deciding whether to serve

`isServiceable` (`is_serviceable` in Python, `IsServiceable` in Go, `serviceable?` in Ruby) is the one request-time gate: it answers `true` only while the stored `status` is `active` **and** the period, when its `end` is non-`null`, has not lapsed. Gate every authenticated request on it rather than combining `status` and the period yourself — `status` alone misses period-based revocation on interval plans, and the period alone misses suspension and closure. Period expiry has no push: nothing arrives at period end, so the check must be local and time-based, against the state you stored.

## The key mint

```json POST /zeroclick/access/zacc_8h2m4x0q9k1f/keys theme={null}
{ "agentId": "agt_x7f2kq93bh0d", "buyerId": "byr_3n8v1c6t5j2w" }
```

A successful response returns the key once. `maxKeys` tells ZeroClick how many live keys this account may hold (the SDK sends `1` automatically when `remintPolicy` is `"rotating"`), and `keyExpiresAt` lets ZeroClick tell the buyer when to come back for a new one. Both are optional.

```json 200 theme={null}
{
  "apiKey": "sk_live_…",
  "maxKeys": 1,
  "keyExpiresAt": "2027-08-01T00:00:00Z"
}
```

ZeroClick passes the key straight to the buyer and never stores it. If the buyer needs it again, they ask for a new one and you mint again under your `remintPolicy`.

## The usage read (required for automatic top-ups)

```http GET /zeroclick/access/zacc_8h2m4x0q9k1f/usage theme={null}
```

Sent with `zc-signature`, `zc-agent-id` and `zc-request-id` headers, an empty body, and a signature whose purpose is `access.read`, so a write or mint signature can never be replayed as a read. ZeroClick calls it for accounts with an active top-up authorization (about every two minutes) and when a buyer enrolls in top-ups. Renewal-only sellers do not need this route. [Pushed balance reports](/integrate/stateful-sellers#automatic-top-ups-reporting-the-balance) supplement polling. Without an `onRead` handler the TypeScript SDK answers `404 {"error":"balance_read_unavailable"}`.

```json 200 theme={null}
{
  "accessId": "zacc_8h2m4x0q9k1f",
  "stateVersion": 7,
  "remainingCreditUsd": "3.250000",
  "observedAt": "2030-02-28T12:00:00.000Z"
}
```

| Field                | Meaning                                                                                                                                  |
| -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
| `accessId`           | Must echo the account in the path.                                                                                                       |
| `stateVersion`       | The last account write you applied, read together with the balance. A reading for an older version is discarded.                         |
| `remainingCreditUsd` | Spendable credit, six decimal places, never negative.                                                                                    |
| `observedAt`         | When you read the balance (normally now), with a UTC offset. Discarded if more than 5 minutes old or more than 30 seconds in the future. |

Answer `404` for an account you do not know. The read must not mint keys or change state. Timeout is 5 seconds; a failing read is retried at a slower pace and never triggers a charge.

## What your handlers receive

`WriteInput`, passed to `onWrite`:

| Field         | Meaning                                                                                                                                                                                                                                                               |
| ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `accessId`    | The durable ZeroClick account handle. Key your account table on this.                                                                                                                                                                                                 |
| `agentId`     | The agent making this call; may differ between calls to one account. Already checked against the signed `zc-agent-id`.                                                                                                                                                |
| `buyerId`     | The body's `buyerId`: the owner behind the agent, or `null` until the credential is claimed. Already checked against the `zc-buyer-id` header.                                                                                                                        |
| `buyerEmail`  | The body's `buyerEmail`: the buyer's verified email, or `null` unless the plan requires one. Already checked against the `zc-buyer-email` header.                                                                                                                     |
| `entitlement` | The parsed request body, plus `basePriceUsdMicros`, `lifetimeCreditGrantedUsdMicros`, and `lifetimeCreditReversedUsdMicros` as integers, and `period`, `status`, `lifetimeCreditGrantedUsd`, and `lifetimeCreditReversedUsd` lifted to the top level for convenience. |
| `requestId`   | The `zc-request-id` header.                                                                                                                                                                                                                                           |
| `dedupeKey`   | `write:{requestId}`. A ready-made unique key if you keep a table of processed callbacks; deduplicating on `stateVersion` is still the rule that protects the account state.                                                                                           |
| `kid`         | Which signing secret verified this request. Useful for logging a rotation.                                                                                                                                                                                            |

`MintInput`, passed to `onMint`, is the same minus `entitlement`, with `dedupeKey` of `mint:{requestId}`. Its `accessId` comes from the URL path rather than a body.

## What your handlers can return

`onWrite`:

| Return                                             | Status | What ZeroClick does                                                                                |
| -------------------------------------------------- | ------ | -------------------------------------------------------------------------------------------------- |
| `{ lifecycle: "active" }`                          | `200`  | Acknowledges the applied state. ZeroClick finalizes the payment; the buyer may then request a key. |
| `{ lifecycle: "provisioning", retryAfterSeconds }` | `200`  | Sends the same write again after the hint, capped at 30 minutes.                                   |
| *throws*                                           | `503`  | Treated as temporary and retried. Your `onHandlerError` callback fires first.                      |

`onMint`:

| Return                                        | Status | What ZeroClick does                                                                        |
| --------------------------------------------- | ------ | ------------------------------------------------------------------------------------------ |
| `{ apiKey, keyExpiresAt? }`                   | `200`  | Delivers the key to the buyer, with `cache-control: no-store`.                             |
| `{ notProvisioned: true, retryAfterSeconds }` | `409`  | The account exists but is not ready; the buyer is told to retry, default 5 seconds.        |
| `{ unknown: true }`                           | `404`  | No such account. The mint fails — use this only when the account genuinely does not exist. |
| `{ conflict: { code } }`                      | `409`  | Your own refusal, with your code in the body.                                              |
| *throws*                                      | `503`  | The mint fails and `onHandlerError` fires.                                                 |

Before either handler runs, the SDK answers `401` on a missing, stale, or invalid signature, and `400` if the body is malformed, its `agentId` does not match the signed `zc-agent-id`, its `buyerId` does not match the `zc-buyer-id` header, or its `buyerEmail` does not match the `zc-buyer-email` header.

## Retries, timeouts, and failures

Account writes are **at-least-once**. Expect duplicates, and expect a retry after a network timeout that happened *after* you committed.

|                         | Behavior                                                                                                                                                                     |
| ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Response budget         | **7 seconds** for both calls. Anything slower belongs behind `lifecycle: "provisioning"`.                                                                                    |
| Retried                 | Network failure, `429`, any `5xx`, and a `200` whose body cannot be parsed.                                                                                                  |
| **Not** retried         | Any other non-2xx, including `400`, `403`, and `404`. A bug that returns `400` permanently fails the delivery — return `503` when unsure.                                    |
| Backoff                 | Starts at 5 seconds and doubles with jitter, to a 30-minute ceiling. A `retryAfterSeconds` hint moves an attempt later, never sooner.                                        |
| Retry hints             | A `Retry-After` header on a `429`/`5xx`, or `retryAfterSeconds` in a `200` body. Both are capped at 30 minutes.                                                              |
| Giving up               | After 20 attempts or 24 hours, whichever comes first.                                                                                                                        |
| Redirects               | Not followed. Serve the account routes directly.                                                                                                                             |
| Signing-secret rotation | If your endpoint answers `401`, ZeroClick retries once with your newest active secret. See [rotating a signing secret](/integrate/keys-and-secrets#rotate-a-signing-secret). |

When a delivery permanently fails, **the buyer is made whole automatically**: a reserved payment is released, a captured one is refunded. You never owe a refund for an account you did not create. If the refund itself fails, ZeroClick flags the account internally and follows up — there is nothing for you to do.

Because ZeroClick considers the write applied when you answer `200` with `lifecycle: "active"`, do not start anything irreversible after that point.

## Buyer-facing routes on your pay URL

Your customer's agent uses these to manage a purchase, all on your pay URL (`https://acme.pay.zeroclick.io`) and all authenticated with the agent's own bearer token. You do not implement them — ZeroClick does — but you may want to point agents at them.

| Route                    | What it does                                                                                                                                                                                                                                                                                                                                                      |
| ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `GET /purchases/receipt` | Lists every purchase this buyer holds with you: `accessId`, `provisioningState`, plan, access period, delivery status, plus a `redeemUrl` and a `topUpUrl` for each.                                                                                                                                                                                              |
| `POST /keys/redeem`      | Asks for an API key, which triggers your mint handler. `accessId` may be omitted when the buyer holds exactly one account with you.                                                                                                                                                                                                                               |
| `POST /extend`           | Tops up active access. For expired access, inspect the eligible purchase contract and recover the existing account before purchasing. `accessId` travels in the request body or in the URL's query — POSTing to the `topUpUrl` above as-is works, and a body value wins if both are present. It may be omitted entirely when the buyer holds exactly one account. |

## Free plans

A `free_trial` plan creates an account with no payment. By default its [verified email policy](/concepts/plans-and-pricing#the-verified-email-policy) is `required` — that is what stops one person minting unlimited free accounts — so its write arrives with `buyerEmail` set, and each human can claim it once. A seller may relax the policy per plan, accepting that unclaimed agents can then open trial accounts (the email then arrives on a later write once the human claims, under `requested`). Once created, the free account is durable and recoverable across the buyer's agents, just like a paid account.

## Signature, for stacks without an SDK

The stateful helpers ship in the TypeScript (`@zeroclickai/sellers/stateful`), Python (`zeroclick_sellers.stateful`), Go (`cdn.zeroclick.io/sdks/sellers-go/stateful`), and Ruby (`zeroclick/sellers/stateful`) SDKs. On any other stack, implement the two routes and verify the signature yourself.

It is HMAC-SHA256 over these seven lines joined by `\n` — the [ordinary canonical string](/integrate/signature-spec#canonical-string) plus a trailing purpose:

```
{timestamp}
{METHOD}
{path and query, unnormalized}
{lowercase hex SHA-256 of the raw body}
{zc-request-id}
{zc-agent-id}
{access.write | access.mint}
```

| Route                             | Purpose        |
| --------------------------------- | -------------- |
| `POST {endpoint}`                 | `access.write` |
| `POST {endpoint}/{accessId}/keys` | `access.mint`  |

Select the purpose from **the route you matched**, never from anything in the request. That is what stops a signature captured from ordinary proxied traffic from authorizing an account write. Compare against the `v1=` value in constant time, and reject a `t=` more than 5 minutes old or in the future.

The header grammar, parsing rules, and raw-bytes rules are exactly those of the [signature spec](/integrate/signature-spec) — only the trailing purpose line is new. Handler results map to the statuses in [what your handlers can return](#what-your-handlers-can-return); refuse a failed verification with a `401` and `{"error":"missing_or_malformed_signature"}`, `{"error":"stale_timestamp"}`, `{"error":"unknown_kid"}`, or `{"error":"invalid_signature"}`.

### Worked verifiers

A compact verifier in the standard library — a porting reference for stacks without an SDK. (On Python and Go themselves, prefer the SDK's `verify_access_signature` / `stateful.Verify`.) Pass the purpose from the route you matched:

<CodeGroup>
  ```python Python theme={null}
  import hashlib, hmac, os, re, time

  SIGNING_SECRETS = {"hsec_k5nq0v7m3d8p": os.environ["ZEROCLICK_SIGNING_SECRET"]}
  TOLERANCE_SECONDS = 300

  def verify_access(method, raw_path_and_query, raw_body, headers, purpose):
      """purpose is "access.write" or "access.mint", from the matched route."""
      header = headers.get("zc-signature")
      if header is None:
          return None
      members = {}
      for entry in header.split(","):
          key, sep, value = entry.partition("=")
          key = key.strip()
          if not sep or not key or key in members:  # malformed or duplicate member
              return None
          members[key] = value.strip()              # unknown members are ignored
      t, kid, v1 = members.get("t"), members.get("kid"), members.get("v1")
      if not (t and kid and v1):
          return None
      if not re.fullmatch(r"\d{1,20}", t) or not re.fullmatch(r"[0-9a-f]{64}", v1):
          return None
      if abs(int(time.time()) - int(t)) > TOLERANCE_SECONDS:
          return None
      request_id = headers.get("zc-request-id")
      if not request_id:
          return None
      agent_id = headers.get("zc-agent-id") or ""
      secret = SIGNING_SECRETS.get(kid)
      if secret is None:
          return None
      canonical = "\n".join([
          t,
          method.upper(),
          raw_path_and_query,                       # as sent, never URL-decoded
          hashlib.sha256(raw_body).hexdigest(),
          request_id,
          agent_id,
          purpose,                                  # the seventh line
      ])
      expected = hmac.new(secret.encode(), canonical.encode(), hashlib.sha256).hexdigest()
      if not hmac.compare_digest(expected, v1):
          return None
      return {"zc_request_id": request_id, "zc_agent_id": agent_id}
  ```

  ```go Go theme={null}
  var signingSecrets = map[string]string{
  	"hsec_k5nq0v7m3d8p": os.Getenv("ZEROCLICK_SIGNING_SECRET"),
  }

  const toleranceSeconds = 300

  // purpose is "access.write" or "access.mint", from the matched route.
  func verifyAccess(r *http.Request, rawPathAndQuery string, rawBody []byte, purpose string) bool {
  	members := map[string]string{}
  	for _, part := range strings.Split(r.Header.Get("zc-signature"), ",") {
  		key, value, found := strings.Cut(part, "=")
  		key = strings.TrimSpace(key)
  		if !found || key == "" {
  			return false // malformed member
  		}
  		if _, duplicate := members[key]; duplicate {
  			return false // duplicate member; unknown members are ignored
  		}
  		members[key] = strings.TrimSpace(value)
  	}
  	t, kid, v1 := members["t"], members["kid"], members["v1"]
  	if t == "" || len(t) > 20 || kid == "" || len(v1) != 64 {
  		return false
  	}
  	timestamp, err := strconv.ParseUint(t, 10, 64)
  	if err != nil {
  		return false
  	}
  	if delta := time.Now().Unix() - int64(timestamp); delta > toleranceSeconds || delta < -toleranceSeconds {
  		return false
  	}
  	requestID := r.Header.Get("zc-request-id")
  	if requestID == "" {
  		return false
  	}
  	agentID := r.Header.Get("zc-agent-id")
  	secret, ok := signingSecrets[kid]
  	if !ok || secret == "" {
  		return false
  	}
  	bodyDigest := sha256.Sum256(rawBody)
  	canonical := strings.Join([]string{
  		t,
  		strings.ToUpper(r.Method),
  		rawPathAndQuery, // as sent, never URL-decoded
  		hex.EncodeToString(bodyDigest[:]),
  		requestID,
  		agentID,
  		purpose, // the seventh line
  	}, "\n")
  	mac := hmac.New(sha256.New, []byte(secret))
  	mac.Write([]byte(canonical))
  	expected, err := hex.DecodeString(v1)
  	if err != nil {
  		return false
  	}
  	return hmac.Equal(expected, mac.Sum(nil))
  }
  ```
</CodeGroup>

The dedup, never-regress, and credit-delta arithmetic from [the account write](#the-account-write) still apply on these stacks — implement `shouldApply`, `isServiceable`, and `deriveCreditDelta` from their descriptions there, and keep money in integer micros, never floats.

### Worked test vector

The ids and secret below are test fixtures in the style of the [signature spec's vector](/integrate/signature-spec#worked-test-vector); production values look the same.

Signing secret:

```text theme={null}
kid:    hsec_k5nq0v7m3d8p
secret: zcsec_vector_secret_do_not_use_in_production
```

Request:

```text theme={null}
POST /zeroclick/access
zc-request-id: zacc_8h2m4x0q9k1f:3
zc-agent-id: agt_x7f2kq93bh0d
zc-signature: t=1760000000,kid=hsec_k5nq0v7m3d8p,v1=f988cc7ebd64478e75502297a81d7e85d3c41535d2ed8c88c21024e21eb0c477
```

Body (exactly these 430 bytes, one line, no trailing newline):

```json theme={null}
{"accessId":"zacc_8h2m4x0q9k1f","agentId":"agt_x7f2kq93bh0d","buyerId":null,"buyerEmail":null,"idempotencyKey":"zacc_8h2m4x0q9k1f:3","stateVersion":3,"plan":{"slug":"research-pro","name":"Research Pro","billingMode":"credit","interval":"none","basePriceUsd":"20.000000"},"state":{"period":{"start":"2026-08-01T00:00:00Z","end":null},"lifetimeCreditGrantedUsd":"45.000000","lifetimeCreditReversedUsd":"0.000000","status":"active"}}
```

Its SHA-256 is `19c2447c4bcd377389b0c7f06660b54e33eacb06a39b6c8ffdfed39936b66e3c`, and the matched route is the write route, so the canonical string is:

```text theme={null}
1760000000
POST
/zeroclick/access
19c2447c4bcd377389b0c7f06660b54e33eacb06a39b6c8ffdfed39936b66e3c
zacc_8h2m4x0q9k1f:3
agt_x7f2kq93bh0d
access.write
```

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.

Your implementation must also refuse two signatures over the same request. Signed with no purpose line — what ZeroClick sends with ordinary proxied traffic — `v1` is `70969f4352049b371bfd6fcff35eb4409c12cc08c734ad0889a79de1708e54a6`; your access verifier must reject it. Signed for the other purpose (`access.mint`), `v1` is `1c3fb09f2d2983a1f725d5459ea2743c61de2aaf79699e171bfd7d15ce0b196e`; a mint signature must never authorize a write, nor the reverse.
