zeroclick-sellers
Python helpers for ZeroClick seller integrations. Verify proxy signatures, check allowance before doing work, return the payment refusals ZeroClick expects, and report what was used.
If your server is TypeScript rather than Python, use
@zeroclickai/sellers
instead — same wire format, same concepts.
Install
pip install zeroclick-sellers
The API key needs both the usage:read and usage:write scopes.
Pick the right client
| Your framework | Client | Guard |
|---|---|---|
| FastAPI, Starlette (ASGI) | create_async_seller |
await zeroclick.guard(...) |
| Flask, Django (WSGI) | create_seller |
zeroclick.guard(...) |
Use the async client in ASGI apps. The blocking one would stall the event loop for the duration of every allowance check.
Quickstart (FastAPI)
import os
from fastapi import FastAPI, Request
from fastapi.responses import Response
from zeroclick_sellers import SyncUsageItem, UsageItem, ZcResponse, create_async_seller
from zeroclick_sellers.adapters import zc_request_from_asgi_scope
zeroclick = create_async_seller(
signing_secrets={
os.environ["ZEROCLICK_SIGNING_SECRET_KID"]: os.environ[
"ZEROCLICK_SIGNING_SECRET"
]
},
api_key=os.environ["ZEROCLICK_API_KEY"],
)
app = FastAPI()
def to_fastapi(response: ZcResponse) -> Response:
return Response(
content=response.body,
status_code=response.status,
headers=dict(response.headers),
)
@app.post("/v1/product-watch")
async def product_watch(request: Request) -> Response:
zc_request = zc_request_from_asgi_scope(request.scope, await request.body())
decision = await zeroclick.guard(
zc_request,
service_slug="product-watch",
usage=[UsageItem(meter_slug="requests", quantity=1)],
)
if decision.action == "deny":
return to_fastapi(decision.response)
result = do_the_work(owner=decision.context.zc_agent_id)
return to_fastapi(
zeroclick.with_usage(
ZcResponse.json(result),
[
SyncUsageItem(
service_slug="product-watch", meter_slug="requests", quantity=1
)
],
)
)
Flask and Django are the same shape with create_seller, no await, and
zc_request_from_wsgi_environ(request.environ, request.get_data()) in place of
the ASGI adapter.
Scoped usage keys
api_key carries both the usage:read and usage:write scopes. To follow
least privilege you can instead pass two scoped keys — usage_read_key for
allowance checks (guard / check_allowance) and usage_write_key for usage
reporting (report_usage), handy when a separate worker reports usage — and
omit api_key. A scoped key falls back to api_key when it is not set, so
either form works.
zeroclick = create_async_seller(
signing_secrets={...},
usage_read_key=os.environ["ZEROCLICK_USAGE_READ_KEY"],
usage_write_key=os.environ["ZEROCLICK_USAGE_WRITE_KEY"],
)
Why the adapters exist
path_and_query must be the raw, percent-encoded request target. Every
framework hands you a decoded one. Observed for GET /v1/items/a%2Fb%20c:
| decoded (unusable) | raw (correct) | |
|---|---|---|
| ASGI / uvicorn | scope["path"] → /v1/items/a/b c |
scope["raw_path"] → /v1/items/a%2Fb%20c |
| WSGI / werkzeug | PATH_INFO → /v1/items/a/b c |
RAW_URI → /v1/items/a%2Fb%20c?… |
Using the decoded path produces a different canonical string and fails
verification. The adapters handle this, including the fact that ASGI's
raw_path excludes the query string while WSGI's RAW_URI includes it.
[!NOTE] On WSGI, if the server sets neither
RAW_URInorREQUEST_URI, an encoded separator cannot be recovered — WSGI decodes%2Fto/before the SDK is called and nothing can tell it from a literal/. gunicorn, werkzeug, uWSGI and nginx all set one of them.
Decisions, not exceptions
guard returns a decision. A bad signature is an expected event, not a
programming error, so it does not raise:
action == "allow"carries the verifiedcontextand anallowanceof"allowed","unavailable", or"not_required".action == "deny"carries a ready-to-returnresponse:401for a bad signature, the exact seller402 payment_requiredbody for a business denial,503when allowance is unavailable under a fail-closed policy.
A verified request without a buyer identity is a signed anonymous probe —
valid, not a failure. Test it with truthiness, not is None: an absent
zc-agent-id header gives None, but a present-but-empty one gives "", and
both mean the same thing. guard_identity already handles both.
if not decision.context.zc_agent_id:
... # anonymous
Who called: zc_agent_id and zc_buyer_id
The verified context carries both identities:
| Field | Header | Meaning |
|---|---|---|
zc_agent_id |
zc-agent-id |
The agent that made this call — never the agent that bought the plan it is drawing down. Empty on a signed anonymous probe. |
zc_anonymous_id |
zc-anonymous-id |
The same value as zc_agent_id, under the name that will eventually replace it. Read either. |
zc_buyer_id |
zc-buyer-id |
The buyer that agent belongs to, or None 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 zc_agent_id, and key durable per-customer records on zc_buyer_id
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 zc_buyer_id under
different agent ids are the same customer.
An empty zc_buyer_id with a non-empty zc_agent_id is an anonymous agent:
identified and billable, just not yet attached to a known owner.
The signature covers
zc_agent_id, notzc_buyer_id. 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, declare the ceiling with
max_quantity instead of quantity:
usage = [
UsageItem(meter_slug="requests", quantity=1),
UsageItem(meter_slug="output_tokens", max_quantity=100_000),
]
The buyer authorises up to that ceiling and settles at the actual amount you report, so a ceiling never overcharges. An item declaring both is rejected.
Free identity-scoped endpoints
For endpoints that cost nothing but must know who is calling, use
guard_identity. It verifies the signature exactly like guard, makes no
allowance call, and denies an unidentified buyer with the usage: [] body
that ZeroClick answers with a free identity challenge.
Allowance outages
Configure the behaviour on create_seller:
"allow"(default) — allow withallowance == "unavailable"."deny"— return the SDK's503."throw"— raiseZCErrorfor your application to handle.
Use on_allowance_unavailable for operational logging. The policy applies
only to allowance-API failures after a signature verifies — it never
applies to a missing or invalid signature.
Asynchronous usage
For work that finishes after the response, report it with a stable, seller-owned idempotency key:
result = zeroclick.report_usage(
zc_agent_id="zcagent_example",
idempotency_key="job_123_output_tokens",
service_slug="research-api",
meter_slug="output_tokens",
quantity=4200,
)
report_usage does not generate idempotency keys and does not retry. A
duplicate=True result means the key already landed — a success, not an
error.
Stateful sellers (standing accounts)
Use zeroclick_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 provisions and services those
accounts by calling two HMAC-signed routes on one endpoint you build, and
handle_access_request serves both:
from fastapi import HTTPException
from zeroclick_sellers.adapters import zc_request_from_asgi_scope
from zeroclick_sellers.stateful import handle_access_request_async
@app.post("/zeroclick/access{tail:path}")
async def zeroclick_access(request: Request) -> Response:
zc_request = zc_request_from_asgi_scope(request.scope, await request.body())
response = await handle_access_request_async(
zc_request,
on_write=apply_entitlement,
on_mint=mint_key,
base_path="/zeroclick/access",
remint_policy="rotating",
signing_secrets=signing_secrets,
)
if response is None:
raise HTTPException(status_code=404)
return to_fastapi(response)
handle_access_request is the synchronous twin for WSGI apps; both return
None for any request that is not one of the two routes.
Your write handler receives a validated WriteInput and upserts the account
keyed on access_id — use should_apply and derive_credit_delta to make
the versioned, cumulative write idempotent. When the write carries
buyer_email (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 — the human then finds their agent's purchase and its
settings when they sign in with that email. Your mint handler returns
MintKey, MintUnknown, MintNotProvisioned, or MintConflict; ZeroClick
relays the minted key to the buyer and never stores it. A handler exception
becomes the 503 ZeroClick retries, never a leak into your request path.
The full contract, with the account model and worked handlers: https://docs.zeroclick.ai/integrate/stateful-sellers.
Encrypted bodies
If your services opt into body encryption, the request arrives as a Compact
JWE and the reply goes back the same way. The signature covers the
ciphertext, so guard runs first and unchanged:
from zeroclick_sellers import decrypt_request, encrypt_response
raw_body = await request.body()
zc_request = zc_request_from_asgi_scope(request.scope, raw_body)
decision = await zeroclick.guard(
zc_request,
service_slug="product-watch",
usage=[UsageItem(meter_slug="requests", quantity=1)],
)
if decision.action == "deny":
return to_fastapi(decision.response)
envelope = decrypt_request(raw_body, resolve_private_key=lookup_private_key)
payload = json.loads(envelope.plaintext)
...
return to_fastapi(encrypt_response(stamped_response, envelope))
resolve_private_key receives the kid from the JWE protected header and
returns that key, or None if it is unknown. encrypt_response returns the
response unchanged when the request carried no reply key, so the same handler
serves encrypted and plaintext buyers.
The suite is fixed at ECDH-ES+A256KW / A256GCM; anything else is refused.
A reply key arriving with private material is rejected outright rather than
used.
Release files for zeroclick-sellers 0.5.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| zeroclick_sellers-0.5.0.tar.gz | 156.8 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| zeroclick_sellers-0.5.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 210.0 kB
Release files / zeroclick_sellers-0.5.0.tar.gz
| Download URL | zeroclick_sellers-0.5.0.tar.gz |
|---|---|
| Size | 156.8 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
c6aaa1bd7a2002180f911a76ef1e1fcabe8913439a609745a293eb368caa4d0c
|
|
BLAKE2b-256 checksum How to use checksums |
243ca735acc8e8bc78ba9489f632bf469f5ba459da637f10232fd107617e86ca
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Aug 27, 2026.
Transparency logRelease files / zeroclick_sellers-0.5.0-py3-none-any.whl
| Download URL | zeroclick_sellers-0.5.0-py3-none-any.whl |
|---|---|
| Size | 53.2 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
2e2bd9723637686208a7d7eec7c1d46fe86384493ccc0056cdba3998f8a9a14a
|
|
BLAKE2b-256 checksum How to use checksums |
ea0c83bd818439c463d342f72fc0a2e3ce7bc11e2949899577bf3419b7037ce0
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Aug 27, 2026.
Transparency log