Developers

Webhooks

Claro sends a signed POST when something changes. Verify it with a Standard Webhooks library, skip repeats by webhook-id, then re-fetch.

Set one up#

A clinic admin adds webhooks in Claro on the web:

  1. Go to Manage → Integrations, open Developer tools and choose the Webhooks tab.
  2. Choose Add webhook. Enter your endpoint. It must be https://.
  3. Pick the events. Leave Include clinical content in payloads off unless you truly need it (below).
  4. Choose Create, copy the signing secret, then choose I've saved it.

Send test event sends a sample. View deliveries shows recent attempts and your responses.

Events#

ResourceEvents
Patientpatient.created, patient.updated
Appointmentappointment.booked, appointment.updated, appointment.cancelled, appointment.completed
Notenote.created, note.updated, note.discarded
Salesale.completed, when a sale is paid in full. Not in the Webhooks tab yet.

One change sends one event. Canceling a series sends one appointment.cancelled per appointment.

What arrives#

POST /your/endpoint HTTP/1.1
Content-Type: application/json
webhook-id: msg_2KWPBgLlAfxdpx2AI54pPJ85f4W
webhook-timestamp: 1761876900
webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=

{
  "event": "patient.updated",
  "occurred_at": "2026-10-31T02:15:00Z",
  "data": {
    "patient_uuid": "3f2a9c1e-7b4d-4e0a-9f6c-2b8d5e1a7c3f",
    "patient_public_id": "pat_3f2a9c1e7b4d4e0a9f6c2b8d5e1a7c3f",
    "clinic_id": 42,
    "clinic_public_id": "clinic_k3v5tq2m7xw4b6n5r2c6hd3yja",
    "user_id": 7,
    "fields_changed": ["mobile"]
  }
}
HeaderMeaning
webhook-idThis event, for this endpoint. The same on every retry.
webhook-timestampUnix seconds, for this attempt.
webhook-signatureOne or more v1,<signature> entries, separated by spaces.
x-claro-eventThe event type.

An older x-claro-signature header is still sent for existing receivers. Use the Standard Webhooks headers for anything new.

Re-fetch by public ID#

Every payload names its records twice: the internal reference it has always carried, and beside it the public ID you can fetch.

Public IDBesideFetch with
clinic_public_idclinic_idGET /clinics/{id}
patient_public_idpatient_uuidGET /patients/{id}
appointment_public_idappointment_id, or a sale's appointment_uuidGET /appointments/{id}
practitioner_public_idpractitioner_id (appointment.booked)GET /practitioners/{id}
  • Use the public IDs. The internal references stay for existing receivers.
  • A public ID is null when its reference is.
  • Notes have no public ID yet, and user_id (who acted) has none.
  • Ignore keys you don't know. Keys are added without a new version.

Verify every request#

Claro follows Standard Webhooks. Use its official libraries with your signing secret (whsec_…), on the raw request body, before you parse it.

# pip install standardwebhooks
import os
from standardwebhooks.webhooks import Webhook

wh = Webhook(os.environ["CLARO_WEBHOOK_SECRET"])

def handle(raw_body: bytes, headers: dict):
    event = wh.verify(raw_body, {k.lower(): v for k, v in headers.items()})  # raises if invalid
    ...
// npm install standardwebhooks
import { Webhook } from "standardwebhooks";

const wh = new Webhook(process.env.CLARO_WEBHOOK_SECRET);

export function handle(rawBody, headers) {
  const event = wh.verify(rawBody, headers); // throws if invalid
  // ...
}

The libraries also reject old timestamps, which stops replays. Libraries exist for many more languages: see standard-webhooks on GitHub.

By hand#

  1. Take the secret after whsec_ and base64-decode it. That is the key.
  2. Build webhook-id + . + webhook-timestamp + . + raw body.
  3. HMAC-SHA256 it with the key, then base64-encode the result.
  4. Accept if it equals any v1, entry in webhook-signature. Compare in constant time.
  5. Reject a timestamp more than five minutes from now.

Handle it#

  • Reply fast. Any 2xx within 10 seconds. Queue the work; do it after you reply.
  • Expect repeats. A failed delivery is retried, up to five attempts in all. Skip a webhook-id you have already handled.
  • Expect any order. Events can arrive out of order. Never treat a payload as the latest state: re-fetch the record by its public ID.
  • Expect gaps. An endpoint that keeps failing is switched off and shows Inactive. Catch up with GET /events, and run your sync on a schedule too.

Clinical content in payloads#

By default a payload carries identifiers and times only. A clinic can tick Include clinical content in payloads to add patient details and note text.

Leave it off where you can. Read what you need through the API instead, under your credential's scopes, with every read audited.

Rotating the secret#

A clinic admin changes the secret on the Webhooks tab: Change signing secret beside the webhook. Claro shows the new secret once, then offers two choices:

  • Change secret. For the next 24 hours, both the new and the old secret sign every delivery, new first. Update your receiver any time inside that window. The libraries accept any matching signature, so nothing fails in between.
  • End the old secret now. For a leaked secret. The old one stops at once, so deliveries fail your checks until your receiver has the new one.

Catching up#

GET /events lists exactly what one webhook subscription was sent, oldest first. Use it to catch up after downtime, or to replay. It still lists the events of a webhook that was switched off.

  • Needs the events:read scope.
  • subscription is the webhook's ID, whsub_…. The Webhooks tab shows it under each webhook.
  • Narrow it with type (comma-separated event types) and since (ISO 8601 with an offset).
curl "https://api-au.claro.clinic/api/public/v1/events?subscription=whsub_q4m2x7c5v3n6b2k5r7w3tz4hea&since=2026-10-31T00:00:00Z" \
  -H "Authorization: Bearer $CLARO_API_KEY"
{
  "object": "list",
  "data": [
    {
      "id": "msg_2KWPBgLlAfxdpx2AI54pPJ85f4W",
      "event": "patient.updated",
      "occurred_at": "2026-10-31T02:15:00Z",
      "data": {
        "patient_uuid": "3f2a9c1e-7b4d-4e0a-9f6c-2b8d5e1a7c3f",
        "patient_public_id": "pat_3f2a9c1e7b4d4e0a9f6c2b8d5e1a7c3f",
        "clinic_id": 42,
        "clinic_public_id": "clinic_k3v5tq2m7xw4b6n5r2c6hd3yja",
        "user_id": 7,
        "fields_changed": ["mobile"]
      }
    }
  ],
  "has_more": false,
  "next_cursor": "eyJzIjoid2hzdWIi..."
}
  • Each event is its delivery, plus id: the same value as that delivery's webhook-id. De-duplicate across both paths on it.
  • Poll with the cursor. next_cursor comes back on the last page too. Keep it, and pass it as cursor next time to get only what happened since.
  • Late events are never skipped. Events from the last few seconds wait for the next page.
  • Data classes apply inside each event. patient_uuid and patient_public_id need patients:read, and are absent without it. Clinical content needs clinical_content:read, the clinic's opt-in for your app, and a subscription that includes it.
  • Order is still not a promise about the records. Re-fetch them.