Skip to main content

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 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). 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)

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 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 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:
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:
The Meta status a Flow moves through (returned on GET /whatsapp/flows/:flowId and in list responses):
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

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: 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:
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.

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 main path.

When flow vs catalog vs template content

Pick the surface that matches what the customer does: 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