WhatsApp Flows

Collect structured, validated data with native in-chat forms — static options or dynamic, per-user data fetched from your server.

WhatsApp Flows are native, in-chat forms. Instead of parsing "i think around 2k maybe?" out of a thread, you define a form once and receive a clean, validated submission. Wabery handles the Flow JSON, the encryption handshake, and the data-exchange transport for you — your code only ever sees plain JSON.

Static vs. dynamic flows

A flow runs in one of two modes. Pick per flow:

Moderuntime_modeWhen to useWhere options come from
StaticSTATIC (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.
DynamicDATA_EXCHANGEOptions 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.

Dynamic flows are first-class. If a user must pick from data that's theirs — say their own Workspace → Project — values that differ per user and change over time — you do not deploy a flow per user. You publish one DATA_EXCHANGE flow and serve the option lists from your server as the user taps. See Dynamic flows below.

Define a flow

Flows are authored as config-as-code in wabery.config.json (or in the dashboard builder — 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: [],
});

The builders are pure helpers — flows are still data, not a runtime engine. Prefer to hand-write canonical Meta Flow JSON? Use flow_json (typed as MetaFlowJson) instead of draft and Wabery stores it as-is.

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"));
}

validateFlowJson (and wabery config validate) are structural checks only — they catch missing versions, dangling/unreachable screens (Meta's INVALID_ROUTING_MODEL), and array fields missing their items schema. A clean result means "no obvious structural problem", not "Meta will accept it". Some rejections are only knowable at publish — notably flow-name uniqueness (names must be unique per WhatsApp Business Account) and account/permission errors. When a publish does fail, Wabery surfaces Meta's real reason and validation_errors (with line pointers) on meta_status_reason — see Versioning & publishing.

Deploy it

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.

Send it

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" },
});
curl https://api.wabery.com/v1/flows/flow_.../send \
  -H "Authorization: Bearer $WABERY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "channel_id": "channel_...",
    "contact_id": "contact_...",
    "body_text": "Tell us about your project",
    "flow_cta": "Start",
    "first_screen": "START",
    "screen_data": { "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.

Receive the submission

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"
  }
}

flow.completed is the single source of truth. There is no separate onSubmit URL on the flow and Wabery does not POST anywhere else on completion. (If you need to react during the form — to populate options or branch screens — that's the data-exchange endpoint below, which is a different mechanism and only applies to dynamic flows.) Submissions are idempotent: one delivery per flow_token, even if Meta retries.

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.

valid is path-aware for branched flows. Wabery validates only the screens the run actually reached, not the union of every screen's required fields. So a flow that branches (e.g. a chooser routing to a GROWER or a LAYER path) is valid: true for a complete run down one branch — the other branch's required fields are not counted as "missing". validation_errors tells you exactly which fields failed when valid is false, so valid === false is a safe signal to gate on.

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",
});

Correlating submissions to your users

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_referenceyour 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 + fromcontact_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.

WhatsApp's raw flow_token is generated by Wabery at send time and is never exposed to the end user, so it can't be tampered with. You don't pass a custom token into the flow — use contact_reference to carry your own identifier.

Dynamic flows (data-exchange)

Set runtime_mode: "DATA_EXCHANGE" and a data_exchange_url (a public HTTPS endpoint you own). Now, as the user moves through the flow, WhatsApp calls Wabery, Wabery decrypts the request and proxies plain JSON to your URL, and relays your JSON response back (re-encrypting for you). You implement zero crypto.

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 — the field's option list is supplied by your endpoint at runtime instead of being hardcoded.
  • refreshOnSelect: true — selecting this field re-calls your endpoint so dependent fields on the same screen can repopulate. This is how you build cascading Workspace → Project dropdowns.

What your endpoint receives

Wabery decrypts WhatsApp's request and POSTs plain JSON to your data_exchange_url with an x-wabery-signature header (sha256=<hex>, HMAC over the raw body using your project's webhook secret — verify it exactly as for a webhook; same secret, same scheme). The body merges WhatsApp's decrypted payload with the session identity Wabery knows:

{
  "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"
}
  • Identity / scopingcontact_reference is your own id (the externalId you set when you enrolled the contact, echoed here), plus contact_id and the E.164 from. Use these to look up the user, scope dropdowns to their account, and return only the workspaces they can see. flow_token also uniquely identifies the send if you'd rather correlate against a mapping you stored at send time. Identity fields are authoritative (Wabery sets them after merging WhatsApp's payload, so they can't be spoofed).
  • Parent selectionsdata holds the values already chosen on the current screen. For cascading dropdowns, read the parent there (data.workspace) and compute the children. A refreshOnSelect field re-posts the screen's current data each time it changes, so "Workspace changed → repopulate Project" is just "recompute from data.workspace".
  • What fired the exchangetriggered_by is "field_change" when a refreshOnSelect field changed (with changed_field naming it) or "footer" when the user advanced the screen. Both otherwise arrive identically on the same screen, so this lets you branch deterministically instead of guessing. (null for INIT/ping.) Wabery strips its internal __wabery_* markers from data before forwarding, so you only see real field values.

