The complete journey from “user clicks the widget button” to “flow finishes”.
Three things to get right:
- Trigger vs. resume —
/messages accepts two body shapes; pick the right one each turn.
- Pick the intent on trigger —
intentName from the session’s intents list.
- 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:
| Constraint | Limit |
|---|
| Must be a JSON object (not an array) | — |
| Keys | snake_case, 1–64 chars, [a-z][a-z0-9_]* |
| Number of keys | ≤ 50 |
| Value types | scalar, 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.
| Field | Meaning |
|---|
question | The text to show on the chip. |
pageType | Which 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. |
flowId | The flow the prompt is bound to. |
intentName | The 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:
- Render every block in
reply.blocks in order. See Blocks for
the payload structure per type.
- Save
executionId and waitToken on the widget side — you’ll need both
when the user submits the form.
- 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:| Field | Meaning |
|---|
prompt_tokens | Tokens billed for the prompt. |
completion_tokens | Tokens billed for the completion. |
total_tokens | Sum of the two. |
model | The model id when the turn used exactly one model; null otherwise. |
cost_micros | Estimated cost in micro-USD (1e-6 USD) — sum these across turns without float drift. |
cost_usd | The 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.
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.
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.
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:
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:
status | Render blocks? | Next action |
|---|
completed | yes | None. Next user message starts a new trigger. |
waiting_input | yes | Wait for input → send resume. |
waiting_time | yes (often empty) | Show “the bot is thinking…” / wait. Server resumes itself; widget can poll the same conversation, or expose a refresh button. |
failed | yes (if any) | Show the agent message or fall back to a generic apology. Allow retry as a new trigger. |
aborted | yes (if any) | Show “this session was closed” + offer to start over. |
Error handling on top of /messages
HTTP / error | When | What to do |
|---|
401 invalid_session_token | token expired or tampered | Silent re-open /sessions, retry once |
403 origin_not_allowed / widget_disabled | misconfig server-side | Stop. Surface a config error; don’t loop |
404 intent_not_matched | the bound flow was unpublished | Show “chat unavailable”, report to tenant |
409 invalid_wait_token | user took too long or already submitted | Drop waitToken + executionId, start a fresh trigger with the user’s text |
410 execution_aborted | admin / runtime cancelled the run | Same — start a fresh turn |
422 validation_failed | form values failed schema | Show inline validation errors from details.validation_errors; let the user fix and resubmit |
| 5xx / network | transient | Exponential 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>