WhatsApp

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.

flows/slots.json
{
  "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"
  }
}
FieldMeaning
actionINIT when the customer opens the flow, data_exchange when a screen submits, BACK when they return to a screen that refreshes on back.
screenThe screen the customer is on. Absent on INIT.
dataThe payload of the screen's data_exchange action. Absent on INIT.
flowTokenThe 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.

flow-endpoint.ts
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 with data as ${data.<key>}. The screen must be one your routing_model allows from the current one.
  • Finish the flow. { "screen": "SUCCESS", "data": { "extension_message_response": { "params": { "flow_token": "...", ... } } } } closes the flow. The params become the response of the message.received webhook, and flow_token is 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:

actionOpens with
navigateThe screen you name (or the first entry screen) and data, with no call to your endpoint.
data_exchangeYour 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-4821

Flow 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. Its flowToken is 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

On this page