Ir al contenido
Start free

WhatsApp Flows

Esta página aún no está disponible en tu idioma.

WhatsApp Flows are forms inside the chat. Define the fields once and receive validated JSON. Wabery handles Meta’s Flow JSON, encryption, and data exchange.

Pick a mode per flow:

Mode runtime_mode When to use Where options come from
Static STATIC (default) Options are the same for everyone and known when you publish (e.g. a fixed timeline list). Hardcoded in the flow definition. Rendered entirely on-device, no server round-trip.
Dynamic DATA_EXCHANGE Options differ per user, change over time, or one field depends on another (cascading dropdowns), or you need to prefill / branch based on who’s filling it in. Fetched from your HTTPS endpoint at runtime, mid-conversation.

Flows are authored as config-as-code in wabery.config.json or from Connections → WhatsApp → Flows. Both compile to the same Flow JSON. A flow’s draft is a set of screens; each screen holds typed fields and explicit navigation.

wabery.config.json
{
"$schema": "https://api.wabery.com/v1/config/schema",
"version": "2026-06-18",
"project_id": "project_...",
"flows": [
{
"key": "lead_intake",
"name": "Lead intake",
"runtime_mode": "STATIC",
"draft": {
"entryScreenId": "START",
"screens": [
{
"id": "START",
"title": "Tell us about your project",
"fields": [
{ "name": "name", "label": "Your name", "type": "text", "required": true },
{ "name": "budget", "label": "Budget (USD)", "type": "number" },
{
"name": "timeline",
"label": "Timeline",
"type": "choice",
"options": [
{ "id": "asap", "title": "ASAP" },
{ "id": "this_month", "title": "This month" }
]
}
],
"primary": { "label": "Submit", "target": { "kind": "complete" } }
}
]
}
}
]
}

Authoring in TypeScript? The SDK ships typed builders that return the same draft data — handy for editor autocomplete and compile-time checks:

import { defineFlow, screen, textField, numberField, choiceField } from "@wabery/sdk";
const draft = defineFlow([
screen("START", "Tell us about your project", [
textField("name", { label: "Your name", required: true }),
numberField("budget", { label: "Budget (USD)" }),
choiceField("timeline", {
label: "Timeline",
options: [
{ id: "asap", title: "ASAP" },
{ id: "this_month", title: "This month" },
],
}),
]),
]);
await wabery.config.apply({
flows: [{ key: "lead_intake", name: "Lead intake", draft }],
automations: [],
});

Hand-writing flow_json is the most error-prone path — a malformed document is only rejected at publish time, after a round-trip to Meta. The SDK ships full MetaFlowJson types (screens / components / actions / data-model) and a local validator that catches the common structural mistakes (missing version, no terminal screen, dangling navigation targets) before you call the API:

import { validateFlowJson, type MetaFlowJson } from "@wabery/sdk";
const flowJson: MetaFlowJson = {
/* ...your hand-written Flow JSON... */
};
const issues = validateFlowJson(flowJson);
if (issues.length) {
throw new Error(issues.map((i) => `${i.path}: ${i.message}`).join("\n"));
}
Terminal window
wabery config validate wabery.config.json
wabery config diff wabery.config.json
wabery config apply wabery.config.json

Publishing a DATA_EXCHANGE flow also registers an encryption key with Meta and wires the flow’s endpoint to Wabery — you don’t manage keys yourself.

Flow publishing requires a Meta-verified WhatsApp Business Account. The shared Wabery sandbox can publish Flows once Wabery’s sandbox account is verified, but customer-owned dedicated WhatsApp channels must be verified on Meta before publishing. Check project.publish_readiness or channel.publish_readiness first; if can_publish_flows is false, flows.publish() returns 412 with blockers such as BUSINESS_NOT_VERIFIED and does not queue a Meta publish job.

await wabery.flows.send("flow_...", {
channelId: "channel_...",
contactId: "contact_...", // or conversationId, or a raw `to` phone number
bodyText: "Tell us about your project",
flowCta: "Start", // button label, ≤ 20 chars
// Static flows open on a specific screen and may prefill it:
firstScreen: "START",
screenData: { name: "Alex" },
});

Provide exactly one recipient: contact_id, conversation_id, or to (an E.164 phone). first_screen is required for static flows and ignored for dynamic ones (the endpoint decides the opening screen). The call returns a flow_dispatch with a flow_token — your correlation key for this send.

