Webhooks
Webhooks push clinic events to your server as they happen, so you don't have to poll. When something changes - an appointment is booked, a patient is updated, a payment is recorded - the API sends a signed POST to your endpoint with the event payload.
#Event types
Subscribe to any combination of these events:
| Event | Fires when |
|---|---|
appointment.created | A new appointment is booked. |
appointment.updated | An appointment changes (time, status, provider…). |
appointment.cancelled | An appointment is cancelled. |
patient.created | A new patient is added. |
patient.updated | A patient's details change. |
treatment.created | A treatment is recorded. |
treatment.updated | A recorded treatment changes. |
treatment.cancelled | A recorded treatment is removed. |
payment.recorded | A payment is recorded. |
payment.cancelled | A payment is cancelled/refunded. |
call.completed | A phone call ended. This is the first event for a call - there is no start-of-call event. |
call.updated | A staff note on the call was added or edited. |
call.recording.available | The recording finished downloading from the PBX and can be fetched. |
call.transcript.completed | Transcription succeeded; the payload carries the summary and full segments. |
call.transcript.failed | Transcription was attempted and failed. No transcript will arrive for this call. |
whatsapp.message.received | A patient sent the clinic a WhatsApp message. |
whatsapp.message.sent | An outbound message was accepted by WhatsApp. |
whatsapp.message.delivered | An outbound message reached the patient's handset. |
whatsapp.message.read | The patient opened an outbound message. |
whatsapp.message.failed | An outbound message failed. |
#Call event ordering
A call arrives in pieces, so expect several events for the same call, minutes apart:
call.completedfires the moment the call ends. At this pointrecording.availableisfalseandtranscript.statusis usuallynull.call.recording.availablefollows once the PBX has finalized the audio - typically 2-15 minutes later, and never for calls the PBX didn't record.call.transcript.completed(or.failed) follows the recording, and only for answered calls with a non-zero duration on clinics with AI transcription enabled. Calls that were never answered are never transcribed, so most calls will never emit a transcript event - don't block on one.
Every one of these carries the complete current call object, so you can treat each delivery as an upsert on the call id rather than a patch.
#WhatsApp message ordering
Inbound is a single event: whatsapp.message.received fires as the patient's message lands, and nothing else happens to it afterwards.
Outbound is a chain, and how much of it you see is up to the patient's phone:
- There is no event for
queued.POST /v1/whatsapp/messagesalready returns that state, and a message staff sent from the CRM shows up a second later assent. whatsapp.message.sentfires when WhatsApp accepts the message - that is acceptance, not delivery.whatsapp.message.deliveredfollows when it reaches the handset. A phone that is off or out of coverage can hold this up for hours.whatsapp.message.readonly ever arrives from patients who left read receipts on, so plenty of messages that were read will never emit it. Don't treat its absence as unread.whatsapp.message.failedcan arrive instead of, or after,sent- a message WhatsApp accepted can still be rejected downstream. Itsfailure_reasonisnullon the event: the reason is recorded a moment after the failure itself, so re-read the message withGET /v1/whatsapp/messages/{id}if you need it.
Each delivery carries the whole message object with its current status, so treat them as upserts on the message id. Ordering is not guaranteed: compare the timestamps in the payload rather than assuming the deliveries arrive in lifecycle order.
Subscribing to any whatsapp.* event needs both webhooks:manage and whatsapp:read on the key - the payload carries the message body, which is whatever the patient chose to type.
#Subscribe
Create a subscription with the URL to deliver to and the events you care about. The response includes a signing secret - save it, it's shown only once.
curl -X POST https://api.smile-app.co.il/v1/webhooks \
-H "Authorization: Bearer sk_live_..." \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/hooks/smile",
"event_types": ["appointment.created", "appointment.updated", "appointment.cancelled"]
}'
See the Webhooks API reference for the full set of management endpoints.
#Payload
Each delivery is a POST with a JSON body shaped like this:
{
"id": "evt_9f2a7c",
"type": "appointment.created",
"created_at": "2026-07-01T09:00:05+03:00",
"data": {
"id": "55021",
"patient": { "id": "8842", "first_name": "Dana", "last_name": "Levi" },
"branch": { "id": "1", "name": "Downtown Branch" },
"start": "2026-07-01T09:00:00+03:00",
"end": "2026-07-01T09:30:00+03:00"
}
}
data mirrors the REST resource
The data object is byte-identical to the matching REST resource (an appointment, patient, treatment, or payment). Whatever you'd get from a GET, you get in the event - no second lookup needed.
#Headers
Every delivery carries these headers:
| Header | Description |
|---|---|
X-Smile-Signature | sha256=<hex hmac> of the raw body, keyed by your subscription secret. |
X-Smile-Timestamp | Unix time the delivery was signed. |
X-Smile-Event-Id | The event id (same as id in the body). |
X-Smile-Event-Type | The event type. |
#Verify the signature
Always verify the signature before trusting a payload. Compute the HMAC-SHA256 of the raw request body using your subscription secret, and compare it to X-Smile-Signature with a constant-time comparison.
import crypto from "node:crypto";
function verify(rawBody, signatureHeader, secret) {
const expected =
"sha256=" + crypto.createHmac("sha256", secret).update(rawBody).digest("hex");
const a = Buffer.from(signatureHeader);
const b = Buffer.from(expected);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
Verify on the raw body
Compute the HMAC over the exact bytes you received, before any JSON parsing or re-serialization. Re-encoding the body will change the bytes and break the signature.
#Delivery, retries, and ordering
- At-least-once. A delivery may arrive more than once. Deduplicate on
X-Smile-Event-Idand make your handler idempotent. - Retries. Failed deliveries (non-2xx or timeout) are retried with exponential backoff for up to ~24 hours.
- Auto-disable. A subscription that keeps failing is automatically disabled; re-enable it by fixing your endpoint and creating a new subscription.
- Respond fast. Return
2xxquickly (ideally after just enqueuing the event). Do heavy work asynchronously so you don't time out.
#Inspect and replay
Use the deliveries log to see attempts and outcomes, and retry a specific failed delivery once your endpoint is healthy again.