> ## 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 non-TypeScript stacks.

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 PUT 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 the current `accessEndpointUrl`, plus `salesEnabled` (can buyers make new purchases) and `servicingEnabled` (can existing customers still recover and rotate keys). Changing those two is not self-serve — email [help@zeroclick.ai](mailto:help@zeroclick.ai).

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 customer this account belongs to. Sent again as `zc-anonymous-id` with the same value.    |
| `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",
  "agent": "agt_x7f2kq93bh0d",
  "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" },
    "creditGrantedUsd": "45.000000",
    "creditReversedUsd": "0.000000",
    "status": "active"
  }
}
```

| Field                     | What it means                                                                                                                                                                                                     |
| ------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `accessId`                | The durable ZeroClick account handle, `zacc_…`. Your account key if you chose `accountKey: "accessId"`.                                                                                                           |
| `agent`                   | The customer, `agt_…`. Your account key if you chose `accountKey: "agent"`. Always equal to the signed `zc-agent-id`; the SDK rejects the request with `400` if they disagree.                                    |
| `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`, or `credit` — see [plans and pricing](/concepts/plans-and-pricing#the-four-billing-modes).                                                                                  |
| `plan.interval`           | `none`, `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.creditGrantedUsd`  | **The total ever granted for this account** — not the remaining balance, and not the size of this top-up. `null` if the plan has no credit component.                                                             |
| `state.creditReversedUsd` | **The total ever pulled back** by card refunds and chargebacks, cumulative like the grant total. Older writes omit it; treat missing as zero.                                                                     |
| `state.status`            | `active` or `suspended`. Suspend service on `suspended`; do not delete anything.                                                                                                                                  |

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 `creditGrantedUsd` and `creditReversedUsd` 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.                                                     |

## The key mint

```json POST /zeroclick/access/zacc_8h2m4x0q9k1f/keys theme={null}
{ "agent": "agt_x7f2kq93bh0d" }
```

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`.

## What your handlers receive

`WriteInput`, passed to `onWrite`:

| Field         | Meaning                                                                                                                                                                                                                               |
| ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `accountId`   | The id selected by your `accountKey` — the `agent` or the `accessId`. Key your account table on this.                                                                                                                                 |
| `accessId`    | Always the durable ZeroClick account handle, whichever `accountKey` you chose.                                                                                                                                                        |
| `agentId`     | Always the customer id, whichever `accountKey` you chose. Already checked against the signed `zc-agent-id`.                                                                                                                           |
| `entitlement` | The parsed request body, plus `basePriceUsdMicros`, `creditGrantedUsdMicros`, and `creditReversedUsdMicros` as integers, and `period`, `status`, `creditGrantedUsd`, and `creditReversedUsd` 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 30 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 or its `agent` does not match the signed `zc-agent-id`.

## 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 30 seconds and doubles with jitter, to a 30-minute ceiling.                                                                                                        |
| 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 or renews. The `topUpUrl` above is this route with `?accessId=…` already filled in.                                                                          |

## Free plans

A `$0` `subscription` or `subscription_usage` plan can create an account, but only for a buyer who has verified their email address — that is what stops one person minting unlimited free accounts. A buyer without a verified email cannot claim a live free plan. Once created, the free account is durable and recoverable across that verified buyer's agents, just like a paid account.

## Signature, for stacks without an SDK

The stateful helpers ship in the TypeScript SDK today. 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.
