Building a WhatsApp lead-intake flow: what the docs don't tell you
Redesigning a 3-screen WhatsApp Flow into a lean 2-screen form, and the platform quirks I only found by publishing to a real number.
On this page · 6 sections
WhatsApp's Flow Builder is one of those platforms where the documentation describes the happy path clearly, and everything else you learn by publishing something and watching it break. This is a writeup of redesigning a lead-intake form for a China-to-Africa logistics business (MonGroupage), and the specific, reproducible quirks that only showed up once real messages were flowing through a real number.
The starting point
The original flow worked, but it had an obvious usability problem: a client would tap a button labeled "Sourcing," and the very next screen would ask them, again, whether they wanted sourcing or a space reservation. The button tap already answered that question. Then came a single screen with ten fixed groups of name / description / quantity fields, laid out one after another, whether the customer needed one item or ten.
The goal was simple to state and harder to build within the platform's real constraints: skip the redundant question, and only show a second or third product block if the customer actually asks for one.
Passing intent through the button tap instead of asking twice
WhatsApp lets a button-triggered Flow-open message carry an initial data payload (flow_action_payload.data), not just which screen to open. So instead of a TYPE screen, the button handler now sends the chosen type (sourcing or reservation_espace) directly into the Flow's first screen as opening data:
1send_whatsapp_flow(2 phone,3 "Tres bien. Dites-nous quel produit vous cherchez...",4 flow_id=flow_id_v2,5 flow_cta="Decrire mon produit",6 initial_screen="PRODUCTS",7 screen_data={"screen_1_type": "sourcing"},8)The Flow screen declares screen_1_type in its own data schema and reads it via ${data.screen_1_type} wherever it needs to branch. One less screen, one less redundant tap.
Progressive reveal instead of ten fixed fields
The real fix for the "wall of fields" problem turned out to be WhatsApp Flow's If component — a genuine conditional-rendering block, not just a visible toggle on individual fields (that path throws a schema error, more on that below). A RadioButtonsGroup asks "add another product?", defaulted to "no," and an If wraps the entire next product block:
1{2 "type": "If",3 "condition": "${form.add_more_1} == 'oui'",4 "then": [5 { "type": "TextSubheading", "text": "Produit 2" },6 { "type": "TextInput", "name": "item_2_name", "label": "Nom du produit", "required": false }7 ],8 "else": []9}Nested one level deeper, the same pattern reveals a third product only if the second one's "add another?" is also "yes." Someone with one item now sees one item's worth of fields, full stop.
Three things the docs did not warn me about
1. String literals in a condition need single quotes, not double.
${form.add_more_1} == "oui" fails validation with a raw parser error pointing at the exact character. Meta's own validation endpoint (POST /{flow-id}/assets, before ever publishing) is the fastest way to catch this — it returns line and column numbers.
2. visible on an individual component doesn't evaluate operators the way If.condition does.
Putting "visible": "${data.x} == 'y'" directly on a TextHeading returned an error saying the expression "return type is 'string' which does not match the schema." The same comparison works fine inside an If block. Two properties that look identical are not interchangeable.
3. ${data.x} interpolation inside displayed text doesn't render at runtime.
This one only showed up in the live preview: a screen meant to recap the customer's entered products showed the literal string ${data.item_1_name} instead of the value. Data references work fine in visible/condition expressions and in payloads passed between screens — just not as string interpolation inside a component's own text. The fix was to drop the in-Flow recap entirely and build it in plain Python instead, in the confirmation message sent after the Flow completes.
1"error while parsing dynamic expression"The header-image trap
Two different WhatsApp interactive message types both accept a header.image, and they don't accept the same reference format. A "button" message is happy with a Media API id:
1{ "header": { "type": "image", "image": { "id": "3364250140541238" } } }The exact same shape on a "flow" message returns:
1{"error":{"message":"(#131008) Required parameter is missing","error_data":{"details":"header image must contain link."}}}The flow type wants a public HTTPS URL instead:
1{ "header": { "type": "image", "image": { "link": "https://.../whatsapp_greeting_header.jpg" } } }Nothing in the reference docs flags this distinction — it only surfaced by sending the message and reading the 400 response.
Closing the loop: a confirmation people can point to
Every submission (sourcing, space reservation, or a catalog order) now gets a real reference number (SRC-2026-0001, RES-2026-0004, CMD-2026-0012, sequenced per year like the rest of the app's document numbering), and an automatic WhatsApp reply summarizing what was captured and what happens next, with a branded header image built from the company's actual logo mark rather than a generic template.
The platform's own documentation describes the shape of the contract. The actual behavior of `visible`, string quoting, and header image formats is something you learn by publishing to a real Flow ID and reading the error Meta actually sends back — which is also why keeping the previous, working Flow live on a separate Flow ID for the entire redesign mattered: every one of these mistakes was recoverable without touching production traffic.