> ## Documentation Index
> Fetch the complete documentation index at: https://docs.orbit.devotel.io/llms.txt
> Use this file to discover all available pages before exploring further.

# WhatsApp Flows: interactive forms end-to-end

> Build, publish, and send WhatsApp Flows through Orbit: flow JSON screens and routing, validation errors to render, curl and dashboard send paths, webhook events, rate limits, and when to pick a Flow versus a catalog message versus a template.

# WhatsApp Flows: interactive forms end-to-end

A WhatsApp Flow is an in-app form your customer completes inside WhatsApp — screens, dropdowns, date pickers, radio groups, and a submit button — without leaving the conversation. You use a Flow where a free-form back-and-forth is clumsy (collecting a structured appointment, a lead-capture form, a survey) but you do not want to pre-write a free text answer set. Orbit wires Meta's Flows API end-to-end: build the JSON, upload it to Meta, publish it, send it as a button on an interactive message, and read submissions back.

This guide covers the whole lifecycle. If you have not connected a WABA yet, run [Get started with WhatsApp](/guides/whatsapp/getting-started) first.

## What a WhatsApp Flow is (and when it applies)

A Flow is a multi-screen form defined in JSON and hosted on Meta. The recipient taps a **CTA button** labelled by you — "Book an appointment", "Complete your checkout" — and WhatsApp renders the screens natively. The answers return to you as one structured reply.

The four rules that shape when you use a Flow:

* **No template review inside the 24h window.** A Flow message is still an interactive message — it rides the 24-hour customer-service window the same way buttons and lists do ([WhatsApp 24h freeform window](/guides/whatsapp/24h-window)). Inside the window you send it directly with no template approval. Outside the window you still need a template the customer replied to, or an approved template that opens the conversation.
* **A Flow has declared categories.** When you create the Flow you choose one or more of `APPOINTMENT_BOOKING`, `LEAD_GENERATION`, `CONTACT_US`, `CUSTOMER_SUPPORT`, `SURVEY`, `OTHER`. Meta enforces the set at create time.
* **Publish is irreversible in Meta.** A Flow starts as a `DRAFT`; you upload its JSON, run Meta's validation, then publish. Meta's `validation_errors` surface on upload and publish — Orbit forwards them verbatim.
* **A submission returns a structured payload.** The completed form replies as an `interactive.nfm_reply` inbound; Orbit parses its `response_json` and stores both the raw answers and the session correlation token (`flow_token`) you mixed in when you sent it.

## Endpoint surface

Two route families operate on Flows. Everything below sits under `https://api.orbit.devotel.io/api/v1/whatsapp` and authenticates with the same `X-API-Key` header as the other WhatsApp endpoints.

### Lifecycle (admin)

| Method | Path                                  | Purpose                                                                                                                                  |
| ------ | ------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
| GET    | `/whatsapp/flows`                     | List the org's Flows. Optional `?waba_id=` scopes the list to one WABA connection in the multi-WABA picker.                              |
| POST   | `/whatsapp/flows`                     | Create a Flow on Meta. Body: `{ name, categories[], waba_id? }`. Returns the `id` Meta assigned.                                         |
| GET    | `/whatsapp/flows/:flowId`             | Read one Flow's Meta status, categories, validation errors, preview URL.                                                                 |
| POST   | `/whatsapp/flows/:flowId/assets`      | Upload the Flow JSON definition. Body is the whole Flow JSON document.                                                                   |
| POST   | `/whatsapp/flows/:flowId/publish`     | Promote a DRAFT to PUBLISHED. Irreversible at Meta.                                                                                      |
| DELETE | `/whatsapp/flows/:flowId`             | Delete a DRAFT Flow.                                                                                                                     |
| GET    | `/whatsapp/flows/:flowId/funnel`      | Per-step drop-off funnel: which screen users abandon at (window-queryable with `since`/`until`).                                         |
| GET    | `/whatsapp/flows/:flowId/submissions` | The read surface for completed submissions, one row per completed final screen, with the CDP write-back marker.                          |
| POST   | `/whatsapp/flows/generate`            | AI flow-JSON generator — pass `{ prompt, category, maxScreens? }`; returns a draft flow document you can edit then upload via `/assets`. |

