Skip to content

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.

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:

EndpointWhat you getLifetime
DefinitionPOST /v3/connections/{id}/templates/header-mediahandlePermanent, describes the template
SendingPOST /v3/connections/{id}/templates/send-mediamediaId~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" }] }
      ]}
    ]}
  ]
}
json

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:

navigatedata_exchange
Screensfixed in the Flow JSONyour backend answers each step
Dropdown optionshard-codedcome from your database, live
Validationformat onlyyours (is the coupon valid? is that slot still free?)
Needs the encryption keynoyes

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"
}
json

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" }
json

and answers with the next screen:

{ "screen": "PICK_TIME",
  "data": { "slots": [{ "id": "09:00", "title": "09:00" }, { "id": "14:30", "title": "14:30" }] } }
json

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 didmessageTypeWhere the id is
Tapped a carousel buttontemplateButtonReplyMessageselectedId — the payload you sent
Submitted a FlowinteractiveResponseMessagenativeFlowResponseMessage.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.

© 2026 Pingo Notify. All rights reserved.

pingonotify.com ·Built with Nuxt and Scalar