Metadata-Version: 2.4
Name: zeroclick-sellers
Version: 0.1.0
Summary: Seller SDK for ZeroClick request verification, allowance checks, and usage reporting.
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
```

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