Official Python SDK for the neolife fulfillment-rail API — a typed client plus Standard-Webhooks signature verification.
Project description
neolife (Python)
Official Python SDK for the neolife fulfillment-rail API — a typed
client plus Standard-Webhooks signature verification. One
runtime dependency (httpx), Python 3.9+, ships py.typed (PEP 561).
A Node/TypeScript SDK 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
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.
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
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:
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
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
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 toverify_webhook.
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 (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,504responses.
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:
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:
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.
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:
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:
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:
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:
client.intake.list_submissions(sandbox=True)
Development
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 - Changelog —
CHANGELOG.md - Issues — https://github.com/mitchellmclennan/neolife/issues
Licensed Proprietary. © neolife.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file neolife-0.1.1.tar.gz.
File metadata
- Download URL: neolife-0.1.1.tar.gz
- Upload date:
- Size: 18.2 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.12.1
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
a6dfadf61d16ea7877319c17629696bed7df699a0bbcba8bf794f3eda1478ed8
|
|
| MD5 |
a449f4daef9b22e28ec8e7808b751103
|
|
| BLAKE2b-256 |
ad8dd219b8e85e7eba016c1b0587969dbed2959066b41a02939f402a613412f2
|
File details
Details for the file neolife-0.1.1-py3-none-any.whl.
File metadata
- Download URL: neolife-0.1.1-py3-none-any.whl
- Upload date:
- Size: 20.4 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.12.1
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
72cc498c73d8af4264d2158244cdce9786e728b298086275e040cc44d1ca29cd
|
|
| MD5 |
b17f54e4aaec29a8e55ba173ae7de589
|
|
| BLAKE2b-256 |
0363931cb55271703c3144cc47ba3013a0cb37c9e1545635cf282ddcdd06f86e
|