Guides
Message types
Everything goes through POST /v1/messages. What changes is content_type and what comes with it: text, a file, a template, buttons, a location or, on WhatsApp Cloud, a Flow or a call button.
Text#
The default type. On WhatsApp Cloud it only goes out within 24 hours of the customer's last message; outside that window, use a template.
curl https://app.heysteff.com/api/v1/messages \
-H "Authorization: Bearer $HEYSTEFF_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"channel": "whatsapp_cloud",
"to": "+56912345678",
"content": "Hi Ana, your order is on its way"
}'To send on behalf of one of your customers, add customer_id: the message goes out through the channel that customer connected with their setup link (see Customers and setup links). It returns 404 customer_not_found if the customer doesn't exist and 422 customer_channel_not_connected if they have no active channel of that type.
Images, audio, video and documents#
First upload the file with POST /v1/media (multipart, field file, up to 15 MB):
curl https://app.heysteff.com/api/v1/media \
-H "Authorization: Bearer $HEYSTEFF_API_KEY" \
-F "[email protected]"{
"data": {
"url": "/media/2c7e9b1a-3d4f-4a5b-8c6d-7e8f9a0b1c2d/9f2c41d0a7b3e815.pdf",
"mime_type": "application/pdf",
"filename": "catalogo.pdf",
"size": 184213
}
}Then send its url in media.url, with the matching content_type. content is optional and goes as the caption.
curl https://app.heysteff.com/api/v1/messages \
-H "Authorization: Bearer $HEYSTEFF_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"channel": "whatsapp_cloud",
"to": "+56912345678",
"content_type": "file",
"media": {
"url": "/media/2c7e9b1a-3d4f-4a5b-8c6d-7e8f9a0b1c2d/9f2c41d0a7b3e815.pdf",
"filename": "catalogo.pdf"
},
"content": "Here is the November catalog"
}'| content_type | Files |
|---|---|
image | JPEG, PNG, WebP, GIF |
audio | MP3, M4A, OGG, WAV, WebM |
video | MP4, MOV |
file | PDF, Word, Excel, PowerPoint |
A public https URL also works in media.url: heysteff downloads it when it receives the request (up to 15 MB, without following redirects) and stores it as if you had uploaded it. If it cannot be downloaded, the response is 422 media_fetch_failed and nothing is sent. SVG, HTML and plain text are never accepted. You can upload or download up to 30 files per minute and 1,000 per day.
WhatsApp templates#
To message someone outside the 24-hour window, WhatsApp requires an approved template. GET /v1/templates lists the approved ones on your numbers, with how many body variables each takes (body_param_count):
curl https://app.heysteff.com/api/v1/templates \
-H "Authorization: Bearer $HEYSTEFF_API_KEY"To send one, pass the name, the language and the body variables in order:
curl https://app.heysteff.com/api/v1/messages \
-H "Authorization: Bearer $HEYSTEFF_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: pedido-1042-despacho" \
-d '{
"channel": "whatsapp_cloud",
"to": "+56912345678",
"content_type": "template",
"template": {
"name": "pedido_despachado",
"language": "es",
"body_params": ["Ana", "#1042"]
}
}'If the template has an image header or buttons with variables, send template.components in Meta's Graph API shape; it wins over body_params. A template with another name, another language or a different number of variables returns 422 before sending anything.
Buttons and lists#
With content_type: "interactive" you send a question with up to 3 buttons (type: "buttons") or a list of options grouped in sections (type: "list", where button is the label that opens the list). Each option's id is what comes back when the customer taps it.
curl https://app.heysteff.com/api/v1/messages \
-H "Authorization: Bearer $HEYSTEFF_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"channel": "whatsapp_cloud",
"to": "+56912345678",
"content_type": "interactive",
"interactive": {
"type": "buttons",
"body": "¿Confirmas tu hora del jueves a las 10:00?",
"buttons": [
{ "id": "confirmar", "title": "Confirmo" },
{ "id": "cambiar", "title": "Cambiar hora" }
]
}
}'A list on Instagram:
{
"channel": "instagram",
"to": "17841400000000000",
"content_type": "interactive",
"interactive": {
"type": "list",
"body": "¿Desde qué sucursal lo retiras?",
"button": "Ver sucursales",
"sections": [
{ "title": "Santiago", "rows": [
{ "id": "providencia", "title": "Providencia", "description": "Av. Providencia 123" },
{ "id": "nunoa", "title": "Ñuñoa" }
] }
]
}
}Each channel shows it as best it can. The response includes delivery, and degraded: true when the channel doesn't have the format:
| Channel | Buttons | List |
|---|---|---|
| WhatsApp Cloud | Native | Native up to 10 rows (in a single section); more becomes numbered text |
| Instagram, Messenger | Quick replies | Quick replies up to 13 options; more becomes numbered text |
| SMS, email, WhatsApp by QR | Text with numbered options | Text with numbered options |
When the customer taps an option, the message:received webhook includes interactive_reply with the id and the title. If they type an answer instead, it arrives as regular text and interactive_reply is null.
{
"event": "message:received",
"data": {
"message_id": "4e3d2c1b-0a9f-4e8d-7c6b-5a4f3e2d1c0b",
"content": "Confirmo",
"interactive_reply": { "id": "confirmar", "title": "Confirmo" }
}
}Buttons, lists and locations are session messages: on WhatsApp Cloud, Instagram and Messenger they only go out within 24 hours of the customer's last message. Outside the window the API returns 409 wa_window_closed or meta_window_closed and sends nothing.
Location#
With content_type: "location" you send a pin on the map. On WhatsApp Cloud it arrives as a native location; on other channels, as text with the name, the address and a Google Maps link (degraded: true).
{
"channel": "whatsapp_cloud",
"to": "+56912345678",
"content_type": "location",
"location": {
"latitude": -33.4489,
"longitude": -70.6693,
"name": "Tienda Centro",
"address": "Alameda 123, Santiago"
}
}Reactions#
With content_type: "reaction" you react with an emoji to an existing message (no to needed). emoji: "" removes the reaction. Works on WhatsApp (Cloud and by QR; on Cloud, within 24 hours) and on Instagram, connected through a Page or without a Facebook Page (Instagram Login), within 24 hours. Instagram has a single reaction, the heart: it accepts ❤️ (also ❤, ♥️ and ♥, stored as ❤️) and any other emoji returns 422 reaction_emoji_unsupported. Messenger can't react through the API. Returns 202: the queue sends it. A test key (sk_test_) can't react: it returns 403 test_key_not_allowed.
{
"channel": "whatsapp_cloud",
"content_type": "reaction",
"reaction": { "message_id": "4e3d2c1b-0a9f-4e8d-7c6b-5a4f3e2d1c0b", "emoji": "👍" }
}Flows, call button and call permission#
Three types that only exist on WhatsApp Cloud: on another channel (or if the sender is not a WhatsApp Cloud number) they return 422 whatsapp_cloud_only, with no fallback to text or a link, because the customer could not open the form or call. They need an sk_live_ key (a test key gets 403 test_key_not_allowed) and the 24-hour window open (otherwise 409 wa_window_closed; outside the window, use a template with a FLOW or call button). If Meta rejects one, the message ends up failed.
content_type: "flow"sends a WhatsApp Flow (a form inside the chat) withflow: { flow_id, cta, body, header?, footer?, flow_token?, mode?, action?, screen?, data? }. The answer arrives inmessage:receivedwithflow_response: { flow_token, data }. See WhatsApp Flows.content_type: "voice_call"sends a button for the customer to call you on WhatsApp:voice_call: { body, display_text?, ttl_minutes?, payload? }.content_type: "call_permission_request"asks the customer for permission to call them:call_permission: { body }. See WhatsApp calls.
{
"channel": "whatsapp_cloud",
"to": "+15550100123",
"content_type": "flow",
"flow": {
"flow_id": "1234567890123456",
"cta": "Book",
"body": "Pick the day of your visit.",
"flow_token": "order-1042"
}
}Status and read receipts#
GET /v1/messages/{id} (scope conversations:read) returns a message's status: queued, sent, delivered, read or failed (with error), plus sent_at, delivered_at and read_at. Changes also arrive by webhook: message.delivered, message.read and message.failed.
curl https://app.heysteff.com/api/v1/messages/1c2d3e4f-5a6b-4c7d-8e9f-0a1b2c3d4e5f \
-H "Authorization: Bearer $HEYSTEFF_API_KEY"POST /v1/messages/{id}/read marks a customer's message as read: the conversation is read in heysteff and the customer sees blue ticks on WhatsApp Cloud or "seen" on Instagram and Messenger (Meta marks the whole thread). SMS, email and WhatsApp by QR have no read receipt: it returns supported: false.
curl -X POST https://app.heysteff.com/api/v1/messages/4e3d2c1b-0a9f-4e8d-7c6b-5a4f3e2d1c0b/read \
-H "Authorization: Bearer $HEYSTEFF_API_KEY"A text longer than the channel allows (Instagram 1,000 characters, Messenger 2,000, WhatsApp 4,096) goes out in several pieces, in order; the response includes parts with each id.
Create and delete templates#
With the templates:write scope, POST /v1/templates creates a WhatsApp template and submits it to Meta for review (it stays PENDING; check the status with GET /v1/templates?status=all). DELETE /v1/templates/{name} deletes it in Meta and heysteff (every language, or only ?language=en). Headers with an image, video or document are created from the dashboard.
{
"name": "order_shipped",
"language": "en",
"category": "UTILITY",
"body_text": "Hi {{1}}, your order {{2}} is on its way.",
"body_examples": ["Ana", "#1042"]
}Common errors#
| Code | What happened |
|---|---|
invalid_media | media.url belongs to another workspace. |
media_not_found | The file no longer exists: upload it again. |
template_not_found | No template with that name and language on that number. |
template_param_mismatch | The number of body_params does not match the template. |
wa_window_closed | Buttons, a list or a location on WhatsApp Cloud outside the 24 hours: send a template. |
meta_window_closed | The same on Instagram or Messenger. |
reactions_unsupported | The channel can't react through the API (Messenger, SMS, email). Reactions exist on WhatsApp and Instagram. |
reaction_emoji_unsupported | Instagram only accepts ❤️ as a reaction. |
whatsapp_cloud_only | A Flow, a call button or a permission request on a channel that is not WhatsApp Cloud. |
not_inbound | Only a customer's message can be marked as read. |