Skip to content
Start free

WhatsApp Flows

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.

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.

Flows are authored as config-as-code in wabery.config.json or from Build tools → Flows in the dashboard. 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_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.

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.

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, …

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.

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

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

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
# 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

Section titled “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 }
});
}