Genuka WA docs

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.version

A 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:

RuleMessage
More than limits.FLOW.componentsPerScreenMax (50) components on a screenscreen "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 screencannot reach a terminal screen — the user would be stuck there
A screen routing to itselfscreen "X" routes to itself, which Meta rejects
Version outside the supported range, or not major.minormust be at least 3.0 / is newer than the 7.3 this library knows about
Duplicate or malformed screen idduplicates screen "X" / must match /^[A-Za-z0-9_]+$/
complete action on a non-terminal screencarries a complete action but is not marked terminal
navigate to a screen routing_model does not declarenavigates to "Y", which routing_model does not declare
Endpoint Flow missing data_api_version or routing_modelmust be "3.0" on an endpoint-backed Flow
data_api_version on a Flow with no data_exchangeis only valid on an endpoint-backed Flow
Two inputs sharing a name in the same Formtwo 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 If does not make a screen legal — countScreenComponents recurses through If, Switch and Form.
  • The routing graph merges both sources. routing_model is what Meta reads in endpoint mode; navigate actions 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

VerbRouteNotes
GET/api/v1/flowsLists the local mirror. ?companyId= intersects with the key's scope.
POST/api/v1/flowsconnectionId, 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}/publishReads 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:

  1. WhatsApp generates a fresh AES-128 key per request.
  2. It wraps that key with your RSA-2048 public key, OAEP/SHA-256.
  3. It encrypts the JSON body with AES-128-GCM under a 16-byte IV (not the 12-byte GCM default), and sends ciphertext || tag.
  4. 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

StatusWhenWhat the client does
200Reply, base64 in a text/plain bodyRenders the screen
400Envelope missing a field, or plaintext that is not a requestGives up
421The AES key or the payload would not decryptRe-fetches your public key and retries
427flow_token failed verification or expiredShows "this flow is unavailable"
500The handler threwGives 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.pem

A 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 expired

Format: 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 message

Then 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 FLOW button when creating the template — belongs to the template builder, which needs a FLOW button sub-type. messages/types.ts also has no { type: "action" } variant in TemplateParameter, which is why FlowTemplateButtonComponent is declared here rather than assembled from the shared template types.


Constants

Everything Meta constrains lives in limits.ts, never inline:

BlockHolds
FLOWcomponentsPerScreenMax (50), flowJsonMaxBytes (10 MB)
FLOW_JSONdefault / min / max version, dataApiVersion, successScreenId, screen id pattern
FLOW_LIFECYCLEcategories, statuses, which statuses allow delete and deprecate, preview TTL, asset name and type
FLOW_ENCRYPTIONRSA and AES parameters, IV and tag lengths, registration path, the 421 / 427 / 400 status codes
FLOW_TOKENwire version prefix, default TTL, max length

On this page