A Flow is a form that opens inside a WhatsApp chat - booking an appointment, capturing a lead, running a short survey - so your customer never leaves the conversation. Flows are Meta's own WhatsApp feature; Avox gives you premade Flows, a JSON editor with a live phone preview, and one-click publishing to Meta.
Riverside Cafe
Book an appointment
Pick a time
Choose a service and share your name to get started.
Service
Your name *
This two-screen preview is rendered by the same component the Flow wizard uses while you edit.
For how Flows work on Meta's side - Flow JSON, screens, actions, and the status lifecycle - see Flows on WhatsApp.
The Manager column shows the Manager defaults. An Admin can give or take away Flow permissions person by person - see Permissions.
If Create Flow doesn't appear for you, Flow creation has been switched off for your account - contact Support.
Open Flows in the sidebar.
A brand-new Switchboard with no Flows and no drafts also sees Get started with your first Flow at the top of the page.
Want the short version? See the Create a Flow walkthrough.
Click Create Flow to open the wizard at /flows/new ("Published straight to Meta."). It has
three steps - Type, Name, and Content - with a live phone preview on the right. The
wizard saves your work as a draft a moment after each change (up to 10 drafts per Switchboard;
see Drafts).
Hover or click a tile to preview it. Nothing is chosen until you click Continue.
Step 2 is headed Name your Flow. Click Next to continue.
The Flow content Editor (JSON) is a full code editor with line numbers, syntax highlighting, and right-click commands such as Format Document. Above it:
Three buttons in the footer open panels above it:
When the Flow has more than one screen, a Previewing menu above the phone picks which screen the preview shows.
Click Create & publish. Avox checks that the Flow has a name ("Give this Flow a name"), at least one category ("Pick at least one category"), valid JSON, and (when the endpoint switch is on) an endpoint URL ("Enter a live endpoint URL"). Then Avox creates the Flow on Meta, uploads its JSON, and publishes it in one go. You see "Flow created and published" and return to the Flows page, where the Flow shows as Published. Its draft is removed.
If Meta's validator rejects the JSON, nothing is published: you see "Meta rejected the Flow's content" (AVX-3035) and the attempt is listed as Failed. Run Validate to see Meta's exact findings in the Errors panel, fix them, and publish again.
A Failed attempt still exists on Meta as a draft and uses one of your Flow slots. Click Refresh from Meta on the Flows page: it then shows as Draft on Meta, and you can open it and click Delete Flow to free the slot.
Create & publish is disabled while every Flow slot on your plan is in use.
Some Flows fetch live data from your own server while the customer fills them in - available appointment slots, a quote, a sign-in check. Meta calls your server directly, encrypted, at the endpoint URL you set. Avox sets up the connection with Meta; your server handles the requests.
Open the Endpoint panel and switch on This Flow needs a live endpoint. The checklist reads "Endpoint is not set up" and shows how many of its five steps are done ("Complete the steps below (3/5)"). Once all five are done it reads "Endpoint is set up":
Set endpoint URI
Enter your server's URL, for example https://your-server.com/flow-endpoint. It must start
with https:// and point to a public address (AVX-3041).
Add phone number
Shows the connected number this Flow uses, or a Connect one link to Connections.
Sign public key
Click Generate key pair. Your browser creates an RSA key pair; the private key never leaves your browser. Copy it with Copy private key and store it as a secret on your server - "Save this private key now - it won't be shown again". Then click Register public key with Meta. Once Meta accepts it, the step shows Registered with Meta.
Connect Meta app
Ticks itself when your WhatsApp number is connected.
Health check
Click Run health check (it's enabled once the endpoint URL is valid). Avox sends a test request to your URL and shows how it answered, for example "Responded 200 in 140ms", or the reason it couldn't. "A plain reachability check, not Meta's own encrypted ping." Meta sends its own encrypted health check later.
Under the checklist, Endpoint snippet has a starting point for your server in two variants.
It decrypts Meta's request, answers Meta's ping health check, and encrypts the response. Your
own screen-by-screen logic goes where it's marked. Copy copies the snippet.
// app/api/flow-endpoint/route.ts - Next.js Route Handler
const crypto = require("crypto");
// PRIVATE_KEY_PEM - never send this anywhere. Store it as a secret
// (e.g. an env var), never in source control.
const PRIVATE_KEY_PEM = process.env.FLOW_PRIVATE_KEY;
function decryptRequest(body) {
const { encrypted_flow_data, encrypted_aes_key, initial_vector } = body;
const aesKey = crypto.privateDecrypt(
{ key: PRIVATE_KEY_PEM, padding: crypto.constants.RSA_PKCS1_OAEP_PADDING, oaepHash: "sha256" },
Buffer.from(encrypted_aes_key, "base64")
);
const flowDataBuffer = Buffer.from(encrypted_flow_data, "base64");
const TAG_LENGTH = 16;
const encryptedBody = flowDataBuffer.subarray(0, -TAG_LENGTH);
const authTag = flowDataBuffer.subarray(-TAG_LENGTH);
const iv = Buffer.from(initial_vector, "base64");
const decipher = crypto.createDecipheriv("aes-128-gcm", aesKey, iv);
decipher.setAuthTag(authTag);
const decrypted = Buffer.concat([decipher.update(encryptedBody), decipher.final()]);
return { decryptedBody: JSON.parse(decrypted.toString("utf-8")), aesKey, iv };
}
function encryptResponse(responseBody, aesKey, iv) {
// Meta requires the response encrypted with the same AES key but the
// IV bit-flipped, not reused as-is.
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(responseBody), "utf-8"), cipher.final()]);
return Buffer.concat([encrypted, cipher.getAuthTag()]).toString("base64");
}
function handleFlowRequest(body) {
const { decryptedBody, aesKey, iv } = decryptRequest(body);
// Meta sends this before trusting the endpoint at all - respond with
// exactly this shape or the "Health check" step above will keep failing.
if (decryptedBody.action === "ping") {
return encryptResponse({ data: { status: "active" } }, aesKey, iv);
}
// Your real per-screen business logic goes here - read
// decryptedBody.screen / decryptedBody.data, decide the next screen.
const responseBody = {
version: decryptedBody.version,
screen: "SUCCESS",
data: { extension_message_response: { params: { flow_token: decryptedBody.flow_token } } },
};
return encryptResponse(responseBody, aesKey, iv);
}
export async function POST(request) {
try {
const body = await request.json();
const encryptedResponse = handleFlowRequest(body);
return new Response(encryptedResponse, { headers: { "Content-Type": "text/plain" } });
} catch (error) {
console.error("[flow-endpoint] failed to process request", error);
return new Response(null, { status: 421 }); // Meta re-fetches the public key on 421
}
}
// endpoint.js - Express route for this Flow's data_endpoint_uri
const crypto = require("crypto");
// PRIVATE_KEY_PEM - never send this anywhere. Store it as a secret
// (e.g. an env var), never in source control.
const PRIVATE_KEY_PEM = process.env.FLOW_PRIVATE_KEY;
function decryptRequest(body) {
const { encrypted_flow_data, encrypted_aes_key, initial_vector } = body;
const aesKey = crypto.privateDecrypt(
{ key: PRIVATE_KEY_PEM, padding: crypto.constants.RSA_PKCS1_OAEP_PADDING, oaepHash: "sha256" },
Buffer.from(encrypted_aes_key, "base64")
);
const flowDataBuffer = Buffer.from(encrypted_flow_data, "base64");
const TAG_LENGTH = 16;
const encryptedBody = flowDataBuffer.subarray(0, -TAG_LENGTH);
const authTag = flowDataBuffer.subarray(-TAG_LENGTH);
const iv = Buffer.from(initial_vector, "base64");
const decipher = crypto.createDecipheriv("aes-128-gcm", aesKey, iv);
decipher.setAuthTag(authTag);
const decrypted = Buffer.concat([decipher.update(encryptedBody), decipher.final()]);
return { decryptedBody: JSON.parse(decrypted.toString("utf-8")), aesKey, iv };
}
function encryptResponse(responseBody, aesKey, iv) {
// Meta requires the response encrypted with the same AES key but the
// IV bit-flipped, not reused as-is.
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(responseBody), "utf-8"), cipher.final()]);
return Buffer.concat([encrypted, cipher.getAuthTag()]).toString("base64");
}
function handleFlowRequest(body) {
const { decryptedBody, aesKey, iv } = decryptRequest(body);
// Meta sends this before trusting the endpoint at all - respond with
// exactly this shape or the "Health check" step above will keep failing.
if (decryptedBody.action === "ping") {
return encryptResponse({ data: { status: "active" } }, aesKey, iv);
}
// Your real per-screen business logic goes here - read
// decryptedBody.screen / decryptedBody.data, decide the next screen.
const responseBody = {
version: decryptedBody.version,
screen: "SUCCESS",
data: { extension_message_response: { params: { flow_token: decryptedBody.flow_token } } },
};
return encryptResponse(responseBody, aesKey, iv);
}
app.post("/flow-endpoint", express.json(), (req, res) => {
try {
const encryptedResponse = handleFlowRequest(req.body);
res.set("Content-Type", "text/plain").send(encryptedResponse);
} catch (error) {
console.error("[flow-endpoint] failed to process request", error);
res.status(421).send(); // Meta re-fetches the public key on 421
}
});
The wizard saves your work a moment after each change, and the page address becomes
/flows/new?draft=... so a reload picks up where you were.
Click Manage on a Flow to open its page. Back to Flows returns to the list. It shows the Flow's name, its Meta Flow ID, its Category, its Status, and (for an endpoint-backed Flow) its Endpoint URL. A Draft on Meta Flow has a Delete Flow button and a Published Flow has Deprecate Flow. Any other status shows "Only a Flow still in draft on Meta's side can be deleted here. (AVX-3039)" in their place.
Meta tells Avox when a Flow's status changes, and Refresh from Meta fetches every status straight away (AVX-3042 if Meta can't be reached). When a Flow becomes Blocked, Throttled, or Deprecated, Avox emails every Admin of the Switchboard (part of the WhatsApp account alerts preference on Compliance).
On a Flow whose status is Draft on Meta, click Delete Flow. Avox removes it from Meta and from Avox, shows "Flow deleted", and returns you to the Flows page. A published Flow can't be deleted - Meta only allows retiring it (AVX-3039).
Click Deprecate Flow
Shown on Published Flows only.
Read the dialog
Deprecate "flow_name"? - "Meta doesn't allow deleting a published Flow - deprecating retires it instead. This can't be undone, and the Flow will stop being usable in any message that references it."
Confirm
Click Deprecate Flow. You see "Flow deprecated" and the status changes to Deprecated.
Automations that send a deprecated or deleted Flow can't be switched on until you pick a published Flow for that action (AVX-3078).
Endpoint-backed Flows show Endpoint requests - last 30 days: the Total requests Meta sent to your endpoint and a daily bar chart, straight from Meta. With no traffic yet it reads "No endpoint traffic in the last 30 days yet." A Flow without an endpoint shows a short note in its place, since Meta reports endpoint traffic only.
When a customer completes a Flow, their answers arrive in Avox. The Flows card on the dashboard's Overview page shows Responses (30d), the number of completed responses in the last 30 days, next to your Flows broken down by status.
A published Flow is sent from an automation's Use Flows action, with a button label of your choice (for example "Book now"). See Actions.
For a short exchange - one or two questions, a confirm or reschedule choice - an automation's Ask a question and Wait for reply steps are often simpler than a Flow: the customer answers right in the chat, and the answer is saved to a tag or contact field. Use a Flow when you need a full form with several fields on one screen. See Actions.