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

# Sell accounts and API keys

> Turn a ZeroClick purchase into a real account on your side, with a subscription, a credit balance, and an API key the buyer then uses with you directly.

Most ZeroClick integrations are stateless: a request arrives, you serve it, you report usage, and nothing is left behind. This page is for the other kind, where a purchase creates something that lasts — an account, a subscription, a credit balance, an API key.

Say you sell a \$20/month research API. A buyer purchases your plan through ZeroClick, and you want them to end up with an account in your database, a current billing period, and an API key they can put in their own code. That is what this integration does. ZeroClick handles buyer identity, payment, refunds, and letting a buyer recover their purchase later; your service stays the source of truth for the account.

## How it works

You build **one HTTPS endpoint**. ZeroClick makes two kinds of call to it.

<Steps>
  <Step title="ZeroClick tells you what the account should look like">
    After a purchase or a top-up, `POST /zeroclick/access` arrives with a complete picture of the account: which customer, which plan, what period it runs for, how much credit has been granted in total. You create or update the account and answer `200`.
  </Step>

  <Step title="ZeroClick asks you for an API key">
    When the buyer requests a key after the account is ready and the payment has gone through, `POST /zeroclick/access/{accessId}/keys` arrives. You generate a key, store its hash, and return the key once in plaintext.
  </Step>

  <Step title="The buyer calls you directly">
    ZeroClick hands the key to the buyer. From then on they talk to your API with your key, on your terms. ZeroClick does not sit in the middle of those calls and never stores the key.
  </Step>
</Steps>

Two calls in, one key out.

## Before you start

<Note>
  Selling accounts is a storefront-level designation made when the storefront is created — today we set that up with you, so email [help@zeroclick.ai](mailto:help@zeroclick.ai). You can build and test the endpoint before that.
</Note>