Create/publish/delete/upload are owner-admin operations. List/funnel/submissions/read are readable with any key.

### Send a Flow inside the window

One POST sends the Flow as an interactive message. This is the route the dashboard's Flow-send panel uses; call it directly from the API the same way.

`POST /api/v1/whatsapp/messages/send-flow`

| Field                 | Type   | Notes                                                                                                                                                                                                                                                     |
| --------------------- | ------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `to`                  | string | Recipient phone (E.164) or WA Business Solution UID.                                                                                                                                                                                                      |
| `flow_id`             | string | The Meta Flow id.                                                                                                                                                                                                                                         |
| `flow_cta`            | string | Button label (max 20 chars).                                                                                                                                                                                                                              |
| `body`                | string | Message prompt (1–1024 chars).                                                                                                                                                                                                                            |
| `header_text`         | string | Optional header (max 60).                                                                                                                                                                                                                                 |
| `footer_text`         | string | Optional footer (max 60).                                                                                                                                                                                                                                 |
| `flow_token`          | string | Your session-correlation token (max 255). Round-trips back on the reply.                                                                                                                                                                                  |
| `flow_action`         | enum   | `navigate` (default; open the named first screen) or `data_exchange`.                                                                                                                                                                                     |
| `flow_action_payload` | object | `{ screen, data? }` — the screen to open and the data to pre-fill; only for `flow_action = navigate`.                                                                                                                                                     |
| `mode`                | enum   | `draft` or `published` — send a Flow that is not yet published (requires Meta permission) or the default published behavior.                                                                                                                              |
| `phone_number_id`     | string | In a multi-WABA setup, forward the connection you picked in the dashboard picker so the send routes through that WABA instead of the org default. All the common work-flow bugs — "the send always uses my default WABA" — reduce to omitting this field. |

