Metadata-Version: 2.5
Name: zeroclick-sellers
Version: 0.3.0
Summary: Seller SDK for ZeroClick request verification, allowance checks, usage reporting, and stateful account provisioning.
Project-URL: Homepage, https://www.zeroclick.ai
Project-URL: Documentation, https://www.zeroclick.ai/docs
Author: ZeroClick
License-Expression: Apache-2.0
Keywords: agents,payments,seller,usage,zeroclick
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Office/Business :: Financial
Classifier: Topic :: Software Development :: Libraries
Classifier: Typing :: Typed
Requires-Python: >=3.10
Requires-Dist: httpx>=0.27
Requires-Dist: joserfc>=1.0
Description-Content-Type: text/markdown

# `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`](https://www.npmjs.com/package/@zeroclickai/sellers)
instead — same wire format, same concepts.

## Install

```sh
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)

```python
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.

```python
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.

```python
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`:

```python
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:

```python
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:

```python
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:

```python
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.