When the contact submits the form, Wabery validates it against the flow’s declared field schema and delivers a single flow.completed webhook:

{
"event": "flow.completed",
"payload": {
"object": "flow_submission",
"id": "fsub_...",
"flow_token": "b1c2...",
"flow_id": "flow_...",
"flow_version": 3,
"organization_id": "org_...",
"agent_id": "agent_...",
"conversation_id": "conv_...",
"contact_id": "contact_...",
"contact_reference": "your-own-user-id",
"from": "+15551234567",
"valid": true,
"validation_errors": [],
"submission": { "name": "Alex", "budget": 2000, "timeline": "asap" },
"submitted_at": "2026-06-22T14:21:09.000Z"
}
}

Validation runs before the webhook fires, so submission is already typed and checked against your field rules. valid is true when every reached field passed; when something fails, valid is false and validation_errors lists the offending fields ({ field, message }). The array is always present — [] when valid — so you never have to guess why a submission was rejected. The raw answers are still delivered either way.

submission is a Record<string, unknown> on the wire. The SDK ships coercion helpers so you don’t hand-cast every field:

import { asNumber, asDate, coerceSubmission } from "@wabery/sdk";
const budget = asNumber(event.payload.submission.budget); // number | undefined
const when = asDate(event.payload.submission.preferred_date); // Date | undefined
// …or coerce the whole thing against your field types at once:
const data = coerceSubmission(event.payload.submission, {
budget: "number",
preferred_date: "date",
interests: "stringArray",
});

You get three stable handles back, in order of preference:

  • flow_token — unique per send (returned by flows.send). The most precise key: it ties this exact submission to the exact send you made. Store it when you send and look it up on completion.
  • contact_reference — your own id for this person. Set it as externalId in the contact’s metadata when you create/enroll the contact, and Wabery echoes it on every flow.completed (and message.received) event, so you never have to store a contact_id → user mapping.
  • contact_id + from — contact_id is stable for a given WhatsApp number within your organization (numbers are normalized to E.164 and deduped). from is that E.164 number. Either is a durable fallback.

A dynamic flow calls your server while the person fills it in, so you can load options for them, prefill answers, choose the next screen, and close the flow. Set runtime_mode: "DATA_EXCHANGE" and a data_exchange_url:

wabery.config.json (excerpt)
{
"key": "ticket_intake",
"name": "Support ticket",
"runtime_mode": "DATA_EXCHANGE",
"data_exchange_url": "https://api.example.com/wabery/flow",
"draft": {
"entryScreenId": "SELECT",
"screens": [
{
"id": "SELECT",
"title": "Where's the issue?",
"fields": [
{
"name": "workspace",
"label": "Workspace",
"type": "choice",
"dropdown": true,
"dynamicOptions": true,
"refreshOnSelect": true
},
{
"name": "project",
"label": "Project",
"type": "choice",
"dropdown": true,
"dynamicOptions": true
}
],
"primary": { "label": "Next", "target": { "kind": "complete" } }
}
]
}
}
  • dynamicOptions: true: your endpoint supplies the field’s options at runtime.
  • refreshOnSelect: true: changing the field calls your endpoint again, so you can repopulate other fields on the same screen (cascading dropdowns).

Each call works like this: WhatsApp sends an encrypted request to Wabery, Wabery decrypts it and POSTs plain JSON to your URL, then checks your reply and sends it back to WhatsApp encrypted. You don’t handle any keys or encryption.

Wabery sends POST <data_exchange_url> with these headers:

Header Value
Content-Type application/json
User-Agent Wabery-FlowExchange/1
X-Wabery-Signature sha256=<hex>: HMAC-SHA256 of the raw body, keyed with your project’s webhook secret. Verify it exactly as for a webhook.
{
"version": "3.0",
"action": "data_exchange",
"screen": "SELECT",
"data": { "workspace": "ws_42" },
"triggered_by": "field_change",
"changed_field": "workspace",
"flow_token": "b1c2...",
"flow_id": "flow_...",
"agent_id": "agent_...",
"organization_id": "org_...",
"contact_id": "contact_...",
"contact_reference": "your-own-user-id",
"from": "+15551234567"
}
Field Type Description
action "INIT" | "data_exchange" | "BACK" Why WhatsApp called. See the table below.
screen string The screen the person is on. Not set for INIT.
data object Values the current screen sends, without Wabery’s internal __wabery_* keys. For a refreshOnSelect field, this holds the screen’s current answers.
triggered_by "footer" | "field_change" | null "footer" when the person pressed the screen’s button, "field_change" when a refreshOnSelect field changed. null for INIT, BACK, and hand-written Flow JSON.
changed_field string | null The field that changed, when triggered_by is "field_change".
version string WhatsApp’s data API version.
flow_token string Identifies this send. It’s the value flows.send returned and the one on flow.completed.
flow_id, agent_id, organization_id string The flow and project this send belongs to.
contact_id string | null The Wabery contact.
contact_reference string | null Your own id for the person: the referenceId you set when you enrolled the contact.
from string | null The person’s phone number in E.164 format.

