Skip to main content
Public HTTP API for integrating a third-party chat widget with a published Flow. You build the UI; we accept the user’s messages, run them through the flow, and stream blocks back.

Where to start

You want to …Go to
Use the admin panel — build flows, manage connections, read history, run analyticsAdmin panel guide
Configure the chat widget — keys, allowed origins, intents, quick questionsWidget settings
Read quick-question prompts or public widget config without a sessionPublic read API
Understand the moving parts (widget key, session, intent, execution, block, wait token, status)Concepts
Walk through the full conversation lifecycle from session-open to completedLifecycle
Know what to render for each block.type returned by the flowBlocks
Look up an error code and decide retry vs. abortErrors
Measure conversions, chat engagement, and which chats lead to a saleConcepts → Goals & telemetry
Build, export, pin, or schedule analytics reports in the adminAnalytics Hub
Drive analytics, flows, and connections from an AI agent over JSON-RPCMCP API
Report server-side conversions or read engine state from your backendEngine API
Receive chat session and execution events on your own endpointWebhooks
Get the precise contract — fields, status codes, JSON shapesAPI reference

The endpoints

Chat — drive a conversation against a published flow:
POST /api/public/v1/chat/sessions               # exchange widget key → session token + intents list
POST /api/public/v1/chat/messages               # trigger an intent OR resume a waiting execution
GET  /api/public/v1/chat/executions/{id}        # poll execution state (used during async waits)
POST /api/public/v1/chat/events                 # client telemetry (widget_open, message_sent, purchase, …)
POST /api/public/v1/chat/events/batch           # up to 50 telemetry events in one request
Forms — render and submit a standalone form, no chat session required:
GET  /api/public/v1/forms/{codeOrId}            # render-safe field schema (title, fields, submitLabel)
GET  /api/public/v1/forms/{codeOrId}/schema     # the same schema, cacheable (ETag/304) and never metered
POST /api/public/v1/forms/{codeOrId}/submissions   # submit { values, … } → submissionId + confirmation
POST /api/public/v1/forms/{codeOrId}/uploads    # multipart file upload for a file field → file ref
A form request is authorized by a public gateway key whose form scope covers the requested form; the tenant is taken from the key. Pass the key as ?publicKey=, an X-Comerix-Public-Key header, or a publicKey body field. Uploads require a ?field= query parameter naming the form’s file field and a multipart file part. See Forms in the admin to build and publish a form. The two schema reads differ in one thing: the plain GET counts a “mount” toward the key’s usage metering and is uncached; GET …/schema never writes and is privately cacheable (max-age=60, private + ETag/If-None-Match304) — use it when an embed polls or refreshes the schema. Full contract: Public read API → Form schema. Published reads — anonymous, cacheable GETs for bootstrapping the widget UI before (or without) a session:
GET  /api/public/v1/chat/quick-questions        # quick-question prompts, filterable by page type(s)
GET  /api/public/v1/config/{section}            # publicly readable config values (e.g. widget appearance)
Both are authorized by the same public key (?publicKey= or X-Comerix-Public-Key), are shared-cacheable (Cache-Control + ETag/If-None-Match304), and report whether they were served from the published edge document via X-Edge-Source. The forms schema read above belongs to the same family — same key, same ETag/X-Edge-Source conventions — but is privately cacheable. Full contract: Public read API. Telemetry events feed the goals & conversions and chat-engagement analytics — see Concepts → Goals, conversions & telemetry. Sales that complete after the chat ends are reported server-side via the Engine API → Conversions endpoint.
Conversation variablesBoth /chat/sessions and /chat/messages accept an optional top-level variables object — a snake_case-keyed map of values you want available to the flow for the rest of the conversation (e.g. cart total, current page, plan tier). It’s bounded (up to 50 keys, 4 KB, nested at most 4 deep) and is surfaced to flow authors and operators, so treat the values as untrusted.
Replies carry token usageEvery chat reply now includes a tokenUsage object when an LLM ran during that turn — prompt_tokens, completion_tokens, total_tokens, model, cost_micros, and a convenience cost_usd. The same figures ride the chat.execution.updated webhook and roll up into the Token spend analytics.

