Three anonymous, cache-friendly GET endpoints under /api/public/v1 let
the widget — or any front-end code on your pages — read published per-tenant
data before, or entirely without, opening a chat session:
| Method | Path | Returns |
|---|
GET | /api/public/v1/chat/quick-questions | The tenant’s quick-question prompts, filterable to several page types in one request. |
GET | /api/public/v1/config/{section} | The resolved values of every publicly readable field of one configuration section. |
GET | /api/public/v1/forms/{codeOrId}/schema | The render-safe schema of one live, embeddable form — fields, labels, validation hints, submit label. |
All three endpoints share one contract — the same key authentication, the same
origin and rate-limit rules, and the same ETag/X-Edge-Source conventions —
so read Shared behavior once and it applies to each. The
one deliberate difference: quick questions and config are shared-cacheable,
the form schema is privately cacheable only — see
Caching & revalidation.
Shared behavior
Authentication — the publishable key
Each request is authorized by the same publishable widget key (pk_live_…)
you pass to POST /chat/sessions — send it either as the publicKey query
parameter or as an X-Comerix-Public-Key header:
GET /api/public/v1/config/widget?publicKey=pk_live_… HTTP/1.1
GET /api/public/v1/config/widget HTTP/1.1
X-Comerix-Public-Key: pk_live_…
The key selects the tenant: the tenant is taken exclusively from the key’s
binding, never from request input, so one tenant’s key can never address
another tenant’s data. A missing key returns 400 invalid_input; an unknown,
paused, or revoked key returns 403 widget_disabled.
There is no session hereNo session token, no Authorization header, no cookies. That’s what makes
the responses safely cacheable by browsers — and, for quick questions and
config, by shared caches too — the full URL (key included) is the cache
key.
Origins & rate limits
The same edge rules as the rest of /api/public/v1 apply:
- Origin allow-list (CORS) — a browser request from an origin outside the
key’s
allowedOrigins is rejected with 403 origin_not_allowed and no CORS
headers. Manage origins on the Public keys
page.
- Rate limits — two independent sliding-window budgets, shared across the
whole public surface: 60 requests/min per source IP and 600
requests/min per public key (across all IPs). Above either budget the call
returns
429 rate_limited with a Retry-After header — see
Errors → Rate-limit responses.
With client caching in play a normal page should consume a tiny fraction of
those budgets — one revalidation per endpoint per minute at most.
Caching & revalidation
Every 200 response is cacheable and revalidatable, but the scope of the
cache differs per endpoint. Quick questions and config are shared-cacheable:
HTTP/1.1 200 OK
Cache-Control: max-age=60, public, s-maxage=300, stale-while-revalidate=300
ETag: "0a1b2c3d4e5f6071"
X-Edge-Source: store
Content-Type: application/json
The form schema is privately cacheable only:
HTTP/1.1 200 OK
Cache-Control: max-age=60, private
ETag: "0a1b2c3d4e5f6071"
X-Edge-Source: store
Content-Type: application/json
| Header | Meaning |
|---|
Cache-Control | Browsers may reuse the response for 60 s. For quick questions and config, shared caches (CDN, reverse proxy) may keep it for 300 s and serve it stale for another 300 s while revalidating in the background. The form schema says private instead — no s-maxage — so shared caches stay out entirely. |
ETag | A fingerprint of the exact body. Send it back as If-None-Match to revalidate cheaply. |
X-Edge-Source | store — the response came from the published edge document; live — it was computed on the spot. Either way the body shape is identical. |
Why is the form schema private-only?The form schema’s outcome varies per presenting key: the key’s form
scope decides whether the same form answers 200 or 403 form_not_allowed. A shared cache entry warmed by an authorized key could
be served past another key’s scope denial, so shared/CDN caching is
deliberately not allowed for this endpoint. Quick questions and config
have no such per-key allow/deny split, so they stay shared-cacheable.
A conditional request whose ETag still matches returns 304 Not Modified with an empty body — the round-trip costs headers only:
# First fetch — note the ETag:
curl -i 'https://flow.example.com/api/public/v1/chat/quick-questions?publicKey=pk_live_…'
# HTTP/1.1 200 OK
# ETag: "0a1b2c3d4e5f6071"
# …
# Revalidate with If-None-Match — nothing changed, no body transferred:
curl -i -H 'If-None-Match: "0a1b2c3d4e5f6071"' \
'https://flow.example.com/api/public/v1/chat/quick-questions?publicKey=pk_live_…'
# HTTP/1.1 304 Not Modified
Browsers do this automatically for fetch() once the response is in the HTTP
cache — you don’t have to manage the ETag yourself unless you cache in your
own storage.
Where the data comes from
All three endpoints serve published documents: whenever the underlying data
changes — an admin saves configuration, a flow or form is published or
withdrawn — the platform recomputes the affected document and writes it into a
key-value store, so the hot path is a single key lookup. When no published document exists the
response is computed live instead; X-Edge-Source tells you which path served
you, and the body’s publishedAt is the publication time of the served
document (null for live responses).
Which backend holds the documents is an operator concern — see
Operate → Edge document store.
Quick questions
GET /api/public/v1/chat/quick-questions
Returns the tenant’s pre-written one-tap prompts (configured under
Settings → Widget → Quick Questions, up to six) so the host page can render
chips on page load — before any session exists, and cached across visitors.
| Query parameter | Required | Meaning |
|---|
publicKey | yes (or the X-Comerix-Public-Key header) | The publishable widget key. |
pageTypes | no | Comma-separated page types to include — e.g. pageTypes=general,category fetches the rows for two page contexts in one request. Omit it for all rows. The page types a prompt can carry come from the widget configuration: general, category, product, cart; an unknown value simply matches no rows. |
curl 'https://flow.example.com/api/public/v1/chat/quick-questions?publicKey=pk_live_…&pageTypes=general,category'
Response (200)
{
"quickQuestions": [
{ "question": "Where is my order?", "pageType": "general",
"flowId": "0193aa…", "intentName": "order_status" },
{ "question": "How do I return an item?", "pageType": "category",
"flowId": "0193bb…", "intentName": null }
],
"publishedAt": "2026-06-09T08:00:00+00:00"
}
| Field | Meaning |
|---|
question | The text to show on the chip. |
pageType | The page context the prompt is meant for. Filtering beyond your pageTypes request is up to the host page — only it knows which page the visitor is on. |
flowId | The flow the prompt is bound to; empty when unbound. |
intentName | The intent to trigger when the visitor taps the chip — or null when the bound flow is unset, not currently published, or outside this key’s intent allow-list. Render such a prompt disabled rather than dropping it. |
publishedAt | Publication time of the served edge document; null when the response was computed live. |
Tapping a chip with a non-null intentName is a normal trigger turn: open a
session if you don’t have one, then post { text: question, intentName } to
/chat/messages — see Lifecycle → Quick Questions.
The rows are the same ones the session response carries in its
quickQuestions array; this endpoint just makes them available earlier and
cacheable.
Per-key viewintentName is evaluated per presenting key: a prompt bound to an
intent the key may not trigger keeps its row but comes back with
intentName: null. Two keys can therefore see different intentName
values for the same prompt — another reason the key is part of the cache
key (the URL).
Public configuration
GET /api/public/v1/config/{section}
Returns the resolved values of every field of {section} that is declared
publicly readable — for example GET /config/widget serves the widget’s
appearance values (accent color, title, subtitle, welcome text, position) and
its quick-question rows, so an embed can paint itself before loading anything
heavier.
| Parameter | Required | Meaning |
|---|
section (path) | yes | The configuration section id, lowercase ([a-z][a-z0-9_]*) — e.g. widget. |
publicKey (query) | yes (or the X-Comerix-Public-Key header) | The publishable widget key. |
curl 'https://flow.example.com/api/public/v1/config/widget?publicKey=pk_live_…'
Response (200)
{
"section": "widget",
"values": {
"widget/appearance/accent": "#4b7bf5",
"widget/appearance/title": "Chat with us",
"widget/appearance/subtitle": "Your personal AI Assistant",
"widget/appearance/welcome": "Hi! How can we help you today?",
"widget/appearance/position": "right",
"widget/quick_questions/questions": [ { "question": "…", "pageType": "general", "assignedFlow": "…" } ]
},
"publishedAt": null
}
values is keyed by the full field path (<section>/<group>/<field>) and
holds the value resolved for the key’s tenant — workspace overrides included. A
section that doesn’t exist, or exists but has no publicly readable fields,
returns 404 not_found; the two cases are deliberately indistinguishable.
What can ever be publicA field is exposed here only when the module that ships it declares
public_readable: true in its configuration schema — there is no admin
toggle, and the values are shown to anonymous visitors by design. Secret
fields (sensitive or encrypted — SMTP passwords, API keys) can never
be public: the platform rejects a schema that tries to combine
public_readable with a secret field. Everything else stays admin-only
behind comerix.config.<section>.view.
GET /api/public/v1/forms/{codeOrId}/schema
Returns the render-safe schema of one live, embeddable form — its fields,
labels, validation hints, and submit label — so an embed can (re)render the
form without the admin builder. It is the cache-friendly sibling of the plain
GET /api/public/v1/forms/{codeOrId}
read: identical field shapes, but served from the published edge document when
one exists, and it never writes.
Which schema read should I use?
| GET /forms/{codeOrId} (plain) | GET /forms/{codeOrId}/schema (this one) |
|---|
| Usage metering | Counts a “mount” toward the key’s usage each call. | Never records usage — sits on the hottest read path and must not write. |
| Caching | Uncached. | Cache-Control: max-age=60, private + ETag/304. |
locale query parameter / response field | Yes — localises labels/help. | No — the document is locale-less by design. |
publishedAt response field | No. | Yes — null when computed live. |
| Use for | The initial, metered mount of an embed. | Polling / refreshing the schema from embeds and widgets. |
| Parameter | Required | Meaning |
|---|
codeOrId (path) | yes | The form’s public code (a slug like contact_us) or its UUID. An uppercase UUID is accepted and canonicalised internally. |
publicKey (query) | yes (or the X-Comerix-Public-Key header) | The publishable widget key. Its form scope must allow the requested form — the same scope rules as the other public form endpoints. A publicKey body field is not read here. |
curl 'https://flow.example.com/api/public/v1/forms/contact_us/schema?publicKey=pk_live_…'
Response (200)
{
"code": "contact_us",
"title": "Contact us",
"submitLabel": "Send",
"fields": [
{
"name": "email",
"type": "email",
"label": "Email",
"required": true,
"placeholder": null,
"help": null,
"options": null,
"config": null,
"validation": { "format": "email" }
}
],
"publishedAt": "2026-06-10T00:00:00+00:00"
}
| Field | Meaning |
|---|
code | The form’s public code. |
title | The form’s display title. |
submitLabel | The label for the submit button. |
fields | The render-safe field list — exactly the same item shape as the plain schema read, including the per-field validation object (server-only rules are withheld but still enforced on submit). |
publishedAt | Publication time of the served edge document; null when the response was computed live for this request. |
Unlike the plain schema read, the response intentionally does not include
a locale field. Caching is private-only (max-age=60, private) — see
Caching & revalidation for why — and X-Edge-Source: store|live
reports where the document came from, same as the other two endpoints.
To go from schema to data, submit the form with
POST /api/public/v1/forms/{codeOrId}/submissions — the error catalog for the
whole forms surface is under
Errors → Public Forms API errors.
Errors
The error envelope is the standard public-API shape (error, message,
details) — see Errors for the catalog. The codes you can get
here:
| HTTP | error | When |
|---|
| 400 | invalid_input | No publicKey query parameter and no X-Comerix-Public-Key header. |
| 403 | widget_disabled | The key doesn’t exist, is paused, or was revoked. |
| 403 | origin_not_allowed | The browser Origin isn’t in the key’s allow-list. |
| 403 | form_not_allowed | Form schema only: the key’s form scope doesn’t cover the requested form. |
| 404 | not_found | Quick questions: the document can’t be resolved. Config: unknown section, or a section with no public fields. |
| 404 | form_not_found | Form schema only: no live, embeddable form by that code/UUID. |
| 429 | rate_limited | Per-IP or per-key budget exceeded — honor Retry-After. |
Where to go next