Carousel and Flows
Send a product carousel and an in-chat form on an official connection, and read the taps and submissions that come back.
Two formats that only exist on an official connection, because both are built on the Cloud API: a carousel of product cards, and a Flow — a form that opens inside WhatsApp without sending the person to a browser.
They share nothing technically, but they arrive at the same place: the taps and the filled form come back as webhooks on the connection.
Neither works on a QR Code connection. Sent there, a carousel degrades to plain text and a Flow is refused.
Product carousel
A carousel is a template with a CAROUSEL component: 2 to 10 cards, each with a media header, its own body, and up to two buttons. Every card must have the same shape — same header format, same number and type of buttons. Meta rejects a heterogeneous set.
Two uploads look alike and are not interchangeable:
| Endpoint | What you get | Lifetime | |
|---|---|---|---|
| Definition | POST /v3/connections/{id}/templates/header-media | handle | Permanent, describes the template |
| Sending | POST /v3/connections/{id}/templates/send-media | mediaId | ~30 days, bound to the phone number |
Never store the mediaId. Upload again on each send.
Upload the sample images
One handle per card, from templates/header-media. This is what Meta reviews.
Create the template
POST /v3/connections/{id}/templates with category MARKETING, a BODY, and a CAROUSEL whose cards[].components carry HEADER (with example.header_handle), BODY and BUTTONS.
Wait for approval
The response comes back PENDING. Poll GET /templates/{templateId} until APPROVED; a rejected_reason tells you what to fix.
Upload the images again, for sending
Now through templates/send-media, which returns the mediaId each card header needs.
Send
POST /v3/connections/{id}/chats/messages/send-template with a single carousel component.
{
"to": "5511999998888",
"name": "catalog_demo",
"language": "pt_BR",
"components": [
{ "type": "carousel", "cards": [
{ "card_index": 0, "components": [
{ "type": "header", "parameters": [{ "type": "image", "image": { "id": "28801092679526768" } }] },
{ "type": "button", "sub_type": "quick_reply", "index": 0,
"parameters": [{ "type": "payload", "payload": "add:SKU-FONE-ANC" }] },
{ "type": "button", "sub_type": "quick_reply", "index": 1,
"parameters": [{ "type": "payload", "payload": "det:SKU-FONE-ANC" }] }
]}
]}
]
}
The casing changes between defining and sending
Defining a template uses HEADER, BUTTONS, QUICK_REPLY. Sending uses header, button, quick_reply — and the button index is an integer, not a string. It is Meta's own asymmetry; our validation reports every offending field at once.
Give each button its own payload. The webhook carries no card index, so add:SKU-FONE-ANC naming the product and the action is the only way to tell which card was tapped.
Flows
A Flow is a form with its own screens. There are two kinds, and the difference decides everything else:
navigate | data_exchange | |
|---|---|---|
| Screens | fixed in the Flow JSON | your backend answers each step |
| Dropdown options | hard-coded | come from your database, live |
| Validation | format only | yours (is the coupon valid? is that slot still free?) |
| Needs the encryption key | no | yes |
A booking form is the canonical data_exchange case: the customer picks a day, and the free slots for that day have to come out of your system right then.
Create and publish
POST /v3/connections/{id}/flows with flowJson (object or string) and publish: true. Check validation_errors in the response before assuming it went out.
Send
POST /v3/connections/{id}/chats/messages/send-flow. Inside the 24-hour window — outside it, use a template with a FLOW button.
{
"to": "5511999998888",
"header": "Contact us",
"body": "Fill in the form and we will get back to you today.",
"cta": "Open form",
"flowId": "1626797462403345",
"flowAction": "navigate",
"screen": "FORM",
"flowToken": "lead-8812",
"mode": "published"
}
flowToken is your correlation id and the only thread back — the webhook that brings the filled form does not include the Flow id. Send an order id, a ticket id, a lead id. Omit it and we generate pingo_<uuid> and return it in the send response.
Dynamic Flows
For data_exchange, Meta encrypts every request with RSA-2048 and AES-128-GCM. Pingo is the registered endpoint: it decrypts, forwards the plain JSON to you signed, and encrypts your answer back.
Provision the key
PUT /v3/connections/{id}/flows-encryption. Pingo generates the RSA pair and registers the public half at Meta. Requires the connection's Meta app secret — it is what authenticates every request Meta sends.
Subscribe a webhook to `flow.data_exchange`
There is no separate URL for Flows: the endpoint that answers the screens is a normal connection webhook subscribed to that event. HMAC must be on.
Point the Flow at Pingo
Put the dataExchangeUrl from GET /flows-encryption in the Flow's endpointUri. The Flow JSON also needs data_api_version: "3.0" and routing_model.
Your endpoint then receives, mid-filling:
{ "action": "data_exchange", "screen": "PICK_DAY",
"data": { "day": "2026-10-05" }, "flow_token": "booking-8812" }
and answers with the next screen:
{ "screen": "PICK_TIME",
"data": { "slots": [{ "id": "09:00", "title": "09:00" }, { "id": "14:30", "title": "14:30" }] } }
This event is a question, not a notification
Every other webhook ignores your response. Here the body you return is the screen the customer sees, and WhatsApp is waiting: answer within about five seconds, with a JSON object. A bare 200 breaks the form. The key belongs to the phone number, not the connection — it survives when you delete the connection.
What comes back
Taps and submissions arrive as messages.upsert on the connection webhook, in the nodes documented in the reference:
| What the person did | messageType | Where the id is |
|---|---|---|
| Tapped a carousel button | templateButtonReplyMessage | selectedId — the payload you sent |
| Submitted a Flow | interactiveResponseMessage | nativeFlowResponseMessage.paramsJson, a string — parse it |
The submitted form always arrives here, on both kinds of Flow. The data-exchange endpoint only handles the conversation during filling — plenty of people have configured it and then wondered why the answers never showed up.
A 201 from a send is Meta accepting the call, not delivering the message. The outcome arrives later, asynchronously, in messages.update — read statuses[].errors. Error 131047 means the 24-hour window is closed.