Skip to main content

Webchat Widget Integration Guide

This guide walks you through building a webchat widget (or integrating Semaswift webchat into an existing one) end-to-end. It covers the full visitor lifecycle: widget status check, bootstrap, real-time delivery via Centrifugo, sending messages, typing indicators, read receipts, reactions, history, offline messages, reconnection, and graceful error handling.

Audience: frontend engineers writing the embeddable widget JS bundle, or building a chat UI on top of the Semaswift backend. Backend version: Phase 5+ — Centrifugo is the only transport; the legacy /v1/webchat/ws endpoint has been removed.

For the admin-side setup (creating widget configurations, managing visitors and offline messages), see §11 Widget Admin Setup.


1. Architecture at a glance

                  Customer website (acme.com)
┌──────────────────────────┐
│ <script src="..."> │
│ widget bundle │
└───────────┬──────────────┘

┌─────────────────────┼─────────────────────────────────┐
│ │ │
▼ ▼ ▼
POST /api/v1/auth/ POST /api/v1/ wss://api.{env}.semaswift.africa
widget/session webchat/bootstrap /connection/websocket
(auth) (engagements) (Centrifugo)
│ │
▼ ▼
persists messages, subscribe to
loads history, visitor:{vid}
issues Centrifugo for inbound
tokens message.new,
typing, etc.

Two services, three endpoints, one realtime channel. The widget never opens a custom WebSocket to the backend — all real-time delivery is through Centrifugo on wss://api.{env}.semaswift.africa/connection/websocket.


2. Token lifecycle

The widget juggles three tokens. Understanding the lifecycle is critical.

