Publish WhatsApp Flows from Avox and connect a live Flow data endpoint on your own server - setup, key pair, health check, errors and FAQ.
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; Meta's side is on Flows on WhatsApp.
https://, that can hold a private key
secret.Click Create Flow
Type
Name
Content
Create & publish
The wizard saves a draft as you go, so you can leave and come back from Drafts (up to 10).
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:
Set endpoint URI
https://your-server.com/flow-endpoint. It must be public https://.Add phone number
Sign public key
Connect Meta app
Health check
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.
// 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"
}
// 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.
interactive message of type flow with the
Avox API.When Meta blocks, throttles or deprecates a Flow, Admins get an email, if WhatsApp account alerts is on in Compliance.
https://.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:
# Environment
FLOW_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----"
// 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"));
// 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 });
}
}
# 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. To receive this number's WhatsApp messages on your own server, see Message forwarding.
No. Flows under No endpoint needed - surveys, feedback, purchase interest - work without one.
Completed Flows come back as a reply event, which you can open on Logs - the answers are in
interactive.nfm_reply.response_json. The number of responses shows on the Flows card on
Overview. For endpoint Flows, your endpoint also receives each data_exchange step as
it happens. See Delivery statuses and replies.
The public key is registered for your phone number, so one key pair serves all your endpoint Flows on that number.
Create a new Flow with the changes, then deprecate the old one from its page.
Last updated