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 carriesx-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.
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.
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
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.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.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
- Generate (we mint the pair)
- Import (you already have one)
We create an RSA-2048 pair, store the private half encrypted, and register the public half with Meta for that number.
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
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:endpointUri — our proxy URL for this Flow — into the Flow’s endpoint_uri at Meta, and answers with the same state the GET returns:
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.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.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.
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.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.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.Related
- Flows — lifecycle, publishing, cloning
- Flows with live data — the same setup without the HTTP contract
- Receive Flow responses — the submitted answers, on a webhook
- Flows API