Zernio
Zernio
PlatformsWhatsAppConnection & SetupBroadcastsTemplatesContactsPhone NumbersCallingSandboxGroup ChatsInboxMeta Business AgentFlowsClick-to-WhatsApp AdsPricing & CostsMedia & Limits
Dashboard
llms.txtOpenAPI
OverviewPlatformsAPI ReferenceResources
WhatsApp

Flows

Build WhatsApp Flows, the native forms, surveys and booking screens inside WhatsApp, publish them, send them, and read what customers submitted.


When you finish this page a flow is published, a customer has opened it from a message, and their submission is in your hands. You need a connected WhatsApp account and its accountId. A flow starts as a DRAFT, receives a Flow JSON definition, and is published, which makes it sendable and immutable; iterate by creating a new version.

Step 1: Create the flow

Call POST /v1/whatsapp/flows with accountId, name and categories (one or more of SIGN_UP, SIGN_IN, APPOINTMENT_BOOKING, LEAD_GENERATION, CONTACT_US, CUSTOMER_SUPPORT, SURVEY, OTHER).

import Zernio from '@zernio/node';

const zernio = new Zernio();
const accountId = '66b2e19d8c3f5a7e9d0b1c2d';

const { data: created } = await zernio.whatsappflows.createWhatsAppFlow({
  body: {
    accountId,
    name: 'lead_capture_form',
    categories: ['LEAD_GENERATION']
  }
});

const flowId = created.flow.id;

Response (200):

{
  "success": true,
  "flow": {
    "id": "1178094743614213",
    "name": "lead_capture_form",
    "status": "DRAFT",
    "categories": ["LEAD_GENERATION"],
    "version": 1,
    "lineageId": "1178094743614213"
  }
}

flow.id is the flowId for every call below.

Step 2: Upload the Flow JSON

Call PUT /v1/whatsapp/flows/{flowId}/json with accountId and flow_json, the screens, components and navigation in Meta's Flow JSON format. Meta validates it on upload.

const { data: uploaded } = await zernio.whatsappflows.uploadWhatsAppFlowJson({
  path: { flowId },
  body: {
    accountId,
    flow_json: {
      version: '6.0',
      screens: [{
        id: 'LEAD_FORM',
        title: 'Get a Quote',
        terminal: true,
        success: true,
        layout: {
          type: 'SingleColumnLayout',
          children: [
            { type: 'TextInput', name: 'full_name', label: 'Full Name', required: true, 'input-type': 'text' },
            { type: 'TextInput', name: 'email', label: 'Email', required: true, 'input-type': 'email' },
            { type: 'Footer', label: 'Submit', 'on-click-action': { name: 'complete', payload: { full_name: '${form.full_name}', email: '${form.email}' } } }
          ]
        }
      }]
    }
  }
});

console.log(uploaded.validation_errors);

Response (200):

{
  "success": true,
  "validation_errors": []
}

Before publishing, GET /v1/whatsapp/flows/{flowId}/preview?accountId=... returns Meta's public web preview of the flow, drafts included, as preview_url. The link needs no login, embeds as an iframe, and is reused across calls for about 30 days; pass invalidate=true to mint a fresh one, which stops the previous link working.

Step 3: Publish

Call POST /v1/whatsapp/flows/{flowId}/publish with accountId.

const { data: published } = await zernio.whatsappflows.publishWhatsAppFlow({
  path: { flowId },
  body: { accountId }
});

console.log(published.success);

Response (200): { "success": true }.

Publishing is irreversible. A published flow and its JSON cannot be changed; to change anything, create a new version with cloneFlowId and asVersion: true, which copies the published flow into a fresh draft.

Step 4: Send the flow

Call POST /v1/whatsapp/flows/send with accountId, to, flow_id, flow_cta (the button label) and body. The customer gets a message with a button that opens the flow natively in WhatsApp. draft: true sends an unpublished flow for testing.

const { data: sent } = await zernio.whatsappflows.sendWhatsAppFlowMessage({
  body: {
    accountId,
    to: '+13105551234',
    flow_id: flowId,
    flow_cta: 'Get a quote',
    flow_action: 'navigate',
    flow_action_payload: { screen: 'LEAD_FORM' },
    body: 'Fill out this quick form to get a personalized quote.'
  }
});

console.log(sent.messageId);

Response (200):

{
  "success": true,
  "messageId": "wamid.HBgLMTMxMDU1NTEyMzQVAgARGBI5QTNEMEY3RjQ4RjE2QjA3QzYA"
}

Omit flow_token and Zernio generates one as <flowId>:<uuid>, which is what lets Step 5 attribute the response to this flow. A flow can also go out inside a conversation as an interactive message of type flow (WhatsApp inbox).

The two flow modes

flow_action on the send picks how the flow gets its screens:

flow_actionHow it runsWhat you have to host
navigate (default)WhatsApp renders the screens straight from the published Flow JSON. The steps above build one of these.Nothing
data_exchangeWhatsApp posts the current screen's data to an endpoint you host and renders whatever screen the endpoint returns, so screens can depend on your data.An HTTPS endpoint, plus an RSA key pair

Point Meta at that endpoint with endpointUri, on POST /v1/whatsapp/flows at creation or on PATCH /v1/whatsapp/flows/{flowId} while the flow is still a DRAFT. The URL must be HTTPS, and the uploaded Flow JSON has to declare data_api_version "3.0" before WhatsApp calls it.

