Errors
Read API errors, retry mutations safely with idempotency keys, and recover from stream errors and timeouts.
Every JSON error has a stable machine-readable code and a human-readable message. Branch on
code and the HTTP status; show or log message.
curl --include "$DOCTRONIC_API_URL/users/" \
-H "Authorization: Bearer invalid-token"{
"code": "error.auth.unauthorized",
"message": "Unauthorized."
}Field validation failures return 400 with code set to error.validation_error and data set to
a list of {field, message, code} entries. field is the request location and camelCase field
path, separated by dots:
{
"code": "error.validation_error",
"message": "Validation error",
"data": [
{
"field": "body.requiredField",
"message": "Field required",
"code": "error.validation.missing"
}
]
}HTTP status codes
| Status | Meaning | What to do |
|---|---|---|
400 | The request is invalid, failed validation, or is missing X-Doctronic-User-ID. | Correct it using code and, when present, data. Retrying it unchanged returns the same error. |
401 | The API token is missing or invalid. | Stop using the credential. See authentication. |
403 | The API token lacks the scope the operation requires. data.requiredScope names it. | Stop. Retrying with the same token returns the same error. See scopes. |
404 | The resource is absent from the authenticated organization and user scope, or the user in X-Doctronic-User-ID isn't one of your organization's active users. | Treat it as unavailable. Resources and users outside your scope return 404 too, so a 404 never reveals whether they exist elsewhere. |
409 | A state conflict or an idempotency conflict. | Read code to decide whether to refresh state or wait and retry. |
422 | A legacy schema response kept in the contract for generated-client compatibility. | Handle it like 400 if you ever receive it. The API returns 400 for validation failures. |
429 | The API itself has no per-caller request limit and doesn't return 429. | If a proxy or gateway in your path returns it, respect Retry-After when present, otherwise back off. Model capacity limits arrive as a stream error. |
502 | An appointment couldn't be provisioned with the clinical service. | Read code. For error.appointments.waiting_room_unavailable, the appointment exists: retrieve it before retrying. |
503 | The operation couldn't start safely, or the service is briefly unavailable. | Wait for Retry-After when present, then retry the same request. |
Other 5xx | Service failure. | Retry safe reads with bounded exponential backoff and jitter. |
The SDKs throw one error class per status, the same in TypeScript and
Python. Each extends APIError, which carries status, code, message, data, and the request
ID.
| Status | SDK error class |
|---|---|
400, and the legacy 422 | BadRequestError |
401 | AuthenticationError |
403 | PermissionDeniedError |
404 | NotFoundError |
409 | ConflictError |
429 | RateLimitError |
503 | ServiceUnavailableError |
Other 5xx, including 502 | InternalServerError |
| No response | APIConnectionError, or APITimeoutError after the timeout |
Some operations add their own statuses. GET /artifacts/{artifactId}/
returns 202 while a consultation document is still generating and 500 when generation failed
permanently; see consultation documents.
Error code reference
Every error.* code in the OpenAPI contract. Codes are
case-sensitive wire values.
| Code | Status | Returned by | Meaning |
|---|---|---|---|
error.address.not_found | 404 | GET /address/ | The user has no address on file. |
error.appointments.cannot_be_cancelled_or_rescheduled | 409 | POST /appointments/{appointment_id}/cancel/, POST /appointments/{appointment_id}/reschedule/ | The appointment's current state doesn't allow this change. |
error.appointments.chat_not_found | 404 | POST /appointments/scheduled/, POST /appointments/asap/ | The chat doesn't exist or doesn't belong to this user. |
error.appointments.chat_not_ready | 400 | POST /appointments/scheduled/, POST /appointments/asap/ | The chat isn't ready for appointment booking. |
error.appointments.incomplete_address | 400 | GET /appointments/scheduled/available-slots/, POST /appointments/scheduled/, POST /appointments/asap/ | The user's address on file is incomplete. |
error.appointments.invalid_slot | 400 | POST /appointments/scheduled/ | startsAt isn't a slot returned by GET /appointments/scheduled/available-slots/. |
error.appointments.missing_address | 400 | GET /appointments/scheduled/available-slots/, POST /appointments/scheduled/, POST /appointments/asap/ | The user has no address on file. |
error.appointments.missing_patient_demographics | 400 | POST /appointments/scheduled/, POST /appointments/asap/ | Required patient demographics are missing from the user record. |
error.appointments.no_practitioners_available | 400 | POST /appointments/scheduled/, POST /appointments/asap/, POST /appointments/{appointment_id}/reschedule/ | No practitioners are available in the requested state. |
error.appointments.not_found | 404 | GET /appointments/, GET /appointments/{appointment_id}/, GET /appointments/{appointment_id}/result/, POST /appointments/{appointment_id}/cancel/, POST /appointments/{appointment_id}/reschedule/ | The appointment doesn't exist in this scope. |
error.appointments.patient_not_found | 404 | POST /appointments/{appointment_id}/cancel/, POST /appointments/{appointment_id}/reschedule/ | The appointment's patient record wasn't found. |
error.appointments.provisioning_failed | 502 | POST /appointments/scheduled/, POST /appointments/asap/ | The clinical service didn't accept the booking. |
error.appointments.result_unavailable | 409 | GET /appointments/{appointment_id}/result/ | The appointment has no available result. |
error.appointments.slot_unavailable | 409 | POST /appointments/scheduled/ | The selected slot is no longer available. Refresh availability. |
error.appointments.unavailable_time | 409 | POST /appointments/{appointment_id}/reschedule/ | The requested time slot is unavailable. |
error.appointments.waiting_room_unavailable | 502 | POST /appointments/asap/ | The appointment is booked, but a secure waiting-room URL is unavailable. Retrieve the appointment before retrying. |
error.artifact.not_found | 404 | GET /artifacts/{artifactId}/ | The artifact doesn't exist, or doesn't belong to this organization or user. |
error.auth.insufficient_scope | 403 | Any operation | The API token doesn't hold the scope the operation requires. data.requiredScope names it. Stop and request a token with that scope; see scopes. |
error.auth.unauthorized | 401 | Any operation | The API token is missing or invalid. |
error.coverage.missing_patient_demographics | 400 | PUT /coverage/ | First name, last name, date of birth, or sex is missing from the user record. |
error.internal | 500, SSE error event | GET /artifacts/{artifactId}/, POST /chats/{chatId}/stream/ | From GET /artifacts/{artifactId}/, artifact generation failed permanently: stop retrying. In a stream, the reply failed and the stream ended; see stream errors. |
error.member.header_required | 400 | User-scoped operations | The request has no X-Doctronic-User-ID header. Send the user's ID on every user-scoped request; see request headers. |
error.member.not_found | 404 | User-scoped operations | The user in X-Doctronic-User-ID doesn't exist, isn't active, or isn't one of your organization's users. All three return the same response. Check the stored patient-to-user mapping and that it comes from the same environment as the token. |
error.partner.chat_not_found | 404 | GET /chats/{chatId}/, GET /chats/{chatId}/messages/, POST /chats/{chatId}/stream/ | The chat doesn't exist in this organization and user scope. Chats outside the scope are masked with this code. |
error.partner.chat_stream_in_progress | 409 | POST /chats/{chatId}/stream/ | Another stream is already running for this chat. |
error.partner.chat_stream_unavailable | 503 | POST /chats/{chatId}/stream/ | Streaming is briefly unavailable and the turn didn't start. |
error.partner.validation | 400 | POST /chats/, POST /chats/{chatId}/stream/ | The request or its context content failed input validation. |
error.pharmacy.missing_patient_demographics | 400 | PUT /pharmacy/ | First name, last name, date of birth, or sex is missing from the user record. |
error.pharmacy.not_found | 404 | PUT /pharmacy/ | No pharmacy matches the NCPDP ID. |
error.rate_limited | SSE error event | POST /chats/{chatId}/stream/ | Model capacity is constrained. Wait at least retryAfterSeconds. |
error.unknown | Any | Any operation | The default code in the error schema. Handle it by HTTP status. |
error.user.conflict | 400 | POST /users/, PATCH /users/ | A user with this email or phone number already exists. Conflicts never merge or transfer users. |
error.validation.missing | 400, inside data | Any operation that validates input | A required field is missing. Appears as data[].code in an error.validation_error response. |
error.validation.phone_number.unavailable | 503 | POST /users/, PATCH /users/ | Phone validation is briefly unavailable. Retry the same request after Retry-After. |
error.validation_error | 400 | Any operation that validates input | The request failed field validation. data lists each failure. |
The stream can also emit error.consultation_not_ready, described under
stream errors.
Idempotent requests
Send an Idempotency-Key header on a mutation so a retry can't apply it twice. These operations
accept one:
| Operation | Reference |
|---|---|
POST /users/ | Create a user |
PATCH /users/ | Update user details |
POST /chats/ | Create a chat |
POST /appointments/scheduled/ | Book a scheduled appointment |
POST /appointments/asap/ | Book an ASAP appointment |
POST /appointments/{appointment_id}/cancel/ | Cancel appointment |
POST /appointments/{appointment_id}/reschedule/ | Reschedule appointment |
PUT /address/ | Set a user's address |
POST /pharmacy/search/ | Search pharmacies |
PUT /pharmacy/ | Set a user's pharmacy |
PUT /coverage/ | Set user insurance coverage |
Generate one unique, opaque key (up to 255 characters) for each intended change. If the response is lost, retry the same method, path, and body with the same key:
IDEMPOTENCY_KEY="$(uuidgen)"
curl --include -X POST "$DOCTRONIC_API_URL/chats/" \
-H "Authorization: Bearer $DOCTRONIC_API_TOKEN" \
-H "X-Doctronic-User-ID: $DOCTRONIC_USER_ID" \
-H "Idempotency-Key: $IDEMPOTENCY_KEY" \
-H "Content-Type: application/json" \
-d '{"timezone": "America/New_York"}'const idempotencyKey = crypto.randomUUID();
const body = JSON.stringify({ timezone: 'America/New_York' });
async function createChat(): Promise<Response> {
for (let attempt = 1; ; attempt += 1) {
try {
const response = await fetch(`${process.env.DOCTRONIC_API_URL}/chats/`, {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.DOCTRONIC_API_TOKEN}`,
'X-Doctronic-User-ID': process.env.DOCTRONIC_USER_ID!,
'Idempotency-Key': idempotencyKey,
'Content-Type': 'application/json',
},
body,
signal: AbortSignal.timeout(10_000),
});
if (response.status !== 503 || attempt === 3) return response;
const retryAfter = Number(response.headers.get('retry-after') ?? '1');
await new Promise((resolve) => setTimeout(resolve, retryAfter * 1000));
} catch (error) {
// Network failure or timeout: the outcome is unknown, so retry with the same key.
if (attempt === 3) throw error;
const backoff = 2 ** attempt * 500 + Math.random() * 250;
await new Promise((resolve) => setTimeout(resolve, backoff));
}
}
}
const response = await createChat();
console.log(response.status, response.headers.get('idempotency-replayed'));The API stores the result for 24 hours. A response served from that store includes:
Idempotency-Replayed: true| Response | Cause | What to do |
|---|---|---|
409 | The key is still being processed, or it was already used with a different payload. | Wait and retry the original request, or generate a new key for a different change. |
503 with Retry-After | The API couldn't coordinate idempotency safely, so the mutation didn't start. | Wait at least Retry-After seconds, then retry with the same key and body. |
Use a key for exactly one request body. POST /chats/{chatId}/stream/
doesn't accept a key; see stream errors for how to recover a turn.
Stream errors
POST /chats/{chatId}/stream/
fails in two ways. Before the event stream starts, it returns an ordinary JSON error. After it
starts, a failure arrives as an error event with errorCode, message, and an optional
retryAfterSeconds, and the stream ends after that event:
event: error
data: {"type":"error","errorCode":"error.rate_limited","message":"The service is busy right now. Please retry in a few moments.","retryAfterSeconds":7}Handle an error event separately from assistant content; your
stream parser should route it like any other
control event in the event reference.
| Error | Where it appears | Recovery |
|---|---|---|
409 error.partner.chat_stream_in_progress | JSON response, before the stream starts | Another stream is running for this chat. Wait for it to finish, check GET /chats/{chatId}/messages/, then send the next turn. |
503 error.partner.chat_stream_unavailable | JSON response, before the stream starts | The turn didn't start. Retry shortly with bounded backoff. |
error.rate_limited | error event | Wait at least retryAfterSeconds when present, then check the transcript before offering a retry. |
error.internal | error event | The reply failed partway through the turn. Check the transcript, then let the patient resend the message. |
error.consultation_not_ready | error event, at the end of a consultation | The consultation couldn't be finalized, so booking is unavailable for this turn and no ai_consultation.complete follows. Check the user's profile and address, then let the patient continue the chat. |
Do not automatically resend a turn after an error event or a dropped connection. The first
attempt may already have produced work. Check
GET /chats/{chatId}/messages/
and let the patient choose to retry. The end of the HTTP connection marks transport completion
only; see end states.
Timeouts and uncertain outcomes
Set finite connection and total timeouts on ordinary JSON requests. A stream stays open for the whole assistant turn, so give it a longer read timeout and an explicit cancel action. The contract doesn't define one timeout value per workflow; choose limits that fit your patient experience.
A client timeout leaves the outcome unknown:
- For an idempotent mutation, retry with the same key and body.
- For any other mutation, read the resource to see whether the change happened before you replay it.
- For a stream turn, read the transcript before offering to resend the patient's message.
Request IDs
Responses carry an X-Request-ID header. Store it with your local request record,
including for failures, so Doctronic can trace the request without you sending credentials or
patient content. See report an API problem for what to
include in a report. The SDKs expose it as requestId (TypeScript) and request_id (Python) on
errors, streams, and raw responses.
Retry checklist
- Retry only failures the operation documents as temporary: network errors,
503, other5xxon reads, and stream errors withretryAfterSeconds. - Keep the same idempotency key and body when retrying a supported mutation.
- Read the resource before replaying a mutation without an idempotency key.
- Cap attempts and total elapsed time.
- Use exponential backoff with jitter, and wait at least
Retry-AfterorretryAfterSecondswhen present. - Treat the end of a stream connection as transport completion, not consultation completion.
- Keep
X-Request-IDwith every failed request.
The SDKs apply this checklist for you. They retry reads and
idempotent mutations after network errors, timeouts, 429, and 503, and idempotent mutations
after an idempotency conflict, always with the same key and body. They back off exponentially with
jitter, wait for Retry-After up to 60 seconds, stop after maxRetries / max_retries retries
(default 2), and never retry a stream. Pass your own idempotencyKey / idempotency_key to keep a
key across process restarts.