Skip to main content
The complete journey from “user clicks the widget button” to “flow finishes”. Three things to get right:
  1. Trigger vs. resume/messages accepts two body shapes; pick the right one each turn.
  2. Pick the intent on triggerintentName from the session’s intents list.
  3. Wait for async work — webhook to your backend, or polling when status: waiting_time.

Picture it

(For widgets without an integrator backend, swap the webhook arrows for GET /executions/{id} polling every 2–3 s.)

Step 1 — Open a session

Once per visitor, ideally lazily (when the user first clicks the chat launcher, not on page load — saves wasted sessions). Request
POST /api/public/v1/chat/sessions
Origin: https://acme.com
Content-Type: application/json

{
  "publicKey": "pk_live_…",
  "customerId": "u-42",        // optional — your stable user id
  "locale": "en",              // optional — BCP-47
  "variables": {               // optional — seed conversation variables
    "plan": "pro",
    "cart_total": 129.5,
    "page_path": "/checkout"
  }
}
Response (200)
{
  "sessionToken": "eyJ0aWQi…",
  "conversationId": "0193f8a1-…",
  "expiresAt": "2026-05-15T13:00:00Z",
  "widget": { "label": "Demo widget" },
  "intents": [
    { "name": "order_status", "displayLabel": "Order status",
      "description": "Look up the status of an order",
      "examples": ["Where is my order #..."], "required_entities": [] },
    { "name": "returns", "displayLabel": "Returns",
      "description": "Start a return",
      "examples": [], "required_entities": [] }
  ],
  "quickQuestions": [
    { "question": "Where is my order?", "pageType": "general",
      "flowId": "0193aa…", "intentName": "order_status" },
    { "question": "How do I return an item?", "pageType": "product",
      "flowId": "0193bb…", "intentName": null }
  ]
}
Stash sessionToken in memory (or sessionStorage for the tab lifetime). The intents array tells the widget what to offer: render a menu of buttons (one per intent), or feed the names into a tiny on-page classifier that decides from the user’s first message. If intents is empty, the widget key permits things that aren’t currently published — show a “chat unavailable” state and ping the tenant admin.

Seeding conversation variables

Pass an optional variables object to prime the conversation with context the flow can read from the first turn — the visitor’s plan tier, cart total, the page they opened the widget on, an A/B bucket, and so on. The values are stored on the conversation, so any later trigger or resume sees them. variables is validated server-side; an out-of-bounds map fails the call with 422 validation_failed:
ConstraintLimit
Must be a JSON object (not an array)
Keyssnake_case, 1–64 chars, [a-z][a-z0-9_]*
Number of keys≤ 50
Value typesscalar, null, or nested array
Nesting depth≤ 4
Serialized size≤ 4096 bytes
Treat seeded variables as untrustedvariables comes straight off a public, unauthenticated endpoint. Anyone who can load your page can set them. Never use a seeded variable for an authorization or pricing decision a flow makes server-side — treat them as hints only.

Quick Questions

quickQuestions are the tenant’s pre-written prompts (configured under the Widget settings section, Quick Questions group — up to six). Render them as tappable chips so the visitor can start a common flow in one click.
FieldMeaning
questionThe text to show on the chip.
pageTypeWhich page context the prompt is meant for — one of general, category, product, cart. The server does not filter by page; the widget decides which chips to show based on the current page.
flowIdThe flow the prompt is bound to.
intentNameThe intent to trigger when the visitor taps the chip — or null when the bound flow is unpublished or this widget key isn’t permitted to trigger it.
When intentName is non-null, tapping the chip is just a normal trigger turn: post { text: question, intentName } to /messages (see Step 2). When intentName is null, render the chip disabled rather than dropping it, so the prompt the admin configured stays visible.
Chips before the sessionThe same rows are also served by an anonymous, cacheable GET /api/public/v1/chat/quick-questions endpoint (optionally narrowed with ?pageTypes=general,category) — handy for rendering the chips on page load, before any session exists. See Public read API → Quick questions.

Step 2 — First user message (trigger)

Send a trigger body — {text, intentName}. The intentName must be one of the names from session.intents[*].name. No waitToken yet because nothing is waiting.
POST /api/public/v1/chat/messages
Authorization: Bearer eyJ0aWQi…
Origin: https://acme.com
Content-Type: application/json

