Metadata-Version: 2.5
Name: cuvo
Version: 0.1.0a1
Summary: Python client for the Cuvo Integrations API, generated from the v1 contract.
Project-URL: Homepage, https://developers.cuvo.co
Project-URL: Documentation, https://developers.cuvo.co/docs
Author: Cuvo
License-Expression: MIT
Keywords: api,cuvo,sdk,telehealth
Classifier: Development Status :: 3 - Alpha
Classifier: Programming Language :: Python :: 3
Classifier: Typing :: Typed
Requires-Python: >=3.11
Requires-Dist: attrs>=24.1
Requires-Dist: httpx>=0.27
Requires-Dist: typing-extensions>=4.12
Description-Content-Type: text/markdown

# `cuvo`

The Python client for the [Cuvo Integrations API](https://developers.cuvo.co): create patients,
record consents, file cases for a licensed clinician to decide, and follow prescriptions and
orders through events and webhooks.

Everything under `cuvo._generated` is written from `api/openapi/v1.yaml`, which is itself generated
from the schemas the server validates with. If it type-checks, it speaks the deployed contract.

> Prerelease. Published as an alpha while the developer product settles; `0.1.x` may still move.

## Install

```sh
pip install cuvo
```

Requires Python 3.11 or newer.

## Authenticate

Two credentials reach the same API, and both arrive as `Authorization: Bearer`.

**An API key** is what one integration uses for its own traffic. Mint it in the developer portal;
`cuvo_sk_test_…` reaches the sandbox and `cuvo_sk_live_…` reaches production.

```python
import os

from cuvo import CuvoClient

cuvo = CuvoClient(
    os.environ["CUVO_API_KEY"],
    # organization="org_...",  # only when the credential is granted to more than one
)
```

**An OAuth client** is what a platform uses to act for the organizations that granted it access.
Exchange the client id and secret for a token at
`https://developers.cuvo.co/api/auth/oauth2/token` with `grant_type=client_credentials`, ask for
the scopes you need, and hand the access token to the same argument. The client id and secret go in
the `Authorization` header as HTTP Basic credentials, which is the one method a client is
registered for:

```python
import httpx

token = httpx.post(
    "https://developers.cuvo.co/api/auth/oauth2/token",
    auth=(os.environ["CUVO_CLIENT_ID"], os.environ["CUVO_CLIENT_SECRET"]),
    data={
        "grant_type": "client_credentials",
        "scope": "patients:write cases:write events:read",
        "resource": "https://api.cuvo.co",
    },
).json()["access_token"]

cuvo = CuvoClient(token, organization="org_...")
```

`resource` is not optional. It is what the provider reads to set the token's audience, and the API
refuses a token whose audience does not name it, so an exchange without it mints a token that fails
on the first call.

Scopes are per credential and are intersected with what the organization granted: a key with
`cases:write` acting for an organization that granted only `cases:read` may read and nothing more.
Tokens are short lived, so mint one per run rather than caching it past its expiry.

The client adds three things to every call, which is most of why it exists:

- `Authorization: Bearer <credential>`, and `Cuvo-Organization` when you named one.
- An `Idempotency-Key` on every POST, PATCH and DELETE. Pass your own to make a retry from your
  own job queue replay instead of creating a second row.
- Retries on 429 and on the server faults a second attempt can clear, with exponential backoff,
  jitter, and `Retry-After` when the server names a delay.

## File a case

```python
from cuvo import CuvoClient

with CuvoClient(os.environ["CUVO_API_KEY"]) as cuvo:
    patient = cuvo.post(
        "/v1/patients",
        json={
            "first_name": "Ada",
            "last_name": "Lovelace",
            "date_of_birth": "1990-04-14",
            "sex_at_birth": "female",
            "email": "ada@example.com",
            "phone": "+14155550123",
            "address": {
                "line1": "1 Market St",
                "city": "San Francisco",
                "state": "CA",
                "postal_code": "94105",
            },
        },
    )

    # A case needs the consents the patient gave before it can be filed.
    consents = [
        cuvo.post(
            f"/v1/patients/{patient['id']}/consents",
            json={"kind": kind, "version": "2026-01"},
        )
        for kind in ("telehealth", "privacy")
    ]

    filed = cuvo.post(
        "/v1/cases",
        json={
            "patient": patient["id"],
            "requested_medications": [
                {
                    "medication_id": "med_sema_0_25",
                    "quantity": 4,
                    "refills": 0,
                    "days_supply": 28,
                    "directions": "Inject 0.25 mg subcutaneously once weekly.",
                }
            ],
            "answers": [
                {
                    "id": "q_pregnant",
                    "question": "Are you pregnant?",
                    "answer": False,
                    "type": "boolean",
                }
            ],
            "consent_ids": [consent["id"] for consent in consents],
            "hold": False,
        },
    )

    print(filed["id"], filed["status"])  # case_... queued
```

A refusal raises `CuvoApiError`, which carries `status`, `code`, `issues`, `request_id` and the
problem body verbatim.

## Typed models, and every endpoint

`cuvo.post` and `cuvo.get` answer with parsed JSON. For typed objects, build a model from that
JSON, or call the generated function for the operation and let it do both:

```python
from cuvo._generated.api.cases import get_case
from cuvo._generated.models.case import Case

case = Case.from_dict(cuvo.get("/v1/cases/case_..."))

# Or the generated endpoint function, over the same transport: the same credential, the same
# organization header, the same minted idempotency keys, the same retries.
same_case = get_case.sync(id="case_...", client=cuvo.generated)
```

Every endpoint has an `asyncio` twin beside its `sync` one, and it carries the same guarantees:
`await get_case.asyncio(id="case_...", client=cuvo.generated)`. Await `cuvo.aclose()` when you are
done with it.

## Page through a list

```python
from cuvo import paginate
from cuvo._generated.api.cases import list_cases
from cuvo._generated.models.list_cases_status import ListCasesStatus


def page(cursor):
    return list_cases.sync(
        client=cuvo.generated, limit=100, status=ListCasesStatus.IN_REVIEW, starting_after=cursor
    )


for case in paginate(page, lambda case: case.id):
    print(case.id, case.status)
```

The iterator is lazy and follows `starting_after` until the API says there is no more. `cursor_of`
reads the id off a row, which is the one thing a page cannot say about itself.

## Receive a webhook

Verify before you parse, and verify the raw bytes. Re-serializing the body changes key order and
every signature fails.

```python
from cuvo import expand_event, parse_event, verify_signature


def handle(request):  # any framework: this needs the raw body and one header
    raw_body = request.body

    if not verify_signature(
        raw_body=raw_body,
        signature_header=request.headers.get("Cuvo-Signature"),
        secret=os.environ["CUVO_WEBHOOK_SECRET"],
    ):
        return 400

    event = parse_event(raw_body)

    # Delivery is at-least-once: dedupe on event.id before you act.
    if already_handled(event.id):
        return 200

    if event.type_ == "case.approved.v1":
        # Events are thin. This returns the embedded resource when the endpoint opted into
        # include_resource, and reads it back otherwise.
        approved = expand_event(cuvo, event)
        print(approved)

    return 200
```

During the 24 hour grace after a secret rotation a delivery carries two signatures.
`verify_signature` accepts a match against either, so you can move to the new secret whenever you
like inside the window.

`parse_event` also refuses a body that pairs an event type with the wrong resource, which no
delivery from Cuvo does. `expand_event` returns `None` for messages, prescriptions, consents and
charges: v1 reads those through their case or patient rather than on their own id. Turn on
`include_resource` on the endpoint, or pass `include_resource=true` to `GET /v1/events`, and the
resource arrives embedded with no second call.

## Development

```sh
uv sync
uv run python scripts/generate.py           # rewrite cuvo/_generated from the contract
uv run python scripts/generate.py --check   # fail if the committed tree no longer matches it
uv run ruff check . && uv run ruff format --check .
uv run mypy --strict cuvo scripts tests
uv run pytest
```

`cuvo/_generated` is committed and never edited by hand. Ruff and mypy leave it alone: the
generator formats its own output, and the drift gate compares those bytes exactly.

## Publishing

```sh
uv build       # wheel and sdist into dist/
uv publish     # requires a PyPI token
```

Nothing is published until the contract is stable and the founder says so.
