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.