Wabery sets the identity fields (flow_token through from) after merging WhatsApp’s payload, so a request can’t spoof them.

action When WhatsApp sends it
INIT The flow opens. Return the first screen.
data_exchange A refreshOnSelect field changed, or the person pressed the button on a screen that can lead to more than one screen. A screen with a single next screen navigates on the phone without calling you.
BACK The person went back to a screen that has refresh_on_back: true (hand-written Flow JSON only).

WhatsApp also sends health checks (ping) and error notifications. Wabery answers those itself, so your endpoint never receives them.

Verify the signature and parse the body with the SDK:

const req = wabery.webhooks.verifyDataExchange(
rawBody,
request.headers["x-wabery-signature"],
process.env.WABERY_WEBHOOK_SECRET,
);
// req.action, req.screen, req.data, req.flow_token, req.contact_reference, …

Return HTTP 200 with a JSON object naming the next screen and its data:

{
"screen": "SELECT",
"data": {
"project_options": [
{ "id": "proj_a", "title": "Project A" },
{ "id": "proj_b", "title": "Project B" }
],
"project": "proj_a"
}
}

screen must be a screen in the flow, or SUCCESS to close it (see Close the flow from your endpoint). data does three things:

  • Options: <field>_options arrays of { id, title } fill dynamicOptions fields.
  • Prefills: a field’s name with a value ("project": "proj_a") preselects it.
  • Errors: error_message shows a message on the screen instead of advancing.

Return the same screen to stay on it (for example, to refresh options), or a different screen to move on. To branch per person, return a different screen; it must be one the current screen can lead to.

When you move to another screen in a flow built from a draft, include the earlier answers in data. Each screen’s data model carries the answers from the screens before it, and the final submission reads them from there. req.data already holds them, so spread it into your reply.

The SDK builds these replies:

import { dataExchangeError, dataExchangeScreen, flowOptions } from "@wabery/sdk";
return dataExchangeScreen("SELECT", {
...flowOptions("project", projects), // { project_options: [{ id, title }] }
project: "proj_a",
});
return dataExchangeError("SELECT", "No active projects in this workspace.");
// { "screen": "SELECT", "data": { "error_message": "No active projects in this workspace." } }

A flow usually closes on the phone: when the person presses a button whose target is complete, WhatsApp sends their answers to Wabery and you receive flow.completed. Your endpoint isn’t involved.

Close it from your endpoint when your server decides the form is done, for example after a loop where the person adds items until they pick “Done”. Reply with WhatsApp’s reserved SUCCESS screen and put the answers in extension_message_response.params:

{
"screen": "SUCCESS",
"data": {
"extension_message_response": {
"params": {
"customer_name": "Alex",
"service": "hemming"
}
}
}
}
import { dataExchangeComplete } from "@wabery/sdk";
return dataExchangeComplete({ customer_name: "Alex", service: "hemming" });

WhatsApp closes the flow and sends params back to Wabery, and you receive flow.completed with params as submission:

  • flow_token: Wabery adds the session’s flow_token to params. If you include it yourself, it must equal req.flow_token; a token from another send is rejected.
  • Answers: WhatsApp returns only what’s in params, not the answers from earlier screens. When the flow declares fields, Wabery keeps and validates only those fields, so use the field names. A required field on a screen the person reached must be present, or valid is false. Without declared fields, submission contains every param except flow_token.
  • Hand-written Flow JSON: mark the screen your endpoint closes from as terminal: true. Don’t give any screen the id SUCCESS: WhatsApp reserves it, and Wabery rejects it in config apply, config validate, and publishing.

Wabery checks every reply. If a check fails, WhatsApp shows the person an error and the screen doesn’t change. They can press the button again, which sends the same request a second time, so make your handler idempotent.

