Build native forms, surveys and booking screens inside WhatsApp, publish them, send them and read what customers submit.
A WhatsApp Flow is a set of screens that opens inside the chat: a lead form, a booking picker, a short survey. The customer fills it in without leaving WhatsApp and you get structured data back instead of free text. You define the screens in Meta's Flow JSON, publish the flow, then send it with a button.
#How a flow moves through its life
| Status | What you can do |
|---|---|
DRAFT |
Upload and replace the JSON, rename it, change categories, send it to yourself for testing |
PUBLISHED |
Send it to anyone. The flow and its JSON can no longer change. |
DEPRECATED |
Nothing. It can no longer be sent. |
BLOCKED |
Meta blocked it for a policy issue |
THROTTLED |
Meta is rate-limiting it for now |
Publishing cannot be undone. To change a published flow you create a new version, which starts as a fresh draft (see Version a flow).
#Build and send a flow
#Create the flow
Call POST /v1/whatsapp/flows with a name (up to 128 characters) and one or more categories: SIGN_UP, SIGN_IN, APPOINTMENT_BOOKING, LEAD_GENERATION, CONTACT_US, CUSTOMER_SUPPORT, SURVEY or OTHER.
curl -X POST https://api.pozy.ai/v1/whatsapp/flows \
-H "Authorization: Bearer $POZY_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"accountId": "6712a9d4c0e3b58f1a7d2e64",
"name": "catering_quote",
"categories": ["LEAD_GENERATION"]
}'const res = await fetch("https://api.pozy.ai/v1/whatsapp/flows", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.POZY_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
accountId: "6712a9d4c0e3b58f1a7d2e64",
name: "catering_quote",
categories: ["LEAD_GENERATION"],
}),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error);
const flowId = data.flow.id; // status "DRAFT", version 1import os
import requests
H = {"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"}
resp = requests.post(
"https://api.pozy.ai/v1/whatsapp/flows",
headers=H,
json={
"accountId": "6712a9d4c0e3b58f1a7d2e64",
"name": "catering_quote",
"categories": ["LEAD_GENERATION"],
},
timeout=30,
)
flow_id = resp.json()["flow"]["id"]#Upload the screens
Send the Flow JSON to PUT /v1/whatsapp/flows/{flowId}/json. This one-screen form asks for a name, an email and a guest count, then completes with those values.
curl -X PUT "https://api.pozy.ai/v1/whatsapp/flows/$FLOW_ID/json" \
-H "Authorization: Bearer $POZY_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"accountId": "6712a9d4c0e3b58f1a7d2e64",
"flow_json": {
"version": "6.0",
"screens": [{
"id": "QUOTE",
"title": "Catering quote",
"terminal": true,
"success": true,
"layout": {
"type": "SingleColumnLayout",
"children": [
{ "type": "TextInput", "name": "name", "label": "Your name", "required": true, "input-type": "text" },
{ "type": "TextInput", "name": "email", "label": "Email", "required": true, "input-type": "email" },
{ "type": "TextInput", "name": "guests", "label": "Number of guests", "required": true, "input-type": "number" },
{ "type": "Footer", "label": "Send request",
"on-click-action": { "name": "complete",
"payload": { "name": "${form.name}", "email": "${form.email}", "guests": "${form.guests}" } } }
]
}
}]
}
}'Meta validates the JSON on upload. A 200 does not mean it passed: check validation_errors, which names the error with its line and column. A flow with validation errors cannot be published.
To look at the flow before you publish, GET /v1/whatsapp/flows/{flowId}/preview returns a public preview_url that works without a login and can sit in an iframe. It stays the same for about 30 days. Pass invalidate=true to issue a new one and kill the old link.
#Publish
Call POST /v1/whatsapp/flows/{flowId}/publish with the accountId. From here the flow is read-only.
#Send it
Call POST /v1/whatsapp/flows/send. The customer receives a message with a button labelled flow_cta (up to 20 characters) that opens the flow.
curl -X POST https://api.pozy.ai/v1/whatsapp/flows/send \
-H "Authorization: Bearer $POZY_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"accountId": "6712a9d4c0e3b58f1a7d2e64",
"to": "+351912345678",
"flow_id": "'"$FLOW_ID"'",
"flow_cta": "Get a quote",
"flow_action": "navigate",
"flow_action_payload": { "screen": "QUOTE" },
"body": "Tell us about your event and we will reply with a quote today."
}'const res = await fetch("https://api.pozy.ai/v1/whatsapp/flows/send", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.POZY_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
accountId: "6712a9d4c0e3b58f1a7d2e64",
to: "+351912345678",
flow_id: flowId,
flow_cta: "Get a quote",
flow_action: "navigate",
flow_action_payload: { screen: "QUOTE" },
body: "Tell us about your event and we will reply with a quote today.",
}),
});
if (!res.ok) throw new Error((await res.json()).error);requests.post(
"https://api.pozy.ai/v1/whatsapp/flows/send",
headers=H,
json={
"accountId": "6712a9d4c0e3b58f1a7d2e64",
"to": "+351912345678",
"flow_id": flow_id,
"flow_cta": "Get a quote",
"flow_action": "navigate",
"flow_action_payload": {"screen": "QUOTE"},
"body": "Tell us about your event and we will reply with a quote today.",
},
timeout=30,
).raise_for_status()Add draft: true to send an unpublished flow while you test. A flow message is a normal message, so the 24-hour window applies: to reach someone who has not written to you recently, send a template with a flow button instead (see Templates).
#Read the answers
Each completed flow arrives as a message.received event with the submitted values. If you would rather not dig through the inbox, list the submissions for one flow with GET /v1/whatsapp/flow-responses:
curl "https://api.pozy.ai/v1/whatsapp/flow-responses?accountId=6712a9d4c0e3b58f1a7d2e64&flowId=$FLOW_ID&limit=50" \
-H "Authorization: Bearer $POZY_API_KEY"{
"responses": [
{
"id": "wamid.HBgMMzUxOTEyMzQ1Njc4FQIAEhgUM0E1",
"receivedAt": "2026-10-28T14:02:11Z",
"from": "351912345678",
"senderName": "Marta Silva",
"conversationId": "6714c1f6e2a5d70b3c9f4a86",
"flowToken": "781203456789099:6b0f3c1e-8a52-4d7e-9c1b-2f4e6a8d0b13",
"data": { "name": "Marta Silva", "email": "marta@example.com", "guests": "40" }
}
]
}Responses come back newest first, up to 200 per call. Pozy can tie a response to its flow because, when you leave flow_token out on send, it generates one in the form <flowId>:<uuid>. If you pass your own flow_token, the response still reaches your webhook but is not grouped under the flow here.
#Two ways to run the screens
flow_action on the send decides where the screens come from.
flow_action |
How it works | What you host |
|---|---|---|
navigate (default) |
WhatsApp renders the screens from the published JSON | Nothing |
data_exchange |
WhatsApp posts each screen's data to your endpoint and shows the screen your endpoint returns, so screens can depend on live data such as available time slots | An HTTPS endpoint and an RSA key pair |
For data_exchange:
- Set
endpointUri(HTTPS) when you create the flow, or withPATCH /v1/whatsapp/flows/{flowId}while it is a draft. - Declare
data_api_version"3.0"in the Flow JSON. - Register the public half of your RSA key with
POST /v1/whatsapp/flows/encryption-key(businessPublicKey, PEM). Keep the private half on your endpoint. One key is active per number; uploading another replaces it.
Check the key with GET /v1/whatsapp/flows/encryption-key and look at registered. Publishing a data_exchange flow with no key fails with Meta error 139002. A key whose private half your endpoint does not hold passes publish and then fails when a customer opens the flow, so test with draft: true first.
#Version a flow
Because a published flow cannot change, you iterate by cloning. Pass cloneFlowId to POST /v1/whatsapp/flows:
- With
asVersion: true, the copy is the next version of the same flow (samelineageId,versiongoes up by one). - Without it, the copy is a separate flow starting at version 1.
GET /v1/whatsapp/flows/{flowId}/versions lists every version newest first, with its live status. missing: true marks a version Meta no longer has. When the new version is live, retire the old one with POST /v1/whatsapp/flows/{flowId}/deprecate.
#Common errors
| Error | Cause | Fix |
|---|---|---|
200 with entries in validation_errors |
Meta rejected part of the Flow JSON | Fix the line and column named and upload again |
400 on upload |
Body is not valid JSON, or the flow is no longer a draft | Validate the JSON; clone a new version to edit a published flow |
400 on publish |
The flow still has validation errors | Upload a clean JSON first |
Meta error 139002 on publish |
data_exchange flow with no encryption key registered |
Register the key, then publish |
#Endpoints
| Endpoint | What it does |
|---|---|
GET /v1/whatsapp/flows |
List flows |
POST /v1/whatsapp/flows |
Create flow |
GET /v1/whatsapp/flows/{flowId} |
Get flow |
PATCH /v1/whatsapp/flows/{flowId} |
Update flow |
DELETE /v1/whatsapp/flows/{flowId} |
Delete flow |
GET /v1/whatsapp/flows/{flowId}/json |
Get flow JSON asset |
PUT /v1/whatsapp/flows/{flowId}/json |
Upload flow JSON |
GET /v1/whatsapp/flows/{flowId}/preview |
Get flow preview URL |
GET /v1/whatsapp/flows/{flowId}/versions |
List flow versions |
POST /v1/whatsapp/flows/{flowId}/publish |
Publish flow |
POST /v1/whatsapp/flows/{flowId}/deprecate |
Deprecate flow |
GET /v1/whatsapp/flows/encryption-key |
Get Flows encryption key status |
POST /v1/whatsapp/flows/encryption-key |
Register a Flows encryption key |
POST /v1/whatsapp/flows/send |
Send flow message |
GET /v1/whatsapp/flow-responses |
List flow responses |
#Webhook events
| Event | When it fires |
|---|---|
message.received |
Message received. |