Skip to main content

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.

EndpointDescription
GET /api/widget/settingsRead the effective widget configuration.
PUT /api/widget/settingsPartial update (all fields optional).
GET /api/widget/installRead the public widget_key and the embed snippet.

Settings​

FieldTypeNotes
enabledbooleanMaster switch. Defaults off.
namestring | nullHeader display name (≤ 120 chars).
welcome_messagestring | nullGreeting shown on open (≤ 2000 chars).
primary_colorstringHex color, e.g. #2563eb.
localestringOne 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_…&amp;locale=tr&amp;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:

EndpointAuthDescription
POST /api/widget/sessionwidget_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 (header X-Widget-Key or body) and no token. The server creates a fresh visitor contact and an open chat conversation, then returns a session token. Response status 201.
  • Returning visitor — additionally presents the previously issued token via Authorization: Bearer <token> (or X-Widget-Session). The server verifies the token and resumes the same conversation. Response status 200.
  • 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).

EndpointAuthDescription
POST /api/widget/tools/runssession tokenMultipart: 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 tokenThe 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.

EndpointAuthDescription
POST /api/widget/messagessession tokenSend a message in the visitor's conversation.
GET /api/widget/messagessession tokenPage 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:

EndpointAuthDescription
POST /api/widget/broadcasting/authsession tokenSign 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 by widget_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.