Docs

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.

TypeScriptPython
Package@doctronic-ai/sdkdoctronic
Sourcedoctronic-sdk-typescriptdoctronic-sdk-python
RuntimeNode 20 or later, server side onlyPython 3.10 or later, sync and async
Release0.x; see versioning0.x; see versioning
LicenseMITMIT

Install

npm install @doctronic-ai/sdk

Configure 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 production

Pass 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.
});
SettingTypeScriptPythonDefault
API tokenapiTokenapi_tokenDOCTRONIC_API_TOKEN. The client throws without one.
EnvironmentenvironmentenvironmentDOCTRONIC_ENVIRONMENT, then staging. See environments.
Base URLbaseUrlbase_urlThe environment's URL. Overrides environment.
TimeouttimeoutMs, in millisecondstimeout, in seconds or an httpx.Timeout60 seconds per attempt. For a stream, it covers only the wait until the stream starts.
RetriesmaxRetriesmax_retries2 retries after the first attempt. 0 turns retries off.
HTTP clientfetchhttp_clientThe 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.

ClientResourceMethods
Baseuserscreate
Basepharmacysearch
User-scopedusersretrieve, update
User-scopedchatscreate, list, retrieve, messages, stream
User-scopedappointmentsavailableSlots, createScheduled, createAsap, list, retrieve, result, cancel, reschedule
User-scopedaddressretrieve, set
User-scopedpharmacyset
User-scopedcoverageset
User-scopedartifactsretrieve, 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 as UnknownChatStreamEvent in Python. In TypeScript, check isKnownChatStreamEvent(event) before you switch on event.type; in Python, narrow with isinstance.
  • Errors before the stream starts throw: ConflictError for 409 error.partner.chat_stream_in_progress and ServiceUnavailableError for 503 error.partner.chat_stream_unavailable.
  • Errors after it starts arrive as an error event. 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 signal you pass or stream.abort() in TypeScript, and stream.close() in Python. Breaking out of the loop, or leaving the Python with block, also closes the connection.
  • Collect a whole turn with stream.finalMessages() (TypeScript) or stream.final_messages() (Python). It assembles messages by messageId with the role from message.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.
);
FailureReads (GET)Idempotent mutationschats.stream()
Network error or timeoutRetriedRetried with the same keyNever
429 or 503RetriedRetried with the same keyNever
409 idempotency conflictNot retriedRetried with the same keyNever
409 documented state conflict, such as error.appointments.slot_unavailableNot retriedNot retriedNever
Other 5xxRetried, unless the contract documents the status for the operation, such as 500 from GET /artifacts/{artifactId}/Not retriedNever
Other 4xxNot retriedNot retriedNever

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:

StatusClass
400, and the legacy 422BadRequestError
401AuthenticationError
403PermissionDeniedError
404NotFoundError
409ConflictError
429RateLimitError
503ServiceUnavailableError
Other 5xxInternalServerError
No responseAPIConnectionError, or its subclass APITimeoutError after the timeout
An aborted signal (TypeScript), or a stream closed before its endAPIUserAbortError
waitUntilReady / wait_until_ready deadlineArtifactNotReadyError
Failed webhook verificationWebhookVerificationError

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;
  }
}
FieldTypeScriptPython
Request IDrequestIdrequest_id
Retry-After, in secondsretryAfterretry_after
Validation entriesfieldErrorsfield_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);
WhereTypeScriptPython
A successful call.withResponse(), then requestIdwith_raw_response, then request_id
An errorerror.requestIderr.request_id
A streamstream.requestIdstream.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 the doctronic logger names the request ID and the fields, never their values.
  • APIResponseValidationError means the SDK can't use the response at all, such as one with a missing or null data.

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.

On this page