TokenIssued byTTLPurposeRenew via
Widget session JWTauth.WidgetService.InitSession24 hAuthenticates every webchat HTTP call (/v1/webchat/*)auth.WidgetService.RefreshSession
Centrifugo connection tokenengagements (/v1/webchat/bootstrap)1 hAuthenticates the WS handshake to CentrifugoCall bootstrap again
Centrifugo subscription tokenengagements (/v1/webchat/bootstrap)1 hAuthorizes subscribing to visitor:{visitor_id}Call bootstrap again (or Centrifugo sub_refresh_proxy does it automatically — see §7.3)

Visitor identity is a client-generated UUID stored in localStorage and reused across page loads. It is not secret — it identifies the visitor session, not the user.


3. Endpoint reference

All endpoints are origin-checked against the widget's allowed_origins list (configured per widget in webchat_widget_configs.allowed_origins). An empty list means "any origin".

Base URLs

EnvironmentBase URL
Developmenthttps://api.dev.semaswift.africa
Staginghttps://api.staging.semaswift.africa
Productionhttps://api.semaswift.africa

3.0 GET /api/v1/engagements/webchat/status/{widget_key} — GetWidgetStatus

Called by: widget on page load, before InitSession, to decide whether to show the live chat UI or the offline form. Auth: none (fully public — no session token required). Rate limit: generous; safe to call on every page load.

Use the opaque wk_* widget key (not the numeric channel_config_id) to prevent sequential enumeration of widget configurations.

Response (200):

{
"is_online": true,
"status_message": "Usually replies in minutes",
"active_agents": 3,
"prechat_form_required": true,
"estimated_wait_time_seconds": 30,
"config": {
"id": 1,
"primary_color": "#0066FF",
"company_name": "Acme Support",
"welcome_message": "Hi! How can we help?",
"prechat_form_enabled": true,
"prechat_form_fields": [
{ "field_name": "name", "label": "Your Name", "type": "text", "required": true },
{ "field_name": "email", "label": "Email", "type": "email", "required": true }
]
}
}
FieldDescription
is_onlinetrue if within business hours and at least one agent is available
status_messageHuman-readable availability text (maps to expected_response_time or away/offline messages)
active_agentsLive count of agents available for webchat
estimated_wait_time_secondsBased on current queue depth; 0 if no queue
prechat_form_requiredWhether the widget must show the pre-chat form before the visitor can send a message
configFull widget appearance and pre-chat-form config for rendering

Widget decision tree:

GET /api/v1/engagements/webchat/status/{widget_key}
├── is_online == true → render chat launcher, proceed to InitSession + Bootstrap
└── is_online == false → render offline form → POST /api/v1/engagements/webchat/offline-message

3.0.1 POST /api/v1/engagements/webchat/offline-message — CreateOfflineMessage

Called by: widget when GetWidgetStatus.is_online == false. Auth: none (public endpoint).

Request:

{
"widget_key": "wk_3f9a1e2b8c4d7e6f0a1b2c3d4e5f6789",
"visitor_id": "v-7c4a9b2e-3d8f-4a1b-9e2f-5d6c8b3a1f2e",
"message": "Hi, I need help with my order #12345",
"email": "jane@example.com",
"name": "Jane Doe",
"phone": "+254712345678",
"page_url": "https://acme.com/orders"
}
FieldRequiredNotes
widget_keyyesPublic wk_* key from the embed snippet
visitor_idyesClient-generated UUID (same one used for the live chat session)
messageyesThe visitor's message
emailyesMust be a valid email address
name, phone, page_urlnoOptional context

Response (200):

{
"id": 42,
"confirmation_message": "Thanks! We'll get back to you at jane@example.com."
}

Admins retrieve and process offline messages via the admin API.

3.1 POST /api/v1/auth/widget/session — InitSession

Called by: widget on first page load, when no valid session JWT is in storage. Auth: none (public endpoint, rate-limited by IP).

Request:

{
"widget_key": "wk_3f9a1e2b8c4d7e6f0a1b2c3d4e5f6789",
"visitor_id": "v-7c4a9b2e-3d8f-4a1b-9e2f-5d6c8b3a1f2e",
"engagement_id": 0,
"visitor_name": "Jane Doe",
"visitor_email": "jane@example.com",
"metadata": {
"page_url": "https://acme.com/pricing",
"referrer": "https://google.com"
}
}
FieldRequiredNotes
widget_keyyesPublic key, embedded in the widget snippet. Identifies organization + channel.
visitor_idyesClient-generated UUID. Generate once, persist in localStorage, reuse forever.
engagement_idnoSet when resuming a known conversation. Usually omitted; the backend reuses the visitor's open engagement automatically on bootstrap.
visitor_name, visitor_email, metadatanoPre-chat form data; freeform metadata is surfaced to agents.

Response (200):

{
"session_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
"expires_at": "2026-05-25T10:30:00Z",
"organization_id": 42,
"engagement_id": 0,
"scopes": ["messages:write", "attachments:write"],
"upload_constraints": {
"max_file_size": 10485760,
"allowed_types": ["image/png", "image/jpeg", "application/pdf"],
"max_files_per_message": 5
}
}

Errors:

  • 400 INVALID_ARGUMENT — malformed widget_key
  • 404 NOT_FOUND — widget key not registered
  • 412 FAILED_PRECONDITION — widget is disabled
  • 403 ORIGIN_NOT_ALLOWED — request Origin not in the widget's allowlist

3.2 POST /api/v1/auth/widget/session/refresh — RefreshSession

Call ~5 minutes before expires_at if the visitor is still active.

Request:

{ "session_token": "eyJ..." }

Response: same shape as InitSession.

3.3 POST /v1/webchat/bootstrap — Bootstrap

Called by: widget after InitSession succeeds, and again whenever the Centrifugo tokens expire. Auth: Authorization: Bearer {session_token}.

Request body (optional — prechat identity + client context):

{
"identity": {
"name": "Jane Doe",
"email": "jane@example.com",
"phone": "+254712345678"
},
"client": {
"timezone": "Africa/Nairobi",
"language": "en-US",
"page_url": "https://acme.com/pricing",
"page_title": "Pricing — Acme",
"screen": "1920x1080",
"referrer": "https://google.com/"
}
}

When identity is supplied, the backend creates (or enriches) the visitor's linked contact with these fields before computing the engagement, so the agent dashboard never sees an anonymous "Website Visitor" row.

When client is supplied, the timezone, language, current page, screen resolution, and referrer are persisted to the visitor row for the agent dashboard. All fields are optional — omit either block entirely if no data is available. The widget can derive client values from:

FieldBrowser API
timezoneIntl.DateTimeFormat().resolvedOptions().timeZone
languagenavigator.language
page_urlwindow.location.href
page_titledocument.title
screen`${screen.width}x${screen.height}`
referrerdocument.referrer

The backend additionally derives browser, os, device_type, and country/city/region automatically from the User-Agent header and the client IP — no widget code needed for those.

Security-sensitive identifiers (org_id, channel_config_id, visitor_id) always come from the JWT — never the body.

Response (200):

{
"centrifugo_url": "wss://api.dev.semaswift.africa/connection/websocket",
"connection_token": "eyJ...",
"subscription_token": "eyJ...",
"visitor_channel": "visitor:v-7c4a9b2e-...",
"visitor_id": "v-7c4a9b2e-...",
"engagement_id": 1234,
"messages": [
{
"id": 98765,
"content": "Hi! How can I help today?",
"message_type": "TEXT",
"sender_type": "USER",
"sender_user_id": 17,
"direction": "OUTBOUND",
"created_at": "2026-05-24T09:00:00Z",
"is_public": true
}
],
"expires_at": 1748165400
}
FieldUse
centrifugo_urlPass to the Centrifugo client constructor.
connection_tokenPass to Centrifugo client as the connect token.
subscription_tokenPass to Centrifugo when subscribing to visitor_channel.
visitor_channelThe channel name to subscribe to (always visitor:{visitor_id}).
engagement_id0 means no open conversation yet; will be set on first send.
messagesLast 50 messages, oldest first. Render directly.
expires_atUnix seconds; refresh tokens before this.

Errors:

  • 401 UNAUTHORIZED — invalid/expired widget JWT → call InitSession again
  • 403 ORIGIN_NOT_ALLOWEDOrigin not in widget's allowlist
  • 404 WIDGET_NOT_FOUND — widget config deleted
  • 500 — transient, retry with backoff

3.4 POST /v1/webchat/messages — Send a message

Auth: Authorization: Bearer {session_token}. Rate limit: 30 / minute / visitor.

Request:

{
"engagement_id": 1234,
"content": "Do you offer enterprise pricing?",
"message_type": "TEXT",
"client_message_id": "cm-9f3e8a1b-...",
"parent_message_id": null,
"metadata": {}
}
FieldRequiredNotes
engagement_idnoOmit (0) on the first message; backend find-or-creates.
contentyesNon-empty.
message_typenoDefaults to "TEXT". Other values: "REACTION".
client_message_idstrongly recommendedIdempotency key. Generate per logical send. Retries with the same key collapse to one row (partial unique index on (org_id, external_id)).
parent_message_idnoFor threaded replies.
metadatanoFreeform.

Response (201):

{
"message_id": 98770,
"engagement_id": 1234,
"created_at": "2026-05-24T10:15:32Z",
"deduped": false
}

deduped: true means the server recognized this client_message_id and returned the original row — render as success.

Errors:

  • 400 INVALID_BODY / 400 EMPTY_CONTENT
  • 401 UNAUTHORIZED / 403 ENGAGEMENT_FORBIDDEN / 403 ORIGIN_NOT_ALLOWED
  • 429 RATE_LIMITEDRetry-After header set to seconds

3.5 POST /v1/webchat/typing — Typing indicator

{ "engagement_id": 1234, "state": "start" }

state"start" | "stop". Rate limit: 120/min. Response: 204 No Content.

3.6 POST /v1/webchat/seen — Read receipt

{ "engagement_id": 1234, "message_id": 98765 }

Rate limit: 240/min. Response: 204 No Content.

3.7 POST /v1/webchat/reaction — Emoji reaction

{
"engagement_id": 1234,
"parent_message_id": 98765,
"emoji": "👍",
"client_message_id": "cm-react-..."
}

Rate limit: 60/min.

3.8 POST /v1/webchat/identify — Submit prechat data after bootstrap

Called by: widget when the prechat form is submitted after bootstrap (e.g. agent triggers a prechat form mid-conversation, or the visitor opts to share contact details later). Auth: Authorization: Bearer {session_token}.

{
"identity": {
"name": "Jane Doe",
"email": "jane@example.com",
"phone": "+254712345678"
},
"attributes": {
"customer_id": "cus_8K9Lm2pQrSt",
"plan": "enterprise",
"mrr": 500,
"signup_date": "2024-01-15"
},
"client": {
"timezone": "Africa/Nairobi",
"language": "en-US",
"page_url": "https://acme.com/pricing"
}
}

Behaviour:

  • identity (optional) — see bootstrap §3.3. Enrichment-only on already-linked contacts; full create on first identification.
  • attributes (optional) — JSON object merged into the visitor's custom_attributes JSONB column (top-level keys only; nested objects are replaced wholesale). Use this to surface CRM context to the agent ("Plan: Enterprise · MRR: $500"). No schema; any JSON value is accepted.
  • client (optional) — same shape as /bootstrap.client. Use this when the prechat form runs after page load and the widget has fresh page data.

At least one of identity, attributes, or client is required; otherwise returns 400 identity_required. Response 200 OK with visitor_id (and contact_id if linked). Rate limit: 30/min.

Tip: prefer supplying identity in the /bootstrap call if you collect prechat data before the visitor sends their first message. Use /identify only when prechat (or attributes) is submitted later.

3.9 POST /v1/webchat/page-view — Record a page navigation

Called by: widget on initial widget load (after bootstrap) and on every subsequent history.pushState / popstate event. Lets the agent see the visitor's current page and full navigation history without leaving the chat. Auth: Authorization: Bearer {session_token}.

{
"page_url": "https://acme.com/pricing",
"page_title": "Pricing — Acme",
"referrer": "https://google.com/",
"session_id": "ses_3f9a1e2b"
}
FieldRequiredNotes
page_urlyesFull URL of the page the visitor just entered.
page_titlenodocument.title.
referrernodocument.referrer on the new page (i.e. previous in-app or external URL).
session_idnoStable per-tab identifier (widget-generated). If supplied, the previous page-view with the same session_id is marked as exited so duration can be computed.

Behaviour:

  • Persists a webchat_page_views row (used for agent-side history rendering).
  • Updates the visitor row's current_page_url + current_page_title so the agent dashboard's header reflects the latest page in real time.
  • Best-effort: all writes are detached. The widget receives 204 No Content the moment validation passes; persistence failures are logged server-side only.

Response 204 No Content on success. Rate limit: 120/min (2/sec) — generous enough that even an aggressive SPA route flipper won't trip it.

// Widget-side SPA integration
const sessionId = ses_${Math.random().toString(36).slice(2, 10)}

function reportPageView() {
fetch('/v1/webchat/page-view', {
method: 'POST',
headers: { 'Authorization': Bearer ${sessionToken}, 'Content-Type': 'application/json' },
body: JSON.stringify({
page_url: location.href,
page_title: document.title,
referrer: document.referrer,
session_id: sessionId
})
}).catch(() => {}) // never block the SPA on this
}

window.addEventListener('popstate', reportPageView)
// Hook history.pushState for SPAs that don't fire popstate on push.
const _push = history.pushState
history.pushState = function (...args) {
_push.apply(history, args)
reportPageView()
}
reportPageView() // initial

4. Centrifugo subscription

Use the official centrifuge-js client.

4.1 Connect

import { Centrifuge } from 'centrifuge'

const centrifuge = new Centrifuge(boot.centrifugo_url, {
token: boot.connection_token,
// Required so Centrifugo can ask us for a new token on expiry.
getToken: async () => {
const fresh = await callBootstrap() // §3.3
return fresh.connection_token
}
})

centrifuge.on('connecting', ctx => console.debug('connecting', ctx))
centrifuge.on('connected', ctx => console.debug('connected', ctx))
centrifuge.on('disconnected', ctx => console.debug('disconnected', ctx))

centrifuge.connect()

4.2 Subscribe to the visitor channel

const sub = centrifuge.newSubscription(boot.visitor_channel, {
token: boot.subscription_token,
getToken: async () => {
const fresh = await callBootstrap()
return fresh.subscription_token
}
})

sub.on('publication', ({ data }) => handleEvent(data))
sub.on('subscribed', ctx => console.debug('subscribed', ctx))
sub.on('error', ctx => console.warn('sub error', ctx))

sub.subscribe()

4.3 Event envelope

Every publication on visitor:{vid} is a JSON object with a top-level type discriminator:

{
"type": "message.new",
"engagement_id": 1234,
"data": { /* event-specific payload */ }
}
typeWhen fireddata shape
message.newAgent (or another visitor tab) sent a message on this engagement{ message_id, content, content_type, sender: { type, id, name? }, created_at, parent_message_id? }
typing.startedAgent started typing{ engagement_id, sender_type: "USER", sender_id, sender_name? }
typing.stoppedAgent stopped typing{ engagement_id, sender_type: "USER", sender_id }
message.readAgent read a visitor message{ engagement_id, reader_type: "user", reader_id, message_id }
engagement.assignedAn agent was assigned (use to update "we'll be with you" UI){ engagement_id, agent: { id, name } }
engagement.transferredConversation handed to a new agent{ engagement_id, from_agent, to_agent }
engagement.closedConversation closed{ engagement_id, closed_at, reason? }
csat.promptBackend asks the widget to show a satisfaction survey{ engagement_id, survey_id, prompt, scale }

