Chat widget
The public chat widget lets anonymous website visitors start a conversation with a workspace. This page covers the admin-managed configuration and the install snippet. The visitor-facing session and messaging endpoints are documented separately.
The widget key
Every tenant has a widget_key — a high-entropy, opaque public
identifier embedded in the widget snippet on the customer's website (analogous to
Intercom's app_id). It is generated by the server at onboarding and is not a
secret:
- It only resolves the tenant for the public widget surface; it grants no privilege on its own.
- It is never writable from client input (server-generated only).
- Visitor authorization is enforced separately by a signed session token — the widget key alone cannot read or write any conversation.
The raw tenant id is never accepted from the client; the widget surface
resolves the tenant exclusively from the widget_key (sent as the
X-Widget-Key header or a widget_key parameter). A missing or unknown key is
rejected (404, fail-closed).
Admin configuration
Admin-only (the manage-widget-settings ability). Tenant isolation is enforced
by Row-Level Security on the underlying tenant_settings row.
| Endpoint | Description |
|---|---|
GET /api/widget/settings | Read the effective widget configuration. |
PUT /api/widget/settings | Partial update (all fields optional). |
GET /api/widget/install | Read the public widget_key and the embed snippet. |
Settings
| Field | Type | Notes |
|---|---|---|
enabled | boolean | Master switch. Defaults off. |
name | string | null | Header display name (≤ 120 chars). |
welcome_message | string | null | Greeting shown on open (≤ 2000 chars). |
primary_color | string | Hex color, e.g. #2563eb. |
locale | string | One of the supported locales. |
Every value is public (served to anonymous visitors); the validation rules are about UI safety (length caps, a strict hex color, the supported-locale allowlist), not secrecy.
Install snippet
GET /api/widget/install returns:
{
"data": {
"widget_key": "…",
"script_url": "https://app.example.com/widget.js",
"snippet": "<script>…</script>"
}
}
Paste the snippet into the target website. It loads the widget loader script
and passes the public widget_key so the widget can resolve the workspace.
Loader settings: attributes or query string
The loader reads its key from, in order: window.SmaibotWidgetKey, the
data-widget-key attribute on its script tag, then ?key= on the script URL.
The optional overrides locale, name, welcome-message and manual are
likewise read from data-<name> first and ?<name>= second, so these two tags
are equivalent:
<script async src="https://app.example.com/widget.js"
data-widget-key="wk_…" data-locale="tr" data-manual="true"></script>
<script async src="https://app.example.com/widget.js?key=wk_…&locale=tr&manual=true"></script>
The query-string form exists for tag managers: Google Tag Manager's Custom HTML
tag re-creates script elements and drops every data-* attribute, but keeps
src. Query values are URL-encoded (a localized name / welcome-message is
the URL-encoded JSON {default, translations} object). data-sso-token,
data-ephemeral and data-api-base-url are attribute-only — a token in a URL
would end up in CDN and proxy logs. With no key anywhere the loader does
nothing.
Hosting the widget assets (CORS)
The loader (widget.js) is a classic script and loads cross-origin without any
special configuration. It then imports the app bundle (widget-app.js) as an
ES module, and browsers fetch module scripts in CORS mode. So when the widget
is embedded on a customer site (a different origin from where the assets are
served), the host serving the bundle — your CDN or app origin — must send
Access-Control-Allow-Origin: * on widget.js, widget-app.js and the
hashed chunks under assets/. Without it the module import is blocked and the
widget never mounts.
This is independent of the widget API CORS (below), which is handled by the
application itself. By default the loader derives the API origin from its own
script URL; pass data-api-base-url in the snippet to point the API at a
different host than the assets.
Visitor session (public surface)
Anonymous visitors authenticate with a signed session token rather than staff credentials. The session is established by:
| Endpoint | Auth | Description |
|---|---|---|
POST /api/widget/session | widget_key (+ optional token) | Start a new visitor session or resume one. |
There is no cookie or server-side session row — the token is a self-contained, stateless credential.
Starting and resuming
- New visitor — sends the
widget_key(headerX-Widget-Keyor body) and no token. The server creates a fresh visitor contact and an open chat conversation, then returns a session token. Response status201. - Returning visitor — additionally presents the previously issued token via
Authorization: Bearer <token>(orX-Widget-Session). The server verifies the token and resumes the same conversation. Response status200. - An invalid, tampered, expired, or foreign-tenant token is treated as no token — the visitor transparently starts a new session (never an error, never a resume of someone else's thread).
The request body is optional and only carries low-trust display fields:
{ "visitor": { "name": "Ada Lovelace", "email": "ada@example.com" } }
No tenant / contact / conversation id is ever accepted from the client. The
widget must be enabled for the tenant or the endpoint returns 403.
The session token
The token is encrypted + authenticated (Laravel Crypt) and scopes the visitor
to exactly one {tenant, contact, conversation}:
- Tampering or forgery fails the integrity check and is rejected.
- It carries an issued-at timestamp and expires after
config('widget.session_ttl_days')(default 30 days). - It grants no access beyond its single conversation; cross-tenant or cross-conversation use is structurally impossible.
- Treat the token as a secret credential on the client.
Response
{
"data": {
"token": "…",
"is_new": true,
"config": { "name": "…", "welcome_message": "…", "primary_color": "#2563eb", "locale": "en", "agents_online": true },
"conversation": { "id": "…", "status": "open", "created_at": "…" },
"messages": [
{ "id": "…", "author": "visitor", "sender_type": "contact", "body": "…", "created_at": "…" }
]
}
}
The visitor payload is deliberately narrow: it never includes staff
identity, AI metadata (meta), contact PII / custom attributes, or any other
tenant's data. Each message is normalized to author: "visitor" | "staff"; a
staff/bot sender_id is never leaked (only the visitor's own contact id is
echoed for their own messages).
config.agents_online is a bare boolean — true when at least one staff member
of the tenant is currently set to Online presence
(AgentAvailability::anyOnline()). It lets the widget show an "online" hint
without ever revealing which agent (or how many) is available.
config.ai_available is true only when the AI assistant would actually answer
a widget chat: the widget's AI toggle is on and AiManager::isEnabled()
(the AI feature in the plan, the tenant's AI switch, a usable key). When it is
false the widget shows no "AI assistant" label and no "talk to an agent"
button, and its header shows the team's availability from agents_online.
config.commerce.enabled is true only when the plan carries the catalogue and
sales, the workspace switched the widget shop on (widget setting
shop_enabled, default false, "Show the shop in the widget" in
Settings → Widget → General), and at least one item is orderable through
the widget (a product, active, priced, with a currency, not hidden from the
widget; services and packages are never sold here). The same definition backs
GET /api/widget/catalogue, which answers 404 (as does POST /api/widget/orders) when the plan lacks the catalogue or sales or
shop_enabled is off. GET /api/widget/orders (the visitor's own orders) is
not gated by the switch: turning the shop off hides the entry and deletes
nothing. POST /api/widget/orders refuses with 409 unknown_item:<key> any line naming a
catalogue row the counter does not offer.
AI tools
A workspace can offer AI tool profiles inside the widget (doc 97 §13). The
session config lists them in ai_tools — only profiles the workspace turned on
for the widget, for the page's host (Origin), in the visitor's language: the
label, intro, icon, ready formats (key + label, never a prompt), per_day,
max_upload_mb and the consent text with its version (sha256 of the text).
| Endpoint | Auth | Description |
|---|---|---|
POST /api/widget/tools/runs | session token | Multipart: module, profile, photo, consent_version, optional format, locale. 201 with the run (state pending, or refused with the profile's message). |
GET /api/widget/tools/runs/{run} | session token | The visitor's OWN run; once delivered, result.url is a short-lived signed link. |
The run belongs to the contact in the token. A start without the current
consent version is 409 consent_required and stores nothing; a profile not
offered (or not on this host) is 404 surface_off. Starting is throttled per
session and per IP per hour and per widget_key per minute
(tool_modules.direct.widget_rate_limits), on top of the profile's
per-person daily limit. Nothing is posted into the conversation unless the
profile has widget.post_to_conversation on (the entry then says
posts_to_conversation: true and its consent text says so): the finished
result is then also sent as a bot message with the image in the visitor's
conversation, which the widget renders like any other attachment.
Messaging
Once a session is established, the visitor sends and reads messages with the
session token in the Authorization: Bearer <token> header (or
X-Widget-Session), alongside the widget_key.
| Endpoint | Auth | Description |
|---|---|---|
POST /api/widget/messages | session token | Send a message in the visitor's conversation. |
GET /api/widget/messages | session token | Page the visitor's conversation history. |
The conversation is always taken from the session token, never from the
request — a visitor can only ever act on their own thread. Each sent message
is authored server-side as sender_type: contact on the chat channel;
sender_type, sender_id, channel and the conversation id are ignored if a
client tries to send them, so a visitor cannot spoof a staff/bot message or
target another conversation.
Because a visitor message is a contact message, it flows through the same inbound pipeline as any channel — including the AI auto-reply when the workspace has AI enabled.
Block restrictions
A visitor message must carry non-empty body text and/or a blocks array.
Visitors may only send a restricted set of block types — text, image and
file. Staff-only interactive blocks (buttons, card, slider,
translation) are rejected with 422. Body length and block count are capped
(config('widget.message_max_chars') / message_max_blocks).
{ "body": "Hello, I need help" }
Realtime
The widget receives live messages over the same private conversation channel the
assigned agent watches — private-tenant.{tenantId}.conversation.{conversationId}
— so the visitor and agent see one shared thread.
Because a visitor has no staff credentials, the Pusher/Echo client points its
authEndpoint at a dedicated, public broadcasting-auth endpoint:
| Endpoint | Auth | Description |
|---|---|---|
POST /api/widget/broadcasting/auth | session token | Sign the visitor's own conversation channel. |
This endpoint authorizes only the visitor's own conversation channel — the
one named in their token. Any other channel (another conversation, the agents
presence channel presence-tenant.{t}.agents, a user's personal channel, a
different tenant) is denied (403). The conversation id comes solely from the
token, so cross-conversation / cross-tenant subscription is structurally
impossible.
CORS
The widget embeds on any customer website, so the widget endpoints
(api/widget/*) use a dedicated, credential-less CORS policy:
Access-Control-Allow-Origin: * and no Access-Control-Allow-Credentials.
That is safe precisely because the widget is cookie-less — the session token
travels in a request header, so there is no CSRF surface and the wildcard origin
cannot be combined with credentials.
This is separate from the staff/SPA CORS (config/cors.php), which keeps its
strict explicit-origin allowlist with credentials. The two never overlap: the
global CORS handler is skipped for widget paths, which are served by the
WidgetCors middleware instead.
Rate limiting
The public widget surface is rate-limited in layers (per-minute, configurable in
config('widget.rate_limits')):
POST /session— keyed bywidget_key+ client IP (session-creation spam).- Messaging + broadcast auth — two stacked ceilings: per visitor session
(so one chatty visitor cannot flood) and a coarser per
widget_key(noisy-neighbour: one workspace cannot exhaust the surface for others).
Exceeding a limit returns 429 with Retry-After and X-RateLimit-* headers.
See the OpenAPI spec (apps/api/openapi/openapi.yaml, tag Widget) for the
authoritative request/response shapes.