SDKs
Call the Doctronic API from TypeScript or Python with typed methods, user scope, streaming, safe retries, and webhook verification.
Doctronic publishes SDKs for TypeScript and Python. They call the same API the rest of these docs describe, so every guide applies: the SDKs add types, send the right headers, retry what is safe to retry, and parse the stream for you.
| TypeScript | Python | |
|---|---|---|
| Package | @doctronic-ai/sdk | doctronic |
| Source | doctronic-sdk-typescript | doctronic-sdk-python |
| Runtime | Node 20 or later, server side only | Python 3.10 or later, sync and async |
| Release | 0.x; see versioning | 0.x; see versioning |
| License | MIT | MIT |
Install
npm install @doctronic-ai/sdkConfigure the client
Both clients read the token from DOCTRONIC_API_TOKEN and the environment from
DOCTRONIC_ENVIRONMENT, and target staging when no environment is set. They ignore
DOCTRONIC_API_URL, which the curl examples use; pass baseUrl or base_url to change the URL.
export DOCTRONIC_API_TOKEN="replace-with-your-staging-token"
export DOCTRONIC_ENVIRONMENT="staging" # or productionPass options to override them:
import { Doctronic } from '@doctronic-ai/sdk';
const client = new Doctronic({
apiToken: process.env.DOCTRONIC_API_TOKEN, // Default: DOCTRONIC_API_TOKEN.
environment: 'production', // Default: DOCTRONIC_ENVIRONMENT, then 'staging'.
timeoutMs: 30_000, // Per attempt. Default: 60 seconds.
maxRetries: 2, // Default: 2.
});| Setting | TypeScript | Python | Default |
|---|---|---|---|
| API token | apiToken | api_token | DOCTRONIC_API_TOKEN. The client throws without one. |
| Environment | environment | environment | DOCTRONIC_ENVIRONMENT, then staging. See environments. |
| Base URL | baseUrl | base_url | The environment's URL. Overrides environment. |
| Timeout | timeoutMs, in milliseconds | timeout, in seconds or an httpx.Timeout | 60 seconds per attempt. For a stream, it covers only the wait until the stream starts. |
| Retries | maxRetries | max_retries | 2 retries after the first attempt. 0 turns retries off. |
| HTTP client | fetch | http_client | The global fetch, or an httpx client the SDK creates. |
TypeScript methods also take per-call options: signal, timeoutMs, maxRetries, and, on
idempotent operations, idempotencyKey. Python methods take idempotency_key on idempotent
operations.
Keep the client on your backend
The API token authorizes your organization's whole scope. The TypeScript client refuses to run in
a browser unless you pass dangerouslyAllowBrowser: true; don't. See
keep the token on your backend.
Organization and user scope
Two operations take the token alone, so they live on the base client:
POST /users/ as users.create and
POST /pharmacy/search/ as
pharmacy.search. Everything else acts on one patient. Call forUser() (TypeScript) or
for_user() (Python) with the user ID from your mapping, and the scoped client sends
X-Doctronic-User-ID on every request.
import { Doctronic } from '@doctronic-ai/sdk';
const client = new Doctronic();
const user = await client.users.create({ firstName: 'Avery', lastName: 'Chen' });
const patient = client.forUser(user.id);
await patient.users.update({ dateOfBirth: '1990-06-15', email: null }); // null clears a field.
const chats = await patient.chats.list({ orderBy: '-createdAt' });User-scoped resources aren't on the base client, so no request about a patient can go out without a user ID. Derive that ID on your backend from the patient your system authenticated; see Users.
| Client | Resource | Methods |
|---|---|---|
| Base | users | create |
| Base | pharmacy | search |
| User-scoped | users | retrieve, update |
| User-scoped | chats | create, list, retrieve, messages, stream |
| User-scoped | appointments | availableSlots, createScheduled, createAsap, list, retrieve, result, cancel, reschedule |
| User-scoped | address | retrieve, set |
| User-scoped | pharmacy | set |
| User-scoped | coverage | set |
| User-scoped | artifacts | retrieve, waitUntilReady |
Python uses the same names in snake_case, such as available_slots and wait_until_ready.
Methods return the data of the response envelope. TypeScript results use the contract's camelCase
field names. Python returns pydantic models with snake_case attributes, such as user.first_name;
model.model_dump(by_alias=True) gives the camelCase wire shape back.
Create a user and stream a turn
This program follows the quickstart: it creates a synthetic user, opens a chat, streams one turn, and prints each finished message and every other event.
// Node 20+. npm install @doctronic-ai/sdk, then run with: npx tsx quickstart.mts
import { randomUUID } from 'node:crypto';
import { Doctronic, isKnownChatStreamEvent } from '@doctronic-ai/sdk';
// Reads DOCTRONIC_API_TOKEN and DOCTRONIC_ENVIRONMENT, and targets staging by default.
const client = new Doctronic();
// 1. Create a user. The SDK sends an Idempotency-Key and reuses it if it retries.
// Email and phone number are unique per user, so each run uses a fresh email and no phone number.
const user = await client.users.create({
firstName: 'Avery',
lastName: 'Chen',
dateOfBirth: '1990-06-15',
email: `avery+quickstart-${randomUUID()}@example.com`,
sex: 'female',
});
// Save user.id on the patient record. Every request from `patient` sends X-Doctronic-User-ID.
const patient = client.forUser(user.id);
// 2. Create a chat.
const chat = await patient.chats.create({ timezone: 'America/New_York' });
// 3. Stream a patient turn. The SDK never retries a turn.
const stream = await patient.chats.stream(chat.id, {
userInput: 'I have had a sore throat and fever since yesterday.',
timezone: 'America/New_York',
});
const messages = new Map<string, { role: string; text: string }>();
for await (const event of stream) {
if (!isKnownChatStreamEvent(event)) {
console.log(event.type, event.data); // An event this SDK version doesn't know yet.
continue;
}
switch (event.type) {
case 'message.start':
messages.set(event.messageId, { role: event.role, text: '' });
break;
case 'message.content': {
const message = messages.get(event.messageId);
if (message) message.text += event.content;
break;
}
case 'message.stop':
console.log(messages.get(event.messageId));
break;
default:
console.log(event.type, event); // guardian, conversation.end, error, and the rest
}
}A turn can emit a guardian event that your product must act on. Read Safety
before you show the stream to patients.
Stream a turn
chats.stream() sends one patient turn and returns once the server accepts it. Iterate the result
for typed events; SDK event types maps each one to
its class and fields.
- Unknown events don't throw. An event name the SDK doesn't know arrives as
{ type, data }in TypeScript and asUnknownChatStreamEventin Python. In TypeScript, checkisKnownChatStreamEvent(event)before you switch onevent.type; in Python, narrow withisinstance. - Errors before the stream starts throw:
ConflictErrorfor409 error.partner.chat_stream_in_progressandServiceUnavailableErrorfor503 error.partner.chat_stream_unavailable. - Errors after it starts arrive as an
errorevent. Handle them apart from message text; see stream errors. - The stream is never retried. After a failure, read
chats.messages(chatId)before you let the patient resend the turn. - Cancel with the
signalyou pass orstream.abort()in TypeScript, andstream.close()in Python. Breaking out of the loop, or leaving the Pythonwithblock, also closes the connection. - Collect a whole turn with
stream.finalMessages()(TypeScript) orstream.final_messages()(Python). It assembles messages bymessageIdwith the role frommessage.start, and returns every other event in arrival order.
Retries and idempotency
The SDKs send an Idempotency-Key on the 11 operations whose contract declares one: every
mutation except the stream. Each call gets a new key, and every retry of that call reuses the same
key and body. Pass your own key to make a change safe to replay later, for example after your
process restarts:
await patient.appointments.createScheduled(
{ chatId, startsAt, location: { state: 'NY' } },
{ idempotencyKey: bookingId }, // One key per intended change, up to 255 characters.
);| Failure | Reads (GET) | Idempotent mutations | chats.stream() |
|---|---|---|---|
| Network error or timeout | Retried | Retried with the same key | Never |
429 or 503 | Retried | Retried with the same key | Never |
409 idempotency conflict | Not retried | Retried with the same key | Never |
409 documented state conflict, such as error.appointments.slot_unavailable | Not retried | Not retried | Never |
Other 5xx | Retried, unless the contract documents the status for the operation, such as 500 from GET /artifacts/{artifactId}/ | Not retried | Never |
Other 4xx | Not retried | Not retried | Never |
Retries back off exponentially from 0.5 to 8 seconds with up to 25% jitter, and wait for
Retry-After when the response carries one. A Retry-After over 60 seconds ends the call with its
error instead, so you decide when to try again. These rules follow the
retry checklist.
Errors
Every error extends DoctronicError. A response with an error status throws a subclass of
APIError. The class names are the same in both SDKs:
| Status | Class |
|---|---|
400, and the legacy 422 | BadRequestError |
401 | AuthenticationError |
403 | PermissionDeniedError |
404 | NotFoundError |
409 | ConflictError |
429 | RateLimitError |
503 | ServiceUnavailableError |
Other 5xx | InternalServerError |
| No response | APIConnectionError, or its subclass APITimeoutError after the timeout |
An aborted signal (TypeScript), or a stream closed before its end | APIUserAbortError |
waitUntilReady / wait_until_ready deadline | ArtifactNotReadyError |
| Failed webhook verification | WebhookVerificationError |
APIError carries status, code, message, data, headers, the request ID, and the
Retry-After value in seconds. BadRequestError also lists the {field, message, code} entries of
an error.validation_error. Branch on code, as on Errors.
import { APIConnectionError, APIError, BadRequestError } from '@doctronic-ai/sdk';
try {
await client.users.create({ email: 'avery@example.com', phoneNumber: '+15555550100' });
} catch (error) {
if (error instanceof BadRequestError && error.code === 'error.user.conflict') {
// Another user has this email or phone number. Reconcile your mapping before you retry.
} else if (error instanceof BadRequestError) {
console.error(error.code, error.fieldErrors, error.requestId);
} else if (error instanceof APIError) {
console.error(error.status, error.code, error.requestId);
} else if (error instanceof APIConnectionError) {
// No response after the SDK's retries, so the outcome is unknown.
} else {
throw error;
}
}| Field | TypeScript | Python |
|---|---|---|
| Request ID | requestId | request_id |
Retry-After, in seconds | retryAfter | retry_after |
| Validation entries | fieldErrors | field_errors |
A success response the SDK can't use, such as one with a missing or null data, throws
APIError in TypeScript and APIResponseValidationError, a subclass of APIError, in Python.
From 0.1.1, other differences from the contract don't throw; see versioning.
Request IDs
Keep the X-Request-ID of every failed request; see request IDs.
Errors carry it, and so does every stream. For a successful call, read the raw response:
const { data, response, requestId, idempotencyKey, idempotencyReplayed } = await patient.chats
.create({ timezone: 'America/New_York' })
.withResponse();
console.log(response.status, requestId, idempotencyKey, idempotencyReplayed, data.id);| Where | TypeScript | Python |
|---|---|---|
| A successful call | .withResponse(), then requestId | with_raw_response, then request_id |
| An error | error.requestId | err.request_id |
| A stream | stream.requestId | stream.request_id |
Verify webhooks
Pass the raw request body exactly as received, the X-Doctronic-Signature header, and your webhook
secret. verifyWebhook() and verify_webhook() check the HMAC-SHA256 signature in constant time
and return the parsed, typed envelope, or throw WebhookVerificationError. Then
process the delivery.
import { isKnownWebhookEvent, verifyWebhook, WebhookVerificationError } from '@doctronic-ai/sdk';
// For any framework that hands you a web-standard Request.
export async function handleDoctronicWebhook(request: Request): Promise<Response> {
let event;
try {
event = verifyWebhook(
await request.arrayBuffer(),
request.headers.get('x-doctronic-signature'),
process.env.DOCTRONIC_WEBHOOK_SECRET!,
);
} catch (error) {
if (error instanceof WebhookVerificationError) return new Response(null, { status: 400 });
throw error;
}
// Deduplicate on event.eventId, queue the work, then acknowledge.
if (isKnownWebhookEvent(event) && event.event === 'appointment.waiting_room_ready') {
// Replace the stored waiting-room URL with event.data.waitingRoomUrl.
}
return new Response(null, { status: 204 }); // Acknowledge unknown events too.
}The typed events are appointment.waiting_room_ready, appointment.practitioner_joined,
appointment.call_ended, and artifact.ready. Any other event name arrives untyped, so new events
never break verification.
Both functions take one secret. While you rotate the secret, verify with
the new secret and, when that throws WebhookVerificationError, with the old one.
Async Python
AsyncDoctronic has the same methods as Doctronic. Await each call, await chats.stream() to
start the turn, and iterate the stream with async for:
import asyncio
import os
from doctronic import AsyncDoctronic
from doctronic.types import MessageContentEvent
async def main() -> None:
async with AsyncDoctronic() as client:
patient = client.for_user(os.environ["DOCTRONIC_USER_ID"])
chat = await patient.chats.create(timezone="America/New_York")
async with await patient.chats.stream(chat.id, user_input="Hello") as stream:
async for event in stream:
if isinstance(event, MessageContentEvent):
print(event.content, end="", flush=True)
asyncio.run(main())Cancelling the task that reads a stream releases the connection, and the
asyncio.CancelledError propagates unchanged. await stream.close() is safe to call from another
task. Pass your own httpx.AsyncClient as http_client to share a connection pool.
Versioning
Both SDKs are 0.x releases, generated from the published
OpenAPI contract. Pin the version you test with, and read the
changelog before you upgrade:
Security fixes go to the latest 0.x release. The API itself grows additively; see
compatibility. An older SDK keeps working when new events and
fields arrive: it passes event names it doesn't know through untyped, and keeps fields it doesn't
know.
From 0.1.1, the Python SDK also accepts responses that differ from the contract:
- An enum value it doesn't list, such as a new appointment status, comes back as an instance of the enum that equals the string. Compare it with the members you handle and treat anything else as unknown.
- Other data that doesn't match, such as a missing field or a value of another type, is returned
instead of raised. Fields that match are typed, the rest are kept as the API sent them, and
missing required fields are
None. A warning on thedoctroniclogger names the request ID and the fields, never their values. APIResponseValidationErrormeans the SDK can't use the response at all, such as one with a missing ornulldata.
0.1.0 raised APIResponseValidationError for the first two as well, so upgrade from it.
Report an issue
Open an issue on the SDK's repository: TypeScript or Python. Include the SDK version, your Node or Python version, and the request ID. Leave out the API token and patient information.
Report a security vulnerability privately instead: open the repository's Security tab and select Report a vulnerability. Don't open a public issue for it.
For a problem with the API rather than the SDK, see report an API problem.