SmileCloudDocs

WhatsApp

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.

FieldTypeDescription
idstringUnique conversation id.
display_namestring | nullThe patient's WhatsApp profile name.
patient_phonestring | nullThe patient's number, in local 0… form.
clinic_phonestring | nullThe clinic's own WhatsApp number.
last_message_previewstring | nullText of the most recent message.
last_activity_atstring | nullISO 8601 timestamp of the last message either way.
last_inbound_atstring | nullISO 8601 timestamp of the last message from the patient.
last_outbound_atstring | nullISO 8601 timestamp of the last message to the patient.
is_handledbooleanWhether staff marked the thread as dealt with.
free_text_window_expires_atstring | nullWhen the 24-hour free-text window closes. null means it is already closed.
patientsobject[]Patient summaries linked to the thread.
created_atstring | nullISO 8601 timestamp.
updated_atstring | nullISO 8601 timestamp.

#The message object

FieldTypeDescription
idstringPrefixed id - in_… for inbound, out_… for outbound.
conversation_idstring | nullThe thread this belongs to.
directionstringinbound or outbound.
typestringtext, image, document, audio, video, sticker, contacts.
statusstringreceived for inbound. For outbound: queued, sent, delivered, read or failed.
from_phonestring | nullSender, as stored (outbound uses the international 972… form).
to_phonestring | nullRecipient, as stored.
patient_phonestring | nullThe patient side, whichever direction the message ran, in local 0… form.
textstring | nullMessage body, or the caption for a media message.
mediaobject{ "available": boolean, "type": string | null } - fetch the link separately.
patientsobject[]Patient summaries matched on patient_phone.
templatestring | nullOutbound only: the approved template this went out as, if any.
replied_to_idstring | nullInbound only: the outbound message this replies to.
failure_reasonstring | nullOutbound only: why Meta rejected it.
timestampsobject | nullOutbound only - see below. null for inbound.
created_atstring | nullISO 8601 timestamp.
updated_atstring | nullISO 8601 timestamp. Bumps on each delivery receipt.

#The timestamps object (outbound only)

FieldTypeDescription
sent_atstring | nullAccepted by WhatsApp.
delivered_atstring | nullReached the patient's handset.
read_atstring | nullOpened by the patient. Never set if they disabled read receipts.
failed_atstring | nullDelivery 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

bash
curl "https://api.smile-app.co.il/v1/whatsapp/conversations?limit=20" \
  -H "Authorization: Bearer sk_live_..."
ParameterDescription
phoneOnly the thread for this number. Separators are ignored.
is_handledtrue or false.
updated_sinceISO 8601. Switches to ascending mutation order for incremental sync.
limit, cursorSee Pagination.

Threads come back most-recent-activity first, unless updated_since is set.

#Get a conversation

bash
curl https://api.smile-app.co.il/v1/whatsapp/conversations/318 \
  -H "Authorization: Bearer sk_live_..."

#List messages in a conversation

bash
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.

bash
curl "https://api.smile-app.co.il/v1/whatsapp/messages?direction=inbound&from=2026-08-01" \
  -H "Authorization: Bearer sk_live_..."
ParameterDescription
directioninbound or outbound.
typetext, image, document, audio, video, sticker, contacts.
phoneMessages to or from this number. Separators are ignored.
from, toISO 8601 bounds on the message timestamp.
updated_sinceISO 8601. Use this to catch delivery receipts on older messages.
limit, cursorSee 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

bash
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.

bash
curl https://api.smile-app.co.il/v1/whatsapp/messages/in_5312/media \
  -H "Authorization: Bearer sk_live_..."
json
{
  "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

bash
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:

bash
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):

bash
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"] }'
FieldDescription
toRecipient phone. Separators are ignored.
textFree-text body. Mutually exclusive with template.
templateTemplate name from Templates. Mutually exclusive with text.
paramsPositional template parameters - params[0] fills {{1}}. The count must equal the template's parameter_count.

Both shapes return 202 Accepted, never 200:

json
{
  "data": { "id": "out_8842", "status": "queued", "direction": "outbound", "...": "..." },
  "meta": { "delivery_mode": "free_text" }
}
delivery_modeMeaning
free_textSent as free text, inside the 24-hour window.
template_fallbackThe window was closed, so your text went out wrapped in a marketing template. Billed as marketing.
templateA 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.

FieldTypeDescription
namestringThe identifier you send.
display_namestring | nullHuman-readable label as configured in SmileCloud.
languagestring | nullTemplate language code.
model_typestring | nullWhat the template attaches to. null or wa_chat_preview means it needs nothing but a phone number.
categorystring | nullWhatsApp's billing category, e.g. UTILITY or MARKETING.
statusstring | nullWhatsApp's review state. Only APPROVED templates can be sent.
rejection_reasonstring | nullWhy WhatsApp rejected it, when it did.
parameter_countinteger | nullHow many {{n}} placeholders the body has - the exact length params must be.
bodystring | nullThe template body, placeholders included.
header, footer, buttonsmixed | nullThe rest of the template as approved.
sendablebooleanWhether this API can send it.
not_sendable_reasonstring | nullWhy not, when sendable is false.

Pass ?sendable=true to list only what you can actually send.

bash
curl "https://api.smile-app.co.il/v1/whatsapp/templates?sendable=true" \
  -H "Authorization: Bearer sk_live_..."
json
{
  "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.