{
  "text": "Check the status of my order",
  "intentName": "order_status",
  "context": {                           // optional, free-form
    "page_url": "https://acme.com/orders",
    "referrer": "https://google.com"
  },
  "variables": {                         // optional — same rules as /sessions
    "cart_total": 129.5
  }
}
Variables on /messagesBoth message body shapes — trigger and resume — accept the same top-level variables object as /sessions, with the identical validation rules (object only, ≤ 50 snake_case keys, depth ≤ 4, ≤ 4096 bytes). Variables sent here are merged into the conversation, so you can keep a long-lived seed at session open and update individual keys per turn.
Response is a Reply envelope. The shape is identical for trigger and resume — your render path is one branch by status.
{
  "reply": {
    "executionId": "01935-aaaa-bbbb-cccc-dddd",
    "conversationId": "0193f8a1-…",
    "status": "waiting_input",
    "blocks": [
      { "id": "b_greeting", "type": "message",
        "payload": { "role": "agent", "text": "What's your order number?", "format": "plain" } },
      { "id": "b_form", "type": "form",
        "payload": {
          "title": "Order lookup",
          "fields": [{ "name": "order_number", "type": "text", "label": "Order #", "required": true }],
          "submit_label": "Check"
        } }
    ],
    "expectedInput": {
      "type": "form_submission",
      "block_id": "b_form",
      "schema": { "type": "object", "required": ["order_number"], "properties": { "order_number": { "type": "string" } } }
    },
    "waitToken": "wt_01935abc",
    "waitExpiresAt": "2026-05-15T13:00:00Z",
    "tokenUsage": {
      "prompt_tokens": 412,
      "completion_tokens": 38,
      "total_tokens": 450,
      "model": "gpt-4o-mini",
      "cost_micros": 124,
      "cost_usd": 0.000124
    }
  }
}
What to do with this:
  1. Render every block in reply.blocks in order. See Blocks for the payload structure per type.
  2. Save executionId and waitToken on the widget side — you’ll need both when the user submits the form.
  3. The expectedInput.schema is a JSON Schema you can use to validate the user’s input client-side before sending.
tokenUsage on every replyEvery reply (trigger, resume, and poll) carries a tokenUsage object — or null when the turn used no LLM tokens (e.g. a purely scripted node). It summarizes the LLM token spend for that one turn:
FieldMeaning
prompt_tokensTokens billed for the prompt.
completion_tokensTokens billed for the completion.
total_tokensSum of the two.
modelThe model id when the turn used exactly one model; null otherwise.
cost_microsEstimated cost in micro-USD (1e-6 USD) — sum these across turns without float drift.
cost_usdThe same estimate as a plain-USD float, for display.
The widget can ignore it; it’s mainly there for in-conversation usage meters and for the tenant’s analytics. The same numbers ride the chat.execution.updated webhook under token_usage.

Step 3 — Form submission (resume)

The user fills the form. Send a resume body — no text, just the wait info plus the form values.
POST /api/public/v1/chat/messages
Authorization: Bearer eyJ0aWQi…
Origin: https://acme.com
Content-Type: application/json

{
  "waitToken": "wt_01935abc",
  "executionId": "01935-aaaa-bbbb-cccc-dddd",
  "values": { "order_number": "12345" }
}
Next reply is the next stop of the same execution:
{
  "reply": {
    "executionId": "01935-aaaa-bbbb-cccc-dddd",
    "conversationId": "0193f8a1-…",
    "status": "completed",
    "blocks": [
      { "id": "b_result", "type": "message",
        "payload": { "role": "agent", "text": "Order #12345 ships tomorrow.", "format": "plain" } }
    ],
    "expectedInput": null,
    "waitToken": null,
    "waitExpiresAt": null,
    "tokenUsage": null
  }
}
status: completed ⇒ render the final blocks, stop polling, the execution is done. If the user sends another message, that becomes a brand-new trigger.

Step 3 (with uploads) — Form submission with file / image / signature fields

