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.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.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 nothing the customer typed — and the whole record expires after 24 hours.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. Saving the URL with us registers nothing at Meta, on purpose: doing it as a side effect of a rename would hijack the Flow of someone already running their own endpoint. Read the endpoint back and compare
endpointUri (ours) with metaEndpointUri (Meta’s) — drift: true means Meta is calling someone else.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 — point Meta at us
RegisterendpointUri as the Flow’s endpoint_uri in Meta’s Flow Manager. Then read the endpoint back:
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:/v1/numbers/{numberId}/flow-endpoint-key):
The forwarding URL (
/v1/flows/{flowId}/endpoint):
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.Related
- Flows — lifecycle, publishing, cloning
- Receive Flow responses — the submitted answers, on a webhook
- Flows API