# WhatsApp Flows Integration

> Publish WhatsApp Flows from Avox and connect a live Flow data endpoint on your own server - setup, key pair, health check, errors and FAQ.

Source: https://docs.theavoxai.com/integrations/whatsapp-flows

**WhatsApp Flows** are forms that open inside a WhatsApp chat - booking, sign-up, lead capture,
surveys. Avox builds a Flow, publishes it to Meta in one step, and keeps its status in sync. For
Flows that need live data (open time slots, a quote, a login check), you connect a **data
endpoint** on your own server, and Avox walks you through setting it up.

The full Flows guide is on [Flows](https://docs.theavoxai.com/flows); Meta's side is on [Flows on WhatsApp](https://docs.theavoxai.com/whatsapp/flows).

## Requirements

- A connected WhatsApp number. The **Flows** page opens once you have one.
- A free Flow slot on your plan. The **Flows used** bar shows how many you have left. See
  [Plans](https://docs.theavoxai.com/plans).
- Admins and Managers can both create, publish and manage Flows.
- For a data endpoint: a server you run, reachable over `https://`, that can hold a private key
  secret.

## Publishing a Flow

**1. Click Create Flow**

On [Flows](https://docs.theavoxai.com/flows), click **Create Flow**, or pick a card under **Start from a premade Flow**.

**2. Type**

Choose a category tile under **Start from scratch** (for example **Appointment booking** or **Survey**), or a premade Flow under **No endpoint needed** or **Needs a live endpoint**. Click **Continue**.

**3. Name**

Enter a **Flow name** and pick **Categories**. Click **Next**.

**4. Content**

Edit the Flow JSON in **Flow content Editor (JSON)**. **Run** refreshes the phone preview; **Validate** checks the JSON with Meta. The **Errors** and **Actions** panels explain what's wrong and what each action does.

**5. Create & publish**

Click **Create & publish**. Avox creates the Flow on Meta, uploads the JSON and publishes it. You'll see "Flow created and published".

The wizard saves a draft as you go, so you can leave and come back from **Drafts** (up to 10).

## Connecting a data endpoint

A data endpoint answers the Flow's `data_exchange` actions with live data. Meta encrypts every
request to it, so it needs a key pair.

In the **Content** step, open **Endpoint** and turn on **This Flow needs a live endpoint**. The
panel shows "Endpoint is set up" or "Endpoint is not set up", with a five-step checklist:

**1. Set endpoint URI**

Enter your endpoint's address, like `https://your-server.com/flow-endpoint`. It must be public `https://`.

**2. Add phone number**

Avox uses your connected number automatically: "Using connected number" and its Phone Number ID.

**3. Sign public key**

Click **Generate key pair**. The key pair is created right in your browser. Copy the private key with **Copy private key** - "Save this private key now - it won't be shown again". Then click **Register public key with Meta** until you see "Registered with Meta".

**4. Connect Meta app**

Shows "Connected." when your number's connection is in place.

**5. Health check**

Click **Run health check**. You'll see "Responded" with the status and time, or the error. This is a plain reachability check.

### Your endpoint code

Under **Endpoint snippet**, Avox gives you a working starting point in **Next.js** or **Express**
(click **Copy**). It decrypts Meta's request with your private key, answers Meta's health `ping`,
and encrypts your response. Put your private key in an environment variable on your server, and
add your own logic where the snippet marks it.

```js
// The shape of a request your endpoint receives (after decryption)
{
  "version": "3.0",
  "action": "data_exchange",
  "screen": "BOOKING",
  "data": { "date": "2026-10-14" },
  "flow_token": "survey-A-1042"
}
```

```js
// The shape your endpoint returns (before encryption)
{
  "version": "3.0",
  "screen": "SLOTS",
  "data": { "slots": [{ "id": "t_0900", "title": "9:00" }, { "id": "t_1400", "title": "14:00" }] }
}
```

The exact request and response rules are Meta's - see [Flows on WhatsApp](https://docs.theavoxai.com/whatsapp/flows).

## Sending a Flow

- From [Automation](https://docs.theavoxai.com/automation): the **Use Flows** action opens one of your published Flows, with
  a button label and intro text.
- From your own systems: send an `interactive` message of type `flow` with the
  [Avox API](https://docs.theavoxai.com/avox-api/messages#flow).

## Check it's working

- The Flow shows **Published** in **Your Flows**. Click **Refresh from Meta** to pull Meta's latest
  status.
- Open the Flow from Automation or the API on your own phone and complete it.
- For endpoint Flows, the Flow's page shows **Endpoint requests - last 30 days** with your traffic.

## Statuses

| Status | Meaning |
| --- | --- |
| **Published** | Live and ready to send. |
| **Draft on Meta** | Exists on Meta but isn't published. Can be deleted with **Delete Flow**. |
| **Throttled** | Meta is limiting how often it can be sent, usually because the endpoint is unhealthy. |
| **Blocked** | Meta stopped it, usually because the endpoint kept failing health checks. |
| **Deprecated** | Retired with **Deprecate Flow**. It can't be sent again. |
| **Failed** | Avox couldn't finish creating or publishing it on Meta. |

When Meta blocks, throttles or deprecates a Flow, Admins get an email, if **WhatsApp account alerts** is on in [Compliance](https://docs.theavoxai.com/compliance).

## Common errors

| Code | Meaning and fix |
| --- | --- |
| [AVX-3033](https://docs.theavoxai.com/error-codes/avx-3033) | Every Flow your plan allows is used. Deprecated and failed Flows still count. See [Plans](https://docs.theavoxai.com/plans). |
| [AVX-3035](https://docs.theavoxai.com/error-codes/avx-3035) | Meta couldn't accept the Flow. Check the **Errors** panel and click **Validate**. |
| [AVX-3036](https://docs.theavoxai.com/error-codes/avx-3036) | Meta rejected the JSON. The errors are listed under it. |
| [AVX-3039](https://docs.theavoxai.com/error-codes/avx-3039) | Only a Flow still in draft on Meta's side can be deleted. Deprecate a published Flow instead. |
| [AVX-3042](https://docs.theavoxai.com/error-codes/avx-3042) | Refreshing from Meta failed. Try again. |
| [AVX-3043](https://docs.theavoxai.com/error-codes/avx-3043) | You have 10 saved drafts. Delete one to start another. |
| [AVX-3046](https://docs.theavoxai.com/error-codes/avx-3046) | The browser couldn't generate a key pair. Try again in an up-to-date browser. |
| [AVX-3047](https://docs.theavoxai.com/error-codes/avx-3047) | Meta couldn't register the public key. Try again. |
| [AVX-3048](https://docs.theavoxai.com/error-codes/avx-3048) | The endpoint couldn't be reached. Check it's deployed and public over `https://`. |
| [AVX-3050](https://docs.theavoxai.com/error-codes/avx-3050) | Endpoint metrics couldn't be loaded from Meta yet. |

## Limits

- Flows per account: set by your plan. See [Plans](https://docs.theavoxai.com/plans).
- Up to 10 saved drafts at a time.
- To change a published Flow, create a new one with the changes and deprecate the old one.
- **Run health check** can be used a few times a minute.

## Security

- The private key is generated in your browser and never sent to Avox. Only the public key goes to
  Meta. Keep the private key in your server's secrets.
- Meta encrypts every request to your endpoint with that key pair.
- The endpoint must be public `https://`.

## Build your own server for WhatsApp Flows

A Flow that's marked **This Flow needs a live endpoint** calls your server on every `data_exchange` step. Your server is the data endpoint: Meta encrypts every request to it, and it must answer in the same encrypted format. These are the requirements Avox's **Endpoint snippet** implements:

| Requirement | What your server does |
| --- | --- |
| Key pair | Uses the private key from **Generate key pair** (step **Sign public key**). Its public key is registered for your number with **Register public key with Meta**. |
| Request | Receives JSON with `encrypted_flow_data`, `encrypted_aes_key` and `initial_vector`. |
| Unwrap the AES key | Decrypts `encrypted_aes_key` with the private key, RSA-OAEP with SHA-256. |
| Decrypt the body | AES-128-GCM with `initial_vector`. The last 16 bytes of `encrypted_flow_data` are the auth tag. |
| Answer the health check | When `action` is `ping`, returns `{ "data": { "status": "active" } }`. |
| Encrypt the reply | AES-128-GCM with the same AES key and the IV with every bit flipped, auth tag appended, Base64-encoded, sent as plain text. |
| Decryption failure | Returns HTTP `421`, which tells Meta to fetch your public key again. |

```bash
# Environment
FLOW_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----"
```

**Express**

```js
// flow-endpoint.js - Node.js 18+ (npm install express). Run: FLOW_PRIVATE_KEY="$(cat private.pem)" node flow-endpoint.js
import express from "express";
import crypto from "node:crypto";

// The private key from "Generate key pair" in Avox. Never commit it; load it from a secret.
const PRIVATE_KEY_PEM = (process.env.FLOW_PRIVATE_KEY ?? "").replace(/\\n/g, "\n");

function decryptRequest(body) {
  const { encrypted_flow_data, encrypted_aes_key, initial_vector } = body;
  // 1. Unwrap the per-request AES key with your RSA private key (RSA-OAEP, SHA-256).
  const aesKey = crypto.privateDecrypt(
    { key: PRIVATE_KEY_PEM, padding: crypto.constants.RSA_PKCS1_OAEP_PADDING, oaepHash: "sha256" },
    Buffer.from(encrypted_aes_key, "base64")
  );
  // 2. Decrypt the body with AES-128-GCM. The last 16 bytes are the auth tag.
  const data = Buffer.from(encrypted_flow_data, "base64");
  const iv = Buffer.from(initial_vector, "base64");
  const decipher = crypto.createDecipheriv("aes-128-gcm", aesKey, iv);
  decipher.setAuthTag(data.subarray(-16));
  const plain = Buffer.concat([decipher.update(data.subarray(0, -16)), decipher.final()]);
  return { request: JSON.parse(plain.toString("utf8")), aesKey, iv };
}

function encryptResponse(payload, aesKey, iv) {
  // 3. Encrypt the reply with the same AES key and the IV with every bit flipped.
  const flippedIv = Buffer.from(iv.map((byte) => ~byte));
  const cipher = crypto.createCipheriv("aes-128-gcm", aesKey, flippedIv);
  const encrypted = Buffer.concat([cipher.update(JSON.stringify(payload), "utf8"), cipher.final()]);
  return Buffer.concat([encrypted, cipher.getAuthTag()]).toString("base64");
}

// Your business logic: decide the next screen and its data.
function handle(request) {
  if (request.action === "ping") return { data: { status: "active" } }; // Meta's health check
  if (request.action === "INIT") return { screen: "BOOKING", data: { dates: ["2026-10-14", "2026-10-15"] } };
  if (request.action === "data_exchange" && request.screen === "BOOKING") {
    return { screen: "SLOTS", data: { slots: [{ id: "t_0900", title: "9:00" }, { id: "t_1400", title: "14:00" }] } };
  }
  // Finish the Flow and hand the answers back to the chat.
  return { screen: "SUCCESS", data: { extension_message_response: { params: { flow_token: request.flow_token } } } };
}

const app = express();

app.post("/flow-endpoint", express.json({ limit: "1mb" }), (req, res) => {
  let decrypted;
  try {
    decrypted = decryptRequest(req.body);
  } catch (error) {
    console.error("[flow-endpoint] could not decrypt request", error.message);
    return res.sendStatus(421); // tells Meta to fetch your public key again and retry
  }
  try {
    const { request, aesKey, iv } = decrypted;
    const reply = { version: request.version, ...handle(request) };
    res.type("text/plain").send(encryptResponse(reply, aesKey, iv));
  } catch (error) {
    console.error("[flow-endpoint] handler failed", error);
    res.sendStatus(500);
  }
});

app.listen(process.env.PORT ?? 3000, () => console.log("Flow endpoint listening"));
```

**Next.js**

```ts
// app/api/flow-endpoint/route.ts - Next.js (App Router). Set FLOW_PRIVATE_KEY as a server secret.
import crypto from "node:crypto";

export const runtime = "nodejs";

// The private key from "Generate key pair" in Avox. Never commit it; load it from a secret.
const PRIVATE_KEY_PEM = (process.env.FLOW_PRIVATE_KEY ?? "").replace(/\\n/g, "\n");

type FlowRequest = { version: string; action: string; screen?: string; data?: Record<string, unknown>; flow_token?: string };

function decryptRequest(body: { encrypted_flow_data: string; encrypted_aes_key: string; initial_vector: string }) {
  // 1. Unwrap the per-request AES key with your RSA private key (RSA-OAEP, SHA-256).
  const aesKey = crypto.privateDecrypt(
    { key: PRIVATE_KEY_PEM, padding: crypto.constants.RSA_PKCS1_OAEP_PADDING, oaepHash: "sha256" },
    Buffer.from(body.encrypted_aes_key, "base64")
  );
  // 2. Decrypt the body with AES-128-GCM. The last 16 bytes are the auth tag.
  const data = Buffer.from(body.encrypted_flow_data, "base64");
  const iv = Buffer.from(body.initial_vector, "base64");
  const decipher = crypto.createDecipheriv("aes-128-gcm", aesKey, iv);
  decipher.setAuthTag(data.subarray(-16));
  const plain = Buffer.concat([decipher.update(data.subarray(0, -16)), decipher.final()]);
  return { request: JSON.parse(plain.toString("utf8")) as FlowRequest, aesKey, iv };
}

function encryptResponse(payload: unknown, aesKey: Buffer, iv: Buffer) {
  // 3. Encrypt the reply with the same AES key and the IV with every bit flipped.
  const flippedIv = Buffer.from(iv.map((byte) => ~byte));
  const cipher = crypto.createCipheriv("aes-128-gcm", aesKey, flippedIv);
  const encrypted = Buffer.concat([cipher.update(JSON.stringify(payload), "utf8"), cipher.final()]);
  return Buffer.concat([encrypted, cipher.getAuthTag()]).toString("base64");
}

// Your business logic: decide the next screen and its data.
function handle(request: FlowRequest) {
  if (request.action === "ping") return { data: { status: "active" } }; // Meta's health check
  if (request.action === "INIT") return { screen: "BOOKING", data: { dates: ["2026-10-14", "2026-10-15"] } };
  if (request.action === "data_exchange" && request.screen === "BOOKING") {
    return { screen: "SLOTS", data: { slots: [{ id: "t_0900", title: "9:00" }, { id: "t_1400", title: "14:00" }] } };
  }
  return { screen: "SUCCESS", data: { extension_message_response: { params: { flow_token: request.flow_token } } } };
}

export async function POST(request: Request) {
  let decrypted: ReturnType<typeof decryptRequest>;
  try {
    decrypted = decryptRequest(await request.json());
  } catch (error) {
    console.error("[flow-endpoint] could not decrypt request", (error as Error).message);
    return new Response(null, { status: 421 }); // tells Meta to fetch your public key again and retry
  }
  try {
    const { request: flowRequest, aesKey, iv } = decrypted;
    const reply = { version: flowRequest.version, ...handle(flowRequest) };
    return new Response(encryptResponse(reply, aesKey, iv), { headers: { "Content-Type": "text/plain" } });
  } catch (error) {
    console.error("[flow-endpoint] handler failed", error);
    return new Response(null, { status: 500 });
  }
}
```

**FastAPI**

```python
# main.py - pip install fastapi uvicorn cryptography ; run: uvicorn main:app --port 3000
import base64
import json
import logging
import os

from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from fastapi import FastAPI, Request
from fastapi.responses import PlainTextResponse, Response

log = logging.getLogger("flow-endpoint")
app = FastAPI()

# The private key from "Generate key pair" in Avox. Never commit it; load it from a secret.
PRIVATE_KEY = serialization.load_pem_private_key(
    os.environ["FLOW_PRIVATE_KEY"].replace("\\n", "\n").encode(), password=None
)


def decrypt_request(body: dict):
    # 1. Unwrap the per-request AES key with your RSA private key (RSA-OAEP, SHA-256).
    aes_key = PRIVATE_KEY.decrypt(
        base64.b64decode(body["encrypted_aes_key"]),
        padding.OAEP(mgf=padding.MGF1(algorithm=hashes.SHA256()), algorithm=hashes.SHA256(), label=None),
    )
    # 2. Decrypt the body with AES-128-GCM (the auth tag is the last 16 bytes, which AESGCM expects).
    iv = base64.b64decode(body["initial_vector"])
    plain = AESGCM(aes_key).decrypt(iv, base64.b64decode(body["encrypted_flow_data"]), None)
    return json.loads(plain), aes_key, iv


def encrypt_response(payload: dict, aes_key: bytes, iv: bytes) -> str:
    # 3. Encrypt the reply with the same AES key and the IV with every bit flipped.
    flipped_iv = bytes(b ^ 0xFF for b in iv)
    return base64.b64encode(AESGCM(aes_key).encrypt(flipped_iv, json.dumps(payload).encode(), None)).decode()


def handle(req: dict) -> dict:
    # Your business logic: decide the next screen and its data.
    if req.get("action") == "ping":
        return {"data": {"status": "active"}}  # Meta's health check
    if req.get("action") == "INIT":
        return {"screen": "BOOKING", "data": {"dates": ["2026-10-14", "2026-10-15"]}}
    if req.get("action") == "data_exchange" and req.get("screen") == "BOOKING":
        return {"screen": "SLOTS", "data": {"slots": [{"id": "t_0900", "title": "9:00"}, {"id": "t_1400", "title": "14:00"}]}}
    return {"screen": "SUCCESS", "data": {"extension_message_response": {"params": {"flow_token": req.get("flow_token")}}}}


@app.post("/flow-endpoint")
async def flow_endpoint(request: Request):
    try:
        req, aes_key, iv = decrypt_request(await request.json())
    except Exception as error:  # wrong key, corrupted data, missing fields
        log.error("could not decrypt request: %s", error)
        return Response(status_code=421)  # tells Meta to fetch your public key again and retry
    try:
        reply = {"version": req.get("version"), **handle(req)}
        return PlainTextResponse(encrypt_response(reply, aes_key, iv))
    except Exception:
        log.exception("handler failed")
        return Response(status_code=500)
```

Replace `handle()` with your own screens and data - the screen ids must match your Flow JSON. Deploy it at the public `https://` address you entered in **Set endpoint URI**, then click **Run health check** in the **Endpoint** panel. For sending a Flow from your own systems, use an `interactive` message of type `flow` through the [Avox API](https://docs.theavoxai.com/avox-api/messages#flow). To receive this number's WhatsApp messages on your own server, see [Message forwarding](https://docs.theavoxai.com/integrations/webhooks).

## FAQ

**Do I need an endpoint for every Flow?**

No. Flows under **No endpoint needed** - surveys, feedback, purchase interest - work without one.

**Where do Flow answers go?**

Completed Flows come back as a reply event, which you can open on [Logs](https://docs.theavoxai.com/logs) - the answers are in
`interactive.nfm_reply.response_json`. The number of responses shows on the **Flows** card on
[Overview](https://docs.theavoxai.com/overview). For endpoint Flows, your endpoint also receives each `data_exchange` step as
it happens. See [Delivery statuses and replies](https://docs.theavoxai.com/avox-api/webhooks).

**Is the key pair per Flow?**

The public key is registered for your phone number, so one key pair serves all your endpoint Flows
on that number.

**Can I edit a published Flow?**

Create a new Flow with the changes, then deprecate the old one from its page.