Handler shape:

function handleEvent(envelope) {
switch (envelope.type) {
case 'message.new': appendMessage(envelope.data); break
case 'typing.started': showTyping(envelope.data); break
case 'typing.stopped': hideTyping(envelope.data); break
case 'message.read': markRead(envelope.data); break
case 'engagement.assigned': setAgent(envelope.data.agent); break
case 'engagement.closed': onClose(envelope.data); break
case 'csat.prompt': showCSAT(envelope.data); break
default: console.debug('unhandled', envelope)
}
}

Important: never use the WebSocket as the source of truth for history. Centrifugo does not replay missed publications. On every reconnect/load, call bootstrap again — the response carries the last 50 messages, which you reconcile against your local state by message_id.


5. Bootstrapping flow (sequence)

Widget loads

├── GET /api/v1/engagements/webchat/status/{widget_key} (GetWidgetStatus — no auth)
│ ├─ is_online == false → show offline form
│ │ └── POST /api/v1/engagements/webchat/offline-message on submit
│ └─ is_online == true → continue

├── localStorage.getItem('visitor_id') ─── if missing, generate UUIDv4 and save

├── if session_token expired or missing
│ POST /api/v1/auth/widget/session (InitSession)
│ ├─ persist session_token + expires_at in localStorage

├── [optional] show pre-chat form if GetWidgetStatus.prechat_form_required == true
│ └── supply identity to Bootstrap body on submit