Rule Failure code
Respond within 8 seconds. timeout
Accept the connection. unreachable
Use a public HTTPS URL on port 443. blocked_url
Don’t redirect. Wabery doesn’t follow redirects, so set the final URL. redirect
Return a 2xx status. http_error
Keep the body at 64 KB or less. response_too_large
Return valid JSON. invalid_json
Return an object with a string screen and an object data. screen is a screen in the flow or SUCCESS. A SUCCESS reply’s params is an object, and any flow_token in it is this send’s. invalid_response

The 8 seconds cover your whole handler. If finishing involves slow work, such as generating a document or sending another message, reply first and do that work afterwards or when flow.completed arrives.

Each failed call is recorded as an endpoint.failed flow event with the failure code and a message that says what to fix:

Terminal window
wabery flows events flow_123
{
"object": "flow_event",
"type": "endpoint.failed",
"level": "warn",
"message": "Your endpoint did not respond within 8 seconds.",
"data": {
"code": "timeout",
"action": "data_exchange",
"screen": "ADD_ITEM",
"flow_token": "b1c2..."
},
"created_at": "2026-10-13T02:01:46.000Z"
}

For http_error and redirect, data.status holds the HTTP status your endpoint returned. Before you publish, check your endpoint with wabery flows test, which applies the same rules, or with the endpoint panel’s Live mode in the dashboard’s flow builder.

Every type below is validated server-side before flow.completed fires, mirroring WhatsApp’s on-device checks.

type Renders as Value shape Options & validation
text TextInput string required, minChars, maxChars, pattern (regex; needs helperText), inputType: text | email | phone | password | passcode
textarea TextArea string required, maxChars
number numeric TextInput number required, minChars, maxChars
boolean OptIn checkbox boolean required
date DatePicker, or CalendarPicker when range: true string, or { start_date, end_date } required, range
choice RadioButtonsGroup, Dropdown when dropdown: true, or CheckboxGroup when multi: true string, or string[] when multi required, static options or dynamicOptions, minSelected, maxSelected, refreshOnSelect (dynamic)
photo PhotoPicker string[] (media handles) required, minFiles, maxFiles, maxFileSizeKb
document DocumentPicker string[] (media handles) required, minFiles, maxFiles, maxFileSizeKb

You can also place non-input display items (heading, subheading, body, caption) on a screen for instructions.

  • Conditional fields — give any field or display item a visibleWhen condition so it only appears when a prior answer on the same screen matches:

    { "name": "other_detail", "label": "Tell us more", "type": "text",
    "visibleWhen": { "field": "reason", "op": "eq", "value": "other" } }
  • Multiple screens — navigation is a graph, not a fixed list. Each screen has a primary button (and up to two link buttons) whose target is either another screen ({ "kind": "screen", "screenId": "..." }) or the end of the flow ({ "kind": "complete" }). Leave navigation unset for a linear walk (next screen in order; the last screen completes). In dynamic flows, branching screens let your endpoint choose which screen comes next.

A Flow is delivered as an interactive WhatsApp message, so the same initiation rules as any message apply:

  • Opt-in is required. Sending to a contact (by contact_id, conversation_id, or to) needs an active WhatsApp opt-in on record, or the send is rejected with 409 contact_opt_in_required. Capture opt-in when you enroll the contact.
  • The 24-hour window applies. You can send a Flow freely while the customer-service window is open (the contact messaged you within the last 24h). To proactively prompt — e.g. a daily check-in Flow when no one has messaged — first re-open the window with an approved template, then send the Flow. That template step requires a Meta payment method; see why WhatsApp templates need payment. flows.send itself sends a plain interactive flow message; it does not wrap a template.

Enroll the contact first so contact_reference is populated from the very first interaction (it’s the externalId echoed on data-exchange, flow.completed, and message.received):

const contact = await wabery.contacts.enroll({
phone: "+15551234567",
projectId: "project_...",
referenceId: "user-42", // becomes contact_reference / externalId
metadata: { plan: "pro" },
optIn: { source: "app-onboarding", method: "checkbox" },
});
await wabery.flows.send("flow_...", {
channelId: "channel_...",
contactId: contact.id,
});

