x-api-key) whose number is a Meta (Cloud API) number. The WABA is derived from that number — there is no parameter to name one, by design.
flows:read is a slug of its own and is not templates:read. A template listing returns text your workspace wrote; a Flow response returns what a customer typed into a form — a name, a phone, sometimes a document number. The two are gated separately on purpose.List Flows
id is the local id and is the only id you ever send back to us. metaFlowId is Meta’s own and is read-only — it is null while a Flow exists here but has not reached Meta.Create or clone
201 with the new Flow in DRAFT. Omit cloneFromFlowId to start from scratch.
Rename and re-categorize
name and categories. This never publishes and never deprecates — those have endpoints of their own, so that a mistake in a form cannot reach an irreversible operation.
Upload the Flow JSON
200 means the document compiled, and validationErrors is then always empty:
422 FLOW_JSON_INVALID, with Meta’s own list attached:
The one refusal that is ours: FLOW_JSON_DEAD_FORM_PREFIX
A form value is referenced by the literal keyword form — ${form.quantity}. The name of the enclosing Form component never appears in the reference: with a Form named form_product it is still ${form.quantity}.
Meta accepts a document with the wrong prefix, reports validation_errors: [], publishes it — and then sends your endpoint "data": {} at runtime, where the only symptom is the person’s phone saying “Could not complete this step. Please try again.” Since 12 September 2026 this route refuses that document instead of uploading it:
deadRefs carries the finished replacement, so the fix is a paste rather than a rule to apply by hand.
Narrow on purpose, and a
200 here is not “the document is valid”. The check fires only when a reference’s root matches the name of a Form declared in the same document — that case cannot resolve, ever. A root we do not recognise is left alone: our ignorance must not become your error. Everything else is still Meta’s to judge, and still arrives as FLOW_JSON_INVALID.Each entry in
validationErrors is Meta’s, verbatim — error, error_type, message and pointers naming the offending components. One you will meet if a Flow collects files is COMPONENT_TYPE_MAX_NUMBER_PER_SCREEN: at most one PhotoPicker or DocumentPicker per screen. See Flows for that one quoted in full. The Node SDK reads the array with flowJsonValidationErrors(err).Publish
Deprecate
supersededByFlowId is optional — but if you send it, it must name a real Flow of yours: an empty string is refused rather than silently ignored, because a broken version chain is invisible afterwards.
Delete
DRAFT can be deleted. Anything else returns 409.
Read form responses
data_exchange Flow the answers arrive screen by screen, and submitted is where they are kept — one entry per exchange, oldest first:
The list is shorter than the exchanges your endpoint answered, and that is correct. Only
data_exchange exchanges are captured. INIT carries the state we seeded — the params sent at send time, handed straight back — and BACK is navigation; neither is anything a person typed. Counting entries against your own traces and reporting the difference as loss is the mistake this paragraph exists to prevent.It is the request Meta sends us, never what your endpoint replied. The data you return on SUCCESS, extension_message_response included, is encrypted back to Meta and written down nowhere in Pilot Status — not here, not on any other surface.Append-only, with no de-duplication. Someone who walks back a screen and submits it again produces a second, genuine entry instead of replacing the first, and at is what separates two runs under one flow_token.A number in
RELAY_ONLY keeps nothing here — not a redacted entry, no entry at all: there is no stored copy to redact.These 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 30 days the exchanges would expire first, and a response still being served would answer submitted: [] — indistinguishable from a form nobody typed into.media array — the files attached inside the form, re-hosted by us:
media is always an array here — empty when nothing was attached — and that is deliberately not what the webhook does. flowMedia on flow.response_received is null rather than [], because the storage redactor replaces a field only when it holds something and an empty array would read as “there were attachments and we hid them”. It also carries sizeBytes, which the event does not.[] is not proof there was no file. The attachment join is best-effort on purpose — an attachment we cannot read costs a field, never the page — so a join that fails leaves every row on that page with media: [], which is “we could not look” served as “there was nothing”. Reconcile a Flow that collects files against something you kept yourself.This is where the record is final — the URL was already final on the event. The re-host is asynchronous: the event is emitted as soon as the submission is stored, and the transfer runs after it, so the event ordinarily arrives before the bytes are in place. url is a link to a route of ours and is present through all of it, on a PENDING row too — fetching it waits for the file and then serves it, and answers 404 with a Retry-After header only if that wait runs out. So read this endpoint back for status, mimeType and sizeBytes, not to obtain a URL the event already handed you. url is null only when the deployment has no signing secret (it then falls back to the direct object URL once STORED), when Meta sent no media_id, or on a RELAY_ONLY number.This is the stored copy, not the live one. For submissions as they arrive, subscribe to the
flow.response_received webhook event — the answers come with it, already parsed. The chat message that carries a submission holds only Meta’s label (Sent / Enviado), so a webhook subscribed to messages alone sees nothing. See Receive Flow responses.data_exchange endpoint key
Only needed for Flows whose screens call your backend — see Connect your API to a Flow for the full picture.
{numberId} is the number your API key is bound to. The private key never leaves Pilot Status and is returned by no endpoint; endpointUrl is the base to register on your Flow, with the Flow id appended as a final segment.The key is per number; the forwarding URL is per Flow. Both have a surface here — the key below, the URL under Flow endpoint — and neither appears on GET /v1/flows, deliberately: an API key can be a third-party application, and the address of your internal webhook is not something a read-only integration is owed.Both endpoints act on the key’s own number, and
{numberId} in the path must agree with it. A number-scoped key names its number by itself; an account-wide key must narrow with x-whatsapp-number-id. A path id that does not match answers 404 FLOW_NUMBER_NOT_FOUND — never 403, and the same body whether the id belongs to another tenant or to another of your own numbers, so that a wrong guess learns nothing.uploadedAt is null when a pair was generated here but Meta never accepted it. That is not the same as no key at all, and it is the state a repeat POST resumes rather than replaces.Read the current state
Generate, rotate, or import
OnePOST, two verbs. The presence of privateKey decides which, and they are not interchangeable: generating registers a new public key at Meta and replaces whatever was there; importing registers nothing.
Only
confirm, privateKey and passphrase are accepted; anything else is refused, never ignored. The response is the read shape above plus "replaced", which says whether a key that was live has just been displaced — a 200 alone does not.
An import is verified before it is stored. We derive the public half from the key you sent and compare it with Meta’s: a match is stored and stamped (
metaStatus: "VALID"); a mismatch is refused outright with FLOW_ENDPOINT_KEY_IMPORT_MISMATCH and nothing is written; a Graph call we could not make leaves the key stored with uploadedAt: null and metaStatus UNKNOWN/NOT_SET, which is the “not confirmed” state, not a failure.Flow endpoint
Where adata_exchange Flow’s decrypted exchanges are forwarded. Per Flow, flows:manage on every verb including the GET, and {flowId} is the local id from GET /v1/flows.
One path, two different acts. GET reads that state and PUT / PATCH store your destination; none of the three talks to Meta. POST registers our proxy as the Flow’s endpoint_uri at Meta.
Body fields:
url (required, https:// or an explicit null to clear — never omitted) and rotateSecret (boolean). PUT and PATCH are the same operation. The number comes from the credential and is refused in the body (FLOW_NUMBER_FROM_KEY).Saving the URL does not register anything at Meta — that is a separate act against a separate system, so that a rename cannot silently hijack a Flow already pointed at someone else’s endpoint. metaEndpointUri, drift and warnings are how you see the difference.Register endpoint_uri at Meta
POST to the same path writes endpointUri into the Flow’s endpoint_uri at Meta. The body is optional; confirmOverwrite (boolean) is the only field it accepts, and the number comes from the credential as everywhere else. The response is the state shape above without secret — POST never mints one.
Idempotent. A Flow already pointing at us answers
200 with no write at Meta. The check reads Meta live rather than the hourly-synced metaEndpointUri, because that column is silent about a URI registered in the last hour — the exact window in which two people wire the same Flow.Errors
Every failure of this surface answers the same envelope — there is exactly one shape to read, and one spelling:FLOW_JSON_INVALID adds a validationErrors array; FLOW_JSON_DEAD_FORM_PREFIX adds deadRefs; FLOW_UNKNOWN_FIELDS adds the offending keys; FLOW_ENDPOINT_URI_WOULD_OVERWRITE adds currentEndpointUri. Never assume the set of codes is closed — branch on the ones you handle and fall through to code for the rest.
The last two carry no
FLOW_ prefix — they are shared across the public API and keep their global spelling. A client matching on code.startsWith("FLOW_") would miss both.