├── POST /v1/webchat/bootstrap (Bootstrap)
│ ├─ render messages[]
│ ├─ remember engagement_id (may be 0)
│ ├─ new Centrifuge(centrifugo_url, { token: connection_token })
│ └─ sub = centrifuge.newSubscription(visitor_channel, { token: subscription_token })

├── centrifuge.connect()
├── sub.subscribe()

└── widget ready

6. Sending and rendering messages

The recommended UX is optimistic UI with reconciliation:

async function sendMessage(text) {
const clientId = `cm-${crypto.randomUUID()}`
const localMsg = {
client_message_id: clientId,
content: text,
sender_type: 'CONTACT',
direction: 'INBOUND',
created_at: new Date().toISOString(),
pending: true
}
appendMessage(localMsg) // render immediately

try {
const res = await fetch(`${BASE}/v1/webchat/messages`, {
method: 'POST',
credentials: 'omit',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${sessionToken}`
},
body: JSON.stringify({
engagement_id: currentEngagementId || 0,
content: text,
client_message_id: clientId
})
})
if (res.status === 429) {
const retry = parseInt(res.headers.get('Retry-After') || '5', 10)
return retryLater(localMsg, retry)
}
if (!res.ok) throw new Error(`send failed: ${res.status}`)

const { message_id, engagement_id } = await res.json()
currentEngagementId = engagement_id
updateMessage(clientId, { id: message_id, pending: false })
} catch (err) {
updateMessage(clientId, { failed: true, pending: false, error: err.message })
}
}

When the matching message.new arrives over Centrifugo, reconcile by message_id (skip duplicates):

function appendMessageFromEvent(evt) {
if (messages.some(m => m.id === evt.message_id)) return // already rendered
appendMessage(toLocal(evt))
}

7. Reconnection, refresh, and resilience

7.1 Token expiry — connection

centrifuge-js's getToken callback handles this automatically. Re-call bootstrap and return the new connection_token. Until that happens, the SDK keeps the existing connection.

7.2 Token expiry — subscription

Centrifugo's sub_refresh_proxy hits our backend automatically; the widget just supplies a getToken callback that re-calls bootstrap and returns subscription_token. No widget code is needed beyond providing the callback.

7.3 Disconnect & reconnect (network blip)

centrifuge-js reconnects with exponential backoff automatically. On connected, treat it as a soft reload:

centrifuge.on('connected', async () => {
// Re-bootstrap to backfill any messages missed during downtime.
const boot = await callBootstrap()
reconcileHistory(boot.messages)
})

7.4 Disconnect grace window (server side, you don't need to do anything)

The backend debounces visitor.disconnected agent-side events for 2 minutes. A visitor closing one tab and opening another within that window does not generate a "visitor left" event, so agents don't see noise. This is invisible to the widget.

7.5 Page reload

Identical to first load:

const visitorId = localStorage.getItem('visitor_id') || crypto.randomUUID()
localStorage.setItem('visitor_id', visitorId)

const sessionToken = await ensureSessionToken(visitorId) // refresh or init
const boot = await fetch(`${BASE}/v1/webchat/bootstrap`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${sessionToken}` }
}).then(r => r.json())

renderHistory(boot.messages)
currentEngagementId = boot.engagement_id
startCentrifuge(boot)

The same visitor_id resolves to the same contact_id → the same open engagement → seamless continuation.

7.6 Session expired (24 h)

On any HTTP 401 response from /v1/webchat/*, refresh the session token via RefreshSession, then retry once. If RefreshSession itself returns 401, the visitor's session is gone — call InitSession again (same visitor_id).

async function fetchWithRefresh(url, opts) {
let res = await fetch(url, opts)
if (res.status === 401) {
sessionToken = await refreshOrInit(visitorId)
opts.headers.Authorization = `Bearer ${sessionToken}`
res = await fetch(url, opts)
}
return res
}

7.7 Rate limiting

Respect 429 with Retry-After. The frontend should also self-throttle typing (debounce start/stop to at most one per second).


8. Origin policy

Each widget config has an allowed_origins TEXT[] column.

AllowlistSemantics
NULL or []Allow any origin (agency mode — for end-customer domains you don't know in advance)
["*"]Allow any origin
["https://acme.com"]Exact match only
["*.acme.com"]Subdomain wildcard. shop.acme.com ✅; apex acme.com ❌; evilacme.com
["acme.com"]Scheme-less host — matches http://acme.com, https://acme.com, acme.com:443 etc.
["localhost:5173"]Useful for local widget development

The check runs at:

  1. InitSession (auth-service) — first line of defence
  2. Every /v1/webchat/* call (engagements) — second line

A widget served from an origin not in the list will see 403 ORIGIN_NOT_ALLOWED and must fail closed.


9. File uploads

The widget uploads files to POST /api/v1/webchat/uploads (engagements service) using the widget session JWT.

  • Request: multipart/form-data with a single file part.
  • Auth: Authorization: Bearer <widget_session_jwt>. The token must carry the upload scope, which is granted only when the widget config has uploads enabled.
  • The server enforces, in order: origin allowlist, upload scope, per-visitor rate limit, multipart body cap, filename sanitization, MIME sniff against the allowlist (magic bytes — the client-declared Content-Type is ignored), and extension/MIME consistency check.
  • Upload constraints (max file size, allowed types, max files per message) are returned in InitSession.upload_constraints and should also be enforced client-side for UX.
  • Response: 201 Created with { "file_id": "...", "filename": "...", "size_bytes": N, "content_type": "..." }. Include file_id in the file_ids[] field of the subsequent POST /api/v1/webchat/messages call.
  • Errors: 400 (invalid multipart, missing file, bad filename), 401/403 (auth, scope, origin), 413 (body too large), 415 (unsupported MIME), 429 (rate-limited), 503 (uploads service unavailable).

Deprecated path: the older uploads.v1.WidgetUploadService presign flow (/api/v1/uploads/files/widget/upload-url + /upload-complete) is deprecated. It cannot validate uploaded bytes server-side because they travel directly to object storage. New widget SDK builds must use /api/v1/webchat/uploads. The presign RPCs will return UNIMPLEMENTED in 2026-Q3 and be removed in 2026-Q4. GetDownloadURL is unaffected.


10. CSAT (post-chat satisfaction rating)

When a conversation is closed, the backend publishes a csat.prompt event on the visitor's Centrifugo channel (see §4.3). The widget should intercept it and show a rating UI.

Once the visitor submits, call the rating endpoint using the widget session JWT:

POST /api/v1/engagements/webchat/ratings
Authorization: Bearer {session_token}
Content-Type: application/json

Request:

{
"engagement_id": 1234,
"rating": 5,
"comment": "Alex was incredibly helpful!"
}
FieldRequiredNotes
engagement_idyesFrom the csat.prompt event or from bootstrap.engagement_id
ratingyesInteger 1 (poor) to 5 (excellent)
commentnoOptional free-text feedback

Response (200):

{
"id": 1,
"engagement_id": 1234,
"rating": 5,
"comment": "Alex was incredibly helpful!",
"rated_by_type": "visitor",
"rated_by_id": 0
}

The endpoint is idempotent-guarded — submitting a second rating for the same engagement returns 409 ALREADY_EXISTS.


11. Widget Admin Setup

These operations are performed by platform admins and agents (user JWT required, not the widget session token). They configure what the widget looks like before the embed snippet is generated.

11.1 Create widget configuration

POST /api/v1/engagements/webchat/widget-configs
Authorization: Bearer {user_jwt}
Content-Type: application/json

Request:

{
"channel_config_id": 7,
"primary_color": "#0066FF",
"secondary_color": "#FFFFFF",
"background_color": "#FFFFFF",
"text_color": "#333333",
"widget_position": "bottom-right",
"widget_offset_x": 20,
"widget_offset_y": 20,
"launcher_icon_type": "chat",
"company_name": "Acme Support",
"company_logo_url": "https://acme.com/logo.png",
"welcome_message": "Hi! How can we help?",
"input_placeholder": "Type a message...",
"offline_message": "We're offline. Leave a message and we'll be in touch.",
"away_message": "All agents are busy. We'll be with you shortly.",
"expected_response_time": "Usually replies in minutes",
"prechat_form_enabled": true,
"prechat_form_title": "Before we start",
"prechat_form_subtitle": "Help us direct you to the right team.",
"prechat_form_fields": [
{
"field_name": "name",
"label": "Your Name",
"type": "text",
"required": true,
"placeholder": "Jane Doe"
},
{
"field_name": "email",
"label": "Email Address",
"type": "email",
"required": true,
"placeholder": "jane@example.com"
},
{
"field_name": "department",
"label": "Department",
"type": "select",
"required": false,
"options": ["Sales", "Support", "Billing"]
}
],
"auto_open_delay": 0,
"show_agent_photos": true,
"show_agent_names": true,
"show_typing_indicators": true,
"sound_enabled": true,
"show_powered_by": true,
"allowed_origins": ["https://acme.com", "*.acme.com"]
}

Widget config fields:

GroupFieldDefaultNotes
Appearanceprimary_color#0066FFHex colour
secondary_color#FFFFFFHex colour
background_color#FFFFFFHex colour
text_color#333333Hex colour
widget_positionbottom-rightbottom-right or bottom-left
widget_offset_x20Pixels from edge
widget_offset_y20Pixels from edge
launcher_icon_typechatchat or custom
custom_launcher_icon_urlURL; only meaningful when launcher_icon_type=custom
Brandingcompany_nameWidget header text
company_logo_urlHeader logo
welcome_messageShown when widget opens
input_placeholderChat input placeholder text
offline_messageShown outside business hours
away_messageShown when online but agents busy
expected_response_timeGetWidgetStatus.status_message source
Pre-chat formprechat_form_enabledfalseShow form before chat
prechat_form_titleForm heading
prechat_form_subtitleForm sub-heading / description
prechat_form_fieldsSee field types below
Behaviourauto_open_delay0Seconds before auto-opening; 0 = disabled
show_agent_photostrueShow agent avatar in chat
show_agent_namestrueShow agent name in chat
show_typing_indicatorstrueAnimate typing state
sound_enabledtrueNotification sounds
show_powered_bytrue"Powered by Semaswift" badge
Securityallowed_origins[] (any)CORS origin allowlist

Pre-chat form field types (prechat_form_fields[].type):

TypeDescription
textSingle-line text input
emailEmail address (validated format)
phonePhone number
textareaMulti-line text input
selectDropdown; options array required

Response (200) returns the created WebchatWidgetConfig including the auto-generated widget_key (wk_*). Embed this key in the snippet.

11.2 Other widget config endpoints

MethodPathDescription
GET/api/v1/engagements/webchat/widget-configs/{channel_config_id}Get config by channel config ID
GET/api/v1/engagements/webchat/widget-configsList all widget configs (optional ?channel_config_id= filter)
PATCH/api/v1/engagements/webchat/widget-configs/{id}Partial update (all fields optional)
DELETE/api/v1/engagements/webchat/widget-configs/{id}Delete config
POST/api/v1/engagements/webchat/widget-configs/{id}/regenerate-keyRotate the widget key — invalidates all existing snippets immediately

11.3 Generate embed snippet

GET /api/v1/engagements/webchat/snippet/{channel_config_id}
Authorization: Bearer {user_jwt}

Response:

{
"snippet": "<script>...</script>",
"snippet_url": "https://cdn.semaswift.africa/widget/v1/widget.js"
}

The widget_js_url field on the config controls which CDN path is embedded. Override it in the database to pin a specific bundle version or switch CDN without a code change or service restart.

11.4 Visitor management

MethodPathDescription
GET/api/v1/engagements/webchat/visitors/{id}Get visitor by internal ID
GET/api/v1/engagements/webchat/visitors/by-visitor-id/{channel_config_id}/{visitor_id}Get visitor by client UUID
GET/api/v1/engagements/webchat/visitorsList visitors (?channel_config_id=, ?last_seen_after=)
POST/api/v1/engagements/webchat/visitors/{visitor_id}/link-contactLink visitor to a CRM contact

The visitor record is enriched automatically from the User-Agent header (browser, OS, device type) and IP geolocation (country, city). Identity fields (email, name, phone) are populated when the visitor submits the pre-chat form or calls /identify.

11.5 Offline message management

MethodPathDescription
GET/api/v1/engagements/webchat/offline-messagesList offline messages (?status=pending|processed|spam)
POST/api/v1/engagements/webchat/offline-messages/{id}/processProcess: {"action": "process"} creates an engagement; {"action": "spam"} marks as spam

11.6 CSAT admin

MethodPathDescription
GET/api/v1/engagements/webchat/ratings/{engagement_id}Get rating for a specific engagement
GET/api/v1/engagements/webchat/ratingsList ratings (?min_rating=4&start_date=...&end_date=...)

12. Embed snippet (customer-facing)

The HTML you ship to customers. Customer pastes this on their site:

<script>
(function () {
window.SemaswiftWidget = {
key: 'wk_3f9a1e2b8c4d7e6f0a1b2c3d4e5f6789',
// Optional overrides
// user: { name: 'Jane Doe', email: 'jane@example.com' },
// metadata: { plan: 'enterprise' }
}
var s = document.createElement('script')
s.src = 'https://cdn.semaswift.africa/widget/v1/widget.js'
s.async = true
document.head.appendChild(s)
})()
</script>

The bundle reads window.SemaswiftWidget.key and runs the bootstrap flow described above.


13. Complete minimal example

A working ~100-line widget core:

import { Centrifuge } from 'centrifuge'

const BASE = 'https://api.dev.semaswift.africa'
const cfg = window.SemaswiftWidget

let state = {
visitorId: localStorage.getItem('semaswift.visitor_id'),
sessionToken: localStorage.getItem('semaswift.session_token'),
sessionExpiresAt: parseInt(localStorage.getItem('semaswift.session_expires_at') || '0', 10),
engagementId: 0,
messages: [],
centrifuge: null,
sub: null
}

async function init() {
if (!state.visitorId) {
state.visitorId = crypto.randomUUID()
localStorage.setItem('semaswift.visitor_id', state.visitorId)
}

await ensureSession()
const boot = await bootstrap()
state.engagementId = boot.engagement_id
state.messages = boot.messages
render()
startRealtime(boot)
}

async function ensureSession() {
const now = Date.now() / 1000
if (state.sessionToken && state.sessionExpiresAt > now + 300) return

if (state.sessionToken) {
try {
const r = await fetch(`${BASE}/api/v1/auth/widget/session/refresh`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ session_token: state.sessionToken })
})
if (r.ok) {
const j = await r.json()
persistSession(j)
return
}
} catch {}
}

const r = await fetch(`${BASE}/api/v1/auth/widget/session`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ widget_key: cfg.key, visitor_id: state.visitorId })
})
if (!r.ok) throw new Error(`init failed: ${r.status}`)
persistSession(await r.json())
}

function persistSession(j) {
state.sessionToken = j.session_token
state.sessionExpiresAt = Math.floor(new Date(j.expires_at).getTime() / 1000)
localStorage.setItem('semaswift.session_token', state.sessionToken)
localStorage.setItem('semaswift.session_expires_at', String(state.sessionExpiresAt))
}

async function bootstrap() {
const r = await fetch(`${BASE}/v1/webchat/bootstrap`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${state.sessionToken}` }
})
if (!r.ok) throw new Error(`bootstrap failed: ${r.status}`)
return r.json()
}

function startRealtime(boot) {
state.centrifuge = new Centrifuge(boot.centrifugo_url, {
token: boot.connection_token,
getToken: async () => (await bootstrap()).connection_token
})
state.sub = state.centrifuge.newSubscription(boot.visitor_channel, {
token: boot.subscription_token,
getToken: async () => (await bootstrap()).subscription_token
})
state.sub.on('publication', ({ data }) => onEvent(data))
state.centrifuge.on('connected', async () => {
// Backfill any missed history.
const fresh = await bootstrap()
reconcile(fresh.messages)
})
state.centrifuge.connect()
state.sub.subscribe()
}

function onEvent(env) {
switch (env.type) {
case 'message.new':
if (!state.messages.some(m => m.id === env.data.message_id)) {
state.messages.push(env.data)
render()
}
break
case 'typing.started': showTyping(env.data); break
case 'typing.stopped': hideTyping(env.data); break
case 'engagement.assigned': setAgent(env.data.agent); break
case 'engagement.closed': onClosed(env.data); break
}
}

async function send(text) {
const clientId = `cm-${crypto.randomUUID()}`
const optimistic = { client_message_id: clientId, content: text, pending: true }
state.messages.push(optimistic); render()

const r = await fetch(`${BASE}/v1/webchat/messages`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${state.sessionToken}`
},
body: JSON.stringify({
engagement_id: state.engagementId,
content: text,
client_message_id: clientId
})
})
if (r.status === 429) {
optimistic.failed = true; optimistic.retryAfter = +r.headers.get('Retry-After') || 5
render(); return
}
if (!r.ok) { optimistic.failed = true; render(); return }
const j = await r.json()
state.engagementId = j.engagement_id
Object.assign(optimistic, { id: j.message_id, pending: false })
render()
}

init().catch(err => console.error('widget init failed', err))

14. Testing checklist

Before shipping, verify each of these:

  • GetWidgetStatus returns is_online: false — offline form shown, no InitSession called
  • GetWidgetStatus returns is_online: true — widget proceeds to InitSession → bootstrap → connect → render
  • Offline message submitted while team is offline — confirmation displayed
  • First load (no localStorage) — InitSession → bootstrap → connect → render
  • Page reload — reuses visitor_id, reuses session_token if valid, gets same engagement_id, history rendered
  • Send message — appears optimistically, replaced by server message_id, no duplicate when message.new arrives
  • Same client_message_id sent twice — second response has deduped: true, only one row in agent's view
  • Agent reply appears in widget without action
  • Typing indicator from agent shows in widget; widget's typing → agent sees it
  • engagement.assigned event sets the agent display
  • engagement.closed disables the composer
  • Close laptop for 30 s, reopen — Centrifugo reconnects, missed messages backfill via bootstrap
  • Wait 1 h+ without sending — getToken callback transparently fetches new tokens
  • engagement.closed received → CSAT prompt shown; submit rating → 200
  • Duplicate CSAT submission for same engagement → 409 ALREADY_EXISTS
  • Embed on evil.com when widget is restricted to acme.com — InitSession returns 403
  • Send 31 messages in 60 s — 31st returns 429 with Retry-After
  • Server returns 401 mid-session — frontend refreshes and retries transparently

15. Common pitfalls

SymptomLikely causeFix
Widget connects but no events arriveSubscribed to wrong channel; check visitor_channel from bootstrapAlways use boot.visitor_channel verbatim, don't construct it client-side
Duplicate messages in UIOptimistic insert not reconciling with message.newDedupe by message_id before append
"Sometimes messages are lost"Treating Centrifugo as historyOn every reconnect, call bootstrap and reconcile
403 ORIGIN_NOT_ALLOWED only on some pagesDifferent protocol/subdomain on those pagesAdd the right variants to the widget's allowed_origins, or relax to *.customer.com
Centrifugo unauthorized after 1 hgetToken callback not wiredProvide getToken on both the client and the subscription
Widget bundle works on dev but not prodCached old bundle from CDNCache-bust on widget release; customers don't reload pages often
First send creates two engagementsConcurrent sends before first response, both without engagement_idSerialize the first send; subsequent sends include engagement_id