Flows
Typed Flow JSON, the Flows management client, signed flow tokens and the encrypted data endpoint runtime.
@genuka/whatsapp/flows covers the four things a Flow needs and nobody wants to write twice:
a typed Flow JSON with structural validation, the management API, the encrypted data_exchange
endpoint runtime, and a signed flow_token.
import {
FlowClient,
buildFlowJson,
createFlowEndpoint,
signFlowToken,
validateFlowJson,
} from "@genuka/whatsapp/flows";Which Flow JSON version, and why
Meta publishes two numbers that do not agree. The component reference documents 6.2; the changelog lists 7.3 as the latest, with tightened validation. They are not interchangeable — a document Meta accepted at 6.2 can be refused at 7.3 without a character changing.
The library defaults to 6.2 (limits.FLOW_JSON.defaultVersion).
The reasoning: every component this package types is documented at 6.2, so the type surface and the default agree. Defaulting to 7.3 would mean shipping types written against a reference that does not describe 7.3, and inheriting stricter server-side validation the local validator cannot mirror. 6.2 is the version we can actually stand behind.
The version is a parameter, not a constant. A document's own version always wins, and the
validator accepts anything between FLOW_JSON.minVersion (3.0, the first version with
routing_model + data_api_version) and FLOW_JSON.maxVersion (7.3):
buildFlowJson({ screens }); // → version: "6.2"
buildFlowJson({ version: "7.3", screens }); // → version: "7.3"
validateFlowJson(document); // honours document.versionA version above maxVersion is refused with a message telling you to check the changelog and
bump limits.FLOW_JSON.maxVersion — deliberately, so a new Flow JSON release is a decision
somebody makes rather than something that silently starts happening.
US-5.1 · Typed Flow JSON and structural validation
Every component family is typed: text (TextHeading, TextSubheading, TextBody,
TextCaption, RichText), input (TextInput, TextArea), selection (CheckboxGroup,
RadioButtonsGroup, Dropdown, ChipsSelector, NavigationList), dates (DatePicker,
CalendarPicker), media (Image, ImageCarousel, PhotoPicker, DocumentPicker), action and
navigation (Footer, EmbeddedLink, OptIn), and logic (If, Switch, Form,
SingleColumnLayout).
validateFlowJson(document, options?) throws a ValidationError — before publication, before
upload — on:
| Rule | Message |
|---|---|
More than limits.FLOW.componentsPerScreenMax (50) components on a screen | screen "X" has N components, the maximum is 50 |
| Not exactly one entry screen (no inbound edge) | has 2 entry screens (A, B) / has no entry screen |
| A screen that cannot reach a terminal screen | cannot reach a terminal screen — the user would be stuck there |
| A screen routing to itself | screen "X" routes to itself, which Meta rejects |
Version outside the supported range, or not major.minor | must be at least 3.0 / is newer than the 7.3 this library knows about |
| Duplicate or malformed screen id | duplicates screen "X" / must match /^[A-Za-z0-9_]+$/ |
complete action on a non-terminal screen | carries a complete action but is not marked terminal |
navigate to a screen routing_model does not declare | navigates to "Y", which routing_model does not declare |
Endpoint Flow missing data_api_version or routing_model | must be "3.0" on an endpoint-backed Flow |
data_api_version on a Flow with no data_exchange | is only valid on an endpoint-backed Flow |
Two inputs sharing a name in the same Form | two components share the name "email" |
Document over limits.FLOW.flowJsonMaxBytes (10 MB) | is N bytes, the maximum upload is … |
Two details worth knowing:
- The component count is the rendered tree. Hiding 60 components behind an
Ifdoes not make a screen legal —countScreenComponentsrecurses throughIf,SwitchandForm. - The routing graph merges both sources.
routing_modelis what Meta reads in endpoint mode;navigateactions are what the client follows in navigate mode.flowGraph()unions them, which is how a document that declares one and does the other gets caught.
US-5.2 · Management API
FlowClient wraps a Transport, so it works against Graph directly (MetaTransport) or through
the Genuka WA API (GenukaTransport).
const flows = new FlowClient(new MetaTransport({ accessToken }));
await flows.create(wabaId, { name: "Booking", categories: ["APPOINTMENT_BOOKING"], flow_json });
await flows.updateMetadata(flowId, { name: "Booking v2" });
await flows.updateJson(flowId, flowJson); // multipart asset, validated locally first
await flows.publish(flowId);
await flows.deprecate(flowId);
await flows.delete(flowId);
await flows.preview(flowId, { invalidate: true });The lifecycle is one-way. DRAFT → PUBLISHED → DEPRECATED. delete() refuses anything but
a DRAFT and deprecate() refuses anything but a PUBLISHED — locally, naming the rule,
instead of surfacing Meta's undetailed 400. Both read the status first; pass the status you
already know as the second argument to skip that call.
publish() can succeed and still return validation_errors: Meta re-runs its own validation and
these carry the JSON path to fix. They are returned verbatim, never flattened.
HTTP API
| Verb | Route | Notes |
|---|---|---|
GET | /api/v1/flows | Lists the local mirror. ?companyId= intersects with the key's scope. |
POST | /api/v1/flows | connectionId, name, categories required; flowJson, endpointUri, cloneFlowId, publish optional. |
GET | /api/v1/flows/{id} | Reads through to Meta to refresh status and return validationErrors. |
PATCH | /api/v1/flows/{id} | name, categories, endpointUri, flowJson, or status: "deprecated". |
DELETE | /api/v1/flows/{id} | Drafts only — 409 otherwise. |
POST | /api/v1/flows/{id}/publish | Reads the status back after publishing. |
GET | /api/v1/flows/{id}/preview | ?invalidate=true mints a new link and kills the old one. |
Deprecation is a PATCH rather than its own verb because it is the one status change a client
can request on a published Flow — deleting one is not an option Meta offers.
US-5.3 · Encrypted endpoint runtime
A data_exchange Flow calls your server on every screen, encrypted:
- WhatsApp generates a fresh AES-128 key per request.
- It wraps that key with your RSA-2048 public key, OAEP/SHA-256.
- It encrypts the JSON body with AES-128-GCM under a 16-byte IV (not the 12-byte GCM
default), and sends
ciphertext || tag. - Your response goes back under the same AES key with the bit-inverted IV.
That inversion is not decoration. Reusing a (key, IV) pair in GCM leaks the authentication
subkey and lets an attacker forge messages; making the response IV the one value the request IV
can never be guarantees the two directions never collide.
Mounting it
// app/api/flows/endpoint/route.ts
import { createFlowEndpoint, flowSuccessReply } from "@genuka/whatsapp/flows";
export const runtime = "nodejs";
export const POST = createFlowEndpoint({
privateKeyPem: process.env.FLOW_PRIVATE_KEY!,
tokenSecret: process.env.FLOW_TOKEN_SECRET!,
handler: async ({ request, token }) => {
switch (request.action) {
case "INIT":
return { screen: "SLOTS", data: { slots: await slotsFor(token!.companyId) } };
case "BACK":
return { screen: "SLOTS", data: {} };
default:
return flowSuccessReply(request.flow_token!, { booking_id: "bk_1" });
}
},
});ping is answered automatically with { status: "active" } and never reaches the handler
(handlePing: false to opt out). Client-side error reports — a data_exchange whose data
carries error_message — are acknowledged automatically with { acknowledged: true }
(acknowledgeErrors: false to see them).
Status codes
| Status | When | What the client does |
|---|---|---|
200 | Reply, base64 in a text/plain body | Renders the screen |
400 | Envelope missing a field, or plaintext that is not a request | Gives up |
421 | The AES key or the payload would not decrypt | Re-fetches your public key and retries |
427 | flow_token failed verification or expired | Shows "this flow is unavailable" |
500 | The handler threw | Gives up |
421 is the one that matters operationally: it is how a key rotation heals itself. Answering 500 there leaves the client stuck on a cached key.
The response body is the raw base64 string, not JSON. Returning {"data":"…"} produces a
silent "something went wrong" in the client with a 200 in your logs.
Keys
const { privateKeyPem, publicKeyPem } = await generateFlowKeyPair();
await setBusinessPublicKey(transport, phoneNumberId, publicKeyPem);
await getBusinessPublicKey(transport, phoneNumberId);
// → { business_public_key, business_public_key_signature_status: "VALID" | "MISMATCH" }The private key must be an unencrypted PKCS#8 PEM (-----BEGIN PRIVATE KEY-----). WebCrypto
cannot open a passphrase-protected key, and this package uses WebCrypto only so the same handler
runs on Node, Vercel Edge and Workers. Convert once:
openssl pkcs8 -topk8 -nocrypt -in encrypted.pem -out private.pemA passphrase-protected PEM is refused with that exact command in the message.
Testing
endpoint.test.ts is anchored twice, because a round trip against our own code passes just as
happily with the IV inverted twice or the tag in the wrong place:
- the published AES-GCM vector (McGrew & Viega, test case 2) pins the primitive;
- every Meta-shaped fixture was produced by
node:crypto— a different implementation from the WebCrypto the runtime uses — and frozen, including the reference ciphertext of the reply.
Regenerate the fixtures only when the wire format changes, never to make a failing test pass.
US-5.4 · Signed flow_token
The token is ours, not Meta's: Meta echoes it in every endpoint request and in the final
nfm_reply. The obvious implementation is a random id plus a flow_sessions table, which then
has to be written on every send, read on every endpoint hit, expired by a cron and replicated
wherever the endpoint runs. Putting the context inside the token and signing it removes all of
that.
const token = await signFlowToken(secret, {
companyId: "cmp_1",
contactId: "ctc_1",
campaignId: "cmg_1", // optional
ttlSeconds: 3600, // defaults to limits.FLOW_TOKEN.defaultTtlSeconds (24h)
meta: { locale: "fr" }, // small, flat extras
});
const payload = await verifyFlowToken(secret, token); // throws on tampered or expiredFormat: gf1.<base64url(payload)>.<base64url(hmac-sha256)>. The prefix is versioned so a later
format change is detected rather than silently mis-parsed; the payload uses single-letter keys
because it rides in every message and every endpoint round trip. Signing refuses a token over
limits.FLOW_TOKEN.maxChars.
decodeFlowTokenUnsafe() reads a token without verifying it. For logs and dashboards only —
a forged payload decodes perfectly, which is the whole point of the test that proves it.
The submission itself needs no re-parsing: the socle already normalizes nfm_reply to
content.type === "flow_reply" ({ name, response }). Take response.flow_token, verify it,
and you have the company, contact and campaign without a lookup.
US-5.5 · Sending a Flow
Inside the 24h service window, messages.flow() builds the interactive message.
Outside it, the interactive message is refused by Meta with 131047 — after the request was accepted, after the campaign partially ran, with an error that says nothing about what to do. Decide before sending:
import { assertFlowSendable, flowTemplateButton } from "@genuka/whatsapp/flows";
assertFlowSendable(contact.lastInboundAt);
// throws WhatsAppError { errorClass: "needs_template", code: 131047 } with the fix in the messageThen send an approved template whose button was declared as a FLOW button, and attach the
token:
messages.template(to, {
name: "booking_reminder",
language: { code: "fr" },
components: [flowTemplateButton({ index: 0, flowToken: token })],
});flowTemplateButton builds the send-side parameter:
{
"type": "button",
"sub_type": "flow",
"index": "0",
"parameters": [{ "type": "action", "action": { "flow_token": "gf1.…" } }]
}Cross-module dependency. This is the send side. The definition side — declaring a
FLOWbutton when creating the template — belongs to the template builder, which needs aFLOWbutton sub-type.messages/types.tsalso has no{ type: "action" }variant inTemplateParameter, which is whyFlowTemplateButtonComponentis declared here rather than assembled from the shared template types.
Constants
Everything Meta constrains lives in limits.ts, never inline:
| Block | Holds |
|---|---|
FLOW | componentsPerScreenMax (50), flowJsonMaxBytes (10 MB) |
FLOW_JSON | default / min / max version, dataApiVersion, successScreenId, screen id pattern |
FLOW_LIFECYCLE | categories, statuses, which statuses allow delete and deprecate, preview TTL, asset name and type |
FLOW_ENCRYPTION | RSA and AES parameters, IV and tag lengths, registration path, the 421 / 427 / 400 status codes |
FLOW_TOKEN | wire version prefix, default TTL, max length |