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:
| 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. |
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.
{
"$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.jsonPublishing 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 byflows.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 asexternalIdin the contact'smetadatawhen you create/enroll the contact, and Wabery echoes it on everyflow.completed(andmessage.received) event, so you never have to store acontact_id→ user mapping.contact_id+from—contact_idis stable for a given WhatsApp number within your organization (numbers are normalized to E.164 and deduped).fromis 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.
{
"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 / scoping —
contact_referenceis your own id (theexternalIdyou set when you enrolled the contact, echoed here), pluscontact_idand the E.164from. Use these to look up the user, scope dropdowns to their account, and return only the workspaces they can see.flow_tokenalso 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 selections —
dataholds the values already chosen on the currentscreen. For cascading dropdowns, read the parent there (data.workspace) and compute the children. ArefreshOnSelectfield re-posts the screen's currentdataeach time it changes, so "Workspace changed → repopulate Project" is just "recompute fromdata.workspace". - What fired the exchange —
triggered_byis"field_change"when arefreshOnSelectfield changed (withchanged_fieldnaming 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. (nullforINIT/ping.) Wabery strips its internal__wabery_*markers fromdatabefore 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>_optionsarrays filldynamicOptionsfields. - Prefill / set values — include a field's value directly (e.g.
"project": "proj_a") to pre-select it. - Advance / branch — set
screento 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.
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 & multiple screens
-
Conditional fields — give any field or display item a
visibleWhencondition 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
targetis 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, orto) needs an active WhatsApp opt-in on record, or the send is rejected with409 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.senditself 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 typedWaberyConflictError/WaberyApiErrorwith thecodeandmessage. - Asynchronously, after send. Delivery outcomes arrive on the
message.statuswebhook — watch forstatus: "failed"and readfailure.code/failure.titlefor Meta's reason.
Inspect a specific send anytime with its flow_token:
const dispatch = await wabery.dispatches.get(flowToken); // status: SENT | COMPLETED | EXPIREDwabery dispatches get <flow_token>Versioning & publishing
config applyupdates 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
FlowVersionand 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 forPUBLISHING,PUBLISH_FAILED,DRAFT,BLOCKED,THROTTLED, orDEPRECATEDflows 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>(orGET /flows/{id}?include=flow_json, orwabery.flows.get(id)in the SDK) returnsflow_json— the compiled Meta WhatsApp Flow JSON (the published version if any, else the current draft), includingscreens,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(whatflow.completedis 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>(orflows 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.
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() } }),
]);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", {});
}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 }
});
}