Metadata-Version: 2.4
Name: zeroclick-sellers
Version: 0.0.1
Summary: Seller SDK for ZeroClick request verification, allowance checks, and usage reporting.
Project-URL: Homepage, https://zeroclick.io
Project-URL: Documentation, https://zeroclick.io/docs
Project-URL: Repository, https://github.com/piedotorg/zeroclick
Project-URL: Issues, https://github.com/piedotorg/zeroclick/issues
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.

The TypeScript sibling is [`@zeroclickai/sellers`](https://www.npmjs.com/package/@zeroclickai/sellers).
Both implement the same wire format; this SDK pins it with the fixtures in
[`tests/vectors/`](tests/vectors).

## 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())`. Complete
runnable versions of all three are in [`examples/`](examples), and the
end-to-end suite runs them as real servers.

## 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 whose `context.zc_agent_id` is `None` is a **signed
anonymous probe** — valid, not a failure.

## 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.

## 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.

## Development

```sh
uv sync
uv run pytest
```

The suite imports the working tree, so packaging problems would not show up
there. Check the built artifact separately before publishing:

```sh
uv build
python -m venv /tmp/check && /tmp/check/bin/pip install dist/*.whl
cd /tmp && /tmp/check/bin/python -c "import zeroclick_sellers"
```

The suite covers three layers: the wire vectors in
[`tests/vectors/`](tests/vectors) — generated independently of this SDK, and in
the JWE case taken straight from the TypeScript implementation — sync/async
parity across every allowance scenario and policy, and an end-to-end suite that
runs the FastAPI, Flask and Django examples as real servers over real HTTP.
