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
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.
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.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file zeroclick_sellers-0.1.1.tar.gz.
File metadata
- Download URL: zeroclick_sellers-0.1.1.tar.gz
- Upload date:
- Size: 102.0 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
174bcae125c6ad52b9d1731197c56f150c79d60d3e54f0f8b6fb3f37307699d6
|
|
| MD5 |
6727251d41f985e398769ad79d92a9e3
|
|
| BLAKE2b-256 |
24c1e7aacae3088401ccaada632421ec9fb01b0e1d728354e20859169055c58b
|
Provenance
The following attestation bundles were made for zeroclick_sellers-0.1.1.tar.gz:
Publisher:
publish-python-sdk.yml on piedotorg/zeroclick
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
zeroclick_sellers-0.1.1.tar.gz -
Subject digest:
174bcae125c6ad52b9d1731197c56f150c79d60d3e54f0f8b6fb3f37307699d6 - Sigstore transparency entry: 2281547687
- Sigstore integration time:
-
Permalink:
piedotorg/zeroclick@02ca70de28e3ec715a4be420796f6196bb645b12 -
Branch / Tag:
refs/heads/main - Owner: https://github.com/piedotorg
-
Access:
private
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish-python-sdk.yml@02ca70de28e3ec715a4be420796f6196bb645b12 -
Trigger Event:
push
-
Statement type:
File details
Details for the file zeroclick_sellers-0.1.1-py3-none-any.whl.
File metadata
- Download URL: zeroclick_sellers-0.1.1-py3-none-any.whl
- Upload date:
- Size: 24.4 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
b5f8478ee63cacf8254a8f61f78f0122bc723fde14de40e81444e655a907cac2
|
|
| MD5 |
0a1d75865c96a298a5585078bc25b056
|
|
| BLAKE2b-256 |
72d4ba822cc70996ccf903dcc3ec887da8ad1bdeadc0c8cb66f9965866f3c66c
|
Provenance
The following attestation bundles were made for zeroclick_sellers-0.1.1-py3-none-any.whl:
Publisher:
publish-python-sdk.yml on piedotorg/zeroclick
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
zeroclick_sellers-0.1.1-py3-none-any.whl -
Subject digest:
b5f8478ee63cacf8254a8f61f78f0122bc723fde14de40e81444e655a907cac2 - Sigstore transparency entry: 2281547713
- Sigstore integration time:
-
Permalink:
piedotorg/zeroclick@02ca70de28e3ec715a4be420796f6196bb645b12 -
Branch / Tag:
refs/heads/main - Owner: https://github.com/piedotorg
-
Access:
private
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish-python-sdk.yml@02ca70de28e3ec715a4be420796f6196bb645b12 -
Trigger Event:
push
-
Statement type: