Skip to main content

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_URI nor REQUEST_URI, an encoded separator cannot be recovered — WSGI decodes %2F to / 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 verified context and an allowance of "allowed", "unavailable", or "not_required".
  • action == "deny" carries a ready-to-return response: 401 for a bad signature, the exact seller 402 payment_required body for a business denial, 503 when 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, not zc_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 with allowance == "unavailable".
  • "deny" — return the SDK's 503.
  • "throw" — raise ZCError for 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.3.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for zeroclick-sellers 0.3.0
File Size Uploaded
zeroclick_sellers-0.3.0.tar.gz 130.7 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for zeroclick-sellers 0.3.0
File Interpreter ABI Platform
zeroclick_sellers-0.3.0-py3-none-any.whl Python 3 none any Details

Total release size: 168.9 kB

Release files / zeroclick_sellers-0.3.0.tar.gz

Download URL zeroclick_sellers-0.3.0.tar.gz
Size 130.7 kB
Tags Source
SHA-256 checksum
How to use checksums
61c5e54a09341c708d0e5381644d83a6b5aab37910033f60c448d152346b2aa1
BLAKE2b-256 checksum
How to use checksums
c3103cda95bcaaf63a631fe30916c3362fd67f9d05652924ec60ab738c2eb4ea
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 18, 2026.

Transparency log

Release files / zeroclick_sellers-0.3.0-py3-none-any.whl

Download URL zeroclick_sellers-0.3.0-py3-none-any.whl
Size 38.3 kB
Tags Python 3
SHA-256 checksum
How to use checksums
1aaee1496ea473d0b84a1ccbcaa3da8c2d308bd7069e74cbaf2520856b915d6e
BLAKE2b-256 checksum
How to use checksums
1f71a7fad7ac2943cfb438f866aedaedec6f5fb3ee8c6eca6e6d01849c30ee3d
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 18, 2026.

Transparency log

Release history Release notifications | RSS feed

0.5.0

2 release files

0.4.0

2 release files

0.3.1

2 release files

This release

0.3.0 This release

2 release files

0.2.0

2 release files

0.1.1

2 release files

0.1.0

2 release files

0.0.3

2 release files

0.0.2

2 release files

0.0.1

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page