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/wsendpoint 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.
| Token | Issued by | TTL | Purpose | Renew via |
|---|---|---|---|---|
| Widget session JWT | auth.WidgetService.InitSession | 24 h | Authenticates every webchat HTTP call (/v1/webchat/*) | auth.WidgetService.RefreshSession |
| Centrifugo connection token | engagements (/v1/webchat/bootstrap) | 1 h | Authenticates the WS handshake to Centrifugo | Call bootstrap again |
| Centrifugo subscription token | engagements (/v1/webchat/bootstrap) | 1 h | Authorizes 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
| Environment | Base URL |
|---|---|
| Development | https://api.dev.semaswift.africa |
| Staging | https://api.staging.semaswift.africa |
| Production | https://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 }
]
}
}
| Field | Description |
|---|---|
is_online | true if within business hours and at least one agent is available |
status_message | Human-readable availability text (maps to expected_response_time or away/offline messages) |
active_agents | Live count of agents available for webchat |
estimated_wait_time_seconds | Based on current queue depth; 0 if no queue |
prechat_form_required | Whether the widget must show the pre-chat form before the visitor can send a message |
config | Full 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"
}
| Field | Required | Notes |
|---|---|---|
widget_key | yes | Public wk_* key from the embed snippet |
visitor_id | yes | Client-generated UUID (same one used for the live chat session) |
message | yes | The visitor's message |
email | yes | Must be a valid email address |
name, phone, page_url | no | Optional 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"
}
}
| Field | Required | Notes |
|---|---|---|
widget_key | yes | Public key, embedded in the widget snippet. Identifies organization + channel. |
visitor_id | yes | Client-generated UUID. Generate once, persist in localStorage, reuse forever. |
engagement_id | no | Set when resuming a known conversation. Usually omitted; the backend reuses the visitor's open engagement automatically on bootstrap. |
visitor_name, visitor_email, metadata | no | Pre-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— malformedwidget_key404 NOT_FOUND— widget key not registered412 FAILED_PRECONDITION— widget is disabled403 ORIGIN_NOT_ALLOWED— requestOriginnot 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:
| Field | Browser API |
|---|---|
timezone | Intl.DateTimeFormat().resolvedOptions().timeZone |
language | navigator.language |
page_url | window.location.href |
page_title | document.title |
screen | `${screen.width}x${screen.height}` |
referrer | document.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
}
| Field | Use |
|---|---|
centrifugo_url | Pass to the Centrifugo client constructor. |
connection_token | Pass to Centrifugo client as the connect token. |
subscription_token | Pass to Centrifugo when subscribing to visitor_channel. |
visitor_channel | The channel name to subscribe to (always visitor:{visitor_id}). |
engagement_id | 0 means no open conversation yet; will be set on first send. |
messages | Last 50 messages, oldest first. Render directly. |
expires_at | Unix seconds; refresh tokens before this. |
Errors:
401 UNAUTHORIZED— invalid/expired widget JWT → callInitSessionagain403 ORIGIN_NOT_ALLOWED—Originnot in widget's allowlist404 WIDGET_NOT_FOUND— widget config deleted500— 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": {}
}
| Field | Required | Notes |
|---|---|---|
engagement_id | no | Omit (0) on the first message; backend find-or-creates. |
content | yes | Non-empty. |
message_type | no | Defaults to "TEXT". Other values: "REACTION". |
client_message_id | strongly recommended | Idempotency key. Generate per logical send. Retries with the same key collapse to one row (partial unique index on (org_id, external_id)). |
parent_message_id | no | For threaded replies. |
metadata | no | Freeform. |
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_CONTENT401 UNAUTHORIZED/403 ENGAGEMENT_FORBIDDEN/403 ORIGIN_NOT_ALLOWED429 RATE_LIMITED—Retry-Afterheader 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'scustom_attributesJSONB 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
identityin the/bootstrapcall if you collect prechat data before the visitor sends their first message. Use/identifyonly 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"
}
| Field | Required | Notes |
|---|---|---|
page_url | yes | Full URL of the page the visitor just entered. |
page_title | no | document.title. |
referrer | no | document.referrer on the new page (i.e. previous in-app or external URL). |
session_id | no | Stable 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_viewsrow (used for agent-side history rendering). - Updates the visitor row's
current_page_url+current_page_titleso the agent dashboard's header reflects the latest page in real time. - Best-effort: all writes are detached. The widget receives
204 No Contentthe 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 */ }
}
type | When fired | data shape |
|---|---|---|
message.new | Agent (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.started | Agent started typing | { engagement_id, sender_type: "USER", sender_id, sender_name? } |
typing.stopped | Agent stopped typing | { engagement_id, sender_type: "USER", sender_id } |
message.read | Agent read a visitor message | { engagement_id, reader_type: "user", reader_id, message_id } |
engagement.assigned | An agent was assigned (use to update "we'll be with you" UI) | { engagement_id, agent: { id, name } } |
engagement.transferred | Conversation handed to a new agent | { engagement_id, from_agent, to_agent } |
engagement.closed | Conversation closed | { engagement_id, closed_at, reason? } |
csat.prompt | Backend 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
bootstrapagain — the response carries the last 50 messages, which you reconcile against your local state bymessage_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.
| Allowlist | Semantics |
|---|---|
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:
InitSession(auth-service) — first line of defence- 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-datawith a singlefilepart. - Auth:
Authorization: Bearer <widget_session_jwt>. The token must carry theuploadscope, 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-Typeis ignored), and extension/MIME consistency check. - Upload constraints (max file size, allowed types, max files per message) are returned in
InitSession.upload_constraintsand should also be enforced client-side for UX. - Response:
201 Createdwith{ "file_id": "...", "filename": "...", "size_bytes": N, "content_type": "..." }. Includefile_idin thefile_ids[]field of the subsequentPOST /api/v1/webchat/messagescall. - 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.WidgetUploadServicepresign 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 returnUNIMPLEMENTEDin 2026-Q3 and be removed in 2026-Q4.GetDownloadURLis 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!"
}
| Field | Required | Notes |
|---|---|---|
engagement_id | yes | From the csat.prompt event or from bootstrap.engagement_id |
rating | yes | Integer 1 (poor) to 5 (excellent) |
comment | no | Optional 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:
| Group | Field | Default | Notes |
|---|---|---|---|
| Appearance | primary_color | #0066FF | Hex colour |
secondary_color | #FFFFFF | Hex colour | |
background_color | #FFFFFF | Hex colour | |
text_color | #333333 | Hex colour | |
widget_position | bottom-right | bottom-right or bottom-left | |
widget_offset_x | 20 | Pixels from edge | |
widget_offset_y | 20 | Pixels from edge | |
launcher_icon_type | chat | chat or custom | |
custom_launcher_icon_url | — | URL; only meaningful when launcher_icon_type=custom | |
| Branding | company_name | — | Widget header text |
company_logo_url | — | Header logo | |
welcome_message | — | Shown when widget opens | |
input_placeholder | — | Chat input placeholder text | |
offline_message | — | Shown outside business hours | |
away_message | — | Shown when online but agents busy | |
expected_response_time | — | GetWidgetStatus.status_message source | |
| Pre-chat form | prechat_form_enabled | false | Show form before chat |
prechat_form_title | — | Form heading | |
prechat_form_subtitle | — | Form sub-heading / description | |
prechat_form_fields | — | See field types below | |
| Behaviour | auto_open_delay | 0 | Seconds before auto-opening; 0 = disabled |
show_agent_photos | true | Show agent avatar in chat | |
show_agent_names | true | Show agent name in chat | |
show_typing_indicators | true | Animate typing state | |
sound_enabled | true | Notification sounds | |
show_powered_by | true | "Powered by Semaswift" badge | |
| Security | allowed_origins | [] (any) | CORS origin allowlist |
Pre-chat form field types (prechat_form_fields[].type):
| Type | Description |
|---|---|
text | Single-line text input |
email | Email address (validated format) |
phone | Phone number |
textarea | Multi-line text input |
select | Dropdown; 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
| Method | Path | Description |
|---|---|---|
GET | /api/v1/engagements/webchat/widget-configs/{channel_config_id} | Get config by channel config ID |
GET | /api/v1/engagements/webchat/widget-configs | List 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-key | Rotate 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
| Method | Path | Description |
|---|---|---|
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/visitors | List visitors (?channel_config_id=, ?last_seen_after=) |
POST | /api/v1/engagements/webchat/visitors/{visitor_id}/link-contact | Link 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
| Method | Path | Description |
|---|---|---|
GET | /api/v1/engagements/webchat/offline-messages | List offline messages (?status=pending|processed|spam) |
POST | /api/v1/engagements/webchat/offline-messages/{id}/process | Process: {"action": "process"} creates an engagement; {"action": "spam"} marks as spam |
11.6 CSAT admin
| Method | Path | Description |
|---|---|---|
GET | /api/v1/engagements/webchat/ratings/{engagement_id} | Get rating for a specific engagement |
GET | /api/v1/engagements/webchat/ratings | List 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 whenmessage.newarrives - Same
client_message_idsent twice — second response hasdeduped: 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.assignedevent sets the agent display -
engagement.closeddisables the composer - Close laptop for 30 s, reopen — Centrifugo reconnects, missed messages backfill via bootstrap
- Wait 1 h+ without sending —
getTokencallback 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.comwhen widget is restricted toacme.com— InitSession returns403 - Send 31 messages in 60 s — 31st returns
429withRetry-After - Server returns
401mid-session — frontend refreshes and retries transparently
15. Common pitfalls
| Symptom | Likely cause | Fix |
|---|---|---|
| Widget connects but no events arrive | Subscribed to wrong channel; check visitor_channel from bootstrap | Always use boot.visitor_channel verbatim, don't construct it client-side |
| Duplicate messages in UI | Optimistic insert not reconciling with message.new | Dedupe by message_id before append |
| "Sometimes messages are lost" | Treating Centrifugo as history | On every reconnect, call bootstrap and reconcile |
403 ORIGIN_NOT_ALLOWED only on some pages | Different protocol/subdomain on those pages | Add the right variants to the widget's allowed_origins, or relax to *.customer.com |
Centrifugo unauthorized after 1 h | getToken callback not wired | Provide getToken on both the client and the subscription |
| Widget bundle works on dev but not prod | Cached old bundle from CDN | Cache-bust on widget release; customers don't reload pages often |
| First send creates two engagements | Concurrent sends before first response, both without engagement_id | Serialize the first send; subsequent sends include engagement_id |
16. Reference links
- Centrifugo client SDK: centrifuge-js
- Centrifugo channels & tokens: Centrifugo channel token auth
- Backend real-time event semantics: see
real-time-events.md - Widget JS bundle source (internal): link to widget repo
- CRM custom fields, forms, deals & pipelines: see
crm-custom-fields-forms-deals-pipelines.md