When a form block carries any of the upload-shaped field types (file_upload, image_upload, signature), the chat endpoint never accepts raw bytes. The widget submits the descriptor of an already-uploaded file — a FileRef object that the form-render layer exchanges for bytes ahead of the resume. The contract is two-phase: Where the upload endpoint lives. POST /api/v1/forms/uploads is an admin-side endpoint (ROLE_ADMIN-scoped under the tenant session); it is not part of the public /api/public/v1/chat surface. Every integrator fronts it with their own thin proxy, applying whatever auth model fits their widget (a signed upload token issued by your backend, an OIDC session, etc). The widget itself stays anonymous — it never holds tenant credentials. Multipart body for the upload proxy:
POST /your-upload-proxy
Content-Type: multipart/form-data; boundary=----xxx

------xxx
Content-Disposition: form-data; name="file"; filename="receipt.pdf"
Content-Type: application/pdf

%PDF-1.4 ... (binary) ...
------xxx--
Per-field constraints. Each upload field on the form may carry an accept allow-list and a maxSizeMb cap. Pass the form id and field name to your proxy so it can enforce them server-side — the chat resume call will reject mismatches with a validation_failed later in the round-trip, but rejecting at upload time gives much better UX. Submit body when the upload comes back:
POST /api/public/v1/chat/messages
Authorization: Bearer eyJ0aWQi…

{
  "waitToken":   "wt_01935abc",
  "executionId": "01935-aaaa-bbbb-cccc-dddd",
  "values": {
    "name":    "Mary",
    "receipt": {
      "file_id": "0193f8a2-7c3e-7c11-bf60-9c1f3f8a2e10",
      "url":     "/api/v1/forms/uploads/0193f8a2-7c3e-7c11-bf60-9c1f3f8a2e10",
      "name":    "receipt.pdf",
      "mime":    "application/pdf",
      "size":    124032
    }
  }
}
multiple: true fields submit a FileRef[] array. Signature fields submit a single FileRef pointing at a PNG of the drawn signature (encode the canvas to PNG, upload it via the same proxy). Retention modes — what the widget sees. A field’s retention (one of persistent, transient) is purely server-side bookkeeping; the widget treats both modes identically. With transient, the server deletes the file when the execution that submitted it terminates — so the url in the descriptor only works for the duration of the conversation. Don’t cache upload URLs in the widget across executions. Conditional logic — what to actually submit. A field whose visibleIf predicate evaluates to false against the values the user has supplied so far is hidden — don’t render it, and don’t include its key in the values payload. The server re-evaluates visibility on resume against the submitted values, so a required field that turned out hidden does not fail validation.

Step 4 — Async work and how the widget learns it’s done

A flow node can hand off work to a queue (Symfony Messenger). When it does, the reply comes back with status: waiting_time and (usually) empty blocks. The widget shows a “thinking…” indicator and waits. Two ways to learn it’s done:

Preferred: webhook → your backend → widget

If you set --webhook=<url> when issuing the widget key, the server POSTs chat.execution.updated to your backend the moment the engine moves the execution past waiting_time. Your backend then relays the new state to the widget over whatever push channel you already operate (WebSocket, Pusher, Server-Sent Events from your own server). One transport per integrator, owned by you. Full details: Webhooks.

Fallback: polling

If you don’t have a backend (a pure static-site embedding), the widget can poll the server directly:
GET /api/public/v1/chat/executions/01935-aaaa-bbbb-cccc-dddd
Authorization: Bearer eyJ0aWQi…
Origin: https://acme.com
Response shape is the same Reply envelope as /messages — re-render based on status. Cadence: 2–3 s while waiting_time. Stop when status becomes any of: waiting_input, completed, failed, aborted. Polling is non-destructive: blocks stay in the snapshot across reads, so dedupe by block.id if you re-render every tick.

Free-text turn vs. form submission

A flow can pause two ways:
  • Form — the previous reply carries expectedInput.type: form_submission and a b_form block. Submit with values keyed by fields[].name.
  • Free text — the previous reply carries expectedInput.type: free_text (or no form block at all). The user types a message; the widget triggers a new turn with {text, intentName}.
Rule of thumb: if the previous reply has a form block, resume. Otherwise, trigger with a fresh intentName.

Status decision table

