> ## Documentation Index
> Fetch the complete documentation index at: /docs/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](/docs/integrate/stateful-sellers.md). 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](/docs/concepts/plans-and-pricing.md#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](/docs/concepts/plans-and-pricing.md#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](/docs/integrate/stateful-sellers.md#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](/docs/concepts/plans-and-pricing.md#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. |

For an automatically renewed Tempo plan, `plan.interval` remains the plan's `month` or `year` label, while the native payment permission charges every 30 or 365 elapsed days. The `state.period` dates in each write are the authoritative access window. Apply those dates instead of calculating a calendar-month or calendar-year end yourself. A top-up changes the cumulative credit totals in the same account; it does not create a second account.

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](/docs/integrate/stateful-sellers.md#automatic-top-ups-reporting-the-balance) supplement polling. Without an `onRead` handler the TypeScript SDK answers `404 {"error":"balance_read_unavailable"}`.

For Tempo automatic top-ups, a low balance alone does not authorize a charge. Tempo allows at most one refill of the approved amount per day; ZeroClick also checks the cooldown, monthly cap, and permission expiry. An immediate first top-up is an explicit choice when adding refills to an existing account and can occur before the balance falls below the threshold; other top-ups require a fresh low-balance reading.

```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](/docs/integrate/keys-and-secrets.md#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](/docs/concepts/plans-and-pricing.md#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](/docs/integrate/signature-spec.md#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](/docs/integrate/signature-spec.md) — 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:

```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))
}
```

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](/docs/integrate/signature-spec.md#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.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.