WhatsApp

WhatsApp Flows

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"]
  }'

#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
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."
  }'

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
curl "https://api.pozy.ai/v1/whatsapp/flow-responses?accountId=6712a9d4c0e3b58f1a7d2e64&flowId=$FLOW_ID&limit=50" \
  -H "Authorization: Bearer $POZY_API_KEY"
Response
{
  "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:

  1. Set endpointUri (HTTPS) when you create the flow, or with PATCH /v1/whatsapp/flows/{flowId} while it is a draft.
  2. Declare data_api_version "3.0" in the Flow JSON.
  3. 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 (same lineageId, version goes 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

#Webhook events

Event When it fires
message.received Message received.