@zeroclickai/sellers
v0.8.0
Published
Web-native seller SDK for ZeroClick request verification, usage reporting, encryption, and stateful account provisioning.
Readme
@zeroclickai/sellers
Web-native TypeScript helpers for ZeroClick seller integrations. The SDK verifies proxy signatures, checks allowance before work, returns ZeroClick-compatible payment refusals, records synchronous or asynchronous usage, and optionally handles encrypted request and response bodies. A separate /stateful entry point covers sellers whose purchases create a durable account and an API key.
The package is ESM and uses the standard Request, Response, fetch, AbortSignal, and Web Crypto APIs. It works in runtimes that provide those APIs, including current Node.js and compatible edge runtimes.
Install
pnpm add @zeroclickai/sellersThe API key used by the SDK should have both the usage:read and usage:write scopes.
Quickstart
Configure the client once, then use guard before doing work and withUsage on the successful response:
import { createSeller } from "@zeroclickai/sellers";
const signingSecretKid = process.env.ZEROCLICK_SIGNING_SECRET_KID;
const signingSecret = process.env.ZEROCLICK_SIGNING_SECRET;
const apiKey = process.env.ZEROCLICK_API_KEY;
if (!signingSecretKid || !signingSecret || !apiKey) {
throw new Error("ZeroClick seller credentials are not configured");
}
const zeroClick = createSeller({
signingSecrets: {
[signingSecretKid]: signingSecret,
},
apiKey,
});
export async function handle(request: Request): Promise<Response> {
const requestedUsage = [{ meterSlug: "requests", quantity: 1 }];
const decision = await zeroClick.guard(request, {
serviceSlug: "hello-api",
usage: requestedUsage,
});
if (decision.action === "deny") return decision.response;
const response = Response.json({
message: "Hello from the seller",
zcRequestId: decision.context.zcRequestId,
});
return zeroClick.withUsage(response, [
{
serviceSlug: "hello-api",
meterSlug: "requests",
quantity: 1,
},
]);
}guard always verifies the signature before it calls the allowance API. Its result is an explicit decision:
action: "allow"includes the verified ZeroClick context and an allowance status of"allowed"or"unavailable".action: "deny"includes theResponseto return. Invalid signatures produce a401, business denials produce the exact seller402 payment_requiredbody, and fail-closed allowance outages produce a503.
Signed anonymous probes are valid. For those requests, decision.context.zcAgentId is null.
Who called: zcAgentId and zcBuyerId
The verified context carries both identities:
| Field | Header | Meaning |
| --- | --- | --- |
| zcAgentId | zc-agent-id | The agent that made this call — never the agent that bought the plan it is drawing down. null on a signed anonymous probe. |
| zcAnonymousId | zc-anonymous-id | The same value as zcAgentId, under the name that will eventually replace it. Read either. |
| zcBuyerId | zc-buyer-id | The buyer that agent belongs to, or null for an anonymous agent. |
One buyer can hold several agents, and every one of them is entitled to everything the buyer owns. So key per-caller state (rate limits, per-run scratch data) on zcAgentId, and key durable per-customer records on zcBuyerId when it is present: a customer can retire one agent and call you with the next, and only the buyer id survives that. Two calls with the same zcBuyerId under different agent ids are the same customer.
A null zcBuyerId with a non-null zcAgentId is an anonymous agent: identified and billable, just not yet attached to a known owner.
The signature covers
zcAgentId, notzcBuyerId. Treat the buyer id as a fact ZeroClick asserts over the authenticated channel rather than an independently proven one, and never let it alone unlock records you would not release to the agent id it arrived with.
Charging up to a maximum
When the price is not known until the work is done — output tokens, seconds of processing — declare the most the request could use with maxQuantity instead of quantity, or declare neither to defer to the meter's configured "Max usage per request":
const decision = await zeroClick.guard(request, {
serviceSlug: "research-api",
usage: [
{ meterSlug: "requests", quantity: 1 },
{ meterSlug: "output_tokens", maxQuantity: 100_000 },
],
});The buyer authorizes up to that ceiling, and the payment settles at the actual usage you report with withUsage or reportUsage, so a ceiling never overcharges. A usage item declaring both quantity and maxQuantity is rejected.
Asynchronous usage
Use a stable, seller-owned idempotency key for work that finishes after the request:
const result = await zeroClick.reportUsage({
zcAgentId: "zcagent_example",
idempotencyKey: "job_123_output_tokens",
serviceSlug: "research-api",
meterSlug: "output_tokens",
quantity: 4200,
});
console.log(result.recorded, result.duplicate);reportUsage does not generate idempotency keys and does not retry automatically. An optional ISO timestamp can be supplied as occurredAt.
Free identity-scoped endpoints
Some endpoints cost nothing but must know which buyer is calling — reads and writes served only to the buyer that owns the underlying records, such as polling a job the buyer created. Use guardIdentity for these instead of guard:
export async function GET(request: Request) {
const decision = await zeroClick.guardIdentity(request, {
serviceSlug: "research-api",
});
if (decision.action === "deny") return decision.response;
const job = await findJob(jobId, { owner: decision.context.zcAgentId });
if (!job) return Response.json({ error: "not_found" }, { status: 404 });
return Response.json(job);
}guardIdentity verifies the signature exactly like guard. When zc-agent-id is present it allows with allowance: { status: "not_required" }; when it is absent it denies with reason: "identity_required" and a 402 whose body carries usage: [] — ZeroClick answers that with a free identity challenge and retries the request with the buyer's zc-agent-id attached. No allowance call is made and no zc-usage belongs on the response: free means free.
Identity alone is not billability. A buyer that has only proven identity has no active access, so reportUsage against it fails with access_not_found; gate billable work with guard.
Selling accounts and API keys
Use @zeroclickai/sellers/stateful when a purchase should leave something behind: an account, a subscription, a credit balance, an API key the buyer then uses with you directly. ZeroClick calls two routes on one endpoint you build:
POST /zeroclick/accessdescribes how the account should look after a purchase or top-up. Apply a write only when itsstateVersionis higher than the one you stored, and store the version in the same transaction as any credit you grant.POST /zeroclick/access/:accessId/keysasks for an API key when the buyer requests one after the account is active and paid. Return the plaintext once and store only a hash.
handleAccessRequest routes, verifies, and validates both, then calls your two functions. It verifies a signature that carries an extra purpose (access.write or access.mint), so a signature captured from ordinary proxied traffic can never authorize an account write or a key mint.
import { handleAccessRequest } from "@zeroclickai/sellers/stateful";
const result = await handleAccessRequest(
{ method, pathAndQuery, rawBody, headers },
{
onWrite: async ({ accountId, entitlement }) => {
await applyDesiredStateAtomically(accountId, entitlement);
// Recommended: when the buyer's verified email is present (plans with
// the "requested" or "required" verified-email policy; it can arrive on
// a later write than the first), link the account to your own user
// account so the human finds their agent's purchase when they sign in.
if (entitlement.buyerEmail) {
await linkAccountToUser(accountId, entitlement.buyerEmail);
}
return { lifecycle: "active" };
},
onMint: async ({ accountId }) => {
const apiKey = await createAndStoreKeyHash(accountId);
return apiKey ? { apiKey } : { unknown: true };
},
},
{
basePath: "/zeroclick/access",
accountKey: "accessId",
remintPolicy: "additive",
secrets: { [signingSecretKid]: signingSecret },
},
);Capture rawBody and pathAndQuery before your framework parses or normalizes them; the signature covers those exact bytes. Return result as the HTTP status, body, and headers. A null result means the request is not one of the two routes, so your own router should handle it.
Every call carries two ids, and accountKey picks which one your handlers receive as accountId:
accountKey: "agent"keys accounts by the customer (agt_…), the right choice when a customer gets one account with one live API key. Your database can ignoreaccessIdcompletely. RequiresremintPolicy: "rotating", so your mint transaction must revoke the previous key before inserting the new hash; the SDK reportsmaxKeys: 1to ZeroClick.accountKey: "accessId"keys accounts by the purchase (zacc_…), for customers who may hold several keys or several separate subscriptions. Pair it withremintPolicy: "additive"to accumulate keys or"rotating"to replace them.
Either way accessId stays on the wire — ZeroClick uses it to match top-ups, renewals, and retries to the right purchase. Ignoring it is a choice about your storage, not about the protocol.
The full guide — wire contract, transaction ordering, retry and timeout behavior, and a pre-launch checklist — is at https://docs.zeroclick.ai/integrate/stateful-sellers.
Error handling
Malformed inputs, API failures, signing-secret resolution failures, and encryption failures throw ZCError. Use isZCError to narrow an unknown error, optionally to one error code:
import { isZCError } from "@zeroclickai/sellers";
try {
await zeroClick.reportUsage({
zcAgentId: "zcagent_example",
idempotencyKey: "job_123_output_tokens",
serviceSlug: "research-api",
meterSlug: "output_tokens",
quantity: 4200,
});
} catch (error) {
if (isZCError(error, "api_status_error")) {
console.error(error.code, error.context.status, error.context.reason);
}
throw error;
}ZCError.context contains only sanitized operational fields such as the operation, status, reason, key ID, and validation issue paths. It never includes API keys, signing secrets, or request body bytes.
Missing, stale, malformed, or invalid ZeroClick signatures are expected protocol outcomes rather than exceptions. guard returns a deny decision, while the lower-level verifyRequest method returns { ok: false, reason, response }.
Client API
createSeller(config) returns these bound methods:
| Method | Purpose |
| --- | --- |
| guard(request, input) | Verify the request, check allowance, and return an allow or deny decision. |
| guardIdentity(request, input) | Verify the request and require a proven buyer for a free, identity-scoped call. No allowance call. |
| verifyRequest(request) | Verify only the ZeroClick request signature without consuming the original body. |
| checkAllowance(input, options?) | Call POST /v1/usage/check directly. |
| paymentRequired(input) | Construct the exact seller 402 payment_required response. |
| withUsage(response, usage) | Set the validated zc-usage header without consuming the response body. |
| reportUsage(input, options?) | Call POST /v1/usage for asynchronous usage. |
The same primitives are named exports from @zeroclickai/sellers for applications that need to place verification, allowance checking, and response construction in separate middleware layers.
Configuration
Exactly one signing-secret source is required.
| Option | Required | Default | Description |
| --- | --- | --- | --- |
| signingSecrets | One secret source | - | A record from ZeroClick signing-secret kid to secret value. Keep current and previous keys in the record during rotation. |
| resolveSigningSecret | One secret source | - | An async ({ kid }) => secret \| null resolver for a secret manager or other dynamic store. |
| apiKey | Yes | - | Seller API key with usage:read and usage:write scopes. |
| apiBaseUrl | No | https://api.zeroclick.io | Absolute API base URL, as a string or URL. |
| fetch | No | globalThis.fetch | Injected Fetch-compatible implementation, useful for private deployments and tests. |
| toleranceSeconds | No | 300 | Maximum absolute age of a request signature. |
| clock | No | Date.now | Millisecond clock function, primarily for deterministic tests. |
| checkTimeoutMs | No | 1500 | Allowance-check timeout in milliseconds. |
| allowanceUnavailable | No | "allow" | Outage policy: "allow", "deny", or "throw". |
| onAllowanceUnavailable | No | - | Callback receiving the sanitized ZCError when the allowance API is unavailable. |
Signing-secret rotation
ZeroClick identifies each signature with a kid. Keep every key that may still sign an in-flight request available to the SDK:
const zeroClick = createSeller({
signingSecrets: {
zcsec_current: process.env.ZEROCLICK_SIGNING_SECRET_CURRENT!,
zcsec_previous: process.env.ZEROCLICK_SIGNING_SECRET_PREVIOUS!,
},
apiKey: process.env.ZEROCLICK_API_KEY!,
});For managed secret storage, resolve by kid instead:
const zeroClick = createSeller({
resolveSigningSecret: async ({ kid }) => secretStore.get(kid),
apiKey: process.env.ZEROCLICK_API_KEY!,
});Never log or return a signing secret. Revoke an old key only after requests signed by it can no longer be in flight.
Allowance outage policy
The default policy is fail-open after a 1.5 second timeout. A verified request is allowed with allowance.status === "unavailable", and onAllowanceUnavailable can report the incident. Configure allowanceUnavailable: "deny" to return a 503, or allowanceUnavailable: "throw" to handle the typed error in application code.
The outage policy applies only after successful request verification. Unverified requests are never allowed because the allowance API is unavailable.
Per-call cancellation
The bound checkAllowance and reportUsage methods accept an optional { signal } argument:
await zeroClick.reportUsage(input, { signal: request.signal });The direct checkAllowance and reportUsage exports additionally accept apiKey, apiBaseUrl, fetch, signal, and timeoutMs in their options object.
Encryption
Encryption helpers are opt-in through the @zeroclickai/sellers/encryption subpath. Always run guard or verifyRequest against the original encrypted request before decrypting it so the signature covers the Compact JWE bytes.
import {
decryptRequest,
encryptResponse,
} from "@zeroclickai/sellers/encryption";
export async function handleEncrypted(request: Request): Promise<Response> {
const decision = await zeroClick.guard(request, {
serviceSlug: "secure-api",
usage: [{ meterSlug: "requests", quantity: 1 }],
});
if (decision.action === "deny") return decision.response;
const envelope = await decryptRequest(request, {
resolvePrivateKey: async ({ kid }) => privateKeys.get(kid),
});
const input = JSON.parse(new TextDecoder().decode(envelope.plaintext));
const response = Response.json({ received: input });
return encryptResponse(response, envelope);
}decryptRequest enforces the ZeroClick ECDH-ES+A256KW / A256GCM suite and resolves private keys by the protected kid, which supports rotation. It returns plaintext bytes, protected-header metadata, cty, and the optional validated public reply JWK.
encryptResponse passes through the original response when the buyer did not supply a reply JWK. Otherwise it encrypts the body for the buyer, preserves status and safe headers, moves the original content type into protected cty, and returns application/jose.
Private encryption keys remain seller-owned. The SDK does not generate, upload, persist, or rotate them.
Contracts
Seller-facing Zod schemas and their inferred TypeScript types are available from the contracts subpath:
import {
checkAllowanceInputSchema,
paymentRequiredBodySchema,
reportUsageInputSchema,
syncUsageSchema,
zeroClickContextSchema,
} from "@zeroclickai/sellers/contracts";
const usage = syncUsageSchema.parse([
{ serviceSlug: "hello-api", meterSlug: "requests", quantity: 1 },
]);The subpath also exports schemas for signature headers, allowance responses and denial reasons, seller configuration, guard input, report results, and API call options. Public SDK methods already validate their inputs, so direct schema use is only needed when an application wants to validate data at an earlier boundary.
Framework integration
Adapt framework-specific request objects to a web-native Request at the boundary, preserving the exact method, URL, headers, and body bytes. The SDK reads a clone, so the original request body remains available to the seller handler. Return the SDK-provided web-native Response directly or adapt it back to the framework response type.
