2026, Jan 09 21:00
Start WhatsApp Flows in Production with data_exchange, Not INIT: Make Webhooks Work Reliably
WhatsApp Flows in production: INIT never arrives. Set flow_action=data_exchange, treat the first data_exchange as init, and fix webhook initialization, logging.
WhatsApp Flows can be deceptive in early tests: you tap a button, expect an INIT action to appear in your webhook, and build logic around that entry point. In the builder, INIT shows up cleanly with a 200 response and everything looks consistent in logs. But in production, you only see a ping and then a data_exchange for your first screen, while INIT never arrives. If your logic relies on INIT to capture the user_id and bootstrap state, the flow stalls.
Problem snapshot
The launch message is sent as an interactive Flow without explicitly declaring how the Flow runtime should start the server-side exchange. The handler, in turn, waits for INIT to perform the initial setup.
out_msg = {
"messaging_product": "whatsapp",
"recipient_type": "individual",
"to": recipient_id,
"type": "interactive",
"interactive": {
"type": "flow",
"header": {
"type": "text",
"text": "Welcome"
},
"body": {
"text": "Click fill form"
},
"action": {
"name": "flow",
"parameters": {
"flow_message_version": "3",
"flow_cta": "Fill form",
"flow_name": "my_main_form_name"
}
}
}
}
def process_flow_webhook(evt, ctx):
payload = json.loads(evt.get('body', '{}'))
decoded = decode_flow_payload(payload)
log.info("Decrypted body: %s", decoded)
usr = decoded.get('to_number')
op = decoded.get('action')
scr = decoded.get('screen')
form = decoded.get('data') or {}
token = decoded.get('flow_token')
if op == 'INIT':
log.info("INIT state")
prof = fetch_profile(usr)
persist_user_meta(usr, {
'SK': 'META',
'flow_token': token,
'profile_name': prof,
'started_at': utc_now()
})
resp = {'version': '3.0', 'screen': 'MYFIRSTSCREEN',
'data': {'greeting': 'Welcome'}}
elif op == 'ping':
resp = {'version': '3.0', 'data': {'status': 'active'}}
elif op == 'data_exchange' and scr == 'MYFIRSTSCREEN':
...
Attempts to embed INIT into a template payload or switch to different template structures do not change the behavior: in production logs, you still observe only ping followed by data_exchange for the first screen.
What’s actually happening
The Flow runtime does not automatically emit INIT when a user opens a Flow from a message. Instead, the server-side conversation begins with data_exchange. That’s why you only see ping and then your first screen’s exchange in live traffic, while the builder sends INIT during simulated runs. Expecting an implicit INIT leads to a dead-end when you try to extract user_id early and prime your storage.
Fix: start the server-side exchange explicitly
Initialize the Flow by specifying flow_action as data_exchange in the message that triggers the Flow. Treat that first data_exchange as your initialization step.
launch_msg = {
"messaging_product": "whatsapp",
"recipient_type": "individual",
"to": recipient_id,
"type": "interactive",
"interactive": {
"type": "flow",
"header": {
"type": "text",
"text": "Welcome"
},
"body": {
"text": "Click fill form"
},
"action": {
"name": "flow",
"parameters": {
"flow_id": "<YOUR_FLOW_ID>",
"flow_cta": "Fill form",
"flow_token": "unique-flow-token-123",
"flow_message_version": "3",
"flow_action": "data_exchange"
}
}
}
}
On the server side, handle that first data_exchange as the entry point and return the initial screen, then proceed with subsequent data_exchange events as the user submits screens.
def process_flow_webhook(evt, ctx):
payload = json.loads(evt.get('body', '{}'))
decoded = decode_flow_payload(payload)
log.info("Decrypted body: %s", decoded)
usr = decoded.get('to_number')
op = decoded.get('action')
scr = decoded.get('screen')
form = decoded.get('data') or {}
token = decoded.get('flow_token')
if op == 'data_exchange' and not scr:
log.info("INIT via data_exchange")
prof = fetch_profile(usr)
persist_user_meta(usr, {
'SK': 'META',
'flow_token': token,
'profile_name': prof,
'started_at': utc_now()
})
resp = {'version': '3.0', 'screen': 'MYFIRSTSCREEN',
'data': {'greeting': 'Welcome'}}
elif op == 'ping':
resp = {'version': '3.0', 'data': {'status': 'active'}}
elif op == 'data_exchange' and scr == 'MYFIRSTSCREEN':
...
Why this matters
Relying on INIT in production Flow runs results in fragile control flow, blocked onboarding, and missing identifiers at the exact moment you need them. Explicitly starting with data_exchange aligns your webhook logic with what the runtime actually emits, keeps your logs consistent between test and live traffic, and lets you bind the interaction to a unique flow_token from the outset.
Wrap-up
Do not wait for an implicit INIT when launching a WhatsApp Flow from a message. Set flow_action to data_exchange in the interactive message parameters, treat the first data_exchange as initialization, and return your initial screen from that branch. Keep using a unique flow_token per interaction to track state, and continue handling ping and subsequent screen-specific data_exchange events as usual.