Every non-2xx response carries a JSON body of one shape:
{
"error": {
"code": "has_open_thread",
"message": "Patient already has an open async consult",
"details": {"asyncConsultId": "aB3xK9mQpZ4r"}
}
}
Branch on code — it is machine-readable and stable. message is for
logs (wording may change without notice), and details is optional
structured context: validation issues for invalid_request, a conflicting
resource id for some 409s. New codes may be added within /v1; treat an
unknown code by its HTTP class (4xx = your request, 5xx = ours).
The HTTP status is fully determined by the code (the server derives it from the same table, ERROR_HTTP_STATUS, so the two cannot disagree). Full per-code semantics live on ErrorCode.
| Code | HTTP | Meaning |
|---|---|---|
invalid_request |
400 | The body/query failed validation; details carries the per-field issues. Retrying unchanged will fail again. |
unauthorized |
401 | API key missing, malformed, revoked, wrong environment, or account suspended. |
embed_token_invalid |
401 | Embed only: session token failed verification (bad signature, suspended partner, invalidated by key rotation). Mint a fresh one. |
embed_token_expired |
401 | Embed only: session token exp passed. Mint a fresh one and call refresh(token). |
origin_not_allowed |
403 | Embed only: the embedding page's origin is not on your allowed-origins list. |
not_found |
404 | No such resource in your tenant (including ids that belong to other tenants — never a 403). |
feature_disabled |
409 | Your tenant does not have the product enabled (asyncEnabled / telehealthEnabled). Contact us; retrying won't help. |
phone_unavailable |
409 | POST /v1/patients: the phone is registered to a patient outside your tenant, or to a different patient inside it. No further detail is disclosed. |
email_in_use |
409 | POST /v1/physicians: a portal account with this email already exists (posted under a new externalId). |
external_id_conflict |
409 | POST /v1/physicians: this externalId is already registered under a different email. Repeats are idempotent only when identical; email changes are not supported over the API. |
has_open_thread |
409 | POST /v1/async-consults: the patient already has an open consult (details.asyncConsultId identifies it). One open thread per patient. |
thread_not_active |
409 | The consult isn't in an open, routable state for this operation (e.g. messaging a closed or still-invited thread). |
not_assigned |
409 | The acting physician is not the current assignee (assignment may have moved). Re-read; use claim/takeover. |
already_rated |
409 | Ratings are write-once. |
already_closed |
409 | The consult is already terminal; closedReason/status say how it ended. |
sla_not_overdue |
409 | takeover: the current assignee's response SLA hasn't elapsed yet. |
message_rejected |
409 | A posted message couldn't be accepted (thread closed between read and write, or empty after normalization). See the asynchronous sibling below. |
consult_not_cancellable |
409 | Telehealth cancel is only allowed from invited/waiting. |
rate_limited |
429 | Back off with jitter. The gateway may also 429 without a JSON body. |
internal_error |
500 | Our fault. Safe to retry idempotent requests. |
Every 409 (other than feature_disabled) is a state conflict: the
consult moved between your last read and your write — the underlying store
applies lifecycle transitions as conditional writes, so a stale operation
fails loudly instead of double-applying. The recovery is always the same:
re-read the resource, then retry only if the operation still makes
sense. A retry that finds the work already done gets a descriptive 409
(already_closed, not_assigned, …), never a double-application.
| Operation | Behavior on retry |
|---|---|
POST /v1/patients |
True upsert keyed on externalId — repeats update mutable fields, never duplicate. Always safe to retry. |
POST /v1/physicians |
Idempotent for an identical repeat (same externalId + same email → 200, names refreshed), so retries are safe. A repeat with a different email → 409 external_id_conflict; a new externalId with an in-use email → 409 email_in_use. Never a duplicate account. |
POST …/messages, POST …/replies |
Accepted with 202 and deduplicated inside the per-patient FIFO pipeline; a retry after a 5xx will not double-deliver. |
Lifecycle posts (claim/takeover/resolve/close/cancel) |
Conditional writes — a repeat gets a descriptive 409, never a second application. |
| All GETs | Naturally safe. |
POST /v1/async-consults/{id}/messages and …/replies return
202 Accepted: the message passed validation and was enqueued into
the per-patient FIFO pipeline — it had not yet been processed when the
response was sent, but it will appear on the transcript in order. The
202 body reports the messageId the message will carry on the transcript
and in webhooks.
To observe delivery:
async_consult.message webhook fires when the message
lands on the transcript.GET /v1/async-consults/{id}/messages.One asynchronous edge: if the thread closes after your 202 but before
the message processes, the message cannot be routed — you receive an
async_consult.message_rejected webhook naming your messageId. An
accepted patient message is never silently dropped; surface the rejection
in your patient UI. (The synchronous sibling — the thread was already
closed at post time — is the immediate 409 message_rejected /
409 thread_not_active instead.)
Every list endpoint uses the same cursor contract ( Page):
GET /v1/async-consults?status=active&limit=50
→ {"items": [...], "nextCursor": "eyJ..."}
GET /v1/async-consults?status=active&limit=50&cursor=eyJ...
→ {"items": [...]} ← no nextCursor: last page
Rules:
limit is 1–100; a server default applies when omitted.nextCursor present ⇒ more results; pass it back verbatim as cursor.
Absent ⇒ you've reached the end. (An empty items with no nextCursor
is also a valid end.)lastActivityAt
descending, telehealth consults newest first, transcripts
(…/messages) oldest first, events (GET /v1/events) oldest first.