Skip to main content
Yes — a Flow can read from your API while the customer is filling it in. That is what a data_exchange Flow is: the customer picks a date and the next screen shows the slots that are actually free, because your backend decided them. You do not implement any of Meta’s cryptography. Pilot Status is the endpoint Meta calls. We decrypt the request, POST plain JSON to a URL you control, and encrypt your answer on the way back.
A NAVIGATE Flow — one whose screens are all fixed in the Flow JSON — needs none of this. Everything on this page is only for Flows whose screens call your backend. See Flows for the difference.This page is the request-by-request contract. For the same feature in product terms — what to set up, in what order, and what a person sees when it is wrong — read Flows with live data.

The shape of one exchange

1

The customer fills a screen

Meta sends an encrypted request — AES-GCM, key RSA-OAEP-wrapped under the public key registered for that phone number — to the endpoint registered on the Flow, which is us.
2

We decrypt and forward

A plain JSON POST to your URL, with an HMAC signature header. No RSA, no AES-GCM, no inverted IV on your side.
3

You answer with the next screen

200 with { "screen": "...", "data": { ... } }.
4

We encrypt the reply

Meta renders the screen you named.

What we POST to you

action is never null, and a request without one is never forwarded. Every call Meta makes carries INIT, BACK, data_exchange or ping. A decrypted payload that carries none of them is dropped before it reaches you, because forwarding it would put our signature on something that is not a Flow exchange — and your endpoint would have no way to tell it apart from one we vouched for on purpose.
A file picked with PhotoPicker or DocumentPicker reaches us in THIS exchange, and never in the nfm_reply. That is why a Flow with no endpoint receives no attachment at all. Pilot Status re-hosts the file and publishes it on the submission as flowMedia / media — the event is emitted as soon as the submission is stored and the re-host runs after it, so status is ordinarily PENDING when the event lands, and flowMedia[].url is a link that already works: fetching it waits for the transfer instead of 404ing. The shape, and what to do with that, are in Receive Flow responses. Meta’s own limits (one picker per screen, 25 MiB, 20 days on its CDN) are in Flows.

The signature

When the Flow has a signing secret, the forward carries x-pilot-status-signature: the hex-encoded HMAC-SHA256 of the raw request body, keyed with that secret. It is the same header and the same scheme as Pilot Status outbound webhooks — if you already verify those, you need no second code path. Compute the HMAC over the bytes as received, before any JSON parsing.
No secret configured means no header. The forward still happens, unsigned, and anyone who can reach your URL can then post a plausible body to it. Set the secret.

What you must return

Plain JSON, 2xx, naming the next screen:
To finish the Flow, return the reserved SUCCESS screen. ⚠️ This shape is Meta’s own convention, not ours: we forward screen and data to Meta untouched, so extension_message_response never appears anywhere in Pilot Status — do not look for it in our API reference.
What comes back on the submission is only what you named in params, and the answers are not in it. Meta unwraps that object into the nfm_reply, so in a data_exchange Flow data.response publishes exactly the shape above and nothing more — {"flow_token": "a1b2c3..."} — while the same submission had already handed {"observacao": "Aobaaa", "foto": [ … ]} to your endpoint. The answers went to you, screen by screen. They never travelled back through Meta.Read them in data.submitted, which carries the data_exchange requests we forwarded, each with its screen and its timestamp — see Receive Flow responses. A plain Flow with no endpoint is the other case: nothing collected the answers on the way, so there response is where all of them are.
A 200 that names no screen is refused on purpose. It is the one failure mode that looks like success from your side and is invisible from the customer’s: Meta renders a payload without a screen as nothing at all, so the person sits in front of a form that never advances and never errs.We treat it exactly like a failed call instead — the customer gets a retryable error on the screen they are already looking at, and the exchange is recorded as a failure you can find.

Answer fast

Meta holds the request open while a person watches a spinner, and there is no retry — a slow answer is a failed screen, not a delayed one. Our forward is abandoned after a few seconds (8 s by default), which is deliberately tighter than the budget for ordinary webhooks: those are notifications nobody is waiting for. Do the slow work after you reply.

What we handle without calling you

Meta’s ping never reaches your endpoint. It is Meta’s health check, and we answer it ourselves. A health check that depends on a third party being awake reports the wrong thing to the wrong party — your server being down for a deploy would make Meta mark the endpoint unhealthy for every Flow on that number.So: do not implement ping, and do not expect to see it in your logs.

When your API fails

Whatever the cause — down, timed out, non-2xx, unparseable body, no screen — the customer never sees your infrastructure and never sees a broken screen.
A failure on your side is never reported to Meta as a key problem. The 421 response that tells Meta “re-download my public key” is returned for exactly one thing: we could not decrypt. Meta caches that fetch, so using it for an outage of yours would make the lie outlive the outage.

