# 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](#version-a-flow)).

## Build and send a flow

### Create the flow

Call [`POST /v1/whatsapp/flows`](https://pozy.ai/docs/api/createWhatsAppFlow) 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`.

```bash cURL
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"]
  }'
```
```ts Node
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 1
```
```python Python
import 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`](https://pozy.ai/docs/api/uploadWhatsAppFlowJson). This one-screen form asks for a name, an email and a guest count, then completes with those values.

```bash 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`](https://pozy.ai/docs/api/getWhatsAppFlowPreview) 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`](https://pozy.ai/docs/api/publishWhatsAppFlow) with the `accountId`. From here the flow is read-only.

### Send it

Call [`POST /v1/whatsapp/flows/send`](https://pozy.ai/docs/api/sendWhatsAppFlowMessage). The customer receives a message with a button labelled `flow_cta` (up to 20 characters) that opens the flow.

```bash cURL
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."
  }'
```
```ts Node
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);
```
```python Python
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](https://pozy.ai/docs/whatsapp/templates#buttons)).

## 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`](https://pozy.ai/docs/api/listWhatsAppFlowResponses):

```bash cURL
curl "https://api.pozy.ai/v1/whatsapp/flow-responses?accountId=6712a9d4c0e3b58f1a7d2e64&flowId=$FLOW_ID&limit=50" \
  -H "Authorization: Bearer $POZY_API_KEY"
```

```json
{
  "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}`](https://pozy.ai/docs/api/updateWhatsAppFlow) 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`](https://pozy.ai/docs/api/setWhatsAppFlowsEncryptionKey) (`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`](https://pozy.ai/docs/api/getWhatsAppFlowsEncryptionKey) 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`](https://pozy.ai/docs/api/createWhatsAppFlow):

- 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`](https://pozy.ai/docs/api/listWhatsAppFlowVersions) 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`](https://pozy.ai/docs/api/deprecateWhatsAppFlow).

## 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`](https://pozy.ai/docs/api/listWhatsAppFlows) | List flows |
| [`POST /v1/whatsapp/flows`](https://pozy.ai/docs/api/createWhatsAppFlow) | Create flow |
| [`GET /v1/whatsapp/flows/{flowId}`](https://pozy.ai/docs/api/getWhatsAppFlow) | Get flow |
| [`PATCH /v1/whatsapp/flows/{flowId}`](https://pozy.ai/docs/api/updateWhatsAppFlow) | Update flow |
| [`DELETE /v1/whatsapp/flows/{flowId}`](https://pozy.ai/docs/api/deleteWhatsAppFlow) | Delete flow |
| [`GET /v1/whatsapp/flows/{flowId}/json`](https://pozy.ai/docs/api/getWhatsAppFlowJson) | Get flow JSON asset |
| [`PUT /v1/whatsapp/flows/{flowId}/json`](https://pozy.ai/docs/api/uploadWhatsAppFlowJson) | Upload flow JSON |
| [`GET /v1/whatsapp/flows/{flowId}/preview`](https://pozy.ai/docs/api/getWhatsAppFlowPreview) | Get flow preview URL |
| [`GET /v1/whatsapp/flows/{flowId}/versions`](https://pozy.ai/docs/api/listWhatsAppFlowVersions) | List flow versions |
| [`POST /v1/whatsapp/flows/{flowId}/publish`](https://pozy.ai/docs/api/publishWhatsAppFlow) | Publish flow |
| [`POST /v1/whatsapp/flows/{flowId}/deprecate`](https://pozy.ai/docs/api/deprecateWhatsAppFlow) | Deprecate flow |
| [`GET /v1/whatsapp/flows/encryption-key`](https://pozy.ai/docs/api/getWhatsAppFlowsEncryptionKey) | Get Flows encryption key status |
| [`POST /v1/whatsapp/flows/encryption-key`](https://pozy.ai/docs/api/setWhatsAppFlowsEncryptionKey) | Register a Flows encryption key |
| [`POST /v1/whatsapp/flows/send`](https://pozy.ai/docs/api/sendWhatsAppFlowMessage) | Send flow message |
| [`GET /v1/whatsapp/flow-responses`](https://pozy.ai/docs/api/listWhatsAppFlowResponses) | List flow responses |

## Webhook events

| Event | When it fires |
| --- | --- |
| [`message.received`](https://pozy.ai/docs/webhooks/events#message-received) | Message received. |