A flows.send returning a flow_dispatch means Wabery accepted the send, not that WhatsApp delivered it. Failures surface in two places:

  • Synchronously, on send. Pre-flight problems reject the request: 409 contact_opt_in_required (no opt-in on record), 409 flow_not_published / flow_blocked / flow_throttled / flow_deprecated (Meta status), or a messaging window error. The SDK throws a typed WaberyConflictError / WaberyApiError with the code and message.
  • Asynchronously, after send. Delivery outcomes arrive on the message.status webhook — watch for status: "failed" and read failure.code / failure.title for Meta’s reason.

Inspect a specific send anytime with its flow_token:

const dispatch = await wabery.dispatches.get(flowToken); // status: SENT | COMPLETED | EXPIRED
Terminal window
wabery dispatches get <flow_token>
  • config apply updates the draft. It upserts the flow’s definition; it does not change what end-users see until you publish.
  • Publishing snapshots a version. Publishing to Meta freezes the current Flow JSON and field schema as a new FlowVersion and pushes it to Meta. Each send is bound to the version that was live at send time, so a submission is always validated against the schema it was collected with.
  • In-flight sessions are unaffected by edits. An open Flow keeps running on the version it started with; re-applying or re-publishing a new version only affects sends made after the publish. (Sessions still expire after 7 days.)
  • Meta reviews flows. Publishing queues an async job that goes through Meta’s draft → publish step and can take time; the flow becomes sendable only once Meta reports it PUBLISHED. Wabery tracks Meta’s status and blocks sends for PUBLISHING, PUBLISH_FAILED, DRAFT, BLOCKED, THROTTLED, or DEPRECATED flows with a clear error. Draft flows can only be test-sent to the phone numbers that own the flow on Meta.

config.apply works in flow keys (your stable config_key); publish and send work in flow_ids. The bridge is flows.findByConfigKey:

// 1. Apply the draft (creates/updates the flow as DRAFT).
await wabery.config.apply({
flows: [{ key: "lead_intake", name: "Lead intake", draft }],
automations: [],
});
// 2. Resolve the flow_id from your config key.
const flow = await wabery.flows.findByConfigKey("lead_intake");
if (!flow) throw new Error("flow not found");
// 3. Queue the publish job…
await wabery.flows.publish(flow.id);
// 4. …and wait until Meta reports PUBLISHED (polls; throws on PUBLISH_FAILED/BLOCKED/DEPRECATED/timeout).
await wabery.flows.waitUntilPublishable(flow.id, { timeoutMs: 120_000 });
// 5. Now it's sendable.
await wabery.flows.send(flow.id, { channelId: "channel_...", contactId: "contact_..." });

To check status yourself instead of blocking, read flow.meta_status from wabery.flows.status(flow.id) or wabery.flows.get(flow.id) — it is PUBLISHED when sendable. Project webhooks also receive flow.status when Meta reports a status change.

A failed publish sets meta_status to PUBLISH_FAILED and carries the actionable detail: meta_status_reason (Meta’s human-facing title/message) and validation_errors (with line/field pointers when present). The CLI prints both:

Terminal window
wabery flows publish flow_123 --wait # waits up to 5 min by default
wabery flows publish flow_123 --wait=600 # or set your own timeout (seconds)
wabery flows status flow_123 # re-check anytime; prints the reason if failed

--wait polls until Meta reports PUBLISHED. Because publishing is asynchronous on Meta’s side, a timeout is not an error — the CLI prints “still publishing, re-check with flows status” and exits 0. It only errors on a terminal PUBLISH_FAILED / BLOCKED / DEPRECATED state, and prints Meta’s reason.

Wabery makes publishing idempotent: it validates the compiled JSON locally before creating anything on Meta, persists the Meta flow id as soon as the asset exists, and — if a name was already taken by a prior failed attempt — adopts that existing flow instead of dead-ending on “Flow name is not unique”. So a transient failure no longer permanently burns a flow name; just retry the publish.

  • Get the compiled Flow JSON. wabery flows get <id> (or GET /flows/{id}?include=flow_json, or wabery.flows.get(id) in the SDK) returns flow_json — the compiled Meta WhatsApp Flow JSON (the published version if any, else the current draft), including screens, routing_model, and data-source bindings. Use it to align your data-exchange endpoint to the compiler’s screen ids and navigation. It’s opt-in (?include=flow_json) because it can be large, so status polls stay lean; field_schema (what flow.completed is validated against) is always returned.
  • Delete a flow. wabery flows delete <id> (SDK: wabery.flows.delete(id)) removes a flow. A draft Meta asset is hard-deleted; a published one is deprecated (Meta forbids deleting published flows). The local record is removed regardless, so this is how you clean up failed/test/orphaned flows.
  • Reconcile with Meta. wabery flows reconcile <id> (or flows status) pulls Meta’s current status + validation_errors, and — if a prior failed publish left the flow’s Meta id unset but a flow by that name exists on Meta — re-adopts it so the record stops drifting.