Seeing what happened

The Flow’s screen in the dashboard lists the recent exchanges: the action, the screen, the outcome, your HTTP status and how long you took. Bodies are recorded only on a failure — a successful exchange keeps timing and shape and neither body, not the one we forwarded nor the one you answered — and the whole record expires after 24 hours.
“Not in this trace” is not “not kept”. What the customer typed into a data_exchange screen is stored and published: it is data.submitted on the submission, each entry carrying the screen it was filled on and the moment it happened, on the flow.response_received event and at GET /v1/flows/{id}/responses, for 31 days.A day longer than the response itself, deliberately. The exchange is captured while the person is still filling, minutes before the submission that creates the response row — on an identical window the exchanges would expire first, and a response’s last minutes on the endpoint would publish an empty list. The full shape is in Receive Flow responses.
submitted is legitimately shorter than this list, and that is not a gap. Only data_exchange exchanges are captured. INIT carries the state we seeded — the params the Flow was sent with, handed straight back — and BACK is navigation; neither is an answer, so neither is stored. Count the rows here and the entries there and the two will not match.It is append-only and never de-duplicated: someone who walks back a screen and re-submits it produces a second, genuine entry, and the timestamp is what separates the two runs under one flow_token. And it is the request Meta sends us — never what your endpoint replied, which is why extension_message_response still appears nowhere in Pilot Status.

Setting it up, in order

Three things have to be true before Meta calls your API, and they become true in this order. Skip one and you get a Flow that looks configured, raises no error anywhere, and is never called.
1

1. The key — on the NUMBER

Generate one with us, or import the one you already use. Meta stores exactly one public key per phone number, so this is a property of the number and never of a Flow.
2

2. The forwarding URL — on the FLOW

Where we POST the decrypted exchange. One number serves several forms, and each form is usually a different service on your side, so this is per Flow.
3

3. endpoint_uri — at Meta, on the Flow

Meta only calls an endpoint it has registered, and saving the URL in step 2 registers nothing at Meta. Registration is a request of its own — POST /v1/flows/{flowId}/endpoint — because performing it as a side effect of a rename would hijack the Flow of someone already running their own endpoint. Until it is done, Meta refuses to publish the Flow at all.
The key is per NUMBER, however it is presented next to a Flow. Changing it changes decryption for every data_exchange Flow on that phone number, not just the one you were looking at.
A destination saved on a number with no key is a Flow that will never be called. Meta has nothing to encrypt with, so it never reaches us, so the destination is never used — and nothing on either side reports it. That is what numberHasKey and the warnings list exist for; FLOW_ENDPOINT_NUMBER_HAS_NO_KEY is the one that matters.
Every one of these endpoints requires flows:manage — including the reads. endpointUri and metaEndpointUri embed the number’s endpoint token, and that token is the only thing the public endpoint authenticates on, while the matching public key is published by Meta. Whoever holds both can forge a request we will decrypt and forward to your webhook under our signature. That is also why the value is deliberately absent from GET /v1/flows: a read-only credential is still a credential.

Step 1 — the key: generate, or import your own

We create an RSA-2048 pair, store the private half encrypted, and register the public half with Meta for that number.
Generating REPLACES the key Meta holds for that phone number. Meta stores one public key per number — registering ours overwrites whatever was there.If you already run your own data_exchange endpoint on that number, it stops decrypting the moment we register. That is why replacing a live key requires {"confirm": true} and is otherwise refused with FLOW_ENDPOINT_KEY_REQUIRES_CONFIRMATION.First-time setup does not ask, and neither does retrying a pair Meta never accepted (uploadedAt: null) — a retry re-uploads the same stored public half rather than minting another, so there is nothing to destroy in either case.
Read the state back at any time — what we hold, when Meta last confirmed it, and whether Meta’s copy still agrees with ours:
UNKNOWN is not NOT_SET. A Graph call that failed says nothing about what Meta holds. Reading it as “no key” leads to rotating — and rotating replaces a key that was probably fine.
endpointUrl stops before the Flow id: the token segment identifies the number, and the segment after it identifies the Flow. The full address Meta must call is {endpointUrl}/{metaFlowId}, which is what endpointUri on the endpoint resource already spells out for you.

Step 2 — the forwarding URL, on the Flow

