Skip to main content

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 to verify_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, 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:

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


Licensed Proprietary. © neolife.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

neolife-0.1.1.tar.gz (18.2 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

neolife-0.1.1-py3-none-any.whl (20.4 kB view details)

Uploaded Python 3

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

Hashes for neolife-0.1.1.tar.gz
Algorithm Hash digest
SHA256 a6dfadf61d16ea7877319c17629696bed7df699a0bbcba8bf794f3eda1478ed8
MD5 a449f4daef9b22e28ec8e7808b751103
BLAKE2b-256 ad8dd219b8e85e7eba016c1b0587969dbed2959066b41a02939f402a613412f2

See more details on using hashes here.

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

Hashes for neolife-0.1.1-py3-none-any.whl
Algorithm Hash digest
SHA256 72cc498c73d8af4264d2158244cdce9786e728b298086275e040cc44d1ca29cd
MD5 b17f54e4aaec29a8e55ba173ae7de589
BLAKE2b-256 0363931cb55271703c3144cc47ba3013a0cb37c9e1545635cf282ddcdd06f86e

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page