action is INIT when the flow opens, data_exchange when a refreshOnSelect field changes or a branching screen advances, BACK on back navigation, and ping for Meta's health check.

Verify the signature and get a typed request with the SDK (same secret and scheme as webhooks):

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

What your endpoint returns

Reply with a plain JSON object naming the screen to render and its data. Wabery relays it back to WhatsApp verbatim (re-encrypting for you). Dynamic option lists are keyed <fieldName>_options, each an array of { id, title }:

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

The same data object does three things:

  • Populate options<fieldName>_options arrays fill dynamicOptions fields.
  • Prefill / set values — include a field's value directly (e.g. "project": "proj_a") to pre-select it.
  • Advance / branch — set screen to the next screen's id (it must be a declared navigation target) to move forward, or pick a different screen per user to branch.

Replace, not merge. The data you return becomes the target screen's data model wholesale — Wabery relays it to WhatsApp verbatim and does not merge it with the screen's previous data. Return everything that screen renders (all <field>_options and any prefilled values) on every response that lands on it. User-entered form values are separate and persist automatically.

The SDK provides builders for this response shape:

import { flowOptions, dataExchangeScreen, dataExchangeError } from "@wabery/sdk";

return dataExchangeScreen("SELECT", {
  ...flowOptions("project", projects), // -> { project_options: [{ id, title }] }
  project: "proj_a", // optional prefill
});

To surface a validation problem, return an error_message instead of advancing:

{ "screen": "SELECT", "data": { "error_message": "No active projects in this workspace." } }
return dataExchangeError("SELECT", "No active projects in this workspace.");

Your endpoint must answer within ~8s. Errors and timeouts surface to the user as "Service temporarily unavailable" and the flow does not advance — keep the handler fast and idempotent.

Field types & validation

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

typeRenders asValue shapeOptions & validation
textTextInputstringrequired, minChars, maxChars, pattern (regex; needs helperText), inputType: text | email | phone | password | passcode
textareaTextAreastringrequired, maxChars
numbernumeric TextInputnumberrequired, minChars, maxChars
booleanOptIn checkboxbooleanrequired
dateDatePicker, or CalendarPicker when range: truestring, or { start_date, end_date }required, range
choiceRadioButtonsGroup, Dropdown when dropdown: true, or CheckboxGroup when multi: truestring, or string[] when multirequired, static options or dynamicOptions, minSelected, maxSelected, refreshOnSelect (dynamic)
photoPhotoPickerstring[] (media handles)required, minFiles, maxFiles, maxFileSizeKb
documentDocumentPickerstring[] (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 & multiple screens

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

Starting the conversation

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. 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,
});

Why a send didn't land

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
wabery dispatches get <flow_token>

Versioning & publishing

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

The full lifecycle in code

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.

When a publish fails

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:

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.

Inspecting and deleting flows

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

Event log (debugging without worker logs)

Every publish attempt, submission outcome, and reconciliation 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"] } }
] }

Event types: publish.queued / publish.succeeded / publish.failed, submission.validated / submission.rejected, flow.reconciled. The same validation_errors are also on each submission in GET /submissions. This is the detail that used to live only in worker logs.

Localization (multi-language flows)

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)

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

locale is part of the WaberyConfig flow-entry type in @wabery/sdk — no cast needed. Each (key, locale) pair must be unique: two entries sharing a key with the same locale (or both omitting it) are rejected by config.apply / config validate, so a typo can't silently overwrite a variant. Give each variant a distinct locale (leave exactly one without, the group default).

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.

Test locally

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:

# Drive your webhook handler:
wabery webhooks send-test --url http://localhost:3000/webhooks \
  --secret "$WABERY_WEBHOOK_SECRET" --event flow.completed

# Drive your data-exchange endpoint (one screen advance):
wabery flows test --url http://localhost:3000/flow \
  --secret "$WABERY_WEBHOOK_SECRET" \
  --action data_exchange --screen SELECT --data '{"workspace":"ws_1"}'

# 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"}'

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 → branch → write to your DB

This is the 90% case — a dynamic flow whose options depend on the user, that branches on an answer, and whose final submission lands in your database. The whole thing is 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" },
    ] }),
  ], {
    // Branch: high severity collects a callback number on its own screen.
    primary: { label: "Next", target: goToScreen("DETAILS") },
  }),
  screen("DETAILS", "Anything else?", [
    textField("notes", { label: "Notes" }),
  ], { 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);

switch (req.action) {
  case "INIT":
    return dataExchangeScreen("SELECT", {
      ...flowOptions("workspace", await workspacesFor(user)),
      ...flowOptions("project", []), // empty until a workspace is chosen
    });
  case "data_exchange": {
    // refreshOnSelect on `workspace` re-posts the screen's current data.
    const projects = await projectsFor(user, req.data?.workspace as string);
    // Replace semantics: resend BOTH option lists.
    return dataExchangeScreen("SELECT", {
      ...flowOptions("workspace", await workspacesFor(user)),
      ...flowOptions("project", projects),
    });
  }
  default:
    return dataExchangeScreen(req.screen ?? "SELECT", {});
}
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 }
  });
}
WhatsApp Flows | Wabery Docs | Wabery