/flows), through the REST API (/v1/flows), or attach one to a template with a FLOW button.
Flows exist only on Meta (Cloud API) numbers. They belong to the WhatsApp Business Account (WABA), not to a single number, so every number of that WABA can send the same Flows.
Two kinds of Flow
The difference is whether your backend is involved while the form is open.
A
NAVIGATE Flow is delivered, filled in, and submitted once. You receive the whole answer at the end. Most Flows are this kind, and they need nothing beyond a Flow JSON.
A data_exchange Flow talks back mid-form: the customer picks a date and the next screen shows the slots that are actually free. That requires an HTTPS endpoint that Meta calls per screen — and Meta encrypts every one of those calls.
Lifecycle
1
Create
A Flow starts in
DRAFT. Give it a name and one or more categories (SIGN_UP, SIGN_IN, APPOINTMENT_BOOKING, LEAD_GENERATION, CONTACT_US, CUSTOMER_SUPPORT, SURVEY, OTHER).2
Upload the Flow JSON
The screens themselves are a JSON document. Meta answers
200 with a list of validation errors rather than refusing — so a green response does not mean the document compiled. Always read validationErrors.3
Publish
Publishing makes the Flow sendable — and is irreversible on Meta’s side. A published Flow can never be edited again.
4
Clone to make a new version
Because publishing is final, the way to change a live Flow is to clone it. The clone is a new Flow with a new id, and Pilot Status records the lineage so you can follow the chain from one version to the next.
5
Deprecate
Retiring a Flow does not delete it, and does not delete the answers people already submitted. You can name the Flow that replaces it, which is what keeps the version chain readable.
Sending a Flow
Attach the Flow to a template using a FLOW button. The button binds byflow_id, by flow_name, or by inline flow_json. Every send mints a fresh flow_token — a one-time value that ties that specific send to the answer that comes back later.
Receiving the answer
When the customer submits, Meta sends annfm_reply carrying the flow_token and, on a NAVIGATE Flow, the filled form as a JSON string. There are two ways to read it:
- Live — the
flow.response_receivedwebhook event, carrying the parsed answers. See Receive Flow responses. - Later —
GET /v1/flows/{id}/responses, joined back to the contact who answered.
Responses are kept for a retention window and pruned afterwards. The window is enforced on read, so a response past its expiry is never served — even if the pruner has not reached it yet.
data_exchange: Pilot Status handles the encryption
This is the part that usually stops teams from shipping an interactive Flow.
Meta encrypts every endpoint call: an AES-GCM payload whose key arrives RSA-2048/OAEP-SHA-256-wrapped under your public key, and the reply must go back encrypted with the same AES key under a bitwise-inverted IV. Getting any of that subtly wrong produces a Flow that simply never advances, with no error on either side.
So Pilot Status is the endpoint. We hold the key, decrypt Meta’s request, and forward plain JSON over HTTPS to a webhook you control. Your reply is encrypted on the way back. You never implement RSA, AES-GCM, or the inverted IV.
Two settings at two levels, and one registration — the key belongs to the number, the forwarding URL to the Flow, and
endpoint_uri is written at Meta. NAVIGATE Flows need none of them. All three are set on the Flows screen in the dashboard, and each has a public-API surface of its own: GET / POST /v1/numbers/{id}/flow-endpoint-key, GET / PUT / PATCH /v1/flows/{id}/endpoint, and POST /v1/flows/{id}/endpoint for the registration — all gated at flows:manage. Neither URL appears on GET /v1/flows, because the token inside it is a credential — see Connect your API to a Flow.1
1. Register a key for the number
Either generate one — Pilot Status creates an RSA-2048 pair, stores the private half encrypted, and registers the public half with Meta for that phone number — or import the private key you already use, which changes nothing at Meta. Generating replaces the key Meta holds, because Meta stores exactly one per number.
2
2. Set your webhook URL on the Flow
This is where we forward the decrypted JSON. It is configured per Flow, because one number can serve several forms and each one is usually a different service on your side.
3
3. Register us at Meta
Register at Meta, in the dashboard, writes our endpoint URL into the Flow’s
endpoint_uri at Meta — as does POST /v1/flows/{id}/endpoint. That URL carries two segments — one identifying the number, one identifying the Flow — because Meta’s request does not say which Flow it belongs to. Saving the URL in step 2 does not do this for you, deliberately: as a side effect of a rename it would hijack a Flow already pointed at someone else’s endpoint. A Flow already pointing at us is answered without a write; one pointing elsewhere is refused until you confirm the takeover.What your webhook receives
action is INIT when the form opens, BACK when the customer goes back, and data_exchange when they submit a screen.
The request is signed with an HMAC-SHA256 of the raw body in the x-pilot-status-signature header — the same header and the same scheme as Pilot Status outbound webhooks, so if you already verify those, you need no second code path.
What your webhook must return
Plain JSON, naming the next screen and the data it needs:SUCCESS screen:
Answer within a few seconds. Meta is holding the request open while a person watches a spinner, and there is no retry — a slow answer is a failed screen. Do the slow work after you reply, not before.
What is kept of the exchanges
Everydata_exchange exchange we decrypt is kept beside the response the form eventually produces, and published as submitted — one entry per exchange, on the flow.response_received event and on GET /v1/flows/{id}/responses alike. Inside Pilot Status it is the only place the answers of a data_exchange Flow exist at all: the nfm_reply Meta sends at the end carries the flow_token and nothing more, so response is empty on exactly the Flows whose screens collected the most.
What is kept is the request Meta sends us — the person’s own input, screen by screen. What your endpoint answered is not: the data you return, extension_message_response included, is encrypted back to Meta and written down nowhere. Neither are INIT and BACK — INIT hands back the params we seeded at send time and BACK is navigation, so neither is anything a person typed, and the list is legitimately shorter than your endpoint’s own traces.
Nothing is dropped in silence. An exchange too large to keep holds its place in the list and says
DATA_TOO_LARGE; one we could not read says UNREADABLE; the ceiling of 50 exchanges per form closes the list with a final entry saying LIMIT_REACHED, and nothing after it was stored. The list is also append-only: someone who walks back a screen and submits it again produces a second, genuine entry, separated from the first only by its timestamp.screen, at, data, omitted — is on the Flows API.
When your webhook fails
If your server is down, times out, or answers something unusable, Pilot Status does not pass the failure through as a broken screen. The customer sees a generic error on the screen they are already looking at, and can retry — nothing they typed is lost, and nothing about your infrastructure is shown to them.Attachments, and what Meta will not let you draw
APhotoPicker or a DocumentPicker puts a file in the submission. Four constraints decide whether the Flow you are designing is buildable at all:
The one-picker limit is Meta’s, and this is Meta’s own wording. Uploading a Flow JSON with a
PhotoPicker and a DocumentPicker on the same screen is refused with 422 FLOW_JSON_INVALID, and Meta’s entry comes back verbatim inside validationErrors:pointers names the components it refused. Moving the second picker onto a screen of its own makes the same document compile, with validationErrors: []. In the Node SDK the same array is read with flowJsonValidationErrors(err).Measured against Meta on 6 September 2026. The code, the wording and the limit are Meta’s to change without telling anyone — if this stops matching what you get back, believe Meta’s response, not this page.flowMedia on the webhook, media on the read surface, and why the URL is there before the bytes are — is in Receive Flow responses.
Health and status
Each Flow carries a status (DRAFT, PUBLISHED, DEPRECATED, BLOCKED, THROTTLED), the Flow JSON version Meta compiled, and a health verdict.
Meta discontinued the Flows Metrics API on 2026-04-30 with no successor. The
flows webhook is now the only channel that ever reports a Flow being throttled or a public key going stale — Pilot Status subscribes to it and records what it says on the Flow, so the signal survives past the log window.Related
- Receive Flow responses
- Flows with live data — setting
data_exchangeup, in product terms - Connect your API to a Flow — the contract your backend implements
- Flows API
- Templates
- Webhooks