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.

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 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.
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 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.
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 — point Meta at us

Register endpointUri 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:
The key (/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.