Flows
Build WhatsApp Flows, the native forms, surveys and booking screens inside WhatsApp, publish them, send them, and read what customers submitted.
By the end of this page, a flow is published, a customer has opened it from a message, and their submission is in your hands.
A flow starts as a DRAFT and receives a Flow JSON definition. Publishing makes it sendable and immutable. Iterate by creating a new version.
Before you start
You need a connected WhatsApp account and its accountId.
Step 1: Create the flow
Call POST /v1/whatsapp/flows with accountId, name and categories (one or more of SIGN_UP, SIGN_IN, APPOINTMENT_BOOKING, LEAD_GENERATION, CONTACT_US, CUSTOMER_SUPPORT, SURVEY, OTHER).
import Zernio from '@zernio/node';
const zernio = new Zernio();
const accountId = '66b2e19d8c3f5a7e9d0b1c2d';
const { data: created } = await zernio.whatsappflows.createWhatsAppFlow({
body: {
accountId,
name: 'lead_capture_form',
categories: ['LEAD_GENERATION']
}
});
const flowId = created.flow.id;Response (200):
{
"success": true,
"flow": {
"id": "1178094743614213",
"name": "lead_capture_form",
"status": "DRAFT",
"categories": ["LEAD_GENERATION"],
"version": 1,
"lineageId": "1178094743614213"
}
}flow.id is the flowId for every call below.
Step 2: Upload the Flow JSON
Call PUT /v1/whatsapp/flows/{flowId}/json with accountId and flow_json, the screens, components and navigation in Meta's Flow JSON format. Meta validates it on upload.
const { data: uploaded } = await zernio.whatsappflows.uploadWhatsAppFlowJson({
path: { flowId },
body: {
accountId,
flow_json: {
version: '6.0',
screens: [{
id: 'LEAD_FORM',
title: 'Get a Quote',
terminal: true,
success: true,
layout: {
type: 'SingleColumnLayout',
children: [
{ type: 'TextInput', name: 'full_name', label: 'Full Name', required: true, 'input-type': 'text' },
{ type: 'TextInput', name: 'email', label: 'Email', required: true, 'input-type': 'email' },
{ type: 'Footer', label: 'Submit', 'on-click-action': { name: 'complete', payload: { full_name: '${form.full_name}', email: '${form.email}' } } }
]
}
}]
}
}
});
console.log(uploaded.validation_errors);Response (200):
{
"success": true,
"validation_errors": []
}Before publishing, GET /v1/whatsapp/flows/{flowId}/preview?accountId=... returns Meta's public web preview of the flow, drafts included, as preview_url.
The link needs no login, embeds as an iframe, and is reused across calls for about 30 days.
Pass invalidate=true to mint a fresh one. This stops the previous link working.
Step 3: Publish
Call POST /v1/whatsapp/flows/{flowId}/publish with accountId.
const { data: published } = await zernio.whatsappflows.publishWhatsAppFlow({
path: { flowId },
body: { accountId }
});
console.log(published.success);Response (200): { "success": true }.
Publishing is irreversible. A published flow and its JSON cannot be changed; to change anything, create a new version with cloneFlowId and asVersion: true, which copies the published flow into a fresh draft.
Step 4: Send the flow
Call POST /v1/whatsapp/flows/send with accountId, to, flow_id, flow_cta (the button label) and body.
The customer gets a message with a button that opens the flow natively in WhatsApp. draft: true sends an unpublished flow for testing.
const { data: sent } = await zernio.whatsappflows.sendWhatsAppFlowMessage({
body: {
accountId,
to: '+13105551234',
flow_id: flowId,
flow_cta: 'Get a quote',
flow_action: 'navigate',
flow_action_payload: { screen: 'LEAD_FORM' },
body: 'Fill out this quick form to get a personalized quote.'
}
});
console.log(sent.messageId);Response (200):
{
"success": true,
"messageId": "wamid.HBgLMTMxMDU1NTEyMzQVAgARGBI5QTNEMEY3RjQ4RjE2QjA3QzYA"
}Omit flow_token and Zernio generates one as <flowId>:<uuid>. That is what lets Step 5 attribute the response to this flow.
A flow can also go out inside a conversation as an interactive message of type flow (WhatsApp inbox).
The two flow modes
flow_action on the send picks how the flow gets its screens:
flow_action | How it runs | What you have to host |
|---|---|---|
navigate (default) | WhatsApp renders the screens straight from the published Flow JSON. The steps above build one of these. | Nothing |
data_exchange | WhatsApp posts the current screen's data to an endpoint you host and renders whatever screen the endpoint returns, so screens can depend on your data. | An HTTPS endpoint, plus an RSA key pair |
Point Meta at that endpoint with endpointUri. Set it on POST /v1/whatsapp/flows at creation, or on PATCH /v1/whatsapp/flows/{flowId} while the flow is still a DRAFT.
The URL must be HTTPS. The uploaded Flow JSON has to declare data_api_version "3.0" before WhatsApp calls it.
A data_exchange flow encrypts every exchange with your endpoint.
Register the public half against the number with POST /v1/whatsapp/flows/encryption-key (accountId and businessPublicKey, an RSA public key in PEM). Serve the private half from the endpoint.
Only one key is active per number, and uploading a new one replaces it.
GET /v1/whatsapp/flows/encryption-key reads back publicKey, signatureStatus and registered. Read registered, since Meta reports an unregistered key as MISMATCH rather than as absent.
Publishing a data_exchange flow with no key registered fails with Meta error 139002, "Missing Flows Signed Public Key".
A key that is registered but whose private half the endpoint does not serve passes publish. It then fails at runtime, when the customer opens the flow.
Step 5: Read the responses
A completed flow arrives as an nfm_reply on the message.received webhook.
To read submissions per flow without touching the inbox, call GET /v1/whatsapp/flow-responses with accountId and flowId.
const { data: responses } = await zernio.whatsappflows.listWhatsAppFlowResponses({
query: { accountId, flowId }
});
for (const r of responses.responses) {
console.log(r.from, r.receivedAt, r.data);
}Response (200), newest first:
{
"responses": [
{
"id": "wamid.HBgLMTMxMDU1NTEyMzQVAgASGBQzQTNEMEY3RjQ4RjE2QjA3QzYyRQA=",
"receivedAt": "2027-01-01T09:12:44Z",
"from": "13105551234",
"senderName": "Ana Costa",
"conversationId": "66c3d2ae7b4f6c8d0e1f2a3b",
"flowToken": "1178094743614213:2c1a7f0e-5b3d-4c9e-8a2f-6d1e0b9c7a54",
"data": { "full_name": "Ana Costa", "email": "ana@example.com" }
}
]
}Scoping by flowId works because the auto-generated flow_token carries the flow id and round-trips through Meta inside the response.
A flow sent with your own flow_token still delivers its responses on the webhook, but they are not attributed to a flow here.
Only flows sent since this token convention shipped are attributable.
Version a flow
A published flow is immutable, so you iterate by cloning it into a new draft. Pass cloneFlowId to POST /v1/whatsapp/flows:
- With
asVersion: true, you get a new version in the same lineage, auto-numbered. - Alone, you get an independent copy with its own lineage at version 1.
GET /v1/whatsapp/flows/{flowId}/versions lists the lineage, newest first, with each version's live name and status from Meta.
const { data: next } = await zernio.whatsappflows.createWhatsAppFlow({
body: {
accountId,
name: 'lead_capture_form',
categories: ['LEAD_GENERATION'],
cloneFlowId: flowId,
asVersion: true
}
});
const { data: history } = await zernio.whatsappflows.listWhatsAppFlowVersions({
path: { flowId: next.flow.id },
query: { accountId }
});
for (const v of history.versions) {
console.log(v.version, v.status);
}Response (200), the clone, a fresh draft at version 2 of the same lineage:
{
"success": true,
"flow": {
"id": "1178094743614299",
"name": "lead_capture_form",
"status": "DRAFT",
"categories": ["LEAD_GENERATION"],
"version": 2,
"lineageId": "1178094743614213"
}
}Response (200), the version list:
{
"versions": [
{ "flowId": "1178094743614299", "version": 2, "parentFlowId": "1178094743614213", "name": "lead_capture_form", "status": "DRAFT", "missing": false },
{ "flowId": "1178094743614213", "version": 1, "parentFlowId": null, "name": "lead_capture_form", "status": "PUBLISHED", "missing": false }
]
}Zernio tracks the lineage, because Meta has no native flow versioning.
A flow that was never cloned reports as version: 1 of its own lineage. missing: true marks a version Meta no longer has.
Flow lifecycle
| Status | Description |
|---|---|
DRAFT | Editable. Upload or update the JSON, change the name and categories. |
PUBLISHED | Immutable and sendable. To change it, create a new version with cloneFlowId and asVersion. |
DEPRECATED | No longer sendable. |
BLOCKED | Blocked by Meta for policy violations. |
THROTTLED | Temporarily rate-limited by Meta. |
If it fails
Step 2 returns 200 even when Meta rejects the definition; the problems are in validation_errors:
{
"success": true,
"validation_errors": [
{
"error": "INVALID_PROPERTY",
"error_type": "JSON_SCHEMA_ERROR",
"message": "Property 'input-type' is invalid",
"line_start": 12,
"line_end": 12,
"column_start": 9,
"column_end": 20
}
]
}Fix the JSON at the line and column named and upload again. A flow with validation errors cannot be published, and Step 3 returns 400 until they are gone.
A 400 on the upload itself means the body is not valid JSON or the flow is no longer a DRAFT.
Every error uses the envelope in error handling.
Related
- Flows API: get, update, delete and deprecate a flow.
- WhatsApp inbox: send a flow inside a conversation.
- Inbox webhooks: the
message.receivedpayload that carriesnfm_reply. - Templates: open the conversation before the flow when the window is closed.
Broadcasts
Send an approved WhatsApp template to many recipients with per-recipient variables, now or at a scheduled time, with delivery tracking per recipient.
Group chats
Create WhatsApp group chats from the API, add and remove participants, share invite links, approve join requests, and message the group from the inbox.