Zernio
APIFor you
Zernio
APIFor you
Platforms

WhatsApp

Overview

Set up

Connection & setupPhone NumbersSandbox

Messaging

InboxTemplatesBroadcastsFlowsGroup chatsContactsMeta Business Agent

Calls

Calling

Ad types

Click-to-WhatsApp ads

Reference

Pricing & costsMedia & limits
Dashboard
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.


By the end of this page, a flow is published, a customer has opened it from a message, and their submission is in your hands.

A flow starts as a DRAFT and receives a Flow JSON definition. Publishing makes it sendable and immutable. Iterate by creating a new version.

Before you start

You need a connected WhatsApp account and its accountId.

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. This 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>. That 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. Set it 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. 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). 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. It 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.

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 to POST /v1/whatsapp/flows:

  • With asVersion: true, you get a new version in the same lineage, auto-numbered.
  • Alone, you get 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. 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?

Broadcasts

Send an approved WhatsApp template to many recipients with per-recipient variables, now or at a scheduled time, with delivery tracking per recipient.

Group chats

Create WhatsApp group chats from the API, add and remove participants, share invite links, approve join requests, and message the group from the inbox.

On this page

Before you startStep 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