Download OpenAPI specification:
A B2B clinical triage engine accessed via HTTPS and authenticated with Bearer API keys. Create sessions, drive Q&A flows, and receive structured triage and differential-diagnosis output. This document covers the cross-cutting conventions every integrator needs: authentication, pagination, errors, idempotency, rate limits, webhooks, and pricing. Per-endpoint schemas are in the reference below.
All requests require an API key supplied in the Authorization header:
Authorization: Bearer nsy_live_XXXXXXXXXXXXXXXXXXXXXXXXXX
Two key prefixes are recognised:
| Prefix | Billing | Intended use |
|---|---|---|
nsy_test_ |
No billing, sandbox | CI, staging, QA, local development |
nsy_live_ |
Billable, production | Production traffic from paying users |
Each key carries a set of scopes enforced per request:
sessions:read — list, read, and export sessionssessions:write — create, answer, route, and finalize sessionspatients:read — read patient profiles and per-patient historypatients:write — create, update, and delete patient profileswebhooks:read — list webhook subscriptions and delivery historywebhooks:write — create, rotate, test, and delete webhook subscriptionsadmin:read — read tenant stats, audit log, keys, tiers, usageadmin:write — create keys, change tier, update settingsCookie-based auth (nessy_session) is also supported for the first-party
dashboard only; all B2B integrations should use Bearer keys.
List endpoints (sessions, audit log, keys, deliveries) accept limit and
offset query parameters. Defaults are limit=50, offset=0; the maximum
limit is 200. Responses include total, limit, offset, and the
data array so clients can build stable pagers.
Every error response — regardless of status code — uses one consistent shape:
{
"error": {
"code": "validation_error",
"message": "Request body failed validation",
"request_id": "b3c4e8a1-2d1f-4e0c-9d6b-6a1d2e3f4a5b",
"detail": { "field_errors": [ ... ] }
}
}
The code field maps directly from HTTP status:
| Status | code |
|---|---|
| 400 | bad_request |
| 401 | unauthorized |
| 403 | forbidden |
| 404 | not_found |
| 409 | conflict |
| 422 | validation_error |
| 429 | rate_limited |
| 500 | internal_server_error |
| 503 | service_unavailable |
request_id is echoed from the X-Request-ID request header (or generated
server-side) and is the single correlation key you should quote when filing
support tickets.
Mutating POST endpoints (notably POST /v1/sessions and its answer/route/
finalize children) accept an Idempotency-Key header. Keys are scoped to the
authenticated tenant and cached for 24 hours. Replaying a request with the
same key + tenant returns the cached response unchanged and sets the header
Idempotency-Replayed: true on the response. Use a fresh UUIDv4 per logical
action on the client side.
Per-API-key rate limits are enforced in a 1-minute rolling window and surfaced on every response via:
X-RateLimit-Limit — permitted requests per windowX-RateLimit-Remaining — requests left in the current windowX-RateLimit-Reset — unix epoch seconds at which the window resetsRetry-After (on 429 only) — seconds the client should waitExceeding the limit returns 429 Rate Limited with the unified error envelope:
{
"error": {
"code": "rate_limited",
"message": "Rate limit of 60 requests/minute exceeded.",
"request_id": "...",
"detail": { "retry_after": 42, "limit": 60 }
}
}
Default limits by tier: free_trial 10 rpm, starter 30 rpm, growth 60 rpm, scale 120 rpm, enterprise 300 rpm. Per-key overrides may set stricter limits.
Webhook subscriptions deliver events over HTTPS POST with an HMAC SHA-256
signature computed over "{timestamp}.{body}" using the subscription's
signing secret, encoded as lowercase hex. Two headers accompany every delivery:
X-NessyAPI-Signature-256: <hex>X-NessyAPI-Timestamp: <unix epoch seconds>Receivers should reject any delivery whose timestamp is more than 5 minutes skewed from now (replay protection), and must compute the expected signature with a constant-time comparison.
Available event types:
session.createdsession.answeredsession.finalizedassessment.completedred_flag.detectedtriage.escalatedsession.timeoutbalance.low_20balance.low_10balance.depletedUse POST /v1/admin/webhooks/{id}/test to fire a synthetic delivery against
the configured URL; the response includes status_code, latency_ms, and any
error returned by the receiver.
| Tier | $/month | Tokens included | Concurrent | Keys | rpm | SLA |
|---|---|---|---|---|---|---|
| free_trial | $0 | 1 000 | 5 | 2 | 10 | — |
| starter | $300 | 10 000 | 20 | 5 | 30 | 99% |
| growth | $1 300 | 50 000 | 50 | 10 | 60 | 99.5% |
| scale | $5 750 | 250 000 | 200 | 25 | 120 | — |
| enterprise | $20 000 | 1 000 000 | 1 000 | 100 | 300 | 99.9% |
| Endpoint | Tokens charged |
|---|---|
POST /v1/sessions (create) |
1 |
POST /v1/sessions/{id}/route |
5 |
POST /v1/sessions/{id}/answer |
8 |
POST /v1/sessions/{id}/answer (skipped) |
0 |
POST /v1/sessions/{id}/finalize |
0 |
GET /v1/sessions/{id}/results |
0 |
GET /v1/sessions/{id}/state |
0 |
Any /v1/admin/* or /v1/patients/* endpoint |
0 |
All charged responses also carry the header
X-NessyAPI-Tokens-Charged: <int> so clients can reconcile cost per call
without parsing the body.
List sessions for this tenant — merges active (Redis) with finalized (PG).
Status (string) or Status (null) (Status) Filter: active, finalized | |
Patient Id (string) or Patient Id (null) (Patient Id) Filter by partner_patient_id | |
Chief Complaint (string) or Chief Complaint (null) (Chief Complaint) Filter by chief complaint | |
Date From (string) or Date From (null) (Date From) ISO date, e.g. 2026-01-01 | |
Date To (string) or Date To (null) (Date To) ISO date, e.g. 2026-12-31 | |
| limit | integer (Limit) [ 1 .. 200 ] Default: 50 |
| offset | integer (Offset) >= 0 Default: 0 |
nullCreate a new clinical anamnesis session.
| chief_complaint required | string (Chief Complaint) [ 1 .. 500 ] characters |
Age (integer) or Age (null) (Age) | |
Sex (string) or Sex (null) (Sex) | |
Free Text (string) or Free Text (null) (Free Text) | |
Initial Fields (object) or Initial Fields (null) (Initial Fields) | |
Patient Id (string) or Patient Id (null) (Patient Id) |
{- "chief_complaint": "string",
- "age": 120,
- "sex": "string",
- "free_text": "string",
- "initial_fields": { },
- "patient_id": "string"
}nullBulk export completed sessions as JSON for compliance/reporting.
Date From (string) or Date From (null) (Date From) | |
Date To (string) or Date To (null) (Date To) | |
Patient Id (string) or Patient Id (null) (Patient Id) | |
| limit | integer (Limit) Default: 1000 |
{- "date_from": "string",
- "date_to": "string",
- "patient_id": "string",
- "limit": 1000
}nullList audit events for this tenant.
| limit | integer (Limit) [ 1 .. 200 ] Default: 50 |
| offset | integer (Offset) >= 0 Default: 0 |
Action (string) or Action (null) (Action) Filter by action, e.g. auth.login | |
Date From (string) or Date From (null) (Date From) | |
Date To (string) or Date To (null) (Date To) |
nullGet current rate limit status for this tenant's API key.
T2.9 — No scope required. Any authenticated API key can query its own
current bucket so partner UIs can show "requests remaining" indicators
without giving their client-side code admin:read.
nullGet current session state (lightweight, no differentials).
| session_id required | string (Session Id) |
{- "session_id": "string",
- "status": "string",
- "questions_asked": 0,
- "is_complete": true,
- "triage_level": "string",
- "active_branches": [
- "string"
], - "red_flags": [
- "string"
], - "current_question": {
- "question_id": "string",
- "text": "string",
- "text_cs": "string",
- "targets": [
- "string"
], - "is_red_flag": false,
- "is_reask": false
}, - "created_at": "2019-08-24T14:15:22Z"
}Submit a patient answer to the current question.
If raw_text is provided and extracted_fields is empty, server-side NLP extraction (Azure OpenAI) populates fields and flags automatically. Client-provided fields always take priority over NLP.
| session_id required | string (Session Id) |
| question_id required | string (Question Id) [ 1 .. 100 ] characters |
Raw Text (string) or Raw Text (null) (Raw Text) | |
object (Extracted Fields) | |
| extracted_flags | Array of strings (Extracted Flags) |
Field Confidences (object) or Field Confidences (null) (Field Confidences) | |
| skip | boolean (Skip) Default: false |
{- "question_id": "string",
- "raw_text": "string",
- "extracted_fields": { },
- "extracted_flags": [
- "string"
], - "field_confidences": {
- "property1": 0,
- "property2": 0
}, - "skip": false
}nullGet current differential diagnoses, triage level, and red flags.
This endpoint is FREE (0 tokens) and can be called at any point during the session — not just after finalization.
| session_id required | string (Session Id) |
{- "session_id": "string",
- "triage_level": "string",
- "differentials": [
- {
- "diagnosis": "string",
- "icd": "string",
- "probability": 0,
- "rank": 0
}
], - "red_flags": [
- {
- "flag": "string",
- "description": "string"
}
], - "primary_diagnosis": "string",
- "primary_diagnosis_icd": "string",
- "primary_probability": 0,
- "questions_asked": 0,
- "meta": {
- "tokens_charged": 0,
- "processing_time_ms": 0
}
}Finalize the assessment session.
Triggers final scoring, generates complete clinical summary, and marks the session as read-only. Emits assessment.completed webhook.
| session_id required | string (Session Id) |
nullDelete a clinical session and all associated data (GDPR Art. 17).
| session_id required | string (Session Id) |
{ }Update session demographics (age, sex) after creation.
| session_id required | string (Session Id) |
Age (integer) or Age (null) (Age) | |
Sex (string) or Sex (null) (Sex) |
{- "age": 0,
- "sex": "string"
}nullSubmit feedback on diagnosis accuracy for a completed session.
| session_id required | string (Session Id) |
| accuracy required | string (Accuracy) |
Actual Diagnosis (string) or Actual Diagnosis (null) (Actual Diagnosis) | |
Actual Icd10 (string) or Actual Icd10 (null) (Actual Icd10) | |
Notes (string) or Notes (null) (Notes) |
{- "accuracy": "string",
- "actual_diagnosis": "string",
- "actual_icd10": "string",
- "notes": "string"
}nullRectify a session's data (GDPR Art. 16 — Right to Rectification).
Only editable BEFORE finalization. Writes an audit event with the list of fields changed (never the values — those would leak PHI). Allowed edits: - Update age/sex demographics - Erase specific answers by question_id (removes from session state)
| session_id required | string (Session Id) |
Age (integer) or Age (null) (Age) | |
Sex (string) or Sex (null) (Sex) | |
Array of Clear Answers (strings) or Clear Answers (null) (Clear Answers) | |
Reason (string) or Reason (null) (Reason) |
{- "age": 0,
- "sex": "string",
- "clear_answers": [
- "string"
], - "reason": "string"
}nullList sessions for this tenant — merges active (Redis) with finalized (PG).
Status (string) or Status (null) (Status) Filter: active, finalized | |
Patient Id (string) or Patient Id (null) (Patient Id) Filter by partner_patient_id | |
Chief Complaint (string) or Chief Complaint (null) (Chief Complaint) Filter by chief complaint | |
Date From (string) or Date From (null) (Date From) ISO date, e.g. 2026-01-01 | |
Date To (string) or Date To (null) (Date To) ISO date, e.g. 2026-12-31 | |
| limit | integer (Limit) [ 1 .. 200 ] Default: 50 |
| offset | integer (Offset) >= 0 Default: 0 |
nullBulk export completed sessions as JSON for compliance/reporting.
Date From (string) or Date From (null) (Date From) | |
Date To (string) or Date To (null) (Date To) | |
Patient Id (string) or Patient Id (null) (Patient Id) | |
| limit | integer (Limit) Default: 1000 |
{- "date_from": "string",
- "date_to": "string",
- "patient_id": "string",
- "limit": 1000
}nullList audit events for this tenant.
| limit | integer (Limit) [ 1 .. 200 ] Default: 50 |
| offset | integer (Offset) >= 0 Default: 0 |
Action (string) or Action (null) (Action) Filter by action, e.g. auth.login | |
Date From (string) or Date From (null) (Date From) | |
Date To (string) or Date To (null) (Date To) |
nullGet current rate limit status for this tenant's API key.
T2.9 — No scope required. Any authenticated API key can query its own
current bucket so partner UIs can show "requests remaining" indicators
without giving their client-side code admin:read.
nullExchange API key for httpOnly JWT session cookie.
Hardened 2026-04-16 per code review — previously this endpoint:
jti so logout revocation silently no-op'dnessy_session cookie without the companion nessy_csrf,
so the client's next mutating call hit _verify_csrf and failedFix: per-IP + per-key-hash rate limit, jti, CSRF cookie companion.
null[- {
- "key_id": "1e779c8a-6786-4c89-b7c3-a6666f5fd6b5",
- "key_prefix": "string",
- "name": "string",
- "scopes": [
- "string"
], - "rate_limit_rpm": 0,
- "is_active": true,
- "last_used_at": "2019-08-24T14:15:22Z",
- "expires_at": "2019-08-24T14:15:22Z",
- "created_at": "2019-08-24T14:15:22Z",
- "key_suffix": "string"
}
]Create a new API key for this tenant.
The raw key is returned ONCE in the response. Store it securely.
Supports Idempotency-Key header (24h TTL). On replay, the cached
response — including the raw_key — is returned verbatim so clients
safely retrying transient 5xxs don't end up with N keys they never
saw. Non-2xx responses are never cached.
| name required | string (Name) [ 1 .. 100 ] characters |
Array of Scopes (strings) or Scopes (null) (Scopes) | |
| rate_limit_rpm | integer (Rate Limit Rpm) [ 1 .. 10000 ] Default: 60 |
| test | boolean (Test) Default: false |
{- "name": "string",
- "scopes": [
- "string"
], - "rate_limit_rpm": 60,
- "test": false
}{- "key_id": "1e779c8a-6786-4c89-b7c3-a6666f5fd6b5",
- "key_prefix": "string",
- "name": "string",
- "scopes": [
- "string"
], - "rate_limit_rpm": 0,
- "is_active": true,
- "last_used_at": "2019-08-24T14:15:22Z",
- "expires_at": "2019-08-24T14:15:22Z",
- "created_at": "2019-08-24T14:15:22Z",
- "key_suffix": "string",
- "raw_key": "string"
}Rotate an API key — generates a new key with the same scopes/rate_limit, revokes the old one.
The new raw key is returned ONCE in the response. Store it securely.
Supports Idempotency-Key header (24h TTL). Retrying the same
idempotency key returns the same response body (including raw_key)
rather than minting a second new key and revoking the rotated one
twice.
| key_id required | string <uuid> (Key Id) |
{- "key_id": "1e779c8a-6786-4c89-b7c3-a6666f5fd6b5",
- "key_prefix": "string",
- "name": "string",
- "scopes": [
- "string"
], - "rate_limit_rpm": 0,
- "is_active": true,
- "last_used_at": "2019-08-24T14:15:22Z",
- "expires_at": "2019-08-24T14:15:22Z",
- "created_at": "2019-08-24T14:15:22Z",
- "key_suffix": "string",
- "raw_key": "string"
}Update mutable fields of an API key: name, scopes, rate_limit_rpm.
Immutable fields (key_prefix, test/live, the secret itself) require rotation instead. Scope escalation is prevented: the caller can only grant scopes they themselves hold.
| key_id required | string <uuid> (Key Id) |
Name (string) or Name (null) (Name) | |
Array of Scopes (strings) or Scopes (null) (Scopes) | |
Rate Limit Rpm (integer) or Rate Limit Rpm (null) (Rate Limit Rpm) |
{- "name": "string",
- "scopes": [
- "string"
], - "rate_limit_rpm": 1
}{- "key_id": "1e779c8a-6786-4c89-b7c3-a6666f5fd6b5",
- "key_prefix": "string",
- "name": "string",
- "scopes": [
- "string"
], - "rate_limit_rpm": 0,
- "is_active": true,
- "last_used_at": "2019-08-24T14:15:22Z",
- "expires_at": "2019-08-24T14:15:22Z",
- "created_at": "2019-08-24T14:15:22Z",
- "key_suffix": "string"
}Get aggregated token usage for the last N days.
| days | integer (Days) Default: 30 |
{- "tenant_id": "34f5c98e-f430-457b-a812-92637d0c6fd0",
- "balance": 0,
- "lifetime_used": 0,
- "period_start": "2019-08-24T14:15:22Z",
- "period_end": "2019-08-24T14:15:22Z",
- "records": [
- {
- "date": "string",
- "action": "string",
- "tokens_charged": 0,
- "count": 0
}
]
}Get individual (non-aggregated) usage records for audit/billing.
Returns up to limit records ordered by created_at DESC.
Optional date filters: from_date/to_date in YYYY-MM-DD format.
From Date (string) or From Date (null) (From Date) | |
To Date (string) or To Date (null) (To Date) | |
| limit | integer (Limit) Default: 100 |
[- {
- "record_id": "8bf519b6-a3e0-49d2-8e42-039542d9a489",
- "session_id": "string",
- "action": "string",
- "tokens_charged": 0,
- "request_id": "string",
- "created_at": "2019-08-24T14:15:22Z"
}
]Per-tenant usage rollup for the dashboard Usage page.
Returns totals, per-route breakdown with latency percentiles (derived
from usage_records when available; otherwise reported as None),
time-bucketed counts for charting, and a balance snapshot series.
Primary source: usage_records (append-only ledger, tenant-scoped
via RLS). Latency percentiles come from the optional
latency_ms column if present; when absent the endpoint still
returns request/token counts so the chart renders.
Scope: admin:read.
| range | string (Range) ^(1h|24h|7d|30d)$ Default: "24h" |
| group_by | string (Group By) ^(route|day|hour)$ Default: "route" |
{ }Return current balance + threshold context for the low-balance UI.
Reads:
token_balances row (balance, initial_balance, lifetime_used, updated_at)tenant.tier from the auth dependencyDoes NOT consult Redis separately — the balance stored in Postgres is
the source of truth; Redis is only a cache for the 402 pre-check hot
path. If you need the cached value, call /v1/admin/balance.
{- "balance": 0,
- "initial_balance": 0,
- "lifetime_used": 0,
- "percentage_remaining": 0,
- "current_threshold": "string",
- "next_threshold": {
- "name": "string",
- "tokens_until": 0,
- "percentage": 0
}, - "tier": "string",
- "last_topup_at": "string"
}Create a new webhook subscription.
The signing secret is returned ONCE. Store it securely.
| url required | string (Url) [ 10 .. 500 ] characters |
| events required | Array of strings (Events) non-empty |
{- "url": "stringstri",
- "events": [
- "string"
]
}{ }Rotate webhook signing secret. Old secret valid for 24h grace period.
The actual encryption + UPDATE lives in
nessyapi.webhooks.dispatcher.rotate_webhook_secret — this handler is
only a tenant-scoped gate before delegating.
| subscription_id required | string <uuid> (Subscription Id) |
{ }List recent webhook deliveries for the calling tenant.
Default filter surfaces the actionable failures (failed,
dead-lettered, exhausted) — matching the dashboard's "show
me what's broken" workflow. Pass ?status=delivered for the
success log.
Subscription Id (string) or Subscription Id (null) (Subscription Id) | |
Status (string) or Status (null) (Status) comma-separated status list | |
| page | integer (Page) >= 1 Default: 1 |
| per_page | integer (Per Page) [ 1 .. 200 ] Default: 50 |
{ }Lightweight counter endpoint for the sidebar badge + top banner.
Returns the number of deliveries in an actionable failure state plus the current DLQ length (shared across tenants, admin-only visibility; other tenants see 0).
{ }Manually retry a failed / dead-lettered delivery.
Resets next_retry_at = now() so the periodic retry worker picks
the row up on its next tick (≤ 30 s). Does NOT re-enqueue via
XADD — that would bypass the per-subscription lock and produce a
new delivery_id (Guardian invariant §10).
| delivery_id required | string <uuid> (Delivery Id) |
{ }Mark a permanently-failed delivery as acknowledged.
The row is preserved for audit. Body: {"reason": "..."} — the
note is stored verbatim (trimmed to 500 chars) so operators can
grep it later.
| delivery_id required | string <uuid> (Delivery Id) |
| property name* additional property | any |
{ }{ }Read the DLQ stream via XRANGE. Admin scope required.
The DLQ is a shared (cross-tenant) Redis stream. Visibility is
gated behind admin:write so only operators see other tenants'
entries — no cross-tenant leak from the partner UI.
| limit | integer (Limit) [ 1 .. 500 ] Default: 50 |
{ }Drain the DLQ stream back into the main stream.
Each entry is XADDed to nessy:webhooks with its original
envelope (minus the DLQ metadata) so the webhook consumer picks it
up on the next loop. The DLQ is cleared on success.
Requires {"confirm": true} in the body to reduce the risk of
accidental drainage.
| property name* additional property | any |
{ }{ }Change tenant tier (upgrade or downgrade).
Rate limits for existing API keys stay at their per-key values. New keys will inherit the new tier's default rate limit.
DX-020 deprecation (2026-04-23): tier is accepted as a query-string
parameter for backward-compat with the dashboard's initial
PUT /v1/admin/tier?tier=<name> shape. This is an anti-pattern — PUT
bodies belong in the JSON request body, not the URL. The query-string
form will be removed on 2026-07-23 (90-day sunset per RFC 8594).
Response emits Deprecation, Sunset, Link, and
Warning: 299 - "Use request body instead" headers. A new body-based
alternative (ChangeTierRequest model) will ship in a future sprint;
track via docs/code-review-2026-04-23/findings/features-dx.md DX-020.
| tier | string (Tier) Default: "starter" |
{ }List audit events for the calling tenant.
Max window: LIST_MAX_DAYS days (use the export endpoint for longer).
From (string) or From (null) (From) | |
To (string) or To (null) (To) | |
Array of Action (strings) or Action (null) (Action) Filter by action (repeatable). | |
Actor Id (string) or Actor Id (null) (Actor Id) Filter by user_id in metadata. | |
| limit | integer (Limit) [ 1 .. 500 ] Default: 100 |
Cursor (string) or Cursor (null) (Cursor) |
{ }Stream the full audit trail for a (potentially long) window.
Requires admin:write scope (stronger than the list endpoint — this
is considered a data-egress operation).
From (string) or From (null) (From) | |
To (string) or To (null) (To) | |
Array of Action (strings) or Action (null) (Action) | |
| format | string (Format) ^(csv|ndjson)$ Default: "csv" |
nullReturn the most recent inbound requests for the tenant.
Lossy ring buffer with a 24h TTL — see request_inspector.py.
| limit | integer (Limit) [ 1 .. 100 ] Default: 50 |
| cursor | integer (Cursor) >= 0 Default: 0 |
{- "entries": [
- {
- "request_id": "string",
- "ts": "string",
- "method": "string",
- "path": "string",
- "status": 0,
- "duration_ms": 0,
- "tokens_charged": 0,
- "api_key_prefix": "string",
- "user_agent": "string",
- "ip": "string",
- "error_code": "string"
}
], - "total": 0,
- "next_cursor": 0,
- "max_entries": 100
}Set or clear settings_json.rate_limit_rpm_override.
Body:
{"rate_limit_rpm": 1000} — set override{"rate_limit_rpm": null} — clear override (use tier default)"reason" field for audit context.Validation:
MAX_OVERRIDE_CEILING (5000)Audit-logged. Triggers RL cache flush so change propagates immediately.
{ }Route free-text description to clinical branch within an existing session.
Uses embedding pre-filter + GPT-4o structured routing. Enriches the session with diagnosis hints and initial fields.
| session_id required | string (Session Id) |
| text required | string (Text) [ 1 .. 2000 ] characters |
{- "text": "string"
}{- "chief_complaint": "string",
- "confidence": 0,
- "secondary_cc": "string",
- "diagnosis_hints": [
- { }
], - "initial_fields": { },
- "flags": [
- "string"
], - "current_question": {
- "question_id": "string",
- "text": "string",
- "text_cs": "string",
- "targets": [
- "string"
], - "is_red_flag": false,
- "is_reask": false
}, - "meta": {
- "tokens_charged": 0,
- "processing_time_ms": 0
}
}List patient profiles for the calling tenant.
Pagination is seek-based: pass the previous response's next_cursor
to fetch the next page. Empty next_cursor means no more rows.
Filtering:
partner_patient_id=<exact> — exact match (takes precedence).q=<substring> — case-insensitive substring on partner_patient_id.Sort order is newest-updated first with profile_id as a stable
tiebreaker, both used as the seek key.
Counts (session_count_total, session_count_active) come from
clinical_sessions for finalized rows. session_count_active is a
best-effort count of in-progress sessions (completed_at IS NULL) —
active sessions that live only in Redis are NOT included here (that
would require scanning Redis per-patient, which doesn't scale to
thousands of patients). Use GET /patients/{id}/sessions for the
full Redis-merged timeline.
Emits a single patient.list_accessed audit row per call (not per
patient) — PHI access tracking without log spam.
| limit | integer (Limit) [ 1 .. 200 ] Default: 50 Page size (max 200). |
Cursor (string) or Cursor (null) (Cursor) Opaque cursor from the previous page's | |
Partner Patient Id (string) or Partner Patient Id (null) (Partner Patient Id) Exact-match filter on partner_patient_id. | |
Q (string) or Q (null) (Q) Case-insensitive substring search on partner_patient_id. Ignored when |
{ }Retrieve patient profile by partner patient ID.
| patient_id required | string (Patient Id) |
{- "profile_id": "bfcb6779-b1f9-41fc-92d7-88f8bc1d12e8",
- "partner_patient_id": "string",
- "age": 0,
- "sex": "string",
- "chronic_conditions": [
- "string"
], - "current_medications": [
- "string"
], - "allergies": [
- "string"
], - "risk_factors": [
- "string"
], - "family_history": [
- "string"
], - "smoking_status": "string",
- "alcohol_status": "string",
- "sessions_count": 0,
- "last_session_at": "2019-08-24T14:15:22Z",
- "created_at": "2019-08-24T14:15:22Z"
}Create or update patient profile. Arrays are merge-appended and deduplicated.
| patient_id required | string (Patient Id) |
Array of Chronic Conditions (strings) or Chronic Conditions (null) (Chronic Conditions) | |
Array of Current Medications (strings) or Current Medications (null) (Current Medications) | |
Array of Allergies (strings) or Allergies (null) (Allergies) | |
Array of Risk Factors (strings) or Risk Factors (null) (Risk Factors) | |
Array of Family History (strings) or Family History (null) (Family History) | |
Smoking Status (string) or Smoking Status (null) (Smoking Status) | |
Alcohol Status (string) or Alcohol Status (null) (Alcohol Status) |
{- "chronic_conditions": [
- "string"
], - "current_medications": [
- "string"
], - "allergies": [
- "string"
], - "risk_factors": [
- "string"
], - "family_history": [
- "string"
], - "smoking_status": "string",
- "alcohol_status": "string"
}{- "profile_id": "bfcb6779-b1f9-41fc-92d7-88f8bc1d12e8",
- "partner_patient_id": "string",
- "age": 0,
- "sex": "string",
- "chronic_conditions": [
- "string"
], - "current_medications": [
- "string"
], - "allergies": [
- "string"
], - "risk_factors": [
- "string"
], - "family_history": [
- "string"
], - "smoking_status": "string",
- "alcohol_status": "string",
- "sessions_count": 0,
- "last_session_at": "2019-08-24T14:15:22Z",
- "created_at": "2019-08-24T14:15:22Z"
}List all sessions for a patient, most recent first.
Merges finalized sessions (from clinical_sessions, RLS-scoped via
tenant_conn) with active sessions (from the Redis tenant index).
This is the fix for T0.2: the Postgres table only ever held finalized
rows, so in-progress sessions were invisible to the patient timeline.
| patient_id required | string (Patient Id) |
[- {
- "session_id": "string",
- "chief_complaint": "string",
- "triage_level": "string",
- "primary_diagnosis": "string",
- "primary_diagnosis_icd": "string",
- "questions_asked": 0,
- "created_at": "2019-08-24T14:15:22Z",
- "completed_at": "2019-08-24T14:15:22Z"
}
]Aggregated patient summary (epicrisis) from all sessions.
DX-003 consolidation (2026-04-23): previously a less-rich handler was
defined first in this module and silently shadowed this one, breaking
the PHI-access audit row and dropping red_flags_ever_seen. The richer
response below is now the only registration. Legacy consumer fields
(partner_patient_id, profile_exists, sessions_finalized,
sessions_active, last_session_at) are preserved so partner
integrations coded against the old shape keep working.
| patient_id required | string (Patient Id) |
null[- {
- "member_id": "435a4844-006a-4cfc-a644-e8eb2dd2ca43",
- "email": "string",
- "name": "string",
- "role": "string",
- "is_active": true,
- "accepted_at": "2019-08-24T14:15:22Z",
- "last_login_at": "2019-08-24T14:15:22Z",
- "created_at": "2019-08-24T14:15:22Z"
}
]Invite a new team member via email.
Creates an invite token in auth_tokens and sends an invite email. The invitee must accept the invite via POST /v1/auth/accept-invite to create their account with a password. Does NOT create a loginable user directly — that would leave an account with no password.
Supports Idempotency-Key header (24h TTL). Replays return the
original 201 body without sending a second invite email or creating
a duplicate token — important because the email-send side-effect is
externally visible.
| email required | string (Email) [ 3 .. 200 ] characters |
| name | string (Name) <= 100 characters Default: "" |
| role | string (Role) ^(admin|developer|viewer|billing)$ Default: "developer" |
{- "email": "string",
- "name": "",
- "role": "developer"
}nullChange a team member's role. Only owner can change roles.
Constraints:
DX-020 deprecation (2026-04-23): role is accepted via query string
for backward-compat with the dashboard. REST convention is a JSON body.
Query-string form will be removed on 2026-07-23 (90-day sunset per
RFC 8594). Response emits Deprecation, Sunset, Link, and
Warning: 299 - "Use request body instead" headers.
| member_id required | string <uuid> (Member Id) |
| role | string (Role) Default: "viewer" |
{- "member_id": "435a4844-006a-4cfc-a644-e8eb2dd2ca43",
- "email": "string",
- "name": "string",
- "role": "string",
- "is_active": true,
- "accepted_at": "2019-08-24T14:15:22Z",
- "last_login_at": "2019-08-24T14:15:22Z",
- "created_at": "2019-08-24T14:15:22Z"
}Register a new user + tenant (email/password).
| email required | string (Email) |
| password required | string (Password) |
| name | string (Name) Default: "" |
| company | string (Company) Default: "" |
{- "email": "string",
- "password": "string",
- "name": "",
- "company": ""
}nullAuthenticate with email + password, set session cookie.
| email required | string (Email) |
| password required | string (Password) |
{- "email": "string",
- "password": "string"
}nullVerify a 6-digit OTP issued during register or login.
On success, mints the session JWT, sets the session + CSRF cookies and
a 5-day signed nessy_trusted_device cookie so subsequent logins
from the same browser skip the OTP step. Also flips
dashboard_users.email_verified = true since holding the code
proves email ownership.
| challenge_id required | string (Challenge Id) |
| code required | string (Code) |
{- "challenge_id": "string",
- "code": "string"
}nullMint a fresh code for an existing challenge and re-send the email.
Rate limit is intentionally aggressive (3/hour per IP) because each call enqueues an outbound email — a leaky resend is a spam vector. The Redis-backed challenge is mutated in place; the challenge_id stays the same so the dashboard can keep its OTP form state.
| challenge_id required | string (Challenge Id) |
{- "challenge_id": "string"
}nullReset password using a valid reset token.
| token required | string (Token) |
| new_password required | string (New Password) |
{- "token": "string",
- "new_password": "string"
}nullInvite a new team member (requires admin:write scope from cookie JWT).
| email required | string (Email) |
| name | string (Name) Default: "" |
| role | string (Role) Default: "developer" |
{- "email": "string",
- "name": "",
- "role": "developer"
}nullAccept an invite and create a user account.
| token required | string (Token) |
| password required | string (Password) |
| name | string (Name) Default: "" |
{- "token": "string",
- "password": "string",
- "name": ""
}nullAuthenticated full-state probe for the dashboard Settings panel.
Returns engine + Postgres + Redis health alongside semver + build SHA.
Public /health is intentionally slimmed in production (it leaks
component state to unauthenticated callers); this endpoint is session-
cookie-auth gated so the dashboard's System Status widget gets the
detailed picture without re-exposing it on a public path.
nullReturn the authenticated tenant's account snapshot.
No scope required. Any valid API key can call this endpoint to discover its own identity, balance, and configured scopes. Returns a compact document suitable for powering a "Hello, $tenant" UI header in partner dashboards or CLI tools.
{ }Public changelog. Returns JSON by default; pass Accept: text/markdown
to get the raw file. No auth required, no rate limit applied beyond the
global per-IP limiter.
Accept (string) or Accept (null) (Accept) |
nullPublic changelog. Returns JSON by default; pass Accept: text/markdown
to get the raw file. No auth required, no rate limit applied beyond the
global per-IP limiter.
Accept (string) or Accept (null) (Accept) |
nullLiveness probe — 200 if the process can serve a request.
Deliberately does NOT touch Postgres, Redis, or the engine. This keeps transient dependency flaps from restarting the container, which would drop in-flight sessions and cascade cold starts.
nullLegacy alias for /readyz — kept for backward compat with the existing Azure Container Apps probes and monitoring. Will be removed once Terraform probes are flipped to /livez + /readyz and external callers are migrated.
{- "status": "ok",
- "version": "string",
- "build": "string",
- "engine_ready": false,
- "postgres_connected": false,
- "redis_connected": false
}