A successful send returns `{ messageId, status }` and fans out a `message.sent` webhook event; the reply callback you actually care about is `message.received` with its `interactive.nfm_reply` payload (see [Webhook events](#webhook-events-for-flow-completion) below).

## Building the flow JSON

A Flow JSON document describes screens, components, and the routing between screens. Orbit pins the JSON `version` to Meta's current `5.0` (the server rejects any other value and rewrites it for you).

A typical three-screen Flow:

```json theme={null}
{
  "version": "5.0",
  "screens": [
    {
      "id": "welcome",
      "title": "Book your appointment",
      "data": {
        "clinic_name": { "type": "string" }
      },
      "layout": {
        "type": "SingleColumnLayout",
        "children": [
          {
            "type": "TextHeading",
            "text": "Choose a slot with ${clinic_name}"
          },
          {
            "type": "Dropdown",
            "name": "slot",
            "label": "Available slots",
            "required": true,
            "data-source": [
              { "id": "morning", "title": "Morning (09:00–12:00)" },
              { "id": "afternoon", "title": "Afternoon (13:00–17:00)" }
            ]
          },
          {
            "type": "RadioButtonsGroup",
            "name": "reminder",
            "label": "Reminder",
            "required": true,
            "data-source": [
              { "id": "sms", "title": "Text me" },
              { "id": "none", "title": "No reminder" }
            ]
          },
          {
            "type": "Footer",
            "label": "Next",
            "on-click-action": {
              "name": "navigate",
              "next": { "type": "screen", "name": "confirm" }
            }
          }
        ]
      }
    },
    {
      "id": "confirm",
      "title": "Confirm",
      "data": {
        "slot": { "type": "string" },
        "reminder": { "type": "string" }
      },
      "layout": {
        "type": "SingleColumnLayout",
        "children": [
          {
            "type": "EmbeddedContent",
            "text": "Slot: ${slot}\nReminder: ${reminder}"
          },
          {
            "type": "Footer",
            "label": "Confirm booking",
            "on-click-action": {
              "name": "complete",
              "payload": {
                "slot": "${form.slot}",
                "reminder": "${form.reminder}",
                "flow_token": "${data.flow_token}"
              }
            }
          }
        ]
      },
      "terminal": true
    }
  ]
}
```

Three constructs do the heavy lifting:

* **Screens** — the top-level `screens[]` array. Each screen has an `id`, an optional `data` map naming fields, and a `layout.children[]` tree of components (`TextHeading`, `Dropdown`, `RadioButtonsGroup`, `TextInput`, `DatePicker`, `EmbeddedContent`, `Footer`).
* **Routing** — a Footer component's `on-click-action` names the next screen (`navigate`) or ends the flow (`complete`). A screen with no outgoing action is reachable only as the first screen; a screen with `"terminal": true` is the last one.
* **Terminal nodes** — the `complete` action's `payload` is what `nfm_reply.response_json` returns on submission. Interpolate entered fields with `${form.<name>}` or carry context forwarded from your send (`${data.flow_token}`).

If the shape or routing is wrong (a screen missing a terminal action, a `next` naming a screen that does not exist, a Footer without an action) Meta's validation rejects the upload — see the next section.

## Validating type-union errors the tests enforce

Meta enforces a closed set of literals for Flow category and status; Orbit ships that exact set in its exported types and pins it under test so a dropped member wakes the build. You see the same set echoed back when you create, list, and publish flows.

The categories the API accepts on `POST /whatsapp/flows`:

```
APPOINTMENT_BOOKING
LEAD_GENERATION
CONTACT_US
CUSTOMER_SUPPORT
SURVEY
OTHER
```

The Meta status a Flow moves through (returned on `GET /whatsapp/flows/:flowId` and in list responses):

```
DRAFT        → asset uploadable, publish not yet run
PUBLISHED    → live, can send with `mode: published` (the default)
DEPRECATED   → Meta retired this Flow — no longer sendable
BLOCKED      → policy-banned — check Meta Business Manager
THROTTLED    → Meta soft-block from quality rating — recoverable
```

A send against a `DRAFT` Flow also returns `validation_errors` from Meta — Orbit normalizes both the top-level `validation_errors` shape (asset upload) and the nested `error.error_data.details` string (publish) into the response's `error.details.validation_errors` list, each item with `{ error, error_type, message, component?, code?, line?, column? }`. Render those items in your dashboard the same way the Orbit dashboard does — they pinpoint the offending screen/component instead of a generic failed-publish toast.

## Sending via curl and via dashboard

### curl

```bash theme={null}
curl -X POST https://api.orbit.devotel.io/api/v1/whatsapp/messages/send-flow \
  -H "X-API-Key: dv_live_sk_..." \
  -H "Content-Type: application/json" \
  -d '{
    "to": "+14155552671",
    "flow_id": "1234567890",
    "flow_cta": "Book a slot",
    "body": "Pick the slot that works for you — takes a minute.",
    "header_text": "Appointment",
    "footer_text": "Clinic Downtown",
    "flow_token": "appt_0192f2_session_7",
    "flow_action": "navigate",
    "flow_action_payload": {
      "screen": "welcome",
      "data": { "clinic_name": "Downtown Clinic" }
    }
  }'
```

The reply you care about is the inbound webhook below. Free-text links in `body` are auto-rewritten to tracked short links (same toggle as the main WhatsApp send path), so long-link attribution still flows.

### dashboard

In the Orbit dashboard open **Messages → WhatsApp → Flows**. The list shows your WABA's Flows; the button next to each `DRAFT` runs validation, the button next to a published one opens **Send flow**. The Send panel is a thin form over the same `flow_id` / `flow_cta` / `body` / `flow_token` fields above — it forwards the connection you picked in the WABA picker as `phone_number_id`, so the send routes through the selected WABA instead of the org default.

## Webhook events for flow completion

Two events matter for the round trip. Subscribe on **Settings → Developer → Webhooks** to both:

| Event              | Meaning                                                                                                        |
| ------------------ | -------------------------------------------------------------------------------------------------------------- |
| `message.sent`     | Your Flow send was accepted by Meta. The reply (`messageId`) carries the same id you saw in the send response. |
| `message.received` | A completed Flow came back. Payload includes `interactive.nfm_reply` with the full form data.                  |

Orbit also dispatches a CDP event `whatsapp_flow_submitted` for the dashboard's contact-profile view — you do not subscribe to it; the Flow-submissions read endpoint is how you inspect it.

Inside the `message.received` payload the parsed form data lands under `interactive` as an `nfm_reply`:

```json theme={null}
{
  "interactive": {
    "type": "nfm_reply",
    "nfm_reply": {
      "response_json": "{\"slot\":\"afternoon\",\"reminder\":\"none\",\"flow_token\":\"appt_0192f2_session_7\"}",
      "body": "afternoon"
    }
  }
}
```

Three fields survive into the stored message row: `interactive_type: "nfm_reply"`, `flow_token` (echo of what the form's last screen interpolated from your send), and `flow_response` (JSON-parsed `response_json` minus the token). Match the inbound `flow_token` against the send's `flow_token` to join a submission to the session you opened.

If submissions are arriving but the dashboard funnel shows them only as final screens (not full screen transitions), that is the funnel-vs-list distinction on [Troubleshoot WhatsApp Flow submissions](/troubleshooting/whatsapp-flow-submissions).

## Rate-limit behavior and the wa-catalog bucket

Direct-send WhatsApp routes that bypass the richer message pipeline (`/messages/send-flow`, `/messages/send-interactive`, catalog product sends) share a **single rate-limited bucket per tenant**: 80 requests per minute, keyed on a tenant-suffixed `:wa-catalog` key. The bucket deliberately stays distinct from the main `POST /messages` path and from template or signup reads — a heavy catalog sender and a heavy Flow sender cannot crash each other on one marginal route, and one tenant cannot starve another on a shared NAT.

If you send Flows in bulk, spread them against a tenant-scoped limiter on your side; the burst limiter will reject the excess with HTTP 429 per request. Reply with a `Retry-After` header into your sender queue — the same pattern as the [Send message endpoint](/api-reference/messages) main path.

## When flow vs catalog vs template content

Pick the surface that matches what the customer does:

| Intent                                                      | Surface                     | Why                                                                                                          |
| ----------------------------------------------------------- | --------------------------- | ------------------------------------------------------------------------------------------------------------ |
| Collect structured answers (appointment, lead form, survey) | **Flow**                    | Screens and routing live in WhatsApp; one structured reply.                                                  |
| Send a product list / single product / catalog item         | **Catalog message**         | Meta Commerce catalog binding; the customer reads the product in-thread.                                     |
| Start a conversation or re-open outside the 24h window      | **Template**                | Only templates are allowed before the customer messages you — see [24h window](/guides/whatsapp/24h-window). |
| Free-form chat / support replies inside the window          | Text or interactive buttons | A Flow's structure is overhead; `send-interactive` with buttons is cheaper.                                  |

A Flow still needs the window to be open (or the customer to have replied to your template first). When you are outside the window, lead with an approved template that nudges the customer to reply, then send the Flow once the window opens.

## See also

* [Get started with WhatsApp](/guides/whatsapp/getting-started) — connect a WABA and send the first template.
* [WhatsApp 24h freeform window](/guides/whatsapp/24h-window) — what governs sending the Flow direct.
* [Troubleshoot WhatsApp Flow submissions](/troubleshooting/whatsapp-flow-submissions) — funnel-empty or `landed_in_cdp: false` diagnostics.
* [Webhook Events](/webhooks/events) — full `message.sent` / `message.received` payload examples.