secret comes back on exactly one response in the life of a secret: the request that minted it. It is stored encrypted and there is no read path back. Copy it now — secret: null on a later save means “one already existed and was kept”, not “there is none”. hasSecret answers that question.Lost it? {"url": "...", "rotateSecret": true} mints a fresh one, which is also the only exit from a secret that leaked.
The URL must be https:// — refused otherwise, with no flag to allow http://. The body we forward is your end user’s form answers, decrypted by us one hop earlier; over http:// that is personal data in the clear, put there by the only participant that had already removed the encryption.{"url": null} clears the destination and keeps the secret, so re-pointing the Flow later does not force you to redeploy a verifier for a value that never leaked.
PUT and PATCH are the same operation here — the resource has one settable field, so there is nothing for “replace” and “merge” to disagree about. GET the same path for the state without writing.The number is not a parameter: it comes from the API key (a number-scoped key names its own; an account-wide key narrows with x-whatsapp-number-id). Naming it in the body is refused with FLOW_NUMBER_FROM_KEY, and a key bound to a number outside the Flow’s WABA gets 422 FLOW_NUMBER_WABA_MISMATCH rather than an answer about a number that could never send this Flow.

Step 3 — register us at Meta

Meta only calls an endpoint it has registered on the Flow. Step 2 registered nothing — this is the request that does:
It writes endpointUri — our proxy URL for this Flow — into the Flow’s endpoint_uri at Meta, and answers with the same state the GET returns:
The body is optional, and confirmOverwrite (boolean) is the only field it accepts — the number comes from the key, as everywhere in /v1. There is no secret on this response: POST never mints one.
The dashboard performs the same act. On the Flows screen, under Point Meta here, the Register at Meta button calls this route; a 409 opens a confirmation showing the address you are about to displace, and confirmOverwrite is what its Take it over anyway button sends.
Without this call a data_exchange Flow cannot be published at all. Meta refuses publish with “Publishing without specifying ‘endpoint_uri’ is forbidden”, and nothing else in Pilot Status writes that field.
Why this is a second request and not a flag on the save. PUT is you describing your webhook; POST is us telling Meta to route the Flow through us. Fused into one act, a tenant who runs their own Flow endpoint would lose every exchange the next time they corrected a typo in a URL — the rename would silently re-point the Flow. They are separate so that re-pointing is something you do on purpose.
It is idempotent. A Flow whose endpoint_uri is already ours answers 200 and writes nothing at Meta. The check reads Meta live, not the hourly-synced metaEndpointUri column — that column is silent about a URI registered in the last hour, which is exactly the window in which two people wire the same Flow.
409 FLOW_ENDPOINT_URI_WOULD_OVERWRITE — Meta already points this Flow somewhere else. The response names it, and only it:
Re-sending the same request with {"confirmOverwrite": true} takes it over — and taking it over removes every exchange of this Flow from whoever is answering it today. Meta’s write is a plain overwrite: no merge, no undo, and nothing tells the displaced endpoint it stopped being called. Find out what is on the other end of currentEndpointUri first.Ours is deliberately not echoed back: our URL embeds the number’s endpoint token, and that token is the credential of the proxy. currentEndpointUri is theirs, which is what you need to recognise them.
422 FLOW_ENDPOINT_NUMBER_HAS_NO_KEY — the number holds no Flow endpoint key, so there is no URL to register in the first place. Generate it (step 1) and register again.
Read the endpoint back at any time:
Treat the list as open, not closed: keep any code you do not recognise on screen. A warning you cannot spell is still a warning, and dropping it is how a screen reports “everything is fine” over a server that just said otherwise.

Error codes

Every failure of these endpoints answers the same envelope — one shape, one spelling:
FLOW_ENDPOINT_URI_WOULD_OVERWRITE is the one failure here that adds a fourth key, currentEndpointUri. The key (/v1/numbers/{numberId}/flow-endpoint-key): The forwarding URL (/v1/flows/{flowId}/endpoint):
FLOW_ENDPOINT_NUMBER_HAS_NO_KEY is the one spelling that appears twice — as a warnings entry on a read or a save, and as a 422 on the registration. That is the same fact in two roles: saving a destination on a key-less number is allowed to succeed with a warning, and registering at Meta cannot succeed at all.
Shared by both: FLOW_BODY_INVALID (400, malformed body — a truncated one is never treated as empty), FLOW_UNKNOWN_FIELDS (400), FLOW_NUMBER_FROM_KEY (400, the body named the number), FLOW_REQUIRES_META_NUMBER (422), INTERNAL_ERROR (500).
Never assume the set is closed — branch on the codes you handle and fall through to code for the rest.

Minimal example

Node + Express. One screen that answers with the slots for a chosen day.
Never send version back. It is Meta’s protocol version, echoed from its request — a wrong one fails the whole exchange rather than one field.