Which flow does the widget talk to?

A widget key permits one or more intents (one intent ⇄ one published flow). Two modes:
  • --intent=<name> (repeatable) on comerix:widget-key:create → explicit allow-list.
  • --all-intents flag → permission to trigger any of the tenant’s currently-published intents.
The two are mutually exclusive — exactly one must be set when issuing a key. An empty list never implicitly means “everything”; the unrestricted mode is opt-in via --all-intents. The widget learns its allowed intents from the session response:
publicKey  →  WidgetKey  →  (allowedIntents ∪ allowsAllIntents) ∩ tenant.publishedIntents

                                                       session.intents[*]
For every user turn that starts a new execution, the widget passes intentName in the request body — the server checks it against this set and returns 403 intent_not_allowed if it’s outside, or 404 intent_not_matched if no published flow exists for it. Resume calls (form submissions) don’t carry intentName — the engine already knows which flow the execution belongs to.

60-second quick start

  1. Tenant admin issues a widget key (one-time):
    # Explicit allow-list — recommended:
    ddev console comerix:widget-key:create \
        --tenant=`<tenantId>` --label=Demo \
        --origin='https://acme.com' \
        --intent=order_status --intent=returns
    
    # Or grant access to every published intent:
    ddev console comerix:widget-key:create \
        --tenant=`<tenantId>` --label=Demo \
        --origin='https://acme.com' --all-intents
    # → pk_live_5940830723da7ddae5699200f465e8d2
    
  2. Widget opens a session (once per visitor):
    curl -X POST https://flow.example.com/api/public/v1/chat/sessions \
      -H 'Origin: https://acme.com' -H 'Content-Type: application/json' \
      -d '{"publicKey":"pk_live_…","customerId":"u-42"}'
    
    Response includes the sessionToken plus the list of intents the widget may trigger (intersected with what’s currently published):
    {
      "sessionToken": "eyJ0aWQi…",
      "conversationId": "0193f8a1-…",
      "expiresAt": "2026-05-15T13:00:00Z",
      "widget": { "label": "Demo widget" },
      "intents": [
        { "name": "order_status", "description": "Look up the status of an order",
          "examples": ["Where is my order #..."], "required_entities": [] },
        { "name": "returns", "description": "Start a return",
          "examples": [], "required_entities": [] }
      ]
    }
    
  3. User types a message — widget picks the intent and calls /messages:
    curl -X POST https://flow.example.com/api/public/v1/chat/messages \
      -H 'Authorization: Bearer `<sessionToken>`' \
      -H 'Origin: https://acme.com' -H 'Content-Type: application/json' \
      -d '{"text":"check my order", "intentName":"order_status"}'
    
  4. Flow asks for input — reply comes back with status: waiting_input, blocks (render them), and a waitToken + executionId. Widget collects the form values and calls /messages again, this time with the wait info:
    curl -X POST https://flow.example.com/api/public/v1/chat/messages \
      -H 'Authorization: Bearer `<sessionToken>`' \
      -H 'Origin: https://acme.com' -H 'Content-Type: application/json' \
      -d '{
        "waitToken": "wt_01935abc",
        "executionId": "01935-…",
        "values": { "order_number": "12345" }
      }'
    
  5. Repeat step 4 until status becomes completed (success), failed, or aborted. The same /messages endpoint handles both trigger and resume.
  6. When a node dispatches async work the reply comes back with status: waiting_time — the engine paused while a queue worker does its job. The widget polls GET /executions/{executionId} every 1–2 s until status transitions back to waiting_input or completed/failed.
Detailed walkthrough with diagrams: Lifecycle.

CORS in one sentence

The widget key carries an allowedOrigins list. A request from any other origin is rejected with 403 origin_not_allowed and no CORS headers, so the browser blocks the body from being read by attacker JS.