A data_exchange flow encrypts every exchange with your endpoint. Register the public half against the number with POST /v1/whatsapp/flows/encryption-key (accountId and businessPublicKey, an RSA public key in PEM), and serve the private half from the endpoint. Only one key is active per number, and uploading a new one replaces it. GET /v1/whatsapp/flows/encryption-key reads back publicKey, signatureStatus and registered; read registered, since Meta reports an unregistered key as MISMATCH rather than as absent.

Publishing a data_exchange flow with no key registered fails with Meta error 139002, "Missing Flows Signed Public Key". A key that is registered but whose private half the endpoint does not serve passes publish and then fails at runtime, when the customer opens the flow.

Step 5: Read the responses

A completed flow arrives as an nfm_reply on the message.received webhook. To read submissions per flow without touching the inbox, call GET /v1/whatsapp/flow-responses with accountId and flowId.

const { data: responses } = await zernio.whatsappflows.listWhatsAppFlowResponses({
  query: { accountId, flowId }
});

for (const r of responses.responses) {
  console.log(r.from, r.receivedAt, r.data);
}

Response (200), newest first:

{
  "responses": [
    {
      "id": "wamid.HBgLMTMxMDU1NTEyMzQVAgASGBQzQTNEMEY3RjQ4RjE2QjA3QzYyRQA=",
      "receivedAt": "2027-01-01T09:12:44Z",
      "from": "13105551234",
      "senderName": "Ana Costa",
      "conversationId": "66c3d2ae7b4f6c8d0e1f2a3b",
      "flowToken": "1178094743614213:2c1a7f0e-5b3d-4c9e-8a2f-6d1e0b9c7a54",
      "data": { "full_name": "Ana Costa", "email": "ana@example.com" }
    }
  ]
}

Scoping by flowId works because the auto-generated flow_token carries the flow id and round-trips through Meta inside the response. A flow sent with your own flow_token still delivers its responses on the webhook, but they are not attributed to a flow here, and only flows sent since this token convention shipped are attributable.

Version a flow

A published flow is immutable, so you iterate by cloning it into a new draft. Pass cloneFlowId with asVersion: true to POST /v1/whatsapp/flows for a new version in the same lineage, auto-numbered; pass cloneFlowId alone for an independent copy with its own lineage at version 1. GET /v1/whatsapp/flows/{flowId}/versions lists the lineage, newest first, with each version's live name and status from Meta.

const { data: next } = await zernio.whatsappflows.createWhatsAppFlow({
  body: {
    accountId,
    name: 'lead_capture_form',
    categories: ['LEAD_GENERATION'],
    cloneFlowId: flowId,
    asVersion: true
  }
});

const { data: history } = await zernio.whatsappflows.listWhatsAppFlowVersions({
  path: { flowId: next.flow.id },
  query: { accountId }
});

for (const v of history.versions) {
  console.log(v.version, v.status);
}

Response (200), the clone, a fresh draft at version 2 of the same lineage:

{
  "success": true,
  "flow": {
    "id": "1178094743614299",
    "name": "lead_capture_form",
    "status": "DRAFT",
    "categories": ["LEAD_GENERATION"],
    "version": 2,
    "lineageId": "1178094743614213"
  }
}

Response (200), the version list:

{
  "versions": [
    { "flowId": "1178094743614299", "version": 2, "parentFlowId": "1178094743614213", "name": "lead_capture_form", "status": "DRAFT", "missing": false },
    { "flowId": "1178094743614213", "version": 1, "parentFlowId": null, "name": "lead_capture_form", "status": "PUBLISHED", "missing": false }
  ]
}

Zernio tracks the lineage, because Meta has no native flow versioning; a flow that was never cloned reports as version: 1 of its own lineage, and missing: true marks a version Meta no longer has.

Flow lifecycle

StatusDescription
DRAFTEditable. Upload or update the JSON, change the name and categories.
PUBLISHEDImmutable and sendable. To change it, create a new version with cloneFlowId and asVersion.
DEPRECATEDNo longer sendable.
BLOCKEDBlocked by Meta for policy violations.
THROTTLEDTemporarily rate-limited by Meta.

If it fails

Step 2 returns 200 even when Meta rejects the definition; the problems are in validation_errors:

{
  "success": true,
  "validation_errors": [
    {
      "error": "INVALID_PROPERTY",
      "error_type": "JSON_SCHEMA_ERROR",
      "message": "Property 'input-type' is invalid",
      "line_start": 12,
      "line_end": 12,
      "column_start": 9,
      "column_end": 20
    }
  ]
}

Fix the JSON at the line and column named and upload again; a flow with validation errors cannot be published, and Step 3 returns 400 until they are gone. A 400 on the upload itself means the body is not valid JSON or the flow is no longer a DRAFT. Every error uses the envelope in error handling.

Related

  • Flows API: get, update, delete and deprecate a flow.
  • WhatsApp inbox: send a flow inside a conversation.
  • Inbox webhooks: the message.received payload that carries nfm_reply.
  • Templates: open the conversation before the flow when the window is closed.
Was this page helpful?

Meta Business Agent

Provision and operate Meta's AI agent on a WhatsApp number by API, so the merchant never opens Business Manager

Click-to-WhatsApp Ads

Capture the click id Meta attaches to a Click-to-WhatsApp conversation and send conversion events back to Meta so they attribute to the ad.

On this page

Step 1: Create the flowStep 2: Upload the Flow JSONStep 3: PublishStep 4: Send the flowThe two flow modesStep 5: Read the responsesVersion a flowFlow lifecycleIf it failsRelated