WhatsApp flows with your own endpoint
Let a WhatsApp flow ask your server for each screen, verify Samva's signed request, answer with the next screen, and watch the flow's health.
A flow that only moves between its own screens needs no server. Use data exchange when a screen depends on something your system knows: available appointment slots, an order's contents, a validation that must run before the customer continues. Each time the customer opens the flow or submits a screen, Samva sends your endpoint a signed request, and your answer says which screen to show next and what data it reads.
This page extends WhatsApp flows; create, publish, register, and send a flow as described there. Samva handles Meta's encryption for you: your endpoint receives and answers plain JSON over HTTPS.
Author a data-exchange flow
A version exchanges data when its Flow JSON has a routing_model, which lists for each screen the
screens your endpoint may send next, or a data_exchange action on a screen. A flow with a
data_exchange action needs a routing_model too.
{
"version": "7.3",
"routing_model": { "PICK_DAY": ["PICK_SLOT"], "PICK_SLOT": [] },
"screens": [
{
"id": "PICK_DAY",
"title": "Pick a day",
"layout": {
"type": "SingleColumnLayout",
"children": [
{
"type": "Form",
"name": "form",
"children": [
{ "type": "DatePicker", "name": "day", "label": "Day", "required": true },
{
"type": "Footer",
"label": "Continue",
"on-click-action": {
"name": "data_exchange",
"payload": { "day": "${form.day}" }
}
}
]
}
]
}
},
{
"id": "PICK_SLOT",
"title": "Pick a time",
"terminal": true,
"success": true,
"data": {
"slots": {
"type": "array",
"items": {
"type": "object",
"properties": { "id": { "type": "string" }, "title": { "type": "string" } }
},
"__example__": [{ "id": "0900", "title": "9:00" }]
}
},
"layout": {
"type": "SingleColumnLayout",
"children": [
{
"type": "Form",
"name": "form",
"children": [
{
"type": "Dropdown",
"name": "slot",
"label": "Time",
"required": true,
"data-source": "${data.slots}"
},
{
"type": "Footer",
"label": "Book",
"on-click-action": {
"name": "data_exchange",
"payload": { "slot": "${form.slot}" }
}
}
]
}
]
}
}
]
}Leave data_api_version and data_channel_uri out: Flow JSON from version 3.0 forbids them, and
Samva sets the flow's endpoint when you register. Recording the version is refused with the reason
when the file breaks this.
Set the endpoint
Give the flow the HTTPS URL that answers its requests. The URL must be reachable on the public internet, as for a webhook.
curl -X PUT https://api.samva.dev/v1/whatsapp/flows/waflow_3xk9mvt4znw8rh7q/endpoint \
-H "X-API-Key: samva_sk_live_your_api_key" \
-H "Content-Type: application/json" \
-d '{ "url": "https://example.com/whatsapp/flows" }'The first time a flow gets an endpoint, the response carries a signing secret that starts with
whsec_. It is shown once: store it where your endpoint reads its configuration. Changing the URL
later keeps the secret.
{
"url": "https://example.com/whatsapp/flows",
"updatedAt": "2026-10-17T10:30:00.000Z",
"secret": "whsec_..."
}Register the version only after the endpoint is set; registering a data-exchange version without one is refused. Registration creates the encryption key for the business account if it has none, uploads its public half to the account's active numbers, and points Meta at Samva. Meta publishes the flow only once it can reach the endpoint, so a deployed handler that answers Meta's health check is the first thing to bring up.
The flow's one endpoint serves every version, so a new version needs no new setup.
The request
Samva posts one JSON envelope per step of the flow, with Content-Type: application/json and the
same three headers as a webhook delivery: webhook-id,
webhook-timestamp, and webhook-signature.
{
"type": "flow.data_exchange",
"timestamp": "2026-10-17T10:36:12.400Z",
"data": {
"organizationId": "org_01j8k4n6p8q2r4s6t9vw",
"flowId": "waflow_3xk9mvt4znw8rh7q",
"flowHandle": "slots",
"registrationId": "wafreg_7q2xk9mvt4znw8rh",
"versionNumber": 1,
"wabaId": "102290129340398",
"action": "data_exchange",
"screen": "PICK_DAY",
"data": { "day": "2026-10-20" },
"flowToken": "booking-4821"
}
}| Field | Meaning |
|---|---|
action | INIT when the customer opens the flow, data_exchange when a screen submits, BACK when they return to a screen that refreshes on back. |
screen | The screen the customer is on. Absent on INIT. |
data | The payload of the screen's data_exchange action. Absent on INIT. |
flowToken | The token your flow message carried, or the message's id when its send named none. |
If the customer's WhatsApp reports an error on a screen, the request is a data_exchange whose
data holds Meta's error and error_message.
flow.data_exchange is a request you answer, not an event you subscribe to: it is never delivered
to a webhook endpoint and does not appear in the event catalog.
Verify the request
Verify every request before you read it, with the flow's secret and the unmodified body.
verifyRequest from @samva/webhooks checks the signature and the timestamp, and rejects a request
that fails either.
import { verifyRequest, WebhookVerificationError } from "@samva/webhooks";
interface FlowRequest {
readonly action: string;
readonly screen?: string;
readonly data?: Record<string, unknown>;
readonly flowToken: string | null;
}
export default {
async fetch(request: Request, env: { SAMVA_FLOW_SECRET: string }) {
let verified;
try {
verified = await verifyRequest(request, env.SAMVA_FLOW_SECRET);
} catch (error) {
if (error instanceof WebhookVerificationError) {
return new Response(null, { status: 401 });
}
throw error;
}
if (verified.event.type !== "flow.data_exchange") return new Response(null, { status: 400 });
const exchange = verified.event.data as FlowRequest;
if (exchange.action === "INIT") {
return Response.json({ screen: "PICK_DAY", data: {} });
}
if (exchange.screen === "PICK_DAY") {
const day = String(exchange.data?.day);
return Response.json({
screen: "PICK_SLOT",
data: { slots: await openSlots(day) },
});
}
return Response.json({
screen: "SUCCESS",
data: {
extension_message_response: {
params: { flow_token: exchange.flowToken, slot: exchange.data?.slot },
},
},
});
},
};Samva signs with the flow's secret, so a request that does not verify did not come from Samva. A
step can arrive more than once: WhatsApp retries a request it got no answer to, and each attempt is
a new request with its own webhook-id. If a step has side effects, key them on your own data,
such as the flowToken and the screen that submitted, rather than on webhook-id.
Answer the request
Answer 200 with a JSON object within the time budget. Samva passes the object to WhatsApp
unchanged, up to 1 MB. Three shapes cover a flow:
- The next screen.
{ "screen": "PICK_SLOT", "data": { ... } }shows that screen withdataas${data.<key>}. The screen must be one yourrouting_modelallows from the current one. - Finish the flow.
{ "screen": "SUCCESS", "data": { "extension_message_response": { "params": { "flow_token": "...", ... } } } }closes the flow. Theparamsbecome theresponseof themessage.receivedwebhook, andflow_tokenis required in it. See Receive the answer. - Acknowledge an error. When the request reports an error, answer
{ "data": { "acknowledged": true } }.
To tell the customer a flow token is no longer valid, for example a booking that was cancelled,
answer 427 with { "error_msg": "This booking has expired." }. Samva relays the status and the
message, and WhatsApp shows the message to the customer.
The time budget
Meta waits 10 seconds for an answer. Samva waits up to 7 seconds for your endpoint, so
the rest is left for decrypting and encrypting. An answer later than that, a connection failure, a
response over 1 MB or not valid JSON, and any status other than 2xx or 427 all reach WhatsApp as
a server error. Meta counts server errors against the flow's error rate (see
Flow health), so keep the handler quick and return 427 rather than 4xx for a
token you refuse.
Send a data-exchange flow
Send the flow as in Send a flow. The action field
chooses how it opens:
action | Opens with |
|---|---|
navigate | The screen you name (or the first entry screen) and data, with no call to your endpoint. |
data_exchange | Your endpoint's answer to an INIT request. It takes neither screen nor data. |
Without action, a data-exchange version opens with data_exchange and any other version with
navigate. Pass token to tie the flow to a record: it arrives as flowToken on every request.
Without one, flowToken is the message's id.
samva whatsapp send --to +16175559876 \
--flow waflow_3xk9mvt4znw8rh7q --flow-action data_exchange --cta "Book now" \
--body "Pick a day and time." --flow-token booking-4821Flow health
Meta watches the endpoint behind a data-exchange flow: its error rate, latency, and availability, and the errors customers' WhatsApp apps report. When a measure crosses its threshold, Meta raises an alert, throttles the flow if the alert persists, and blocks it if it still does.
Active alerts appear on the flow's registrations as endpointAlerts, and each change publishes a
flow.registration.updated webhook event, so a handler on that event can page your team.
{
"id": "wafreg_7q2xk9mvt4znw8rh",
"status": "published",
"endpointAlerts": [
{
"kind": "ENDPOINT_LATENCY",
"message": "p90 latency crossed the threshold",
"value": 6200,
"threshold": 5000,
"activatedAt": "2026-10-17T11:02:00.000Z"
}
]
}kind is Meta's: ENDPOINT_ERROR_RATE, ENDPOINT_LATENCY, ENDPOINT_AVAILABILITY, or
CLIENT_ERROR_RATE, and others Meta adds. value and threshold are a percentage, or milliseconds
for latency; either is null when Meta sends none. An alert leaves the list when Meta clears it. A
throttled or blocked flow follows registration status.
Rotate the signing secret
Rotate when a secret may have leaked or on your own schedule:
curl -X POST https://api.samva.dev/v1/whatsapp/flows/waflow_3xk9mvt4znw8rh7q/endpoint/rotate-secret \
-H "X-API-Key: samva_sk_live_your_api_key"The response carries the new secret, shown once. For the next 24 hours Samva signs each request
with both the new and the previous secret, and webhook-signature lists both signatures, so you can
deploy the new secret without dropping requests. verifyRequest accepts a request that matches
either signature.
Rotate the encryption key
Samva holds one encryption key for each of your WhatsApp business accounts and uploads its public half to every active number on the account. You never handle it. Rotate it if you suspect it was exposed:
curl -X POST https://api.samva.dev/v1/whatsapp/flows/encryption-key/rotate \
-H "X-API-Key: samva_sk_live_your_api_key" \
-H "Content-Type: application/json" \
-d '{}'Pass businessAccountId to rotate another account's key. The response is the new public key and
the numbers it was uploaded to. The replaced key keeps working for 24 hours, because customers'
WhatsApp apps fetch the new key as they need it.
Limits
- A flow message still needs an open service window, data exchange or not.
- A flow has one endpoint, which serves all its versions and must be HTTPS on a public host.
- A template's flow button can open a data-exchange flow with
action="data_exchange"; see Open a flow from a button. ItsflowTokenis the one you pass when you send the template, or the message's id, as it is for each contact a broadcast or journey step messages.
Related documentation
WhatsApp flows
Author a WhatsApp flow as a project file, publish it as a version, register it with Meta, send it in the service window, and read the customer's answers.
Answer WhatsApp in a mailbox
Connect a WhatsApp number to a mailbox so a person or an agent reads customer messages and replies in the same thread.