WhatsApp Flows let a business put a real form inside a chat: screens, inputs, dropdowns, date pickers, a submit button. They're also the part of the WhatsApp platform with the most surprising rules. The Flow JSON version you publish with gets frozen on a schedule, published Flows can never be edited, and the character limits in the docs aren't enforced the way the docs suggest. Here's what we learned building a Flow builder into WAPGROWAI, with the TypeScript we use now.
All snippets are simplified versions of our code and were run against the cases shown.
Static or endpoint: decide first
A Flow can run entirely on the phone, or call your server between screens.
-
Static Flows move from screen to screen with
navigate, carry earlier answers forward, and send everything back in one response when the user submits. No server involved until the end. -
Endpoint Flows call your backend with
data_exchangeto load options, validate input or branch on live data. That means generating a key pair, uploading the public key, decrypting every request with AES-GCM, encrypting every response, and answering Meta's periodicpinghealth checks.
Meta's own advice in the endpoint guide is that endpoint-powered Flows "should avoid using the endpoint when it is not needed". We ship static Flows only. Lead forms, booking requests and surveys rarely need live data mid-form, and every Flow without an endpoint is one less service that can take your forms down.
The version that expires
Every Flow JSON document starts with a version:
{ "version": "7.3", "screens": [ ... ] }
(7.3 was the latest recommended version when we checked; the changelog has the current list.)
Meta freezes and then expires old versions:
- Frozen: you can no longer publish a Flow that targets the version, but existing Flows can still be sent.
- Expired: Flows on that version can no longer be sent to customers.
- How soon: "In general, the period before freeze or expiry will be 90 days from the release of a new version", and it can be shorter.
We had the version in an environment variable, set once and forgotten. When that version was frozen, every new publish failed, while every Flow published earlier kept working. To the people using the builder it looked intermittent: "it worked last week". It took longer to find than it should have, because our API caught Meta's error and returned a generic 500. The real message was sitting in the error log: Given Flow JSON version is not supported.
Three fixes, in order of value:
- Surface Meta's error message to whoever pressed Publish. Flow errors are specific and readable. Swallowing them turns a one-line fix into an investigation.
- Keep the version in one place, and stamp it into every document you upload.
- Put the changelog on a calendar. Meta lists the currently supported versions and freeze dates in the Flows changelog. Bumping the version is usually a one-line change; finding out you need to is the hard part.
Published means permanent
From Meta's Flows API reference: once a Flow is published, it "cannot be modified or deleted, but can be marked as deprecated". A deprecated Flow can't be sent or opened, and it can't be brought back.
So publishing has to be the last step of a sequence that cleans up after itself:
// provision.ts
type Graph = (method: "GET" | "POST" | "DELETE", path: string, body?: unknown) => Promise<any>;
export class FlowPublishError extends Error {}
const FLOW_JSON_VERSION = process.env.META_FLOW_JSON_VERSION ?? "7.3";
// Create a draft, upload the JSON, publish. A failure anywhere deletes the draft,
// because a published Flow can never be edited or deleted, only deprecated.
export const publishFlow = async (graph: Graph, wabaId: string, name: string, screens: unknown[]) => {
const { id } = await graph("POST", `/${wabaId}/flows`, { name, categories: ["OTHER"] });
try {
const flowJson = { version: FLOW_JSON_VERSION, screens };
const asset = await graph("POST", `/${id}/assets`, { name: "flow.json", asset_type: "FLOW_JSON", file: flowJson });
const [first] = asset.validation_errors ?? [];
if (first) throw new FlowPublishError(`Flow JSON is invalid: ${first.error_type ?? first.error} ${first.message}`);
await graph("POST", `/${id}/publish`);
return id as string;
} catch (error) {
await graph("DELETE", `/${id}`).catch(() => {});
// Meta's message is the only useful part ("Given Flow JSON version is not supported").
throw error instanceof FlowPublishError ? error : new FlowPublishError((error as Error).message);
}
};
(graph stands in for your Graph API client. The asset upload is a multipart form in reality; it's shortened here.)
- Drafts are deletable; published Flows aren't. If validation or publishing fails, delete the draft so Meta doesn't fill up with half-made Flows.
- Save your own record as a draft first, and mark it published only after Meta says so. Otherwise a failed publish leaves your database claiming a Flow exists that doesn't.
- "Editing" a published Flow means creating a new one and deprecating the old one once nothing sends it anymore.
-
Test before you publish. Meta lets you send the current draft with
mode: "draft".
The limits table is a rendering budget
Meta's components reference lists character limits for every component: a 20-character label for text inputs, 30 for radio button groups, and so on. We built validation that blocked publishing over those limits. Then we tested them.
We uploaded Flows whose labels ran past every limit, some to four times the documented length. They uploaded with no validation errors and published. Meta's validator is real (it rejects unknown component types and missing required properties) but it didn't check lengths.
What does notice is Meta's preview. Request GET /{flow-id}?fields=preview.invalidate(false) and you get a public preview URL, valid for 30 days. Over-length text overflows its box there, and the preview shows inline warnings like "should be N characters or less to avoid truncation on different screen sizes". Those preview numbers didn't always match the docs: radio and checkbox group labels warned at 80 rather than the documented 30, and opt-in text at 300 rather than 120.
So we treat the limits as a budget to warn about, using the numbers the preview actually enforces:
// budget.ts
// Character budgets from Meta's preview renderer, which differ from the docs table.
// Exceeding one doesn't fail validation; it truncates on some phones.
const LABEL_BUDGET: Record<string, number> = {
TextInput: 20,
TextArea: 20,
Dropdown: 20,
DatePicker: 40,
TextHeading: 80,
TextSubheading: 80,
RadioButtonsGroup: 80,
CheckboxGroup: 80,
OptIn: 300,
};
type Component = { type: string; name?: string; label?: string; text?: string; children?: Component[] };
type Screen = { id: string; layout: { children: Component[] } };
export type BudgetWarning = { screen: string; component: string; length: number; budget: number };
const walk = (components: Component[], visit: (c: Component) => void) => {
for (const c of components) {
visit(c);
if (c.children) walk(c.children, visit);
}
};
// Warn, never block, and never truncate: the author should see what will ship.
export const budgetWarnings = (screens: Screen[]): BudgetWarning[] => {
const warnings: BudgetWarning[] = [];
for (const screen of screens) {
walk(screen.layout.children, (c) => {
const budget = LABEL_BUDGET[c.type];
const value = c.label ?? c.text;
if (budget && value && value.length > budget) {
warnings.push({ screen: screen.id, component: c.name ?? c.type, length: value.length, budget });
}
});
}
return warnings;
};
Two rules we settled on:
- Warn, don't block. A label one character over budget may truncate on a small phone. That's worth a visible counter, not a refusal to publish.
- Never silently truncate. Cutting text to fit ships something the author didn't write. Show the warning and let them decide.

Our Flow editor warns when a label is over the preview's budget, and still lets you publish.
These numbers are what we measured on our own account and Flow JSON version. Meta can change the renderer at any time, which is one more reason to warn rather than block.
Tokens and responses
When the user submits a Flow, you get an nfm_reply message in your webhook. Its response_json is a string containing the answers plus the flow_token you sent. The token is yours to design, so make it point back to where the Flow came from:
// responses.ts
import { randomUUID } from "node:crypto";
// The token comes back in the response, so make it say where it belongs.
export const newFlowToken = (sessionId: string, nodeId: string) => `${sessionId}.${nodeId}.${randomUUID()}`;
type NfmReply = { type: "nfm_reply"; name: "flow"; response_json: string };
type Pending = { sessionId: string; pendingFlowToken: string | null };
export type FlowAnswers = Record<string, string>;
export const readFlowReply = (reply: NfmReply, pending: Pending): FlowAnswers | null => {
let parsed: Record<string, unknown>;
try {
parsed = JSON.parse(reply.response_json);
} catch {
return null;
}
const { flow_token: token, ...fields } = parsed;
// An old or forwarded Flow can be submitted long after the bot moved on.
if (!pending.pendingFlowToken || token !== pending.pendingFlowToken) return null;
const answers: FlowAnswers = {};
for (const [key, value] of Object.entries(fields)) {
if (Array.isArray(value)) answers[key] = value.join(", ");
else if (value !== null && typeof value !== "object") answers[key] = String(value);
}
return answers;
};
-
Name your fields so answers map straight to your data. We derive component names from the field they fill (
lead_city, notTextInput_3), so a response needs no lookup table. - Check the token against what you're waiting for. A customer can open an old Flow message and submit it days later. Our bot currently matches a submission to the conversation's active Flow step; comparing the token as well is cheap insurance, and it's the version shown here.
- Flatten what you store. Checkbox answers arrive as arrays; decide once how you'll store them.
Sending: the 24-hour rule still applies
A Flow sent as an interactive message is a non-template message, so the customer service window must be open, just like a normal reply. The message uses flow_message_version: "3", your flow_token, the Flow ID and a button label:
{
"type": "interactive",
"interactive": {
"type": "flow",
"body": { "text": "Tell us what you're looking for" },
"action": {
"name": "flow",
"parameters": {
"flow_message_version": "3",
"flow_token": "<your token>",
"flow_id": "<flow id>",
"flow_cta": "Get a quote",
"flow_action": "navigate",
"flow_action_payload": { "screen": "SCREEN_A" }
}
}
}
}
To reach someone outside the window, attach the Flow to a template instead: templates support a FLOW button type.
"Published" isn't the same as "sendable"
A Flow's status can be DRAFT, PUBLISHED, DEPRECATED, BLOCKED or THROTTLED. Blocked Flows can't be sent; throttled ones can send only 10 messages an hour. Meta also exposes a health_status with can_send_message values of AVAILABLE, LIMITED or BLOCKED.
It's tempting to collapse all of that into "published or not". Our own sync does that today, which works because static Flows rarely hit endpoint health problems. If you run endpoint Flows, keep the states apart:
// health.ts
type Graph = (method: "GET", path: string) => Promise<any>;
export type FlowState = "draft" | "live" | "limited" | "blocked" | "deprecated";
// Keep Meta's states distinct: "published" and "can actually be sent" are different questions.
export const flowState = async (graph: Graph, flowId: string): Promise<FlowState> => {
const flow = await graph("GET", `/${flowId}?fields=status,json_version,health_status`);
if (flow.status === "DRAFT") return "draft";
if (flow.status === "DEPRECATED") return "deprecated";
if (flow.status === "BLOCKED") return "blocked";
if (flow.status === "THROTTLED") return "limited";
const canSend = flow.health_status?.can_send_message;
if (canSend === "BLOCKED") return "blocked";
if (canSend === "LIMITED") return "limited";
return "live";
};
Asking for json_version in the same call also tells you which Flows sit on a version that's about to be frozen.
Checklist
- Prefer static Flows; add an endpoint only for a screen that truly needs live data.
- Keep the Flow JSON version in one place, show Meta's publish errors to users, and check the changelog on a schedule.
- Treat publishing as permanent: draft first, test with
mode: "draft", delete failed drafts, and replace rather than edit. - Warn about character budgets using what the preview shows; don't block, don't truncate.
- Design the
flow_tokenso a response can be traced and checked. - Interactive Flows need an open 24-hour window; use a template with a
FLOWbutton otherwise. - Read
health_statusandjson_version, not just "published".
I work on WAPGROWAI, a WhatsApp Business platform where Flows are built visually and sent from chatbots. Other posts in this series: webhooks, the 24-hour window, templates and campaigns, phone numbers and build vs buy.
Top comments (0)