WhatsApp is how most clinics actually talk to their patients: appointment reminders, confirmations, x-ray photos, and the replies to all of it. You can read conversations, messages and media with whatsapp:read, and send with whatsapp:write.
Message bodies are patient conversations
A message body carries whatever a patient chose to tell the clinic, and the media may be a clinical photo. Both are covered by the single whatsapp:read scope - only grant it to integrations that genuinely need to read what patients said.
#Two things to know first
Message ids are prefixed. Inbound and outbound messages are stored separately, so ids look like in_5312 and out_8842. Treat the whole string as the id - a bare number is not a valid id and will 404.
The 24-hour window governs what you can send. WhatsApp forbids free-form text outside a 24-hour window that reopens each time a patient messages the clinic. Send free text outside it and it goes out wrapped in a marketing template, which is billed differently. The API does not hide this: free_text_window_expires_at on a conversation tells you the window state before you send, and meta.delivery_mode on the response tells you which product was actually used.
#The conversation object
One per patient phone number the clinic has exchanged messages with.
| Field | Type | Description |
|---|---|---|
id | string | Unique conversation id. |
display_name | string | null | The patient's WhatsApp profile name. |
patient_phone | string | null | The patient's number, in local 0… form. |
clinic_phone | string | null | The clinic's own WhatsApp number. |
last_message_preview | string | null | Text of the most recent message. |
last_activity_at | string | null | ISO 8601 timestamp of the last message either way. |
last_inbound_at | string | null | ISO 8601 timestamp of the last message from the patient. |
last_outbound_at | string | null | ISO 8601 timestamp of the last message to the patient. |
is_handled | boolean | Whether staff marked the thread as dealt with. |
free_text_window_expires_at | string | null | When the 24-hour free-text window closes. null means it is already closed. |
patients | object[] | Patient summaries linked to the thread. |
created_at | string | null | ISO 8601 timestamp. |
updated_at | string | null | ISO 8601 timestamp. |
#The message object
| Field | Type | Description |
|---|---|---|
id | string | Prefixed id - in_… for inbound, out_… for outbound. |
conversation_id | string | null | The thread this belongs to. |
direction | string | inbound or outbound. |
type | string | text, image, document, audio, video, sticker, contacts. |
status | string | received for inbound. For outbound: queued, sent, delivered, read or failed. |
from_phone | string | null | Sender, as stored (outbound uses the international 972… form). |
to_phone | string | null | Recipient, as stored. |
patient_phone | string | null | The patient side, whichever direction the message ran, in local 0… form. |
text | string | null | Message body, or the caption for a media message. |
media | object | { "available": boolean, "type": string | null } - fetch the link separately. |
patients | object[] | Patient summaries matched on patient_phone. |
template | string | null | Outbound only: the approved template this went out as, if any. |
replied_to_id | string | null | Inbound only: the outbound message this replies to. |
failure_reason | string | null | Outbound only: why Meta rejected it. |
timestamps | object | null | Outbound only - see below. null for inbound. |
created_at | string | null | ISO 8601 timestamp. |
updated_at | string | null | ISO 8601 timestamp. Bumps on each delivery receipt. |
#The timestamps object (outbound only)
| Field | Type | Description |
|---|---|---|
sent_at | string | null | Accepted by WhatsApp. |
delivered_at | string | null | Reached the patient's handset. |
read_at | string | null | Opened by the patient. Never set if they disabled read receipts. |
failed_at | string | null | Delivery failed; see failure_reason. |
status is derived from these, and terminal beats progress: a message that failed after WhatsApp accepted it reports failed, not sent.
Messages carry no patient foreign key. The link is a phone-number match made when you read, so patients may contain more than one person (a shared household number) or none at all.
#List conversations
curl "https://api.smile-app.co.il/v1/whatsapp/conversations?limit=20" \
-H "Authorization: Bearer sk_live_..."
| Parameter | Description |
|---|---|
phone | Only the thread for this number. Separators are ignored. |
is_handled | true or false. |
updated_since | ISO 8601. Switches to ascending mutation order for incremental sync. |
limit, cursor | See Pagination. |
Threads come back most-recent-activity first, unless updated_since is set.
#Get a conversation
curl https://api.smile-app.co.il/v1/whatsapp/conversations/318 \
-H "Authorization: Bearer sk_live_..."
#List messages in a conversation
curl "https://api.smile-app.co.il/v1/whatsapp/conversations/318/messages" \
-H "Authorization: Bearer sk_live_..."
Inbound and outbound are merged into one stream, newest first. Accepts the same filters as the cross-conversation listing below.
#List messages
Across every conversation.
curl "https://api.smile-app.co.il/v1/whatsapp/messages?direction=inbound&from=2026-08-01" \
-H "Authorization: Bearer sk_live_..."
| Parameter | Description |
|---|---|
direction | inbound or outbound. |
type | text, image, document, audio, video, sticker, contacts. |
phone | Messages to or from this number. Separators are ignored. |
from, to | ISO 8601 bounds on the message timestamp. |
updated_since | ISO 8601. Use this to catch delivery receipts on older messages. |
limit, cursor | See Pagination. |
Prefer webhooks, and poll on updated_since
Don't poll for inbound replies - subscribe to whatsapp.message.received instead and you'll hear about a patient's message as it lands, which matters if anything you build answers them.
If you do poll: a delivered or read receipt can land hours after a message was sent, and it bumps updated_at without changing created_at. Poll on updated_since, not from, or you will miss status changes on messages you have already seen.
#Get a message
curl https://api.smile-app.co.il/v1/whatsapp/messages/in_5312 \
-H "Authorization: Bearer sk_live_..."
#Get a message's media
Mints a fresh signed link, valid for about 10 hours. 404 when the message carries no media.
curl https://api.smile-app.co.il/v1/whatsapp/messages/in_5312/media \
-H "Authorization: Bearer sk_live_..."
{
"data": {
"url": "https://s3.eu-central-1.amazonaws.com/...",
"expires_at": "2026-08-16T01:27:11+03:00"
}
}
Fetch the link when you are about to use it. Links expire, so storing one is storing something that will stop working.
#A patient's messages
curl https://api.smile-app.co.il/v1/patients/9021/whatsapp/messages \
-H "Authorization: Bearer sk_live_..."
Matched on the patient's phone number. A patient with no phone on file returns an empty page rather than a 404.
#Send a message
Requires the whatsapp:write scope, which is never bundled with whatsapp:read - this endpoint writes to a patient's phone in the clinic's name.
Free text:
curl -X POST https://api.smile-app.co.il/v1/whatsapp/messages \
-H "Authorization: Bearer sk_live_..." \
-H "Content-Type: application/json" \
-d '{ "to": "0501234567", "text": "Running late? Let us know." }'
A template (see Templates):
curl -X POST https://api.smile-app.co.il/v1/whatsapp/messages \
-H "Authorization: Bearer sk_live_..." \
-H "Content-Type: application/json" \
-d '{ "to": "0501234567", "template": "appointment_reminder", "params": ["Dana", "Thursday 10:00"] }'
| Field | Description |
|---|---|
to | Recipient phone. Separators are ignored. |
text | Free-text body. Mutually exclusive with template. |
template | Template name from Templates. Mutually exclusive with text. |
params | Positional template parameters - params[0] fills {{1}}. The count must equal the template's parameter_count. |
Both shapes return 202 Accepted, never 200:
{
"data": { "id": "out_8842", "status": "queued", "direction": "outbound", "...": "..." },
"meta": { "delivery_mode": "free_text" }
}
delivery_mode | Meaning |
|---|---|
free_text | Sent as free text, inside the 24-hour window. |
template_fallback | The window was closed, so your text went out wrapped in a marketing template. Billed as marketing. |
template | A template you asked for by name. |
202 means accepted, not delivered
The message has been handed to WhatsApp; delivery is reported later. The returned status is queued. Subscribe to whatsapp.message.sent / .delivered / .read / .failed to be told as each receipt arrives, or re-read the message id to watch the status move.
#Which templates can you send?
Only templates whose active version WhatsApp has approved, and which are not bound to an internal record. Most reminder templates fill their placeholders from an appointment or a patient inside SmileCloud, and this API does not accept those record ids - Templates marks them sendable: false with a not_sendable_reason rather than hiding them.
Sending an unsendable or unknown template returns 422, with different messages for the two cases so a correct name never looks like a typo.
#Templates
GET
The clinic's message templates. Requires whatsapp:read. Use name as the template on POST /v1/whatsapp/messages.
| Field | Type | Description |
|---|---|---|
name | string | The identifier you send. |
display_name | string | null | Human-readable label as configured in SmileCloud. |
language | string | null | Template language code. |
model_type | string | null | What the template attaches to. null or wa_chat_preview means it needs nothing but a phone number. |
category | string | null | WhatsApp's billing category, e.g. UTILITY or MARKETING. |
status | string | null | WhatsApp's review state. Only APPROVED templates can be sent. |
rejection_reason | string | null | Why WhatsApp rejected it, when it did. |
parameter_count | integer | null | How many {{n}} placeholders the body has - the exact length params must be. |
body | string | null | The template body, placeholders included. |
header, footer, buttons | mixed | null | The rest of the template as approved. |
sendable | boolean | Whether this API can send it. |
not_sendable_reason | string | null | Why not, when sendable is false. |
Pass ?sendable=true to list only what you can actually send.
curl "https://api.smile-app.co.il/v1/whatsapp/templates?sendable=true" \
-H "Authorization: Bearer sk_live_..."
{
"data": [
{
"name": "appointment_reminder",
"display_name": "Appointment reminder",
"category": "UTILITY",
"status": "APPROVED",
"parameter_count": 2,
"body": "Hi {{1}}, this is a reminder for your appointment on {{2}}.",
"sendable": true,
"not_sendable_reason": null
}
]
}
Unsendable templates are still listed
Most reminder templates fill their placeholders from an appointment or a patient record inside SmileCloud, and this API does not accept those internal ids - they come back sendable: false with a reason rather than being hidden, so a template you configured never silently disappears from the list.
#Related
- Calls - the phone log, same phone-matching behavior.
- Scopes
- Pagination
- Errors