Skip to content

Sending messages

WhatsApp does not let a business message a customer freely. What you may send depends on whether the customer has messaged you recently.

When a customer sends a message, a 24-hour window opens. Inside it a business may reply with free text. Outside it, only a template that Meta has approved in advance may be sent.

This is Meta’s rule, not ours.

Give exactly one of template or text on a send. A template works at any time; free text works only while the window is open.

Terminal window
curl -s -X POST "$TKANA_API/messages" \
-H "Authorization: Bearer $TKANA_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{
"to": { "phone": "+966501234567" },
"channel": "whatsapp",
"template": {
"name": "booking_reminder",
"language": "ar",
"variables": { "customer_name": "Sara", "time": "14:30" }
}
}'

You get 202 with the provider’s message id:

{
"id": "wamid.HBgMOTY2NTAxMjM0NTY3...",
"conversationId": null,
"status": "sent",
"sentAt": "2026-09-22T21:30:00.000Z"
}

An unknown phone creates the customer, so you do not have to look one up first. Give to.customerId instead when you already know them — one or the other, never both. A send that is refused creates nobody.

The Idempotency-Key is required here, unlike elsewhere in this API: a retried send that went through twice is a message your customer received twice. Without one you get 422.

You cannot choose the conversation a message lands in. It joins the customer’s WhatsApp conversation when it is delivered, so conversationId is refused rather than quietly ignored.

Bind every variable the template declares, by name:

  • A named template ({{customer_name}}) takes those names as keys.
  • A positional template ({{1}}, {{2}}) takes the numbers as keys: {"1": "Sara"}.

The names come from your template as Meta holds it, not from anything you send, so a key we do not recognise does not silently replace one we do. A missing one is missing_variables, and every missing name is listed in errors — Meta rejects a template with an empty parameter rather than sending it with a gap.

A template is also per language: booking_reminder in Arabic and in English are two separate approved templates, so name the language explicitly.

Code Status What to do
channel_not_connected 409 The organization has no connected WhatsApp number. Connect one in the portal under Channels.
template_not_approved 422 No template by that name and language, or Meta has not approved it yet. detail carries the status.
missing_variables 422 Bind the names listed in errors.
provider_error 502 Meta refused the send; detail carries their code and message, for example 131047 re-engagement message.

Inside the window, reply in plain text — no approved template needed:

Terminal window
curl -s -X POST "$TKANA_API/messages" \
-H "Authorization: Bearer $TKANA_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{
"to": { "customerId": "0d2f6a7e-4c3b-4b1a-9c58-2f1e8b6a9d10" },
"channel": "whatsapp",
"text": "Your technician is on the way and will arrive by 4 pm."
}'

Unlike a template, the reply is recorded in the conversation immediately, so the response carries a real conversationId and the message appears in the portal thread at once, labelled with your API key’s name.

Outside the window you get 422 outside_service_window, with the moment it closed in detail, and nothing is sent — so falling back to a template is safe.

Only the customer’s own messages open the window. Your replies do not extend it, which is why a busy thread can still be closed to free text. If you are unsure, send the text and fall back to a template on outside_service_window; that costs one refused call and is more reliable than tracking the clock yourself.

Terminal window
curl -s "$TKANA_API/messages/$MESSAGE_ID" \
-H "Authorization: Bearer $TKANA_KEY"

status moves sentdeliveredread, or failed with the provider’s reason in failureReason — Meta reports a failure asynchronously, minutes after accepting the send.

Poll this endpoint for a template send. The message.sent webhook fires for messages that are in a conversation, and a template joins the conversation only once it is delivered, so it is not the event to wait on here.

conversationId is null until the message is confirmed delivered. The customer’s conversation shows what they actually received, so a message sits outside the thread until Meta says it arrived — then it appears, labelled with the name of the API key that sent it.