Metadata-Version: 2.4
Name: neolife
Version: 0.1.1
Summary: Official Python SDK for the neolife fulfillment-rail API — a typed client plus Standard-Webhooks signature verification.
Project-URL: Homepage, https://neolife.health
Project-URL: Documentation, https://docs.neolife.health
Project-URL: Repository, https://github.com/mitchellmclennan/neolife
Project-URL: Changelog, https://github.com/mitchellmclennan/neolife/blob/main/packages/sdk-python/CHANGELOG.md
Project-URL: Bug Tracker, https://github.com/mitchellmclennan/neolife/issues
Author-email: neolife <developers@neolife.health>
License: Proprietary
Keywords: fulfillment,healthcare,neolife,pharmacy,sdk,webhooks
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: License :: Other/Proprietary License
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Typing :: Typed
Requires-Python: >=3.9
Requires-Dist: httpx<1,>=0.24
Provides-Extra: dev
Requires-Dist: pytest<9,>=7; extra == 'dev'
Description-Content-Type: text/markdown

# neolife (Python)

Official Python SDK for the [neolife](https://neolife.health) fulfillment-rail API — a typed
client plus [Standard-Webhooks](https://www.standardwebhooks.com/) signature verification. One
runtime dependency (`httpx`), Python 3.9+, ships `py.typed` (PEP 561).

> A [Node/TypeScript SDK](../sdk-node/README.md) with the same surface exists too — pick whichever
> matches your stack.

## What this SDK does

- **Talks to the fulfillment rail.** List/retrieve orders, run the clinical approve gate, submit to
  a pharmacy, reroute on failure, cancel.
- **Never double-acts.** Every mutation carries a stable `Idempotency-Key`, and transient failures
  (429/5xx, network errors) auto-retry with exponential backoff — safe by construction.
- **Verifies inbound webhooks** with constant-time HMAC-SHA256, replay protection, and rotated-key
  support.
- **Stays PHI-free.** Order/webhook payloads carry ids + status only; the SDK never logs bodies or
  headers. Hydrate PHI over an authenticated, BAA-covered GET when you need it.

## Install

```bash
pip install neolife
```

The only runtime dependency is `httpx>=0.24,<1`.

## Authentication

The client authenticates with a **machine API key** — a clinic mints these from the developer
console (or via `client.developer_keys.create`). Keys are prefixed, and the prefix determines the
**mode**:

| Prefix | Mode | Data |
| --- | --- | --- |
| `nk_live_…` / `rk_live_…` | `live` | Real orders, real pharmacies |
| `nk_sandbox_…` / `rk_sandbox_…` | `sandbox` | Synthetic data only — nothing ships |

The SDK reads the prefix and exposes it as `client.mode`. Unknown/legacy prefixes default to
`"live"` — the SDK never silently downgrades a real key to sandbox. Load the key from the
environment; never hard-code it.

```python
import os
from neolife import Neolife

client = Neolife(api_key=os.environ["NEOLIFE_API_KEY"])
print(client.mode)      # "live" or "sandbox"
print(client.base_url)  # "https://api.neolife.health"
```

### Client options

```python
client = Neolife(
    api_key=os.environ["NEOLIFE_API_KEY"],
    base_url=None,       # override the API base (default https://api.neolife.health)
    timeout=30.0,        # per-request timeout, seconds
    max_retries=2,       # auto-retries on 429/5xx + network errors (0 disables)
    transport=None,      # inject an httpx.BaseTransport (handy for tests / mocking)
)
```

The client is stateless and thread-safe once constructed — build one and reuse it. It's also a
context manager, which closes the underlying connection pool for you:

```python
with Neolife(api_key=os.environ["NEOLIFE_API_KEY"]) as client:
    orders = client.orders.list(status="approved")
# pool closed on exit; or call client.close() yourself
```

## Quickstart

```python
import os
from neolife import Neolife, NeolifeError

client = Neolife(api_key=os.environ["NEOLIFE_API_KEY"])

# List + retrieve. Returns parsed JSON (dicts / lists).
orders = client.orders.list(status="approved", limit=25)
order = client.orders.retrieve("ord_123")   # or client.orders.get("ord_123")

# Run the clinical gate, then submit to the pharmacy.
# approve() is provider-gated and auto-submits on approval.
try:
    client.orders.approve(order["id"])
    # A whole workflow can be made idempotent by passing your own key.
    client.orders.submit(order["id"], idempotency_key="fulfill:ord_123")
except NeolifeError as err:
    print(err.type, err.code, err.request_id)
    if err.is_retryable:
        ...  # back off + retry — idempotency keeps it safe
```

### Typical order lifecycle

```mermaid
flowchart LR
    A["orders.list / retrieve"] --> B{amber fields?}
    B -- yes --> C["orders.resolve<br/>(pick patient,<br/>confirm flags)"]
    B -- no --> D["orders.approve<br/>(clinical gate,<br/>auto-submits)"]
    C --> D
    D --> E["orders.submit<br/>(to pharmacy)"]
    E -- rejected/failed --> F["orders.reroute<br/>(failover pharmacy)"]
    F --> E
    E --> G[["order.* webhooks:<br/>routed → submitted →<br/>accepted → shipped →<br/>delivered"]]
```

## Resources

Every method returns parsed JSON (dicts / lists). Every mutation accepts an optional
`idempotency_key=`.

| Namespace | Methods |
| --- | --- |
| `client.orders` | `list`, `list_auto_paging`, `retrieve`/`get`, `reconciliation`, `plan_routing`, `resolve`, `approve`, `submit`, `reroute`, `cancel` |
| `client.catalog` | `list_products`, `list_protocols` |
| `client.intake` | `list_questionnaires`, `get_questionnaire`, `list_submissions`, `get_submission` |
| `client.developer_keys` | `list`, `create`, `revoke` |
| `client.events` | `list`, `list_auto_paging`, `retrieve`/`get`, `replay` |
| `client.webhook_endpoints` | `event_catalog`, `list`, `create`, `update`, `remove`, `send_test`, `deliveries` |

### One-time secrets

Two create calls return a secret exactly **once** — capture it immediately, it is never shown
again:

- `client.developer_keys.create(name, …)` → the `"key"` field of the result is the plaintext API
  key.
- `client.webhook_endpoints.create(url, events=[…])` → the `"secret"` field (`whsec_…`) is the
  signing secret you pass to `verify_webhook`.

## Pagination

List endpoints use cursor pagination (`limit` + `starting_after`). The SDK can follow the cursor
for you:

```python
# Manual: one page at a time.
page = client.orders.list(status="submitted", limit=100, starting_after="ord_999")

# Auto: iterate every order across all pages.
for order in client.orders.list_auto_paging(status="submitted", page_size=100):
    process(order)

# Events paginate the same way (following the `has_more` cursor).
for event in client.events.list_auto_paging(type="order.shipped"):
    ...
```

## Idempotency & retries

`orders.submit`, `orders.approve`, `developer_keys.create`, and every other mutation accept an
optional `idempotency_key=`. If omitted, the SDK generates a `uuid.uuid4()` and sends it as the
`Idempotency-Key` header. The API replays the first response for a repeated key and returns a
`409 conflict` if the same key is reused with a **different** body.

The client auto-retries (up to `max_retries`, default 2) on:

- network-level failures (DNS/TCP/TLS/timeout), and
- `429`, `500`, `502`, `503`, `504` responses.

Backoff honors a `Retry-After` header when present, otherwise it's exponential
(0.5s → 1s → 2s, capped at 8s) with jitter. Retries are **safe** because GETs are idempotent and
every mutation carries a stable `Idempotency-Key` reused across the retry — a retried `submit` can
never double-ship. Set `max_retries=0` to disable.

## Errors

Every non-2xx response is raised as a `NeolifeError` (or a typed subclass) carrying the API's
structured, PHI-free fields:

```python
from neolife import NeolifeError

try:
    client.orders.submit(order_id)
except NeolifeError as err:
    err.type        # "authentication_error" | "conflict" | "rate_limit_error" | ...
    err.code        # stable, more specific machine code
    err.status      # HTTP status (0 for connection-level failures)
    err.param       # for validation errors: the offending request field, if named
    err.request_id  # req_… — quote this in support (from the X-Request-Id header)
    if err.is_retryable:   # transient 5xx/503
        ...
    if err.is_rate_limit:  # 429
        ...
```

### Catch by type

Errors are dispatched to Stripe-style subclasses so you can `except` the exact family. Every
subclass extends `NeolifeError`, so a broad `except NeolifeError` still catches them all:

```python
from neolife import (
    AuthenticationError, PermissionError, NotFoundError, ConflictError,
    RateLimitError, ValidationError, ApiError, ServiceUnavailableError,
    PharmacyError, NeolifeConnectionError, NeolifeError,
)

try:
    client.orders.approve(order_id)
except AuthenticationError:
    ...  # 401 — bad/expired key
except ConflictError:
    ...  # 409 — already-approved order, or Idempotency-Key reused with a new body
except PharmacyError as err:
    ...  # downstream pharmacy rejected/failed the request
except NeolifeConnectionError:
    ...  # DNS/TCP/TLS/timeout before any HTTP status (status == 0)
except NeolifeError as err:
    ...  # anything else
```

| Subclass | `.type` | Typical status |
| --- | --- | --- |
| `AuthenticationError` | `authentication_error` | 401 |
| `PermissionError` | `permission_error` | 403 |
| `NotFoundError` | `not_found` | 404 |
| `ConflictError` | `conflict` | 409 |
| `RateLimitError` | `rate_limit_error` | 429 |
| `ValidationError` | `validation_error` | 400 / 422 |
| `ServiceUnavailableError` | `service_unavailable` | 503 |
| `ApiError` | `api_error` | 5xx |
| `PharmacyError` | `pharmacy_error` | — |
| `NeolifeConnectionError` | `api_error` (`code="connection_error"`) | 0 (no response) |

The `raw` parsed error body is available on `err.raw` for debugging, but is never logged by the
SDK.

## Webhooks

### Verifying inbound deliveries

`verify_webhook(payload, headers, secret, *, tolerance_secs=300, now_secs=None)` performs
constant-time (`hmac.compare_digest`) Standard-Webhooks HMAC-SHA256 verification over the
`webhook-id` / `webhook-timestamp` / `webhook-signature` headers, rejects timestamps outside ±5
minutes (replay protection), supports rotated-key signature lists (space-separated `v1,<sig>`
candidates), and returns a parsed `WebhookEvent(id, type, timestamp, data)`.

**Always pass the raw request bytes, before `json.loads`** — re-serializing the JSON changes
whitespace/key order and breaks the signature.

```python
import os
from flask import Flask, request
from neolife import verify_webhook, WebhookVerificationError

app = Flask(__name__)

@app.post("/webhooks")
def webhook():
    try:
        event = verify_webhook(
            request.get_data(),                       # RAW bytes — do not parse first
            request.headers,                          # case-insensitive lookup
            os.environ["NEOLIFE_WEBHOOK_SECRET"],     # whsec_…
        )
    except WebhookVerificationError:
        return "", 400

    # event.id is stable across retries — use it as your dedupe key.
    print(event.type, event.data)   # PHI-free: ids + status only
    return "", 204
```

The signed content is `f"{id}.{timestamp}.{raw_body}"` and the secret is `whsec_<base64>` (the
base64 part is the raw HMAC key) — a byte-for-byte match to the neolife server signer.
`sign_payload(secret, id, timestamp_secs, payload)` is exported for symmetry (e.g. to sign in
tests); verification does not depend on the caller using it.

### Event types

Event types are exported as the `WEBHOOK_EVENTS` tuple (namespaces `intake.*` / `order.*` /
`refill.*`):

```
intake.submission.created   order.routed      order.rejected
intake.certificate.issued   order.submitted   refill.followup_required
intake.review.approved      order.accepted
intake.review.rejected      order.shipped
                            order.delivered
```

Payloads are **PHI-free by contract** — ids and status only. Hydrate PHI over an authenticated,
BAA-covered GET.

### Managing endpoints

Register and manage outbound webhook endpoints from code:

```python
ep = client.webhook_endpoints.create(
    "https://example.com/webhooks",
    events=["order.shipped", "order.delivered"],
)
secret = ep["secret"]                       # whsec_… — returned ONCE, store it now
client.webhook_endpoints.send_test(ep["id"])
client.webhook_endpoints.deliveries(ep["id"])
```

### Pull + replay (reconciliation)

If a webhook delivery is ever missed, the durable event log is the source of truth — independent
of any endpoint. Pull it, or replay a stored event:

```python
for event in client.events.list_auto_paging(type="order.shipped"):
    ...
client.events.replay("evt_123")                          # re-deliver to all endpoints
client.events.replay("evt_123", endpoint_id="ep_456")    # …or one endpoint
```

## Sandbox vs live

Point at sandbox by simply using a sandbox key — no separate base URL is needed. `client.mode`
reflects the key, so you can guard destructive paths:

```python
client = Neolife(api_key=os.environ["NEOLIFE_SANDBOX_KEY"])
assert client.mode == "sandbox"      # nothing ships; data is synthetic
```

Intake submissions can also be scoped to sandbox explicitly:

```python
client.intake.list_submissions(sandbox=True)
```

## Development

```bash
cd packages/sdk-python
pip install -e ".[dev]"    # or: pip install httpx pytest
python -m pytest -q
```

Tests inject an `httpx.MockTransport` via the `transport=` client option, so no network is
required.

## Links

- API reference — https://docs.neolife.health
- Node/TypeScript SDK — [`../sdk-node/README.md`](../sdk-node/README.md)
- Changelog — [`CHANGELOG.md`](./CHANGELOG.md)
- Issues — https://github.com/mitchellmclennan/neolife/issues

---

Licensed Proprietary. © neolife.
