> ## Documentation Index
> Fetch the complete documentation index at: https://docs.connectly.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# WhatsApp Flow endpoints

> Implement the data-exchange endpoint that powers dynamic WhatsApp Flows

A static flow defines all of its screens up front in the Flow JSON. A **dynamic** flow builds screens at runtime: when the customer opens the flow or submits a screen, WhatsApp calls an HTTPS endpoint you host, and your endpoint answers with the next screen and its data. Use it when screen content depends on live information — available time slots, account details, product lists.

This page covers the contract your endpoint implements. For sending flows through the API, see [Send WhatsApp Flows](/messaging/whatsapp-flows).

<Note>
  WhatsApp calls your endpoint directly — the traffic does not pass through Connectly. You configure the endpoint URL and its encryption key on the flow in WhatsApp Manager. Meta's reference documentation, including sample endpoint implementations, is at [Implementing your Flow endpoint](https://developers.facebook.com/docs/whatsapp/flows/guides/implementingyourflowendpoint).
</Note>

## Request lifecycle

Your endpoint receives one `POST` per event:

| When                                           | `action`        | Your endpoint returns                |
| ---------------------------------------------- | --------------- | ------------------------------------ |
| Periodic health check                          | `ping`          | `{ "data": { "status": "active" } }` |
| Customer opens the flow (`data_exchange` mode) | `INIT`          | The first screen                     |
| Customer submits a screen                      | `data_exchange` | The next screen and its data         |
| Customer taps back                             | `BACK`          | The screen to show                   |

A completed flow (a screen whose action is `complete`) does not call your endpoint. The customer's answers are delivered as a flow response message — see [Webhooks](/webhooks/overview).

## Encryption

Every request body carries three base64 fields:

```json theme={null}
{
  "encrypted_flow_data": "…",
  "encrypted_aes_key": "…",
  "initial_vector": "…"
}
```

**To decrypt the request:**

1. Decrypt `encrypted_aes_key` with your RSA private key, using OAEP padding with SHA-256. The result is a 128-bit AES key.
2. Decrypt `encrypted_flow_data` with AES-128-GCM using that key and `initial_vector`. The last 16 bytes of the ciphertext are the GCM authentication tag.
3. The plaintext is the JSON request.

**To encrypt your response:**

1. Flip every byte of the request's `initial_vector` (XOR each byte with `0xFF`).
2. Encrypt your response JSON with AES-128-GCM using the **same AES key** from the request and the flipped IV. Append the 16-byte authentication tag to the ciphertext.
3. Return the result base64-encoded as the raw response body — plain text, not JSON — with HTTP status `200`.

If you cannot decrypt a request (for example, after rotating keys), return HTTP `421`. WhatsApp then re-fetches your public key and retries.

<Note>
  The private key stays on your server. Its public key is registered on your WhatsApp phone number — one key pair per phone number. If you generate a new key pair, the public key must be registered again before your endpoint can decrypt traffic.
</Note>

## Requests and responses

The decrypted request:

```json theme={null}
{
  "version": "3.0",
  "action": "data_exchange",
  "screen": "SELECT_SERVICE",
  "data": { "service": "haircut" },
  "flow_token": "ref-000123"
}
```

Your decrypted response — the next screen and the data it needs:

```json theme={null}
{
  "version": "3.0",
  "screen": "SELECT_TIME",
  "data": {
    "time_slots": [
      { "id": "0900", "title": "9:00" },
      { "id": "1030", "title": "10:30" },
      { "id": "1500", "title": "15:00" }
    ]
  }
}
```

<Warning>
  Every `screen` your endpoint returns must exist in the **published** Flow JSON. Returning a screen name the flow does not contain is the most common endpoint bug: the customer sees "Something went wrong" when the flow opens, while your logs show successful `200` responses. Keep your Flow JSON and endpoint in sync — when you rename or add screens, update both.
</Warning>

## Dynamic screen data

List components in the Flow JSON (`RadioButtonsGroup`, `CheckboxGroup`, `Dropdown`) bind to data your endpoint returns:

```json theme={null}
{ "type": "RadioButtonsGroup", "name": "slot", "data-source": "${data.time_slots}" }
```

The number of options is the length of the array you return — 2 items render 2 options, 5 items render 5. Each item needs a string `id` and a `title`; optional fields include `description` and `enabled`. Limits: 20 items for radio buttons and checkboxes, 200 for a dropdown.

