Coming from a template FLOW button?
There is no button object to build. That is the one thing that trips people up on the way over: a template’s FLOW button is a component you declare and Meta approves, so the reflex is to look for the same thing here. Here the button iscta. Whatever string you put there is the label the customer taps. No components, no buttons array, no review.
Two things do carry over from the template world, and both are refusals rather than surprises:
- The Flow and the sending number must be in the same WhatsApp Business Account. If you own several numbers across several WABAs, a Flow created under one of them cannot be sent from another — you get
FLOW_NOT_SENDABLE. - The Flow must be published (or send
mode: "draft"on purpose). A deprecated Flow stops sending the moment Meta marks it so.
text is required — the Flow is a button, not the whole message. The flow block cannot be combined with templateId, list, carousel, buttons, header, or a direct media send.
The 24-hour window is not optional
This is the part you cannot see, and the reason we refuse the send instead of trying it. We measured this on 9 September 2026 against three versions of the Graph API — v22.0, v25.0 and v26.0 — with real numbers and real handsets. Outside the 24-hour window, Meta answersHTTP 200 with a wamid and silently discards the message. No error. No failure status. Nothing in any log. Eight sends, four delivered, 200 on all eight.
So if we let it through, your message would vanish and neither you nor we would ever know. Instead you get:
Errors
FLOW_NOT_SENDABLE returns one message for all three causes on purpose. Telling you “it exists, but it belongs to another account” would confirm that an id you do not own is real.
data_exchange takes no screen and no data
A data_exchange Flow is answered by your endpoint, so there is nothing to seed at send time — and Meta enforces that. Send the entry screen alongside it and the whole message is rejected:
data_exchange Flow reaches it through the INIT request Meta makes to your endpoint — that is the seam, not the send.
So screen and data are refused at the door instead of being silently dropped:
We refuse rather than drop, and that is deliberate. Quietly discarding
data would let you believe values reached the Flow that never left our process. Remove the field, or switch to action: "navigate" — which is exactly what those two fields are for.Getting the answers back — there are TWO destinations
Which one you get depends on the action, and mixing them up is the most common surprise here.
When someone submits the Flow, the answers arrive on the
flow.response_received webhook event and in GET /v1/flows/{id}/responses, correlated with the send that produced them.
You do not have to do anything for that correlation to work. The token that links the two is generated per message and stored when the message goes out — which is also why flow_token in the request body is refused. A token chosen by the caller would let one account claim another account’s answers.
Subscribing to
flow.response_received is a separate step. See Flow responses.Two things worth knowing before you test
A Cloud API number does not receive Flows. Sending to another Meta number — even with the window open — does not deliver, and Meta still answers200. Test with an ordinary phone.
mode: "draft" works. You can try a Flow before publishing it. Remember the message reaches a real person.