WhatsApp Flows Endpoints
Flows with dynamic screens need a data-exchange endpoint that WhatsApp calls on every screen transition, with all traffic encrypted against a business key pair. Turn hosts this endpoint and manages the encryption for you, so you never handle key material.
Get started by clicking Add endpoint below the Flow JSON editor. It adds
data_api_version: "3.0" and an empty routing_model (both required by
Meta) to the Flow JSON and saves the flow, and the Turn UI will display new
form fields to help you set up your endpoint.
Static flows don't need any of this. A flow without a
data_api_version in its Flow JSON is driven entirely on-device by
WhatsApp. No endpoint is ever called, and this page doesn't apply. The
setup below is only for flows that use the data exchange channel.
WhatsApp Flows are a feature provided by Meta. For flow specifications, screen types, and components, refer to the official WhatsApp Flows documentation. Meta's Implementing Your Flow Endpoint guide describes the underlying endpoint protocol that Turn implements for you.
Setup
- Open your flow under Content → Flows and scroll to Endpoint.
- Click Set up encryption (once per number). Turn generates the key pair, stores the private key in a secure external vault, and registers the public key with Meta.
- On a draft flow, click Register with Meta. This registers the flow's data-exchange endpoint (hosted by Turn) with Meta — required before the flow can be published.
- Set a forward URL (or pick an installed Turn app). This is where your flow's requests are answered. Without one, screen transitions fail.
Bring your own encryption key
If you have already registered your own public key with Meta for this
number, Turn never holds the matching private key and can't decrypt your
requests — so it doesn't try. The Endpoint panel shows Your own
encryption key, hides the encryption setup (which would overwrite your
key), and passes each request through to your forward URL exactly as
WhatsApp sent it: still encrypted, with Meta's own x-hub-signature-256
header intact. Your endpoint decrypts and answers per
Meta's endpoint protocol,
and Turn relays your response back to WhatsApp untouched.
The rest of this page (Turn-decrypted webhooks with an X-Turn-Hook-Signature)
applies only when Turn manages the key for you.
Forward webhooks
Turn decrypts each request from WhatsApp and delivers it to your HTTPS endpoint as a plain JSON webhook, with no encryption code needed on your side.
Request
Each request is a POST with these headers:
| Header | Value |
|---|---|
Content-Type | application/json |
X-Turn-Hook-Signature | Base64 HMAC-SHA256 of the raw request body |
X-Turn-Flow-Id | The flow's id (as assigned by Meta) |
X-Turn-Number-Uuid | The number's uuid |
The body is exactly what the WhatsApp client sent, already decrypted:
{
"version": "3.0",
"action": "data_exchange",
"screen": "HELLO",
"data": { "name": "Maria" },
"flow_token": "<token you supplied when sending the flow>"
}
action is one of INIT (first screen requested), data_exchange (user
completed a screen) or BACK.
Meta also periodically verifies that a flow's endpoint is up (for example
when you publish the flow) by sending a request with action: "ping", and
reports client-side errors with error notification requests. Turn answers
both of these automatically; they never reach your endpoint.
Verifying the signature
Your forward URL is publicly reachable, so anyone could POST to it. To
prove a request really came from Turn, every webhook is signed: the
X-Turn-Hook-Signature header carries the Base64 HMAC-SHA256 of the raw
request body, keyed with a secret only you and Turn know. Recompute the
signature and compare before trusting the payload.
The secret is shown under Show signing secret in the flow's Endpoint panel:
import base64, hashlib, hmac
expected = base64.b64encode(
hmac.new(secret.encode(), raw_body, hashlib.sha256).digest()
).decode()
valid = hmac.compare_digest(expected, request.headers["X-Turn-Hook-Signature"])
Response
Respond within 8 seconds: WhatsApp blocks the user's screen while it waits:
-
200with the next screen:{ "screen": "SCREEN_ID", "data": {} } -
200with the terminal payload to close the flow:{"screen": "SUCCESS","data": {"extension_message_response": {"params": { "flow_token": "<flow_token>" }}}} -
427when theflow_tokenis no longer valid (the client discards the flow session). -
Any other status (or a timeout) shows the user a transient error; they can retry from the same screen.
Complete example
A hello-world flow and the Python/Flask server that answers it.
Create a flow with this Flow JSON — one HELLO screen with a name field
whose Footer submits over the data channel:
{
"version": "7.2",
"data_api_version": "3.0",
"routing_model": {},
"screens": [
{
"id": "HELLO",
"title": "Hello World",
"terminal": true,
"layout": {
"type": "SingleColumnLayout",
"children": [
{
"type": "Form",
"name": "hello_form",
"children": [
{
"type": "TextInput",
"input-type": "text",
"label": "Your name",
"name": "name",
"required": true
},
{
"type": "Footer",
"label": "Submit",
"on-click-action": {
"name": "data_exchange",
"payload": { "name": "${form.name}" }
}
}
]
}
]
}
}
]
}
The server answers INIT with that screen and logs the submitted data on
data_exchange. If you pair it with your own flow instead, set
ENTRY_SCREEN to that flow's entry screen id:
import base64
import hashlib
import hmac
import os
from flask import Flask, jsonify, request
SECRET = os.environ["TURN_SIGNING_SECRET"]
# The id of your flow's entry screen, as defined in its Flow JSON
ENTRY_SCREEN = "HELLO"
app = Flask(__name__)
def signed(req):
expected = base64.b64encode(
hmac.new(SECRET.encode(), req.get_data(), hashlib.sha256).digest()
).decode()
return hmac.compare_digest(
expected, req.headers.get("X-Turn-Hook-Signature", "")
)
@app.post("/whatsapp-flows")
def flow_endpoint():
if not signed(request):
return "", 401
payload = request.get_json()
if payload["action"] == "INIT":
# First screen requested. If your screen declares dynamic data
# in its Flow JSON, include it under "data".
return jsonify({"screen": ENTRY_SCREEN, "data": {}})
if payload["action"] == "data_exchange" and payload["screen"] == ENTRY_SCREEN:
# Your business logic goes here: payload["data"] holds the answers
print("received:", payload["data"])
# Terminal payload: closes the flow on the user's device
return jsonify(
{
"screen": "SUCCESS",
"data": {
"extension_message_response": {
"params": {"flow_token": payload["flow_token"]}
}
},
}
)
# Anything else means the session state no longer makes sense:
# 427 tells the client to discard the flow session
return "", 427
Run it locally
Save the file as app.py, then:
python3 -m venv .venv && source .venv/bin/activate
pip install flask
# expose port 5000 publicly, for example with ngrok
ngrok http 5000
# the secret comes from "Show signing secret" in the Endpoint panel
TURN_SIGNING_SECRET="<secret>" flask --app app run --port 5000
Paste the tunnel's public URL plus the /whatsapp-flows path (for
example https://<your-tunnel>.ngrok-free.app/whatsapp-flows) into the
flow's forward URL field, and publish the flow if it isn't published yet.
Publishing requires the Setup steps first: Meta refuses to
publish or send an endpoint flow until its endpoint_uri is set, so a
flow that skipped Register with Meta fails with exactly that error.
Send the flow from the inbox or a journey and tap through it on a phone (the WhatsApp desktop apps don't reliably open flows) — the submitted data appears in the server log, with no encryption code anywhere in the file.
flask run starts Flask's development server, which is exactly right for
this walkthrough. When you deploy for real, run the app under a
production WSGI server such as gunicorn instead — or implement the same
webhook contract in whatever language your stack already uses.
Answering from a Turn app
Instead of hosting your own server, an installed
Turn app can answer the flow: pick it in
the Endpoint panel's app picker, which points the forward URL at the
app's public HTTP endpoint. Each request then reaches the app as an
http_request event whose path ends in /whatsapp_flows, and the app
answers with the same response contract as above:
local App = {}
local turn = require("turn")
local router = turn.http.server.router.new("Flows endpoint")
router:post("/whatsapp_flows")(function(request, response)
local payload = request.form
if payload.action == "INIT" then
return response:json({ screen = "HELLO", data = {} })
end
-- Your business logic goes here: payload.data holds the answers
return response:json({
screen = "SUCCESS",
data = {
extension_message_response = {
params = { flow_token = payload.flow_token },
},
},
})
end)
function App.on_event(app, number, event, data)
if event == "http_request" then
return true, router:handle(data)
end
-- Nothing to do for install, uninstall or config events
return true
end
return App
To package it: turn-app new flows_endpoint (the SDK CLI, see the apps
Getting Started guide), paste the code into
flows_endpoint.lua, run make build, and upload the generated ZIP
under Apps → Upload app.
These forwards are signed like any other: the X-Turn-Hook-Signature
header is among the request headers, and
turn.crypto.verify_hmac_sha256 can verify it. More detail lives in the
HTTP Server API reference.