Every publish attempt, submission outcome, reconciliation, and failed data-exchange call is recorded as a flow event with the raw detail. Read them with wabery flows events <id> (GET /flows/{id}/events, SDK wabery.flows.events(id)):

{ "object": "list", "has_more": false, "data": [
{ "object": "flow_event", "type": "publish.failed", "level": "error",
"message": "Create flow: Flow name is not unique",
"data": { "code": "meta_error",
"validation_errors": [{ "error": "INVALID_ROUTING_MODEL", "line_start": 12 }] },
"created_at": "2026-06-24T10:00:00.000Z" },
{ "object": "flow_event", "type": "submission.rejected", "level": "warn",
"message": "Submission rejected: brooder",
"data": { "valid": false,
"validation_errors": [{ "field": "brooder", "message": "required field missing" }],
"reached_screens": ["select", "grower"] } }
] }
type Recorded when
publish.queued, publish.succeeded, publish.failed A publish to WhatsApp is queued, succeeds, or fails.
publish.blocked A required flow can’t go live until it’s tested in the Sandbox.
submission.validated, submission.rejected A flow.completed submission passes or fails validation. The same validation_errors are on each submission in GET /submissions.
flow.reconciled Wabery syncs the flow’s status or id from WhatsApp.
endpoint.failed A call to your data_exchange_url fails. data.code says why; see Reply rules.

WhatsApp Flows have no built-in locale switch — Flow JSON is single-language, and Meta only localizes message templates, not Flows. There are two supported patterns:

Static copy → one flow per language (resolved by config key)

Section titled “Static copy → one flow per language (resolved by config key)”

Author the flow once, then translate its user-facing strings and publish one flow per locale sharing a single config_key, each with a distinct locale (omit locale for the group default). localizeFlow does the translation step: give it a draft and a dictionary per locale (mapping the source string to its translation), and it returns one draft per locale (missing keys fall back to the source string).

import { localizeFlow } from "@wabery/sdk";
const byLocale = localizeFlow(draft, {
en: {}, // group default (no locale)
es: { "Your name": "Tu nombre", Submit: "Enviar" },
});
await wabery.config.apply({
flows: [
{ key: "lead_intake", name: "Lead intake", draft: byLocale.en },
{ key: "lead_intake", name: "Lead intake (es)", locale: "es", draft: byLocale.es },
],
automations: [],
});

Publish each variant, then send by config key. Wabery resolves the variant for the contact — explicit locale → the contact’s preferred_language → the "" group default — and skips any variant Meta hasn’t approved:

const { flow_token, locale } = await wabery.flows.sendByConfigKey("lead_intake", {
channelId: "channel_...",
contactId: "contact_...", // uses this contact's preferred_language
locale: "es", // optional override
});

Set the contact’s language with contacts.enroll({ preferredLanguage }) or contacts.update(id, { preferredLanguage }). In automations, the Send Flow node has a “use contact’s language” toggle that resolves by config key at send time. (WhatsApp doesn’t report a user’s language, so you supply it.)

Dynamic copy → translate via data-exchange

Section titled “Dynamic copy → translate via data-exchange”

For DATA_EXCHANGE flows, bind user-facing strings to ${data.*} and return the right language from your endpoint based on the contact. Wabery echoes contact_reference (your own id) on every data-exchange call, so you know whose language to use:

const locale = await localeFor(req.contact_reference); // "en" | "id" | …
return dataExchangeScreen("SELECT", {
...flowOptions("timeline", optionsFor(locale)),
heading: t(locale, "tell_us_about_your_project"),
});

Use the first pattern for fixed copy (simplest), the second when copy is per-user or already dynamic.

You don’t need a deploy or a Meta publish to exercise your handlers. Sign a fake payload with your webhook secret and POST it to your local server — the CLI does this for you:

