Skip to main content
A Flow is a form that opens inside the WhatsApp chat — a booking screen, a lead form, a support triage — instead of a conversation the customer has to type their way through. Pilot Status manages the whole lifecycle: create and edit in the dashboard (/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.
Meta will not publish a data_exchange Flow that has no endpoint_uri registered — it answers “Publishing without specifying ‘endpoint_uri’ is forbidden”. Registering is step 3 below, and it is a request of its own.

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.
Publishing cannot be undone. There is no edit and no unpublish — only clone. The dashboard asks for an explicit confirmation, and the API requires "confirm": true in the body, precisely so a mis-click cannot do it.

Sending a Flow

Attach the Flow to a template using a FLOW button. The button binds by flow_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 an nfm_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_received webhook event, carrying the parsed answers. See Receive Flow responses.
  • Later — GET /v1/flows/{id}/responses, joined back to the contact who answered.
The chat message that carries a submission has no answers in it, and the event has to be subscribed to. Meta’s nfm_reply puts a bare label in the message body — Sent / Enviado — and that label is what reaches content, GET /v1/messages and the Chatwoot mirror. A webhook subscribed only to messages therefore receives a message that looks empty, with no error anywhere. Add flow.response_received to its events.
In a data_exchange Flow the answers are not in response at all. Meta’s nfm_reply carries the flow_token and nothing else, so response — on the event and on the read surface — holds a bare token while your own endpoint received what the person actually typed. Those answers are published as submitted, one entry per exchange. response keeps its old meaning only on a Flow with no endpoint, where free-text components still arrive in it.
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.
If data arrives empty, the cause is almost always one character in your Flow JSON.A form value is referenced by the literal keyword form — ${form.quantity} — and the name you gave the enclosing Form component never appears in the reference. With a Form named form_product, it is still ${form.quantity}, never ${form_product.quantity}.Meta does not refuse a document with the wrong prefix. It compiles it, publishes it, reports no validation error — and then, at runtime, resolves nothing and sends your endpoint "data": {}. The person only sees “Could not complete this step. Please try again.”, which names nothing.Since 12 September 2026 POST /v1/flows/{id}/json refuses this document instead of uploading it, answering 422 FLOW_JSON_DEAD_FORM_PREFIX with the finished replacement for each reference. That check only covers documents uploaded through us — a Flow already published elsewhere still has to be corrected and republished.
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:
To finish the Flow, return the reserved 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

Every data_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.
A number that keeps no customer content keeps none of this. On RELAY_ONLY there is no entry at all — not a redacted one — and submitted comes back empty, which is also what redaction leaves behind. Empty is never proof that nobody typed anything.⚠️ And “empty” is spelled differently on the two surfaces, deliberately. On GET /v1/flows/{id}/responses submitted is [] and never null: the value comes from a lookup that answers nothing when there are no exchanges, and a field set to nothing would vanish from the JSON instead of arriving empty. On the flow.response_received event it is null and never []: that payload is stored, the redactor blanks a field only when it holds something, and an empty array would come back as the “content unavailable” marker and read as answers that were hidden. Each surface picked the right answer to its own problem.The entries are pruned on 30 days + 1, and the extra day is not a rounding. An exchange is captured while the person is still filling the form, minutes before the submission that creates the response beside it; on an identical window the exchanges would expire first, and a response still being served would answer with an empty submitted beside everything else it still had.
The field-by-field shape — 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.
Two health checks are handled for you and never reach your webhook:
  • Meta’s ping — answered by Pilot Status, so your server being asleep does not make Meta mark the endpoint unhealthy for every Flow on the number.
  • Key mismatch — if Meta’s copy of the public key ever drifts from ours, the endpoint tells Meta to re-fetch it. That is the only thing that response means, so a failure on your side is never reported as a key problem.

Attachments, and what Meta will not let you draw

A PhotoPicker 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.
The re-hosted file sits at a public URL: whoever holds the link reads it. That is an explicit owner decision — the URL is the access control, with nothing else in front of it — and it is the same property the inbound mediaLink already has. Decide that before a Flow of yours asks someone for an identity document, and treat the URLs as you would treat the files.The content type is decided by the bytes, never by the file name, and it is never text/html or image/svg+xml even when the bytes look like one — both would execute script in the origin that serves them.The copy never expires, and it outlives the response it belongs to. Flow responses, sessions and endpoint traces are pruned on a retention window; the re-hosted file is not — it carries no expiry, and nothing in the platform deletes it. The answers stop being served at GET /v1/flows/{id}/responses on schedule; the link to the identity document keeps working after them.
The wire contract — 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.