* **A plan that is not pay-as-you-go** — a `subscription`, `subscription_usage`, `credit`, or `free_trial` plan ([plans and pricing](/concepts/plans-and-pricing#the-five-billing-modes)). Pay-as-you-go buyers pay per call and leave nothing behind, so there is no account to create.
* **A signing secret.** ZeroClick signs every call with it and you verify that signature. Create one in the dashboard and keep its id (the `kid`) and value in your secret manager ([keys and secrets](/integrate/keys-and-secrets)).
* **An HTTPS endpoint.** By default it is `/zeroclick/access` on the origin of your upstream base URL; you can [point it somewhere else](/resources/stateful-access-reference#configuring-the-endpoint).
* **Durable storage** for accounts, the last version you applied, the credit you have granted in total, and your API key hashes.

<CodeGroup>
  ```sh TypeScript theme={null}
  pnpm add @zeroclickai/sellers
  ```

  ```sh Python theme={null}
  pip install zeroclick-sellers
  ```

  ```sh Go theme={null}
  go get cdn.zeroclick.io/sdks/sellers-go
  ```

  ```sh Ruby theme={null}
  bundle add zeroclick-sellers
  ```
</CodeGroup>

<Note>
  The stateful helpers ship in all four SDKs: `@zeroclickai/sellers/stateful`, `zeroclick_sellers.stateful`, the Go module's `stateful` sub-package, and Ruby's `zeroclick/sellers/stateful`. This guide's examples are TypeScript; the other three mirror them name for name. On any other stack, implement the same two routes by hand — the [reference](/resources/stateful-access-reference) has the full wire contract, the signature with worked verifiers, and a test vector to check yours against.
</Note>

## The three ids, and which one to key on

**Key your accounts on `accessId`** (`zacc_…`). It is the durable account handle ZeroClick mints for the customer at this seller: stable across renewals, top-ups, plan switches, and payment methods, present on every call, and never reused. One customer normally holds one per seller — a repeat purchase or plan switch updates the same account rather than opening a second one.

The other two ids describe **who is calling**, not the account:

* `agentId` (`agt_…`) is the per-call caller identity — the same id your stateless endpoints see as `zc-agent-id`. A customer's human can hold several agents, and any of them may act on the account, so **different calls to one account may carry different agent ids**. Verify it, log it, but never key on it.
* `buyerId` (`byr_…`) appears once the caller's credential has been claimed by its human — the same value proxied traffic carries as `zc-buyer-id`. Not a key either, but worth an ordinary column if you also keep [buyer-keyed customer records](/resources/headers#zc-agent-id-and-zc-buyer-id): it is what joins this account to them.

The remaining choice is your remint policy. `"rotating"` replaces the previous key on every mint — the one-live-key shape most APIs want; `"additive"` mints another:

```ts theme={null}
{ remintPolicy: "rotating" }
```

Whether the customer paid by card or from a crypto wallet is invisible to you, and so is anything else about how they paid. Key everything on `accessId`; never try to infer a customer from a payment.

## Tying the account to a user account

The ids above identify agents and buyers inside ZeroClick — none of them tells you which account on **your** side the customer is. **We strongly recommend making that link**: run your plans with the `requested` verified-email policy (or `required`, when your product cannot work without an email) and link on `buyerEmail`. It costs the buyer nothing under `requested`, and it means a human who later registers or signs in with the same email finds the entitlements their agents bought — able to see them, manage settings, and treat the purchase like any they made by hand. Without it, the resource stays reachable only through the agent that bought it.

Set the policy per plan ([plans and pricing](/concepts/plans-and-pricing#the-verified-email-policy)):

* **`requested`** — the recommended default when you want the email. Purchases are never blocked. When the buying agent is already claimed by a human with a verified email, the account write carries it immediately; when the agent is still anonymous, the purchase goes through without it, and **ZeroClick calls your endpoint again the moment the human claims** — a fresh account write, one `stateVersion` higher, identical except that `buyerEmail` is now set. Your existing write handler needs nothing new: apply the newest picture as always, and treat a `buyerEmail` appearing partway through an account's life as your cue to link.
* **`required`** — ZeroClick refuses the purchase (`403 verified_email_required`) until the claim exists, so every write for the account carries the email from the first version. Use it only when you cannot deliver anything without an email; it costs you the fully autonomous purchase.

Either way the email arrives as `buyerEmail` in the signed body, mirrored as the `zc-buyer-email` header (the SDKs hand it to your write handler as `buyerEmail` and reject a header/body disagreement for you). It is verified before it ever reaches you, so it is safe to key on. In your write handler:

* **The email matches an existing user** — attach the ZeroClick account (and the key you later mint) to that user, so the purchase shows up in the account they already have with you.
* **The email is new** — create a fresh user account for it, exactly as if they had signed up on your site, and provision the ZeroClick purchase into it.
* **You would rather wait** — store the email against the `accessId` and link the resource when the human registers or signs in with that address later; until then the agent still uses the minted key normally.

Any of the three keeps one truth: the human who verified that email owns the resource, whichever of their agents bought it. Plans with the policy `off` never carry `buyerEmail`, so those purchases stay anonymous, agent-held resources keyed only on `accessId`.

## Build the endpoint

`handleAccessRequest` routes, verifies, and validates both calls, then hands off to your two functions. It returns `null` for anything that is not one of its routes, so you can mount it inside an existing handler.

<Warning>
  Give the SDK the **raw request bytes** and the **original path and query**, before any framework parses or rewrites them. The signature covers those exact bytes, so a body parsed to JSON and re-serialized will not verify — the same rule as [verifying ordinary requests](/integrate/verify-requests#verify-the-raw-body-bytes).
</Warning>

```ts theme={null}
import {
  type AccessOperation,
  handleAccessRequest,
} from "@zeroclickai/sellers/stateful";

const options = {
  basePath: "/zeroclick/access",
  remintPolicy: "rotating" as const,
  secrets: {
    [process.env.ZEROCLICK_SIGNING_SECRET_KID!]:
      process.env.ZEROCLICK_SIGNING_SECRET!,
  },
  // `AccessOperation` ("write" | "mint" | "read") is exported by the SDK; typing
  // the union by hand breaks when a route is added.
  onHandlerError: (error: unknown, operation: AccessOperation) => {
    logger.error({ error, operation }, "ZeroClick access handler failed");
  },
};

export async function handle(request: Request): Promise<Response> {
  const rawBody = new Uint8Array(await request.clone().arrayBuffer());
  const url = new URL(request.url);

  const result = await handleAccessRequest(
    {
      method: request.method,
      pathAndQuery: `${url.pathname}${url.search}`,
      rawBody,
      headers: Object.fromEntries(request.headers),
    },
    { onWrite, onMint },
    options,
  );

  // Not one of ZeroClick's two routes: let your own router handle it.
  if (result === null) return new Response("Not found", { status: 404 });

  return new Response(
    result.body === undefined ? null : JSON.stringify(result.body),
    {
      status: result.status,
      headers: {
        ...(result.body === undefined
          ? {}
          : { "content-type": "application/json" }),
        ...result.headers,
      },
    },
  );
}
```

<Warning>
  Do not guard this endpoint with `guard`, `guardIdentity`, or `verifyRequest`. Those verify the signature on ordinary paid traffic, which is a *different* signature. Account calls carry an extra purpose, so a signature captured from normal proxied traffic can never be replayed against your account routes — but only if you keep the two verifiers apart.
</Warning>

## Create the account

The write is a complete description of how the account should look, not a list of changes — which makes duplicates and late arrivals harmless, as long as you follow two rules inside **one database transaction**:

1. **Ignore anything you have already applied.** `shouldApply` compares the incoming `stateVersion` against the one you stored.
2. **Never grant the same credit twice.** `lifetimeCreditGrantedUsd` is a running total, not the size of this top-up, so you add the *difference*. `deriveCreditDelta` computes it and tells you which of [three cases](/resources/stateful-access-reference#why-the-write-is-a-complete-picture) you are in.

```ts theme={null}
import { deriveCreditDelta, shouldApply } from "@zeroclickai/sellers/stateful";
import type { WriteInput } from "@zeroclickai/sellers/stateful";

async function onWrite({ accessId, entitlement }: WriteInput) {
  await database.transaction(async (tx) => {
    const account = await tx.lockAccount(accessId);
    if (!shouldApply(account?.stateVersion, entitlement.stateVersion)) return;

    const delta = deriveCreditDelta(
      account
        ? {
            stateVersion: account.stateVersion,
            lifetimeCreditGrantedUsdMicros:
              account.lifetimeCreditGrantedUsdMicros,
            lifetimeCreditReversedUsdMicros:
              account.lifetimeCreditReversedUsdMicros,
          }
        : null,
      entitlement,
    );

    if (delta.outcome === "credit") {
      await tx.addCredit(accessId, delta.deltaUsdMicros);
      await tx.storeGrant(accessId, delta.next);
    } else if (delta.outcome === "debit") {
      // A refund or dispute clawed money back; clamp the balance at zero.
      await tx.subtractCredit(accessId, delta.deltaUsdMicros);
      await tx.storeGrant(accessId, delta.next);
    } else if (delta.outcome === "no_credit_dimension") {
      await tx.storeGrant(accessId, delta.next);
    }

    await tx.applyPlanAndPeriod(accessId, entitlement);

    // Recommended: link the account to your own user account whenever the
    // verified email is present — it can arrive on a later write than the
    // first (see "Tying the account to a user account" above).
    if (entitlement.buyerEmail) {
      await tx.linkAccountToUser(accessId, entitlement.buyerEmail);
    }
  });

  return { lifecycle: "active" as const };
}
```

The row lock, the balance change, and the stored version must commit together — if they can commit separately, a retry landing between them grants credit twice. Never lower a total you have already recorded.

**Answer `active` only when the account really works.** If setup is slow — provisioning a tenant, warming an index — answer `{ lifecycle: "provisioning", retryAfterSeconds: 5 }` and ZeroClick sends the same write again until you say `active`. Hint the time you actually need: the hint only ever pushes the next attempt later, and the buyer waits out whatever you name.

Every field in the request body is documented in the [reference](/resources/stateful-access-reference#the-account-write).

## Mint the key

```ts theme={null}
import type { MintInput } from "@zeroclickai/sellers/stateful";

async function onMint({ accessId }: MintInput) {
  const plaintext = generateApiKey();
  const digest = hashApiKey(plaintext);

  const created = await database.transaction(async (tx) => {
    const account = await tx.lockActiveAccount(accessId);
    if (!account) return false;

    // Required when remintPolicy is "rotating"; skip it for additive keys.
    await tx.revokeLiveKeys(account.id);
    await tx.insertKeyHash(account.id, digest);
    return true;
  });

  return created ? { apiKey: plaintext } : { unknown: true as const };
}
```

* **Return the plaintext exactly once** and store only a hash. ZeroClick passes the key to the buyer and keeps no copy, so nobody can look it up again.
* **Hash it appropriately.** For a random key with real entropy — 32 bytes from a CSPRNG or better — a plain SHA-256 is right: fast enough to check on every request, with nothing to brute-force. Reach for a slow KDF like Argon2 only if your keys are short or human-chosen.
* **Never log it**, and keep it out of error messages. The SDK already sets `cache-control: no-store`.
* **Revoke and insert in one transaction** when rotating, or a retry can leave the customer with two live keys or none.

If the account is not ready yet, return `{ notProvisioned: true, retryAfterSeconds: 5 }` rather than an error. `{ unknown: true }` is only for an account that genuinely does not exist, and `{ conflict: { code } }` is for refusing with a reason of your own — a key cap reached, say. Every return value, and the status each becomes, is in the [reference](/resources/stateful-access-reference#what-your-handlers-can-return); the [inputs your handlers receive](/resources/stateful-access-reference#what-your-handlers-receive) are there too.

## Decide whether to serve

Once the key is out, every authenticated request to your API carries a serving decision, and it should be **one derived check** against the state you stored:

```ts theme={null}
import { isServiceable } from "@zeroclickai/sellers/stateful";

if (!isServiceable(account.state)) {
  return refuseInYourOwnErrorShape(account);
}
```

`isServiceable` answers `true` only while `status` is `active` **and** the period, when its `end` is non-`null`, has not lapsed. Do not hand-combine the two conditions: checking `status` alone misses period-based revocation on interval plans, and checking 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, on every request.

The two statuses that turn the gate off ask for different follow-through:

* **`suspended` is recoverable.** Suspend service and delete nothing; a later write may return the account to `active`.
* **`closed` is terminal.** ZeroClick sends it when the account is torn down on the ZeroClick side: revoke live API keys, stop serving, and keep your own records. No higher `stateVersion` will ever follow.

## When a customer runs out of credit

Once you have minted a key, the customer calls your API directly. ZeroClick is not in that path, so it never sees the moment a subscription lapses or a credit balance hits zero — **the refusal is yours, in your own error shape.** ZeroClick does not define an envelope for it, and you should not invent one that changes what your directly-signed-up customers already see. Same status, same body, same as ever.

What you can usefully add, *only* on the branch where you already know this account came from ZeroClick, is where to go to fix it. Every purchase has a top-up URL on your pay URL:

```
https://acme.pay.zeroclick.io/extend?accessId=zacc_8h2m4x0q9k1f
```

Putting that in your refusal is the difference between an agent retrying blindly and an agent topping up and carrying on. The URL works as handed out — an agent can `POST` to it verbatim, and the query string names the account — and your account key is exactly the id it needs.

If you would rather add nothing, an agent can still find its own way: `GET /purchases/receipt` on your pay URL, with the agent's own bearer token, lists every purchase it holds with you and includes both a `topUpUrl` and a `redeemUrl` for each. See [buyer-facing routes](/resources/stateful-access-reference#buyer-facing-routes-on-your-pay-url).

Note that a top-up does not go through your error path at all. It arrives as another account write, with a higher `stateVersion` and a larger `lifetimeCreditGrantedUsd` — which your existing `onWrite` already handles.

Card refunds and chargebacks arrive the same way: an account write with a larger `lifetimeCreditReversedUsd` (and, for a fully reversed purchase, an ended period or `status: "suspended"`). `deriveCreditDelta` answers `debit` for the difference; subtract it and clamp the balance at zero. Both revocation signals fall inside [`isServiceable`](#decide-whether-to-serve), so if you gate requests on it, applying the debit is the only reversal handling you need.

## Automatic top-ups: reporting the balance

Automatic top-ups require a signed usage endpoint that returns the remaining credit. Renewal-only subscriptions do not need it.

Add an `onRead` handler with the TypeScript SDK 0.10+, or implement the [usage-read contract](/resources/stateful-access-reference#the-usage-read-required-for-automatic-top-ups) directly. ZeroClick sends `GET <accessEndpointUrl>/:accessId/usage` with signature purpose `access.read` when a buyer enrolls and roughly every two minutes while top-ups are active. Read from your existing balance store; do not mint keys or change state.

You can also push updates to `POST /v1/stateful/accounts/:accessId/balance` with an organization API key carrying `stateful:write`. Pushes supplement polling; they do not replace the usage endpoint.

```ts theme={null}
onRead: async ({ accessId }) => {
  const account = await accounts.read(accessId); // balance and version from one row
  if (!account) return { unknown: true };
  return {
    accessId,
    stateVersion: account.stateVersion, // the last write you applied
    remainingCreditUsd: account.remainingUsd.toFixed(6),
    observedAt: new Date().toISOString(), // when you read it: now
  };
},
```

`observedAt` is the time you read the balance, not when it last changed. ZeroClick discards a reading that is more than five minutes old or more than thirty seconds ahead of its clock, and a reading whose `stateVersion` is not the account's latest acknowledged version, so an idle account must still answer with the current time.

## Retries and failures

Account writes are **at-least-once**, so expect duplicates and expect a retry after a network timeout that happened *after* you committed. Three things to design around:

* **You have 7 seconds to respond.** Commit, then answer. Anything slower belongs behind `lifecycle: "provisioning"`.
* **Return `503` — or just throw — for anything temporary.** ZeroClick retries network errors, `429`, and any `5xx`, backing off for up to 24 hours. It does **not** retry other 4xx responses, so a bug that returns `400` permanently fails the delivery.
* **A permanent failure refunds the buyer automatically.** A reserved payment is released, a captured one refunded. You never owe a refund for an account you did not create.

Exact backoff, attempt limits, and retry-hint handling are in the [reference](/resources/stateful-access-reference#retries-timeouts-and-failures).

## Customers with more than one agent

**The short version: you do nothing, and your account key never changes.**

A ZeroClick buyer can hold several agent identities — a replacement for a rotated credential, a second agent for another workload ([agents belong to buyers](/concepts/agents-and-access#agents-belong-to-buyers-zcbuyerid)). Any of them can come back later and ask for a key to a purchase the buyer already owns, or top the account up.

When that happens, the call carries the same `accessId` — ZeroClick resolves which account the caller may act on before anything is signed toward you — with the acting agent's own id in `agentId` and, once claimed, the shared `buyerId`. So a second agent minting a key looks like a new `agentId` value against a familiar `accessId`: expected, not suspicious. You never enumerate the buyer's identities and do not need to model them.

## Before you go live

* Serve the endpoint over HTTPS, with no redirects.
* Verify signatures against the raw body bytes and the original path and query.
* Look up secrets by `kid` and keep the previous one through a rotation.
* Never route these requests through `guard`, `guardIdentity`, or `verifyRequest`.
* Apply `stateVersion` and credit in one locked transaction, and never lower a recorded total.
* Store key hashes only, return plaintext once, keep keys out of logs.
* Make rotation revoke-and-insert atomic.
* Gate every authenticated request on `isServiceable` — period expiry has no push.
* Return `503` for anything temporary; remember other 4xx responses are permanent.
* Alert on handler errors and on repeated retries for the same account.
* Test a duplicate write, an out-of-order write, a handler timeout, a repeated key request, and a signing-secret rotation.

## Next steps

<Columns cols={2}>
  <Card title="Account and key reference" icon="book" href="/resources/stateful-access-reference">
    Every field, status code, retry rule, and the signature for stacks without an SDK.
  </Card>

  <Card title="Keys and secrets" icon="key" href="/integrate/keys-and-secrets">
    Create, scope, and rotate the signing secret this endpoint verifies against.
  </Card>

  <Card title="Agents and access" icon="user" href="/concepts/agents-and-access">
    What an agent id is, and how one buyer can hold several.
  </Card>

  <Card title="The integration contract" icon="plug" href="/integrate/overview">
    The stateless side: verify, check, serve, settle.
  </Card>
</Columns>
