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 bootstrap, real-time delivery via Centrifugo, sending messages, typing indicators, read receipts, reactions, history, 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: post Phase 5 (Centrifugo is the only transport; the legacy /v1/webchat/ws endpoint has been removed).


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.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": "[email protected]",
"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": ["message", "read", "upload"],
"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
  • 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: ignored (everything comes from the JWT).

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.


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

├── 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

├── 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 (optional)

Out of scope for this guide — see the legacy widget upload flow which is unchanged. Upload constraints are returned in InitSession.upload_constraints. The endpoint is POST /api/v1/auth/widget/uploads with the widget session JWT.


10. 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: '[email protected]' },
// 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.


11. 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))

12. Testing checklist

Before shipping, verify each of these:

  • 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
  • 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

13. 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