Skip to main content
Every endpoint below authenticates with a number-scoped API key (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.
Reading the endpoint key or the Flow endpoint needs flows:manage, not flows:read, even though both are GETs. Their responses carry endpointUrl / endpointUri / metaEndpointUri, and the token inside those URLs is a credential: Meta authenticates to the endpoint by decrypting, and the matching public key is public by construction. Whoever holds both can forge a request Pilot Status will decrypt and forward to your webhook under our signature. A read-only credential is still a credential — which is also why the value never appears on GET /v1/flows.
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

Returns 201 with the new Flow in DRAFT. Omit cloneFromFlowId to start from scratch.
cloneFromFlowId is the local id of the source Flow — the id field, not metaFlowId. Cloning is the only way to change a Flow that has been published.

Rename and re-categorize

Accepts only 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

A 200 means the document compiled, and validationErrors is then always empty:
A document Meta refuses comes back as 422 FLOW_JSON_INVALID, with Meta’s own list attached:
This is deliberately NOT how Meta’s own API behaves, and the difference is the point.Meta answers 200 with the problems listed inside the payload — so an apparent success over an invalid document is the normal case there, and a client that only checks the status ships a Flow that never compiled.This route reads the list rather than the Graph status, and answers 422 whenever it is non-empty. A 200 from us therefore means what a 200 should mean. Read validationErrors on the 422 — it is Meta’s own answer, verbatim.And one refusal here is OURS, not Meta’s — see FLOW_JSON_DEAD_FORM_PREFIX below. Meta would have accepted that document.flowJson is a string, never a nested object: Meta compiles the exact bytes, and re-serializing would silently reorder what you wrote.

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:
Each entry of 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

Irreversible on Meta. A published Flow can never be edited or unpublished — only cloned into a new one. "confirm": true is required; without it the request is refused with 400.

Deprecate

Retires the Flow without deleting it, and without deleting the responses people already submitted. 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

Only a DRAFT can be deleted. Anything else returns 409.

Read form responses

Returns what customers submitted, joined to the contact who answered, within the retention window. Responses past their expiry are never served. In a data_exchange Flow the answers arrive screen by screen, and submitted is where they are kept — one entry per exchange, oldest first:
In a data_exchange Flow, response is the empty place — what the person typed is here. response is Meta’s nfm_reply, parsed, and on a Flow with an endpoint that reply carries only the flow_token — {"flow_token":"a1b2c3…"} — while your own endpoint received {"observacao":"Aobaaa"} out of the very same submission. Read response there and you find a token, and conclude the person submitted nothing.On a Flow with no endpoint the opposite holds: there are no exchanges to capture, submitted is [], and free-text components arrive in response exactly as they always have.
Here submitted is always an array — [] when there is nothing, never null. ⛔ On the flow.response_received webhook the very same field is the opposite: null, never []. That is not an inconsistency waiting to be tidied up, and “the same field behaves the same on both surfaces” is the assumption most likely to cost you an evening. The two are solving different problems.On this read surface the value comes from a lookup that answers nothing for a submission with no captured exchanges — and a field set to nothing vanishes from the JSON instead of arriving empty. [] is what keeps the key present, and what keeps “nothing was captured” apart from “we could not look”.On the event the payload is also stored, and the redactor blanks a field only when it holds something. An empty array there would come back as the “content unavailable” marker and read as “there were answers and we hid them” — so null is the honest value there.So branch on submitted.length here and on submitted !== null on the event. Neither spelling is ever proof the person typed nothing. media below follows this surface’s rule too: always an array, [] when nothing was attached.
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.
Nothing is ever silently truncated — an entry missing its content says why. An over-sized exchange keeps its row and its place in the order and says DATA_TOO_LARGE; one that could not be read says UNREADABLE. The per-form ceiling of 50 exchanges closes the list with a final entry saying LIMIT_REACHED, and nothing after it was stored. That size ceiling — the one behind DATA_TOO_LARGE — is measured in bytes, not characters, so accented text reaches it sooner than its length suggests.
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.
Each response carries a 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.
The stored object is at a public URL — whoever holds the link reads the file. An explicit owner decision, and the same property the inbound mediaLink has. There is no cdn_url and no encryption_metadata on this surface or any other: Meta derives one AES key per file, and neither the key nor the CDN reference is stored or published.The content type comes from the bytes and is never text/html or image/svg+xml, whatever the bytes look like — both would execute script in the origin that serves them.And the copy never expires. Responses, sessions and endpoint traces are pruned on a retention window; the file is not. flow_media_assets carries no expiry column and nothing in the platform deletes the row or the object, so the link keeps working after the response it belonged to has stopped being served here.
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.
A short page can be a truncated page, and it says so. FlowResponse carries no flowId column — the only edge from a Flow to its answers runs through the session token — so one request correlates at most 10,000 of that Flow’s sends. Past that ceiling the payload carries "notice": "FLOW_RESPONSES_WINDOW_TRUNCATED" alongside the normal responses/total/page/pageSize/totalPages.It is a 200, not an error, and the field is absent whenever nothing was cut. Read it: a caller that reconciles against a short page without checking notice concludes submissions were lost.

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

UNKNOWN is not NOT_SET, and collapsing them is expensive. A Graph call that failed says nothing about what Meta holds. Treating it as “no key” means rotating — and rotating replaces a key that was probably fine.

Generate, rotate, or import

One POST, 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.
Replacing a LIVE key requires {"confirm": true}, on both verbs.Generating is destructive at Meta: Meta holds exactly one public key per phone number, so registering a new one replaces the old — and the previous private half is overwritten here at the same moment. It cannot be restored, and every data_exchange exchange in flight on that number stops decrypting.Importing is destructive here: nothing at Meta changes, but the stored private half is replaced, and if Meta cannot confirm the new one the number is left holding a key that may decrypt nothing. The two are refused with different codes for that reason — FLOW_ENDPOINT_KEY_REQUIRES_CONFIRMATION and FLOW_ENDPOINT_KEY_IMPORT_REQUIRES_CONFIRMATION — because a caller who read “this replaces your Meta registration” after an import would go checking Meta for a change that never happened.⚠️ The first setup does not ask, and neither does retrying a key Meta never accepted (uploadedAt: null) — there is nothing to destroy in either case. A confirmation demanded where nothing can be lost trains the reflex that disarms it in the one case where it protects.
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.
passphrase is used once and is not stored. It opens the PEM; the key is then kept normalised and encrypted at rest, and the passphrase has no reader afterwards. Keep your own copy — we cannot return it. Send it only when the key has one (an empty string is a passphrase, not the absence of one), and never on its own: passphrase without privateKey is refused with FLOW_ENDPOINT_KEY_PASSPHRASE_ORPHAN, because that same request would otherwise generate a pair and replace your registration at Meta.

Flow endpoint

Where a data_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.
A data_exchange Flow cannot be published until this is done. Meta refuses publish with “Publishing without specifying ‘endpoint_uri’ is forbidden”.
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.
409 FLOW_ENDPOINT_URI_WOULD_OVERWRITE names the endpoint you are about to displace, in currentEndpointUri. Ours is not echoed — it embeds the number’s endpoint token, which is the credential of the proxy.Re-sending with {"confirmOverwrite": true} takes the Flow over, and that removes every exchange of it from whoever answers today. Meta’s write is a plain overwrite: no merge, no undo, and no notice to the displaced endpoint.
secret is shown exactly once, on the request that minted it. It is stored encrypted with no read path back. secret: null on a later save means one already existed and was kept — hasSecret is what says whether there is one at all. {"rotateSecret": true} mints a fresh one, and is the only exit from a secret that leaked.
Full walkthrough, including the contract your endpoint must satisfy: Connect your API to a 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.