developers
ES Sign up

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#

Create and publish a Flow#

  1. POST /v1/whatsapp-flows creates a draft with name, categories (SIGN_UP, SIGN_IN, APPOINTMENT_BOOKING, LEAD_GENERATION, CONTACT_US, CUSTOMER_SUPPORT, SURVEY or OTHER) and, optionally, the flow_json (object or string, up to 1 MB). Without channel_connection_id it is created in the account of the most recently connected WhatsApp Cloud number.
  2. Check validation_errors in the response: empty means the Flow JSON is ready. If there are errors, fix them and upload the JSON again with PUT /v1/whatsapp-flows/{id}/json.
  3. Test it: GET /v1/whatsapp-flows/{id}/preview returns a link that opens the form in the browser, and you can also send it to yourself with mode: "draft".
  4. POST /v1/whatsapp-flows/{id}/publish publishes it. From then on it no longer changes: for a new version, create another Flow.
curl
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}" }
                }
              }
            ]
          }
        }
      ]
    }
  }'
JSON
{
  "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.

TypeScript
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);
EndpointScopeWhat it does
GET /v1/whatsapp-flowsflows:readYour Flows, filtered by channel_connection_id and status.
POST /v1/whatsapp-flowsflows:writeCreates a draft (publish: true publishes it on creation if it has no errors).
POST /v1/whatsapp-flows/syncflows:writeBrings in the Flows you created in WhatsApp Manager and removes from heysteff the ones that no longer exist.
GET /v1/whatsapp-flows/{id}flows:readA Flow with its status and errors.
PATCH /v1/whatsapp-flows/{id}flows:writeName, categories or save_to_contact.
GET /v1/whatsapp-flows/{id}/jsonflows:readThe Flow JSON (the one uploaded from heysteff or, if none, the one Meta has).
PUT /v1/whatsapp-flows/{id}/jsonflows:writeUploads or replaces the Flow JSON of a draft.
POST /v1/whatsapp-flows/{id}/publishflows:writePublishes a draft.
POST /v1/whatsapp-flows/{id}/deprecateflows:writeRetires a published Flow.
DELETE /v1/whatsapp-flows/{id}flows:writeDeletes a draft (a published one is deprecated, not deleted).
GET /v1/whatsapp-flows/{id}/previewflows:readPreview link (expires after 30 days; ?invalidate=true asks for a new one).
GET /v1/whatsapp-flows/responsesflows:readYour 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
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"
    }
  }'
FieldWhat it is
flow_idRequired. The Flow id at Meta (flow_id from GET /v1/whatsapp-flows).
ctaRequired. Button text, up to 30 characters.
bodyRequired. Up to 1,024 characters.
header, footerOptional, up to 60 characters.
flow_tokenUp 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.
modepublished (default) or draft to test a draft.
actionnavigate (default) opens a screen; data_exchange asks your Flow's data endpoint for the first one.
screen, dataOnly 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:

json
{
  "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.

JSON
{
  "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#

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