Sent.
Nothing was lost. The answers travel on a different event, and that event has to be subscribed to explicitly.
What Meta actually delivers
A submitted Flow does not arrive as a text message. It arrives as an interactive reply of typenfm_reply, with three fields:
Why the message text says Sent
Because that is the only thing in the payload that is message text. The chat bubble, chat_messages.text, content on message.* events and GET /v1/messages all carry the same value, and that value is Meta’s body (falling back to the Flow name when body is blank).
The answers are deliberately not folded into
content. It is a public contract: an integrator reading content is promised message text. Putting a JSON blob there would break every consumer that renders or matches on it, and would spill names, phone numbers and document numbers into a plain-text field with no schema — irreversibly, once a customer has started parsing it.The answers get a surface of their own instead, where they are structured, correlatable by flow_token, and pruned on a retention window.The answers arrive on flow.response_received
- Dashboard
- API
Open Webhooks (
/webhooks), edit the webhook attached to the Flow’s number, and tick flow.response_received in the event picker. events is replaced wholesale when you save, so keep the events you already had.The payload
A Flow with an endpoint puts the answers somewhere else
response is Meta’s nfm_reply, and what that reply carries depends on how the Flow was built.
A plain Flow — no endpoint — carries all of it: the selections as their ids, the free text as it was typed. That is the payload above.
A data_exchange Flow does not. Its answers reached your endpoint screen by screen, minutes earlier, while the person was still filling the form; what Meta sends at the end is the receipt. Measured three times in production on 6 September 2026, response was exactly this, in full:
{ "observacao": "Aobaaa", "foto": [ … ] }. Nothing failed to parse and nothing was dropped: in a data_exchange Flow, response is the empty place.
So the exchanges themselves are published — as data.submitted on this event, and as submitted on GET /v1/flows/{id}/responses, the same shape on both:
An entry has one of two shapes and never a third:
data filled with omitted: null, or data: null with the reason it is not there.
submitted is null — never [], for the same reason flowMedia is. The stored copy of a field is replaced by a marker only when it holds something, so an empty array would come back reading as “there were answers and we hid them”. null says there is nothing to show.It is append-only and never de-duplicated. Someone who walks back a screen and submits it again produces a second entry, and that entry is genuine — it is what they did. Two passes under the same flow_token are told apart by at, not by the absence of repeats.The exchanges are kept 31 days — one day longer than the response they belong to, and the extra day is deliberate. An exchange is captured while the person is still filling the form, minutes before the submission that creates the response row. On an identical 30 days the exchanges would expire first, and a response served in its last minutes would publish an empty
submitted beside everything else it still had.A chosen option arrives as its id, never as the label
A selection component — RadioButtonsGroup, and equally Dropdown and ChipsSelector — declares its options in the Flow JSON as a data-source, where every option is an id paired with a title:
title. What Meta submits is the id. Someone who tapped Otimo produces exactly this:
nota is "5"; the word Otimo never travels. Free-text components (TextInput, TextArea) do carry what was typed — comentario above — so a single submission mixes both kinds, and only the selections are opaque. That is a plain Flow. With a data_exchange endpoint none of it is in response — what was typed and what was chosen both travelled to your endpoint, and what we captured of them is in submitted.
WhatsApp’s own “Your response” / “Sua resposta” screen shows
Otimo, and that is not a contradiction. The phone still holds the Flow JSON, so it resolves the label for the person who answered. Your endpoint receives the submission alone: 5, with nothing in the payload saying what 5 meant. The id → title mapping lives only in the Flow JSON, so keep your own copy of the data-source if you need the words.The flow_token sitting among the answers is Meta’s echo of the token, not an answer — the same value already arrives as data.flowToken.Files attached inside the form
APhotoPicker or a DocumentPicker puts a file in the submission, and it does not arrive in response — the bytes go to Meta, and the reference that reaches them travels inside the encrypted data_exchange request only the proxy decrypts, never in the nfm_reply that becomes response. The attachments come as their own array, flowMedia:
url is null in three cases, and “not transferred yet” is not one of them. The deployment has no signing secret configured — it then falls back to the older behaviour, the direct object URL once status is STORED, never nothing; Meta sent no media_id for us to sign an identity over; or the number is RELAY_ONLY, so no attachment was stored at all.Matching a file to the answer that names it
One picker can hold more than one file, and thenfield and fileName stop telling them apart. Two photos taken in the camera arrive under the same field, and because a camera reports no name, both take the field’s own name — so both entries read "field": "documento", "fileName": "documento". They are two different files and nothing in those two values says so.
mediaId is the join. The same id appears on the answer side, in submitted[].data.<field>[].media_id:
flowMedia is null — never [] — when nothing is listed, and the two are not interchangeable. On a number that keeps no customer content the stored copy of a field is replaced by a placeholder only when it holds something, so an empty array would come back looking like “there were attachments and we hid them”.The read surface makes the opposite choice: media on GET /v1/flows/{id}/responses is always an array, empty when there was nothing. It also carries sizeBytes, which the event does not.No
cdn_url, and no encryption_metadata, on any surface. Meta derives one AES key per file and ships it beside the reference. Forwarding either would put the key that opens the document into your webhook and its logs, so neither is stored and neither is published.Chatwoot shows content: null — that is Chatwoot
On the API-mirror channel, we do add them. When the conversation is mirrored by Pilot Status, a submission is posted twice: the public bubble is replaced with a short fixed line (
📋 Formulário respondido) instead of Meta’s label, and the answers go into a private note on the same conversation — one field: value line per answer, agents only, never shown to the customer.The note is a convenience for humans and is capped per value; the event is the machine-readable copy and is never truncated. See Chatwoot.Reading answers later
The event is the live channel. The stored copy is atGET /v1/flows/{id}/responses, joined to the contact who answered, and kept for 30 days — with the submitted exchanges beside it kept for 31, so a response is never served without the answers it was made of.
The retention window is enforced on read, so a submission past its expiry is never served — pull what you need to keep into your own store rather than treating this endpoint as the archive.
Minimal example
Node + Express. It verifies the signature over the raw body, then reads the answers.Still empty? Check these, in order
1
Is flow.response_received in the webhook's events?
GET /v1/webhooks and read the list back. A webhook with ["messages"] receives inbound messages and nothing else.2
Is the webhook on the right number?
Webhooks are scoped per number, and a Flow is answered on the number that sent it.
3
Is the webhook active, and is the delivery being attempted?
GET /v1/webhooks/{id}/logs shows recent attempts. A paused webhook (active: false) delivers nothing.4
Are you reading content, or data.response on a Flow with an endpoint?
content is Meta’s label and always will be — it never carries answers. And data.response carries them only on a plain Flow.If the Flow has a data_exchange endpoint, response is { "flow_token": "..." } and nothing else; the answers are in data.submitted, one entry per screen the person submitted. A payload holding a token and nothing more is not an empty submission — it is the wrong field. See A Flow with an endpoint puts the answers somewhere else.5
Are you reading the delivery LOG rather than the delivery?
On a number configured to keep no customer content, the answers are still delivered to your endpoint in full — it is the stored copy of the payload that is blanked (
response, responseRaw, submitted and flowMedia become null; from becomes the placeholder string conteúdo indisponível, which is a string and not a null). A log entry that looks empty next to a 200 is that redaction, not a failed delivery.That redaction is stored on a successful delivery only. A delivery that FAILED keeps the real payload, so it can be inspected and replayed; a sweep replaces the payload of those rows — whole, with a marker object — once they are more than 24 hours old. Until then the failed row holds the submission.Attachments and captured exchanges are the exception in the other direction: on such a number the files are never re-hosted and the exchanges are never stored, so flowMedia and submitted are null on the delivery too. Nothing was lost — nothing was ever kept.Related
- Flows — lifecycle, publishing, cloning
- Connect your API to a Flow — the
data_exchangehalf - Webhook events
- Flows API