This guide walks through the full developer workflow for WhatsApp Flows using the SDK — from authoring Flow JSON in camelCase to deploying, previewing, and serving Data Endpoint callbacks.
- CamelCase authoring: Write JSON in idiomatic TS/JS; the SDK converts to Meta’s mixed casing automatically.
- Lifecycle helpers:
client.flows.create/updateAsset/publish/deploy/preview/get/listmirror the Graph API. - DX-focused validation: Meta errors come back camelized with hints when casing mistakes occur.
- Server utilities:
receiveFlowEvent,respondToFlow, anddownloadFlowMediahandle encryption/HMAC, PhotoPicker/DocumentPicker payloads, and camelCase normalization.
Create a file such as flows/csat.flow.ts:
export default {
version: "7.2",
screens: [
{
id: "CSAT",
title: "How was your experience?",
terminal: true,
layout: {
type: "SingleColumnLayout",
children: [
{
type: "RadioButtonsGroup",
name: "rating",
label: "Rate us",
required: true,
dataSource: [
{ id: "up", title: "👍 Great" },
{ id: "down", title: "👎 Not great" }
]
},
{
type: "Footer",
label: "Submit",
onClickAction: {
name: "complete",
payload: { rating: "${form.rating}" }
}
}
]
}
}
]
} as const;✅ Use camelCase everywhere. The SDK maps keys like
onClickAction,dataSource,maxUploadedPhotos, etc., to Meta’son-click-action,data-source,max-uploaded-photoson upload.
client.flows.create/updateAsset/deploysurface Meta validation errors with camelCase pointers and DX hints when common casing mistakes occur.toFlowJsonWireCase(flow, { strictCamel: false })is available if you need to ingest legacy snake-case JSON temporarily.
Use the client to upload Flow JSON, optionally publish, and fetch a preview URL. The deploy helper is idempotent: it skips asset uploads when the content hash hasn’t changed.
import { WhatsAppClient } from "@kapso/whatsapp-cloud-api";
import csatFlow from "./flows/csat.flow";
const client = new WhatsAppClient({ accessToken: process.env.WHATSAPP_TOKEN! });
const result = await client.flows.deploy(csatFlow, {
wabaId: process.env.WABA_ID!,
name: "csat-flow",
publish: true,
preview: true, // or { interactive: true, params: { flowAction: "navigate" } }
});
console.log("Flow ID:", result.flowId);
console.log("Preview URL:", result.previewUrl);
console.log("Validation errors:", result.validationErrors);Use client.messages.sendInteractiveFlow (typed with FlowInteractiveInput) to trigger your flow. flowCta is required; flowMessageVersion defaults to "3" when omitted.
import { WhatsAppClient, type FlowInteractiveInput } from "@kapso/whatsapp-cloud-api";
const client = new WhatsAppClient({ accessToken: process.env.WHATSAPP_TOKEN! });
const message: FlowInteractiveInput = {
phoneNumberId: "1234567890",
to: "+15551234567",
bodyText: "Check out our new experience",
parameters: {
flowId: "1234567890",
flowCta: "Open",
flowToken: "token123",
flowAction: "navigate",
flowActionPayload: { screen: "WELCOME" }
}
};
await client.messages.sendInteractiveFlow(message);Other lifecycle helpers:
await client.flows.create({ wabaId, name, flowJson, publish: false });
await client.flows.updateAsset({ flowId, json: flowJson });
await client.flows.publish({ flowId });
await client.flows.list({ wabaId, limit: 20 });When Meta calls your Data Endpoint, use receiveFlowEvent to decrypt the payload and normalize it to camelCase, and respondToFlow to send the next screen data.
import express from "express";
import { receiveFlowEvent, respondToFlow } from "@kapso/whatsapp-cloud-api/server";
const app = express();
const phoneNumberId = process.env.PHONE_ID!;
const flowKey = process.env.FLOW_PRIVATE_KEY_PEM!;
app.post("/flows/csat", express.raw({ type: "*/*" }), async (req, res) => {
try {
const ctx = await receiveFlowEvent({
rawBody: req.body as Buffer,
phoneNumberId,
getPrivateKey: async () => flowKey
});
if (ctx.action === "COMPLETE") {
console.log("CSAT rating:", ctx.form.rating);
const reply = respondToFlow({ screen: ctx.screen, data: {} });
return res.status(reply.status).set(reply.headers).send(reply.body);
}
const reply = respondToFlow({ screen: ctx.screen, data: {} });
res.status(reply.status).set(reply.headers).send(reply.body);
} catch (error) {
if (error instanceof Error && "status" in error) {
const flowError = error as any;
return res.status(flowError.status).set(flowError.headers).send(flowError.body);
}
res.status(500).send({ error: "Unexpected error" });
}
});import { receiveFlowEvent, respondToFlow } from "@kapso/whatsapp-cloud-api/server";
export default async function handler(request: Request) {
const buffer = new Uint8Array(await request.arrayBuffer());
const ctx = await receiveFlowEvent({
rawBody: buffer,
phoneNumberId: process.env.PHONE_ID!,
getPrivateKey: async () => process.env.FLOW_PRIVATE_KEY_PEM!
});
const reply = respondToFlow({ screen: ctx.screen, data: {} });
return new Response(reply.body, { status: reply.status, headers: reply.headers });
}Use downloadFlowMedia (alias for downloadAndDecrypt) to pull media from the CDN once your webhook receives the descriptor.
import { downloadFlowMedia } from "@kapso/whatsapp-cloud-api/server";
const media = await downloadFlowMedia({
cdnUrl: descriptor.cdnUrl,
encryptionMetadata: descriptor.encryptionMetadata,
});
// media is an ArrayBuffer — persist to storage, run validations, etc.When Meta returns validation errors, the SDK camelizes field names and pointer paths, and adds a hint when the fix is a casing issue:
Use these hints to fix your camelCase authoring quickly.
- Unit tests:
npm testcovers case mapping, hashing, resource requests, and server helpers. - Manual integration (without publish):
npm run buildnpm pack→ install the tarball in your app (npm install ../@kapso-whatsapp-cloud-api-*.tgz), ornpm install file:../whatsapp-cloud-api-js(with build artifacts), ornpm linkfor rapid iteration.
- Preview flows locally: use
client.flows.preview({ flowId, interactive: true })to generate a URL you can share with stakeholders.
- Flow JSON authored in camelCase (
dataApiVersion,routingModel,onClickAction, etc.). - Data Endpoint responds within 10 seconds (Meta timeout).
- Handle
FlowServerErrorstatuses: 421 (decrypt error), 432 (signature/HMAC), 427 (invalid flow token). - Refresh Flow private key when rotating phone numbers.
- Log
ctx.action,ctx.screen, and timing for observability (recommended).
- Meta docs: Flow JSON reference • Flows API
- Kapso docs: coming soon (stay tuned).
Have feedback? Open an issue or PR — the Flow DX is still evolving and we’d love your input.
[ { "error": "INVALID_PROPERTY_VALUE", "errorType": "FLOW_JSON_ERROR", "message": "Invalid value for property", "pointers": [ { "path": "screens[0].layout.children[0].on-click-action", "lineStart": 10, "columnStart": 5 } ], "hint": "Use onClickAction (camelCase). We map it to on-click-action." } ]