Terminal window
# Drive your webhook handler:
wabery webhooks send-test --url http://localhost:3000/webhooks \
--secret "$WABERY_WEBHOOK_SECRET" --event flow.completed
# Send one data-exchange request and check the reply:
wabery flows test --url http://localhost:3000/flow \
--secret "$WABERY_WEBHOOK_SECRET" \
--action data_exchange --screen SELECT --data '{"workspace":"ws_1"}' \
--triggered-by field_change --changed-field workspace
# Walk the whole journey (open, field changes, branch), then fire flow.completed:
wabery flows simulate --url http://localhost:3000/flow \
--webhook-url http://localhost:3000/webhooks \
--secret "$WABERY_WEBHOOK_SECRET" \
--steps '[{"action":"INIT"},{"action":"data_exchange","screen":"SELECT","data":{"workspace":"ws_1"}}]' \
--submission '{"workspace":"ws_1","project":"proj_a"}'

Both commands check each reply against the reply rules and exit with an error that lists what Wabery would reject. They can’t check that screen exists in your flow; Wabery checks that when the flow runs. In simulate, a SUCCESS reply ends the walk, and its params become the flow.completed submission unless you pass --submission.

In code, signPayload produces the same x-wabery-signature header so you can write your own fixtures and integration tests:

import { signPayload } from "@wabery/sdk";
const body = JSON.stringify(myFakeEvent);
await fetch("http://localhost:3000/webhooks", {
method: "POST",
headers: { "x-wabery-signature": signPayload(body, secret) },
body,
});

The shared sandbox number is the other half of the loop: enrolling a contact on a sandbox project returns a channel_id you can send real flows through, and joining emits a participant.joined webhook — useful for end-to-end tests against a real WhatsApp client without a dedicated number.

End-to-end example: cascading dropdowns, a branch, and your database

Section titled “End-to-end example: cascading dropdowns, a branch, and your database”

A dynamic flow whose options depend on the person, that branches on an answer, and whose submission lands in your database. It’s one DATA_EXCHANGE flow plus two handlers you own.

flow definition
import { defineFlow, screen, dropdown, radio, textField, completeFlow, goToScreen } from "@wabery/sdk";
const draft = defineFlow([
screen("SELECT", "Where's the issue?", [
dropdown("workspace", { label: "Workspace", dynamic: true, refreshOnSelect: true }),
dropdown("project", { label: "Project", dynamic: true }),
radio("severity", { label: "Severity", options: [
{ id: "low", title: "Low" },
{ id: "high", title: "High" },
] }),
], {
// Two possible next screens, so pressing Next calls your endpoint
// (triggered_by: "footer") and your reply picks the screen.
primary: { label: "Next", target: goToScreen("DETAILS") },
links: [{ label: "Callback", target: goToScreen("CALLBACK") }],
}),
screen("DETAILS", "Anything else?", [
textField("notes", { label: "Notes" }),
], { primary: { label: "Submit", target: completeFlow() } }),
screen("CALLBACK", "We'll call you back", [
textField("callback_number", { label: "Phone number", required: true }),
], { primary: { label: "Submit", target: completeFlow() } }),
]);
data-exchange endpoint (cascading options + branch)
import { dataExchangeScreen, flowOptions } from "@wabery/sdk";
const req = wabery.webhooks.verifyDataExchange(rawBody, sigHeader, secret);
const user = await userByRef(req.contact_reference);
if (req.action === "INIT") {
return dataExchangeScreen("SELECT", {
...flowOptions("workspace", await workspacesFor(user)),
...flowOptions("project", []), // empty until a workspace is chosen
});
}
if (req.triggered_by === "footer") {
// Next on SELECT: pick the screen, and carry the answers forward.
const next = req.data?.severity === "high" ? "CALLBACK" : "DETAILS";
return dataExchangeScreen(next, { ...req.data });
}
// The workspace changed: repopulate projects. Replace semantics, so resend both lists.
const projects = await projectsFor(user, req.data?.workspace as string);
return dataExchangeScreen("SELECT", {
...flowOptions("workspace", await workspacesFor(user)),
...flowOptions("project", projects),
});
webhook handler (write the final submission)
const event = wabery.webhooks.constructEvent(rawBody, sigHeader, secret);
if (event.event === "flow.completed" && event.payload.valid) {
if (seen.add(event.payload.flow_token)) return; // idempotent per flow_token
await db.tickets.insert({
userId: await userIdByRef(event.payload.contact_reference),
...event.payload.submission, // { workspace, project, severity, notes | callback_number }
});
}