Webhooks
Verify, deduplicate, and process signed appointment and consultation document events at your HTTPS endpoint.
Doctronic sends a signed HTTP POST to your HTTPS endpoint when an appointment changes state or a
consultation document is ready. Every delivery uses the same camelCase envelope:
{
"eventId": "0f8fad5b-d9cb-469f-a165-70867728950e",
"event": "appointment.waiting_room_ready",
"occurredAt": "2026-08-25T18:30:00Z",
"data": {
"appointmentId": "dc_01K1EXAMPLE00000000000000",
"userId": "mem_01K6JGGM2G2DB4Y9KB9H9MJJ6Q",
"waitingRoomUrl": "https://example.com/waiting-room/dc_01K1EXAMPLE00000000000000"
}
}| Field | Meaning |
|---|---|
eventId | Stable across every delivery attempt of one event. Use it as your deduplication key. |
event | The event name. Match it exactly; event names are case-sensitive. |
occurredAt | When the event happened, not when this attempt reached your endpoint. |
data | Event-specific fields, listed below. |
Events
| Event | data fields | Meaning |
|---|---|---|
appointment.waiting_room_ready | appointmentId, userId, waitingRoomUrl | A join URL is ready. Replace any URL you stored earlier; waiting-room URLs expire. |
appointment.practitioner_joined | appointmentId, userId | A practitioner joined the appointment. |
appointment.call_ended | appointmentId, userId | The appointment call ended. |
artifact.ready | artifactId, artifactType, chatId, userId | A consultation document is ready to retrieve. userId can be null. |
Doctronic sends only the events enabled for your organization. Acknowledge unrecognized event names
with 2xx, so new events leave the ones you handle unaffected.
Verify the signature
X-Doctronic-Signature carries a lowercase hexadecimal HMAC-SHA256 digest of the exact request
body bytes, keyed with your webhook secret. Verify it against the raw body before you parse the
JSON, and compare in constant time. Verify the bytes exactly as received: reserializing the body
changes them and breaks the signature. The SDKs do this in one call.
import { createHmac, timingSafeEqual } from 'node:crypto';
export async function verifyDoctronicWebhook(
request: Request,
secret: string,
) {
const body = Buffer.from(await request.arrayBuffer());
const supplied = request.headers.get('x-doctronic-signature') ?? '';
const expected = createHmac('sha256', secret).update(body).digest('hex');
const suppliedBytes = Buffer.from(supplied, 'utf8');
const expectedBytes = Buffer.from(expected, 'utf8');
const valid =
suppliedBytes.length === expectedBytes.length &&
timingSafeEqual(suppliedBytes, expectedBytes);
if (!valid) throw new Error('Invalid Doctronic webhook signature');
return JSON.parse(body.toString('utf8'));
}Store the webhook secret the same way you store the API token: see keep the token on your backend.
Process a delivery
- Read and keep the raw request body.
- Verify
X-Doctronic-Signaturewith a constant-time comparison. - Validate the envelope and the
datafields you expect for that event. - Insert
eventIdinto a durable uniqueness store. If it's already there, return2xxand stop. - Queue the work and return
2xxpromptly. - Apply the event only to the matching organization and patient records in your system.
Signature verification and eventId deduplication together are your replay protection. The signed
occurredAt is the only timestamp, and a retried delivery can arrive several hours after it, so use
it only for workflow-specific freshness checks.
Delivery behavior
| Your response | What Doctronic does |
|---|---|
2xx | Marks the delivery successful. |
Network failure, 429, or 5xx | Retries with exponential backoff for up to six hours. |
Any other 4xx | Treats the delivery as a permanent failure and stops. |
appointment.waiting_room_ready retries for up to five minutes and stops once the event is no
longer useful for the appointment. Other appointment events can stop retrying when the appointment
has ended. The attempt count varies, so keep the receiver available, idempotent, and fast.
Deliveries can arrive out of order. When the order matters, compare occurredAt or read the
current state from
GET /appointments/{appointment_id}/.
Test your receiver
Sign a sample body with your staging secret and post it to your local endpoint:
export DOCTRONIC_WEBHOOK_SECRET="replace-with-your-staging-webhook-secret"
body='{"eventId":"0f8fad5b-d9cb-469f-a165-70867728950e","event":"appointment.call_ended","occurredAt":"2026-08-25T19:05:00Z","data":{"appointmentId":"dc_01K1EXAMPLE00000000000000","userId":"mem_01K6JGGM2G2DB4Y9KB9H9MJJ6Q"}}'
signature=$(printf '%s' "$body" | openssl dgst -sha256 -hmac "$DOCTRONIC_WEBHOOK_SECRET" | sed 's/^.* //')
curl --fail "http://localhost:3000/webhooks/doctronic" \
--header "Content-Type: application/json" \
--header "X-Doctronic-Signature: $signature" \
--data-binary "$body"Then confirm, with signed staging deliveries as well as local ones, that:
- A valid signature succeeds and a changed body fails verification.
- A repeated
eventIdapplies its side effect once. - An unknown event name returns
2xxand leaves other events unaffected. - Retries and out-of-order deliveries leave your records in the latest state.
- A new waiting-room URL replaces the stored one.
- Processing failures reach your on-call team, with no secrets or unrestricted patient content in the logs.
Limits
- Doctronic configures webhooks manually. Endpoint registration, event subscriptions, secret delivery and rotation, delivery history, and event replay have no API or dashboard. To request any of them, see get access.
- Doctronic delivers the signing secret through a secure channel. Plan rotation as a short
cutover: accept the new secret at the agreed point, verify a signed test delivery, then remove
the old secret from your systems. The SDKs'
verifyWebhook()andverify_webhook()take one secret, so during the cutover verify with the new secret and, when that fails, with the old one.