To personalize from the very first screen, use the `flowToken` you set when [sending the flow](/messaging/whatsapp-flows#flow_token): it is echoed to your endpoint on every request, including `INIT`, so it can carry a customer reference your endpoint resolves before building the first screen.

## Example endpoint

A complete Node.js implementation of the contract, with the crypto and a two-screen booking handler. The same structure ports directly to other languages.

```javascript theme={null}
import crypto from "node:crypto";
import http from "node:http";
import fs from "node:fs";

const PRIVATE_KEY = fs.readFileSync("./private_key.pem", "utf8");

function decryptRequest(body) {
  const aesKey = crypto.privateDecrypt(
    { key: PRIVATE_KEY, padding: crypto.constants.RSA_PKCS1_OAEP_PADDING, oaepHash: "sha256" },
    Buffer.from(body.encrypted_aes_key, "base64"),
  );
  const iv = Buffer.from(body.initial_vector, "base64");
  const flowData = Buffer.from(body.encrypted_flow_data, "base64");
  const tag = flowData.subarray(flowData.length - 16);
  const ciphertext = flowData.subarray(0, flowData.length - 16);
  const decipher = crypto.createDecipheriv("aes-128-gcm", aesKey, iv, { authTagLength: 16 });
  decipher.setAuthTag(tag);
  const plain = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
  return { payload: JSON.parse(plain.toString("utf8")), aesKey, iv };
}

function encryptResponse(responseObj, aesKey, iv) {
  const flippedIV = Buffer.from(Uint8Array.from(iv, (b) => b ^ 0xff));
  const cipher = crypto.createCipheriv("aes-128-gcm", aesKey, flippedIV, { authTagLength: 16 });
  const enc = Buffer.concat([cipher.update(JSON.stringify(responseObj), "utf8"), cipher.final()]);
  return Buffer.concat([enc, cipher.getAuthTag()]).toString("base64");
}

async function nextScreen({ action, screen, data, flow_token }) {
  if (action === "ping") return { data: { status: "active" } };
  if (action === "INIT") return { version: "3.0", screen: "SELECT_SERVICE", data: {} };

  if (action === "data_exchange" && screen === "SELECT_SERVICE") {
    const slots = await fetchAvailableSlots(data.service); // your business logic
    return { version: "3.0", screen: "SELECT_TIME",
             data: { time_slots: slots.map((s) => ({ id: String(s.id), title: s.label })) } };
  }

  return { version: "3.0", screen: "SELECT_SERVICE", data: {} };
}

http.createServer((req, res) => {
  let raw = "";
  req.on("data", (c) => (raw += c));
  req.on("end", async () => {
    try {
      const { payload, aesKey, iv } = decryptRequest(JSON.parse(raw));
      console.log(`in: action=${payload.action} screen=${payload.screen ?? "-"}`);
      const response = await nextScreen(payload);
      res.writeHead(200, { "Content-Type": "text/plain" });
      res.end(encryptResponse(response, aesKey, iv));
    } catch (e) {
      res.writeHead(421);
      res.end();
    }
  });
}).listen(3000);
```

## Production checklist

* **Respond fast.** WhatsApp allows only a few seconds per request. Cache slow upstream calls; keep the endpoint close to your data.
* **Use a stable HTTPS URL.** Tunnel tools such as ngrok are fine for development, but free tunnels change their URL on every restart — and the flow keeps calling the old one. Host production endpoints on a fixed domain.
* **Log every exchange.** Log the incoming `action` and `screen` and the `screen` you return. Most flow issues are diagnosed from exactly these two lines.
* **Return `421` on decryption failures** so WhatsApp refreshes your public key instead of retrying blindly.

## Troubleshooting

| Symptom                                                    | Likely cause                                                                                                                                | Fix                                                                                                              |
| ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- |
| "Something went wrong" when the flow opens                 | Endpoint unreachable, or `INIT` returns a screen that is not in the published flow                                                          | Confirm the endpoint URL on the flow matches your current URL; check the screen name your `INIT` handler returns |
| Flow opens, but a specific screen fails                    | That screen's `data_exchange` handler errors or returns an unknown screen                                                                   | Check your logs for that screen's request; verify the returned screen name and data shape                        |
| Your logs show `200` everywhere, but the flow still errors | Response encryption is wrong (IV not flipped, tag missing, or body wrapped in JSON), or the returned screen name does not exist in the flow | Verify the encrypt steps above; return the base64 string as a plain-text body                                    |
| Every request fails to decrypt                             | Your private key does not match the public key registered on the phone number                                                               | Re-register the current public key, or restore the matching private key; return `421` meanwhile                  |
| Options list renders empty                                 | The bound array is missing from your response data, or item `id`s are not strings                                                           | Return the array under the exact property name the screen declares; stringify ids                                |
| Worked yesterday, fails today with no code change          | Your endpoint URL changed (common with tunnels)                                                                                             | Update the endpoint URL on the flow, or move to a stable URL                                                     |
