SDKs

Python SDK

The official Python SDK — typed, retry-safe, idempotent by default, with built-in webhook verification.

The Python SDK wraps the REST API with typed errors, automatic retries, and idempotency handled for you — one runtime dependency (httpx), Python 3.9+, and a py.typed marker for full type checking.

Install#

pip install neolife

Initialize#

Load your key from the environment — never hard-code it. The SDK reads the key prefix and exposes the resulting mode on the client:

import os
from neolife import Neolife

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

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

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

Client options#

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

Submit an order#

Every method returns parsed JSON (dicts / lists). A licensed provider approves every clinical order before it can ship — approve() runs that provider gate, then submit() routes the approved order to a pharmacy:

order = client.orders.retrieve("ord_123")   # or client.orders.get("ord_123")

client.orders.approve(order["id"])           # provider-gated clinical approval
result = client.orders.submit(order["id"])   # route to the pharmacy
print(result["status"])                      # "submitted"

An order must be provider-approved before it can be submitted. The API refuses to submit an unapproved order.

Idempotency handled for you#

Every mutation accepts an optional idempotency_key=. If you omit it, the SDK generates a uuid.uuid4() and sends it as the Idempotency-Key header for you. Pass your own stable key to make a whole workflow replay-safe:

# Deterministic in the order id — a retry of this exact call never double-ships.
client.orders.submit("ord_123", idempotency_key="fulfill:ord_123")

The API replays the first response for a repeated key and returns 409 conflict if the same key is reused with a different body. See Idempotency for how to choose keys.

Automatic retries#

The client auto-retries (up to max_retries, default 2) on network-level failures (DNS/TCP/TLS/timeout) and on 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 by construction: GETs are idempotent, and every mutation carries a stable Idempotency-Key reused across the retry — so a retried submit can never double-ship. Set max_retries=0 to disable.

Typed errors#

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

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
    if err.is_retryable:   # transient 5xx/503
        ...
    if err.is_rate_limit:  # 429
        ...

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

from neolife import (
    AuthenticationError, PermissionError, NotFoundError, ConflictError,
    RateLimitError, ValidationError, 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:
    ...  # downstream pharmacy rejected/failed the request
except NeolifeConnectionError:
    ...  # DNS/TCP/TLS/timeout before any HTTP status (status == 0)
except NeolifeError:
    ...  # anything else

Pagination#

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

# 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.
for event in client.events.list_auto_paging(type="order.shipped"):
    ...

Webhook verification#

Verify inbound deliveries with constant-time HMAC-SHA256 (Standard Webhooks), including replay protection and rotated-key support. Always pass the raw request bytes, before parsing — re-serializing the JSON changes whitespace and breaks the signature:

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

Payloads are PHI-free by contract — ids and status only. Hydrate patient and order detail over an authenticated GET when you need it. See Webhooks & events for the delivery model, event types, and replay.

Sandbox vs. live#

Behavior is identical in both environments — only the key prefix differs. A sandbox key resolves to synthetic data and nothing ships. client.mode reflects the key, so you can guard destructive paths:

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

Build against nk_sandbox_…, then switch the environment variable to go live — no base-URL change needed.