What the widget does on each terminal/waiting status:
statusRender blocks?Next action
completedyesNone. Next user message starts a new trigger.
waiting_inputyesWait for input → send resume.
waiting_timeyes (often empty)Show “the bot is thinking…” / wait. Server resumes itself; widget can poll the same conversation, or expose a refresh button.
failedyes (if any)Show the agent message or fall back to a generic apology. Allow retry as a new trigger.
abortedyes (if any)Show “this session was closed” + offer to start over.

Error handling on top of /messages

HTTP / errorWhenWhat to do
401 invalid_session_tokentoken expired or tamperedSilent re-open /sessions, retry once
403 origin_not_allowed / widget_disabledmisconfig server-sideStop. Surface a config error; don’t loop
404 intent_not_matchedthe bound flow was unpublishedShow “chat unavailable”, report to tenant
409 invalid_wait_tokenuser took too long or already submittedDrop waitToken + executionId, start a fresh trigger with the user’s text
410 execution_abortedadmin / runtime cancelled the runSame — start a fresh turn
422 validation_failedform values failed schemaShow inline validation errors from details.validation_errors; let the user fix and resubmit
5xx / networktransientExponential backoff with jitter, max 3 retries
Full error catalog: Errors.

Telemetry (optional)

While the conversation runs, you can fire telemetry events that land in the tenant’s audit log and analytics:
POST /api/public/v1/chat/events
Authorization: Bearer eyJ0aWQi…
Origin: https://acme.com
Content-Type: application/json

{ "name": "widget_open", "props": { "page": "/pricing" } }
204 No Content. Send these for widget_open, widget_close, message_view, link_click, plus any custom events your product team wants. The audit log will tie them to the same conversationId.

Reference implementation — vanilla JS

<script>
  const ENDPOINT = 'https://flow.example.com/api/public/v1/chat';
  const KEY = 'pk_live_…';

  class FlowChat {
    constructor() { this.session = null; this.pending = null; }

    async _openSession(customerId, variables) {
      const r = await fetch(`${ENDPOINT}/sessions`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ publicKey: KEY, customerId, variables }),
      });
      if (!r.ok) throw new Error(`/sessions ${r.status}`);
      this.session = await r.json();
      // this.session.intents       — render a menu of buttons
      // this.session.quickQuestions — render tappable chips (skip when intentName is null,
      //                               or render disabled)
      return this.session;
    }

    async _post(body) {
      if (!this.session) await this._openSession();
      const call = () => fetch(`${ENDPOINT}/messages`, {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          'Authorization': `Bearer ${this.session.sessionToken}`,
        },
        body: JSON.stringify(body),
      });
      let r = await call();
      if (r.status === 401) { this.session = null; await this._openSession(); r = await call(); }
      if (!r.ok) throw new Error(`/messages ${r.status}`);
      const { reply } = await r.json();
      this.pending = reply.status === 'waiting_input'
        ? { waitToken: reply.waitToken, executionId: reply.executionId }
        : null;
      // reply.tokenUsage (or null) — feed an in-conversation usage meter if you show one
      return reply;
    }

    // First message or any free-text turn — pass the chosen intent.
    // `variables` is optional and merged into the conversation.
    sendText(intentName, text, context, variables) {
      return this._post({ intentName, text, context, variables });
    }

    // Tapping a quick-question chip is just a trigger with its intentName:
    askQuickQuestion(q) {
      if (!q.intentName) return Promise.resolve();  // chip is disabled
      return this.sendText(q.intentName, q.question);
    }

    // Form submission while a wait is pending:
    submitValues(values, context, variables) {
      if (!this.pending) throw new Error('No pending form — call sendText() instead');
      return this._post({ ...this.pending, values, context, variables });
    }

    // Poll execution state while the flow is on waiting_time (async work):
    async poll(executionId) {
      const r = await fetch(`${ENDPOINT}/executions/${executionId}`, {
        headers: { 'Authorization': `Bearer ${this.session.sessionToken}` },
      });
      if (!r.ok) throw new Error(`poll ${r.status}`);
      return (await r.json()).reply;
    }

    track(name, props) {
      if (!this.session) return Promise.resolve();
      return fetch(`${ENDPOINT}/events`, {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          'Authorization': `Bearer ${this.session.sessionToken}`,
        },
        body: JSON.stringify({ name, props }),
        keepalive: true,
      });
    }
  }
</script>