Guides
WhatsApp Flows
A Flow is a form that opens inside the WhatsApp chat: book an appointment, sign up, answer a survey or build an order. The customer fills it in without leaving WhatsApp and the answer reaches you by webhook. It only exists on WhatsApp Cloud.
How it works#
- Flows live in the WhatsApp account (WABA) of your number, at Meta. heysteff keeps a copy to list them without calling Meta and for its own settings (
save_to_contact). - Each Flow goes through
DRAFT(edit and test it),PUBLISHED(send it to any customer; it no longer changes) andDEPRECATED(it can no longer be sent or opened). Meta can set it toBLOCKEDorTHROTTLEDif its health drops. - Screens are defined in the Flow JSON, Meta's format (Flow JSON reference). heysteff uploads it as is and returns the errors Meta finds.
- You need a connected WhatsApp Cloud number (WhatsApp via QR does not work) and an
sk_live_key withflows:readandflows:write. A test key gets 403test_key_not_allowed: the shared sandbox number has no Flows of yours.
Create and publish a Flow#
POST /v1/whatsapp-flowscreates a draft withname,categories(SIGN_UP,SIGN_IN,APPOINTMENT_BOOKING,LEAD_GENERATION,CONTACT_US,CUSTOMER_SUPPORT,SURVEYorOTHER) and, optionally, theflow_json(object or string, up to 1 MB). Withoutchannel_connection_idit is created in the account of the most recently connected WhatsApp Cloud number.- Check
validation_errorsin the response: empty means the Flow JSON is ready. If there are errors, fix them and upload the JSON again withPUT /v1/whatsapp-flows/{id}/json. - Test it:
GET /v1/whatsapp-flows/{id}/previewreturns a link that opens the form in the browser, and you can also send it to yourself withmode: "draft". POST /v1/whatsapp-flows/{id}/publishpublishes it. From then on it no longer changes: for a new version, create another Flow.
curl https://app.heysteff.com/api/v1/whatsapp-flows \
-H "Authorization: Bearer $HEYSTEFF_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Book a visit",
"categories": ["APPOINTMENT_BOOKING"],
"save_to_contact": true,
"flow_json": {
"version": "6.3",
"screens": [
{
"id": "BOOKING",
"title": "Book your visit",
"terminal": true,
"layout": {
"type": "SingleColumnLayout",
"children": [
{ "type": "TextInput", "name": "name", "label": "Name", "required": true },
{ "type": "DatePicker", "name": "date", "label": "Date", "required": true },
{
"type": "Footer",
"label": "Send",
"on-click-action": {
"name": "complete",
"payload": { "name": "${form.name}", "date": "${form.date}" }
}
}
]
}
}
]
}
}'{
"data": {
"id": "0b6f4c1e-2d3a-4b5c-8d9e-1f2a3b4c5d6e",
"flow_id": "1234567890123456",
"channel_connection_id": "3a2b1c0d-9e8f-4a7b-8c6d-5e4f3a2b1c0d",
"name": "Book a visit",
"status": "DRAFT",
"categories": ["APPOINTMENT_BOOKING"],
"validation_errors": [],
"save_to_contact": true,
"responses_count": 0
}
}Keep both ids: id is heysteff's (the one in /v1/whatsapp-flows/{id} routes) and flow_id is Meta's, the one that goes in the message and in a template button.
import { Heysteff } from '@heysteff/sdk';
const heysteff = new Heysteff({ apiKey: process.env.HEYSTEFF_API_KEY });
const flow = await heysteff.whatsappFlows.create({
name: 'Book a visit',
categories: ['APPOINTMENT_BOOKING'],
flow_json: flowJson,
});
if (flow.validation_errors.length === 0) await heysteff.whatsappFlows.publish(flow.id);
const { preview_url } = await heysteff.whatsappFlows.preview(flow.id);| Endpoint | Scope | What it does |
|---|---|---|
GET /v1/whatsapp-flows | flows:read | Your Flows, filtered by channel_connection_id and status. |
POST /v1/whatsapp-flows | flows:write | Creates a draft (publish: true publishes it on creation if it has no errors). |
POST /v1/whatsapp-flows/sync | flows:write | Brings in the Flows you created in WhatsApp Manager and removes from heysteff the ones that no longer exist. |
GET /v1/whatsapp-flows/{id} | flows:read | A Flow with its status and errors. |
PATCH /v1/whatsapp-flows/{id} | flows:write | Name, categories or save_to_contact. |
GET /v1/whatsapp-flows/{id}/json | flows:read | The Flow JSON (the one uploaded from heysteff or, if none, the one Meta has). |
PUT /v1/whatsapp-flows/{id}/json | flows:write | Uploads or replaces the Flow JSON of a draft. |
POST /v1/whatsapp-flows/{id}/publish | flows:write | Publishes a draft. |
POST /v1/whatsapp-flows/{id}/deprecate | flows:write | Retires a published Flow. |
DELETE /v1/whatsapp-flows/{id} | flows:write | Deletes a draft (a published one is deprecated, not deleted). |
GET /v1/whatsapp-flows/{id}/preview | flows:read | Preview link (expires after 30 days; ?invalidate=true asks for a new one). |
GET /v1/whatsapp-flows/responses | flows:read | Your customers' answers. |
From the console: in the developer console, the WhatsApp Flows section lists your Flows, creates them, uploads the JSON, publishes or deprecates them, syncs them with Meta and shows the answers. From your AI: the MCP tools list_whatsapp_flows, get_whatsapp_flow, create_whatsapp_flow, update_whatsapp_flow, upload_whatsapp_flow_json, publish_whatsapp_flow, deprecate_whatsapp_flow, delete_whatsapp_flow, get_whatsapp_flow_preview, sync_whatsapp_flows and list_whatsapp_flow_responses.
Send a Flow#
While the conversation is open (within 24 hours of the customer's last message), send content_type: "flow" with POST /v1/messages. It goes out as a message with a button that opens the form.
curl https://app.heysteff.com/api/v1/messages \
-H "Authorization: Bearer $HEYSTEFF_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"channel": "whatsapp_cloud",
"to": "+15550100123",
"content_type": "flow",
"flow": {
"flow_id": "1234567890123456",
"cta": "Book",
"body": "Pick the day of your visit and we will confirm right away.",
"flow_token": "order-1042"
}
}'| Field | What it is |
|---|---|
flow_id | Required. The Flow id at Meta (flow_id from GET /v1/whatsapp-flows). |
cta | Required. Button text, up to 30 characters. |
body | Required. Up to 1,024 characters. |
header, footer | Optional, up to 60 characters. |
flow_token | Up to 512 characters. It comes back with the answer: use it to know which order or case it belongs to. If you omit it, heysteff generates an hs_… one. |
mode | published (default) or draft to test a draft. |
action | navigate (default) opens a screen; data_exchange asks your Flow's data endpoint for the first one. |
screen, data | Only with navigate: the first screen (default, the first one) and its initial data. |
After 24 hours it returns 409 wa_window_closed: use a template with a FLOW button. Create it with POST /v1/templates and a button {"type": "FLOW", "text": "Book", "flow_id": "1234567890123456", "flow_action": "navigate", "navigate_screen": "BOOKING"} (the Flow must be published), and send it like this:
{
"channel": "whatsapp_cloud",
"to": "+15550100123",
"content_type": "template",
"template": {
"name": "book_visit",
"language": "en",
"components": [
{
"type": "button",
"sub_type": "flow",
"index": "0",
"parameters": [
{ "type": "action", "action": { "flow_token": "order-1042", "flow_action_data": { "store": "downtown" } } }
]
}
]
}
}If Meta rejects the Flow when sending it (for example, because it is deprecated), the message ends up failed and the message.failed webhook arrives. It is not replaced with text: a form turned into text is no use to the customer.
Data endpoint (data_exchange): heysteff does not host yet the encrypted endpoint a Flow uses to fetch live data between screens. If your Flow needs it, set it up at Meta with your own server; heysteff still sends the Flow with action: "data_exchange" and delivers the final answer to you.
Receive the answers#
When the customer submits the form, the message:received webhook arrives with flow_response: the flow_token you sent the Flow with and the fields in data, keyed as in your Flow JSON. The message content is the same answer as text (one field: value line per field), which is what your team sees in the inbox.
{
"event": "message:received",
"data": {
"conversation_id": "9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d",
"message_id": "4e3d2c1b-0a9f-4e8d-7c6b-5a4f3e2d1c0b",
"channel": "whatsapp_cloud",
"content": "name: Ana\ndate: 2026-11-20",
"content_type": "interactive",
"interactive_reply": null,
"flow_response": {
"flow_token": "order-1042",
"data": { "name": "Ana", "date": "2026-11-20" }
}
}
}On any other message, flow_response is null. GET /v1/messages/{id} returns it too. To read them later, GET /v1/whatsapp-flows/responses lists them newest first (flow_id filters by the heysteff Flow id; for the next page, send before with the response's next_before).
Save the answers to the contact#
With save_to_contact: true (when creating the Flow or with PATCH /v1/whatsapp-flows/{id}), each simple field of the answer (text, number, yes/no or a list of texts) is saved to the contact field with the same key. Keys must be lowercase letters, numbers and underscores, starting with a letter (name, visit_date). If the field exists in Contacts, the value is validated against its type; if not, it is saved as is. GET /v1/whatsapp-flows/responses shows saved_to_contact.
Errors#
| Code | What happened |
|---|---|
whatsapp_cloud_only (422) | You sent a Flow on another channel, or the sender is not a WhatsApp Cloud number. |
test_key_not_allowed (403) | You used an sk_test_ key: Flows need an sk_live_ one. |
no_whatsapp_cloud_channel (422) | The workspace has no connected WhatsApp Cloud number. |
flow_json_invalid (422) | The Flow JSON is not a JSON object, is over 1 MB or Meta rejected it. The detail (line, column and path of each error) is in details.validation_errors. |
flow_not_draft (409) | The Flow is already published: its JSON does not change, it is not published again and it is not deleted (deprecate it). |
flow_not_published (409) | Only a published Flow is deprecated; a draft is deleted. |
wa_window_closed (409) | More than 24 hours since the customer's last message: send a template with a FLOW button. |
meta_flow_error (424) | Meta rejected the operation or did not answer. Check message; the number's token needs the whatsapp_business_management permission. |