Widget API Reference
Build a fully custom chat widget UI while Chattr handles everything on the backend — conversations, real-time delivery, operator panel, FCM push, and email notifications. You call the REST API below; the operator sees everything in the Chattr dashboard and app as normal.
site_key is required. There are no auth headers or API tokens. Instead, every request that creates or looks up site data requires your site_key (a UUID from your Chattr dashboard). It identifies which operator account receives the conversation. The site_key is intentionally public — it lives in your website's embed code — so it is a routing identifier, not a secret. Once a conversation is created, subsequent calls (send message, typing, etc.) use the conversation uuid returned at creation — no site_key needed for those.
Core Concepts
| Concept | Description |
|---|---|
site_key | UUID identifying your site — copy it from the Chattr dashboard → Sites. Required on all requests that target a site (start conversation, list conversations, preset Q&A). This is your account credential; without it the backend doesn't know which operator to route messages to. |
visitor_uuid | UUID you generate once per device/browser and persist in a cookie. Ties all conversations from the same visitor together. |
conversation uuid | UUID returned when a conversation is created. Used in all subsequent calls for that conversation. |
conversation_id | Numeric ID — only needed to subscribe to the Pusher real-time channel. |
Step-by-step Flow
- Call GET /widget/config?site={site_key} on init to get your Pusher credentials and site settings. Your
site_keyis in the Chattr dashboard → Integrations → Custom Widget. - Generate
visitor_uuidonce per browser (UUID v4) and store it in a cookie (1-year expiry). - Call GET /widget/visitor-conversations to check if this visitor already has an open conversation.
- If an open conversation exists: resume it — load messages, subscribe to Pusher.
- If not: show your pre-chat UI (name/email fields or skip based on site config), then call POST /widget/conversations.
- After creating or resuming, subscribe to the Pusher channel for real-time incoming messages.
- Use GET /widget/conversations/{uuid}/messages as a polling fallback (5s offline, 15s when Pusher is connected).
1. Get Site Config
Start here. Returns everything your custom widget needs to connect — Pusher key, cluster, host, port, and all site settings. Your site key is in the Chattr dashboard → Integrations → Custom Widget section.
Response 200
{
"success": true,
"data": {
"site_key": "your-site-uuid",
"pusher_key": "xxxxxxxxxxxxxxxx",
"pusher_cluster": "mt1",
"pusher_host": "soketi.chattr.ge",
"pusher_port": 443,
"pusher_tls": true,
"widget_color": "#2D3FE0",
"widget_position": "bottom-right",
"widget_vertical_offset": 22,
"widget_button_style": "gradient",
"welcome_message": "Hello! How can we help you?",
"pre_chat_enabled": true,
"pre_chat_ask_name": true,
"pre_chat_name_required": true,
"pre_chat_ask_email": true,
"pre_chat_email_required": false,
"pre_chat_ask_phone": false,
"pre_chat_phone_required": false,
"show_typing": true,
"show_read_receipts": true
},
"message": null
}
2. List Visitor's Conversations
Call this on page load before showing any UI. If an active (non-resolved) conversation exists, go straight to the chat view.
Query Parameters
| Param | Type | Required | Description |
|---|---|---|---|
visitor_uuid | UUID string | Yes | Visitor's persistent device UUID |
site_key | UUID string | Yes | Your site key |
Response 200
{
"success": true,
"data": [
{
"uuid": "conv-uuid-here",
"status": "open",
"created_at": "2025-05-24T10:00:00.000000Z",
"last_message_at": "2025-05-24T10:05:00.000000Z",
"preview": "Hi, I need help with my order…"
}
],
"message": null
}
Status values: open | pending | resolved
status !== "resolved". If multiple, prefer the one matching a stored conversation UUID cookie, then fall back to the most recent active one.3. Start a Conversation
Request Body (JSON)
| Field | Type | Required | Notes |
|---|---|---|---|
site_key | UUID | Yes | |
visitor_uuid | UUID | Yes | Generate once, store in cookie |
message | string (max 5000) | Yes | First message text |
visitor_name | string (max 255) | No | Defaults to "Visitor" |
visitor_email | email (max 255) | No | |
visitor_phone | string (max 50) | No | |
visitor_page | string (max 2000) | No | Current page URL |
visitor_meta | object | No | Any key-value pairs — shown to the operator alongside the conversation. Send as many custom fields as you need. |
visitor_meta accepts any arbitrary keys. Common ones: id, firstName, lastName, fullName, email, company, phone, address1, city, country, language, status, currency, groupName — but you can include any custom field (e.g. planTier, accountId, orderId, region).
// Request body example
{
"site_key": "your-site-uuid",
"visitor_uuid": "generated-visitor-uuid",
"message": "Hello, I need help!",
"visitor_name": "Jane Doe",
"visitor_email": "jane@example.com",
"visitor_page": "https://yoursite.com/checkout",
"visitor_meta": {
"id": "12345",
"fullName": "Jane Doe",
"company": "Acme Corp",
"phone": "+1 555 000 0000",
"planTier": "enterprise",
"accountId": "acc_9xkZ2",
"region": "us-east"
}
}
Response 201
{
"success": true,
"data": {
"uuid": "conv-uuid-here",
"conversation_id": 42,
"messages": [
{
"id": 1,
"conversation_id": 42,
"sender_type": "visitor",
"sender_id": null,
"sender": null,
"type": "text",
"body": "Hello, I need help!",
"attachment_path": null,
"attachment_name": null,
"attachment_size": null,
"reactions": [],
"is_read": false,
"read_at": null,
"edited_at": null,
"created_at": "2025-05-24T10:00:00.000000Z"
}
]
},
"message": null
}
data.uuid in a cookie (e.g. chattr_conv_{site_key}), then subscribe to the Pusher channel using data.conversation_id.4. Get Messages
Returns all messages (notes are excluded — those are internal operator-only). Also returns the current conversation status and numeric conversation_id needed for Pusher.
Response 200
{
"success": true,
"data": [
{
"id": 1,
"conversation_id": 42,
"sender_type": "visitor",
"sender_id": null,
"sender": null,
"type": "text",
"body": "Hello, I need help!",
"attachment_path": null,
"attachment_name": null,
"attachment_size": null,
"reactions": [],
"is_read": false,
"read_at": null,
"edited_at": null,
"created_at": "2025-05-24T10:00:00.000000Z"
},
{
"id": 2,
"conversation_id": 42,
"sender_type": "operator",
"sender_id": 7,
"sender": { "id": 7, "name": "Support Agent", "avatar": null },
"type": "text",
"body": "Hi Jane, happy to help!",
"attachment_path": null,
"attachment_name": null,
"attachment_size": null,
"reactions": [{ "emoji": "👍", "operator_id": 7 }],
"is_read": true,
"read_at": "2025-05-24T10:01:30.000000Z",
"edited_at": null,
"created_at": "2025-05-24T10:01:00.000000Z"
}
],
"conversation_id": 42,
"status": "open",
"message": null
}
| Field | Values |
|---|---|
sender_type | visitor | operator | system |
type | text | image | file |
status | open | pending | resolved |
5. Send a Message
Text message (Content-Type: application/json)
{ "body": "Can you help me with order #1234?" }
File attachment (Content-Type: multipart/form-data)
| Field | Type | Required | Notes |
|---|---|---|---|
body | string | No* | Text caption. Required if no attachment. |
attachment | file | No* | Max 10 MB. Allowed: jpg, jpeg, png, gif, webp, pdf, doc, docx, xls, xlsx, zip, txt |
Response 201
{
"success": true,
"data": {
"id": 5,
"type": "image",
"body": "See this screenshot",
"attachment_path": "https://chattr.ge/storage/attachments/42/screenshot.jpg",
"attachment_name": "screenshot.jpg",
"attachment_size": 204800,
"sender_type": "visitor",
"created_at": "2025-05-24T10:03:00.000000Z"
},
"message": null
}
type is set to "image" for image MIME types, "file" for all others.
422 if the conversation is resolved. Call reopen first.6. Typing Indicator
{ "is_typing": true }
Send is_typing: false after 3 seconds of inactivity. The operator panel shows a live typing indicator.
Response 200
{ "success": true, "data": null, "message": null }
7. Rate a Conversation
| Field | Type | Required | Notes |
|---|---|---|---|
rating | integer | Yes | 1–5 |
comment | string (max 1000) | No |
{ "rating": 5, "comment": "Very helpful, thank you!" }
Response 200
{ "success": true, "data": { "rating": 5 }, "message": "Thank you for your feedback!" }
8. Reopen a Resolved Conversation
No request body required.
Response 200
{ "success": true, "data": { "status": "open" }, "message": null }
9. Preset Q&A Buttons
Response 200
{
"success": true,
"data": [
{
"id": 1,
"question_display": "What are your hours?",
"answer": "We're available Monday–Friday, 9am–6pm GMT.",
"trigger_keywords": ["hours", "open", "available"],
"is_sticky": true
}
],
"message": null
}
| Field | Notes |
|---|---|
question_display | Label to show on the button |
answer | Auto-reply text rendered when clicked |
trigger_keywords | Show this button when any keyword matches visitor's typed text |
is_sticky | Always show (vs. only on keyword match) |
question_display as a visitor message via the API, then immediately render answer as an operator bubble in your UI — no extra API call needed for the answer.
Real-time via Pusher
Subscribe to the conversation channel after creating or resuming a conversation. Use Pusher.js (or any Pusher-compatible client library).
Setup
const pusher = new Pusher(PUSHER_KEY, {
cluster: PUSHER_CLUSTER, // e.g. "mt1"
forceTLS: true
});
// conversationUuid = the `uuid` returned from POST /widget/conversations
// (the unguessable conversation identifier). The channel is keyed by uuid,
// never the numeric id, so it cannot be subscribed to by guessing ids.
const channel = pusher.subscribe(`conversation.${conversationUuid}`);
Event: MessageSent
Fires when any message is sent. The event payload has the same shape as a message object from GET /messages.
channel.bind('MessageSent', (data) => {
// Skip internal operator notes
if (data.type === 'note') return;
// Deduplicate — you may have already added it optimistically
const alreadyHave = messages.some(m => m.id && m.id === data.id);
if (!alreadyHave) {
messages.push(data);
renderMessages();
}
});
Event: OperatorTyping
channel.bind('OperatorTyping', ({ is_typing }) => {
typingIndicator.hidden = !is_typing;
});
Session Management
Two cookies drive the session. Generate them client-side — no server interaction needed.
// Persistent visitor identity (1 per browser, 1-year expiry)
function getVisitorUuid() {
let id = getCookie('chattr_uuid');
if (!id) {
id = crypto.randomUUID();
setCookie('chattr_uuid', id, 365);
}
return id;
}
// Per-site conversation pointer (30-day expiry)
function getConvUuid(siteKey) {
return getCookie(`chattr_conv_${siteKey}`);
}
function setConvUuid(siteKey, uuid) {
setCookie(`chattr_conv_${siteKey}`, uuid, 30);
}
// Cookie helpers
function getCookie(name) {
const m = document.cookie.match('(^|;) ?' + name + '=([^;]*)(;|$)');
return m ? m[2] : null;
}
function setCookie(name, value, days) {
const exp = new Date(Date.now() + days * 86400000).toUTCString();
document.cookie = `${name}=${value};expires=${exp};path=/;SameSite=Lax`;
}
Pre-chat form logic
Read the site config to decide what to show before the first message:
| Setting | Meaning |
|---|---|
preChatEnabled | Show the form at all — if false, let the visitor type immediately |
preChatAskName | Include a name field |
preChatAskEmail | Include an email field |
Visitor identity pre-fill (skip pre-chat form)
If you already know the logged-in user (e.g. SaaS app), pass their data in visitor_meta and skip the form entirely:
startConversation(user.name, user.email, firstMessage, {
id: user.id,
fullName: user.name,
email: user.email,
company: user.company,
});
Full Vanilla JS Example
A minimal working implementation covering the full flow (~70 lines).
const BASE = 'https://chattr.ge';
const SITE = 'your-site-key-uuid';
const VIS_ID = getVisitorUuid();
let convUuid = null;
let messages = [];
async function init() {
// 1. Check for existing conversations
const r = await fetch(`${BASE}/widget/visitor-conversations?visitor_uuid=${VIS_ID}&site_key=${SITE}`);
const { data: convs } = await r.json();
const active = convs.find(c => c.status !== 'resolved');
if (active) {
convUuid = active.uuid;
await loadMessages(); // loads messages + subscribes Pusher
} else {
showPreChatForm();
}
}
async function startConversation(name, email, firstMessage, meta = null) {
const r = await fetch(`${BASE}/widget/conversations`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
site_key: SITE,
visitor_uuid: VIS_ID,
visitor_name: name,
visitor_email: email,
message: firstMessage,
visitor_page: location.href,
visitor_meta: meta,
}),
});
const { data } = await r.json();
convUuid = data.uuid;
setConvUuid(SITE, convUuid);
messages = data.messages;
subscribeRealtime(data.conversation_id);
renderMessages();
}
async function sendMessage(text) {
const r = await fetch(`${BASE}/widget/conversations/${convUuid}/messages`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: text }),
});
const { data: msg } = await r.json();
messages.push(msg);
renderMessages();
}
async function loadMessages() {
const r = await fetch(`${BASE}/widget/conversations/${convUuid}/messages`);
const res = await r.json();
messages = res.data;
subscribeRealtime(convUuid);
renderMessages();
if (res.status === 'resolved') showReopenBar();
}
function subscribeRealtime(conversationUuid) {
const pusher = new Pusher(PUSHER_KEY, { cluster: PUSHER_CLUSTER, forceTLS: true });
const ch = pusher.subscribe(`conversation.${conversationUuid}`);
ch.bind('MessageSent', (msg) => {
if (msg.type === 'note') return;
if (messages.some(m => m.id === msg.id)) return; // deduplicate
messages.push(msg);
renderMessages();
playNotificationSound();
});
ch.bind('OperatorTyping', ({ is_typing }) => {
document.getElementById('typing-indicator').hidden = !is_typing;
});
// Slow poll as fallback
setInterval(() => {
if (convUuid) loadMessages();
}, ch.connection?.state === 'connected' ? 15000 : 5000);
}
function sendTypingIndicator(isTyping) {
if (!convUuid) return;
fetch(`${BASE}/widget/conversations/${convUuid}/typing`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ is_typing: isTyping }),
});
}
init();
Errors & Rate Limits
All endpoints return a consistent shape on error:
{
"success": false,
"data": null,
"message": "Error description"
}
| HTTP Status | Meaning |
|---|---|
404 | Site key or conversation UUID not found |
422 | Validation error or business rule violation (e.g. conversation is resolved) |
429 | Rate limit exceeded — back off and retry. Use exponential backoff starting at 2s. |
Per-endpoint rate limits
| Endpoint | Limit |
|---|---|
| POST /widget/conversations | 30 req/min |
| POST /widget/conversations/{uuid}/messages | 120 req/min |
| POST /widget/conversations/{uuid}/typing | 120 req/min |
| POST /widget/conversations/{uuid}/rating | 20 req/min |
| POST /widget/conversations/{uuid}/reopen | 20 req/min |
| All GET widget endpoints | 240 req/min (shared) |
Powered by Chattr — Live Chat Platform