Documentation · API

API v1

REST and JSON. The Onix app itself uses the same API, so the data you see in the app comes through these endpoints.

Base URL: https://onix.sassly.ai/api/v1. Every path on this page is relative to the base URL. Request bodies are JSON (Content-Type: application/json). The full OpenAPI 3.1 spec is at /docs/openapi.yaml — import it into Postman, Insomnia, or an SDK generator.

Authentication

Onix app (browser)Integrations (your server)
IdentitySession cookie after signing in with GoogleHeader Authorization: Bearer onx_live_…
Requests that change dataRequire the session's X-CSRF-Token header; the app sends it automaticallyNo CSRF; the key needs the write scope
PermissionsFollow the user's role and channel accessThe whole workspace, limited by the key's scope
PlansAll plansPro and Custom

Creating an API key

  1. Open Settings → API (requires the Manage API keys permission; Pro only).
  2. Click Create key, give it a name (e.g. "CRM integration"), and choose a scope: read (read-only) or write (read and change data).
  3. Keys start with onx_live_ and are shown only once. Keep them in a secret manager, never in frontend code or a repository.
  4. Up to 10 active keys per workspace. Requests with a revoked key get 401.
export ONIX_API_KEY="onx_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"

An API key acts on behalf of the workspace and isn't limited by roles, so treat it like a password. Management endpoints (members, roles, settings, API keys, billing) are app-only. The API doesn't support CORS: call it from your server, not from browser JavaScript.

Response format

Successful responses are always {"data": …, "meta": {…}}; meta appears only when relevant. Errors always look like:

{
  "error": {
    "code": "validation_error",
    "message": "Workspace name must be 2–120 characters.",
    "fields": {"name": "Workspace name must be 2–120 characters."}
  }
}
  • code is stable and safe to use in program logic; message is human-readable text that follows the language (header X-Locale: en|id).
  • fields appears only on input errors, with a message per field.
  • Times are always UTC in ISO 8601, e.g. 2026-10-08T02:30:00Z. Convert them to your own time zone for display; the Onix app uses the user's personal time zone, or the workspace time zone.
  • Paginated lists accept page (from 1) and per_page (max 100, default 25 unless stated otherwise) and return meta.page, meta.per_page, meta.total, and meta.pages.

Rate limits

  • 120 requests per minute per API key.
  • Responses include the X-RateLimit-Limit and X-RateLimit-Remaining headers.
  • Going over the limit returns 429 rate_limited with a Retry-After header (seconds until the next minute).

Error codes

HTTPcodeMeaning
401unauthorizedThe API key is missing, malformed, or revoked; or the app session has ended.
402plan_requiredThe workspace isn't on an active Pro or Custom plan (API keys and Pro-only features).
403forbiddenMissing permission, an app-only endpoint called with an API key, or a read key trying to change data.
404not_foundThe endpoint or data doesn't exist, including data that belongs to another workspace.
405method_not_allowedThe method isn't supported by this endpoint; see the Allow header.
409conflict / workspace_requiredThe data conflicts with the current state (e.g. a role still in use), or the user has no workspace yet.
419csrf_mismatchAn app request other than GET without a valid X-CSRF-Token. Reload the page and try again.
422validation_errorInvalid input; per-field details are in error.fields.
423workspace_frozenThe workspace is frozen because its Custom contract ended. Only Plan & Billing, ownership transfer, notifications, and basic read data keep working.
429rate_limitedToo many requests; wait for Retry-After.
502ai_errorThe AI provider is having trouble. Your AI quota isn't used; try again shortly.
503ai_unavailableAI features aren't set up on the Onix server yet. Contact the Onix admin.
500server_errorA server problem. Safe to retry a little later.

Endpoints

The Permission column shows what an app user needs (set through roles); "—" means every workspace member. API keys are treated as holding every workspace permission and see all data.

Data limits (teams): permissions control menus & actions, while the conversations & tickets a user sees follow teams. Unassigned conversations are visible to everyone with interactions.view; conversations assigned to a Frontline team are visible only to that team's members; conversations assigned to a person are visible only to that person. Tickets without an assignee are visible to the members of their Back Office team; tickets with an assignee are visible only to participants (assignees, creator, people @mentioned). Team-only knowledge base articles are visible only to members of the chosen teams (no team = all teams). Pipeline leads without an owner are visible to the lead's team; leads with an owner only to that owner. The Owner, admin roles, and members with "Can view all data" see everything (channel access still applies). Data a user may not see returns 404.

Available with an API key

EndpointPermissionDescription
GET /me—User, active workspace, role, permissions, and effective time zone. With an API key: the user is the key's creator and via is "key".
GET /workspace—Active workspace: name, plan, paid-until date, time zone, and plan limits.
GET /dashboarddashboard.viewGetting-started steps, quota usage, 14-day activity, recent activity, and a team summary (some parts need extra permissions).
GET /channels—Channels the user can access (type whatsapp/email/livechat/api, status, settings, icon_url, hours = own business hours or null, auto_reply; live chat also includes the public key & embed code, API the webhook URL — never keys or secrets), plus the channel quota (shared by all types).
GET /channels/{id}/icon—The channel's uploaded icon/logo (channel managers or members with access to that channel); 404 when it uses the default icon.
GET /interactionsinteractions.viewInbox: conversations by kind (chat/group) and tab (new = not replied to at all, waiting = waiting reply, unreplied = both, replied, solved, closed). Each item has a reply_state. Filters channel_ids (one or more channel ids separated by commas, e.g. 1,3; the old channel_id is still accepted), assignee (me/none/id), team (mine/none/id), label_id, contact_id, q, created_from/created_to (date received, YYYY-MM-DD in the caller's time zone), category (id = that category and all its subcategories, or none). meta.counts = counts per tab; meta.teams = Frontline teams for the filter. On Pro/Custom each item has sla (reply target in business hours: level warn/breach, minutes, due time). Only conversations the caller may see (team data limits).
GET /interactions/{id}interactions.viewOne conversation: contact/group, channel, status, team, assignee, WhatsApp labels, response times, custom_fields (custom interaction fields with their values), notes_count, tickets (ALL tickets from this conversation — even without ticket access — each with can_open, update_request, sla, and the thread with the ticket team), and for holders of contacts.view the contact's details & custom fields (contact.details, contact.custom_fields).
POST /interactions/{id}/tickets/{ticket}/messagesinteractions.replyFrom the conversation to one of its tickets (no ticket access needed): {"text": "…"} sends a message into the ticket discussion; {"request_update": true} (text optional) asks the ticket team for an update — the ticket shows update_request until the team sends feedback/a conclusion or resolves it. Active tickets only (422), at most once every 15 minutes per ticket (429). Returns {"message", "tickets"}.
GET /interactions/{id}/notesinteractions.viewThe conversation's internal notes (newest first, up to 200), including ticket feedback & conclusions (meta.source). Write a note with POST /interactions/{id}/messages and type = note.
GET /interactions/{id}/messagesinteractions.viewMessages oldest → newest. Cursors before/after (message id), limit up to 100.
POST /interactions/{id}/messagesinteractions.replySend a message (write scope): {"text": "…"}, or multipart/form-data with file. Other types: note, sticker, location, contact; quote with reply_to_id; templates with template_id. Email conversations: html (minimal formatting: bold, italic, underline, lists, links; text is generated), cc (comma-separated, up to 10), reply_all, and several attachments files[] (multipart, up to 10 files, 20 MB in total); the recipient, "Re: …" subject, quoted previous email, and signature are filled in automatically. On a WhatsApp number with agent initials on, the sender's initials are added on the last line (not via API keys). Live chat & channel API conversations: text, one attachment, or a note (no quotes, stickers, locations, or contacts); replying to a live chat nobody has picked up also picks it up (not via API keys).
PATCH /interactions/{id}interactions.replyChange status (open/solved/closed), team_id (a Frontline team), assignee_id, category_id (conversation category, null = none), and/or custom (custom interaction fields {key: value}; an empty value clears it). Moving teams or assigning others requires interactions.assign; the person must belong to the conversation's team (admins may have no team). Returns {"id", "visible": false} when the caller can no longer see it.
PATCH /interactions/{id}/messages/{message}interactions.replyEdit your own text message (within 15 minutes of sending).
DELETE /interactions/{id}/messages/{message}interactions.replyUnsend a message (delete for everyone), within 2 days.
POST /interactions/{id}/messages/{message}/retryinteractions.replyResend a failed message.
POST /interactions/{id}/messages/{message}/reactioninteractions.replyEmoji reaction: {"emoji": "👍"}; empty = remove.
GET /interactions/{id}/assigneesinteractions.replyMembers who can be assigned to this conversation (with team_ids) and the Frontline teams with their members in meta.teams.
GET /messages/{id}/mediainteractions.viewA message's media file (private). Supports Range; ?download=1 to download.
GET /messages/{id}/emailinteractions.viewThe original email (sanitized HTML without scripts) for display in a safe frame. Images from the internet are blocked; ?images=1 loads them.
GET /messages/{id}/attachments/{index}interactions.viewEmail attachment number index (from 0, in the order of the message's email.attachments). ?download=1 to download.
GET /contacts/{id}/avatarinteractions.viewThe contact's WhatsApp profile photo.
GET /groups/{id}/avatarinteractions.viewThe WhatsApp group photo.
POST /interactionsinteractions.replyStart a chat with a contact (write scope): {"contact_id", "channel_id", "text"}. An open conversation with that contact on the same number is reused. Email channels: subject is required, body in html or text, optional cc; always starts a new conversation to the contact's email.
POST /interactions/{id}/feedback-seeninteractions.viewMark the ticket feedback/conclusion in a conversation as read.
GET /groups/{id}interactions.viewGroup page: info, members (matched to contacts), and the group's conversations.
GET /ticketstickets.viewTickets the caller may see (team data limits). Filters status (active, open, …), priority, assignee (me/none/id), team (mine/id), q, contact_id, interaction_id, overdue. meta.counts = counts per status, meta.teams = Back Office teams for the filter.
POST /ticketstickets.createCreate a ticket (write scope): subject, team_id (a Back Office team, required), assignee_ids (optional, members of that team), description, priority, due_date, interaction_id or contact_id, and custom (custom ticket fields {key: value}). The monthly ticket quota applies (402).
GET /tickets/candidatestickets.createMembers who can be assigned or mentioned on tickets, with their Back Office team_ids & is_admin.
GET /tickets/{id}tickets.viewTicket details, team, participants, the caller's permissions, custom_fields (custom ticket fields with their values), and a preview of the original conversation.
PATCH /tickets/{id}tickets.manageChange status, priority, due_date, subject, description, team_id (moving teams releases assignees who are not in the new team), or custom (custom ticket fields; changed fields are logged).
POST /tickets/{id}/assigneestickets.manageAdd an assignee (a member of the ticket's team, or an admin): {"user_id": 5}.
DELETE /tickets/{id}/participants/{user}tickets.manageRemove a participant (followers may unfollow themselves). Without an assignee, the ticket is visible to the whole team again.
GET /tickets/{id}/messagestickets.viewTicket discussion + log in meta.events; after_message/after_event for new items only.
POST /tickets/{id}/messagestickets.viewPost a discussion message: {"body": "…", "mention_ids": [5]}, or multipart with files[] (max. 5). @Name in the text is recognized too; people mentioned can see the ticket.
POST /tickets/{id}/messages/{message}/feedbacktickets.viewSend a discussion message as feedback to the original conversation (internal note).
POST /tickets/{id}/conclusiontickets.viewConclusion for the agent: {"conclusion": "…", "resolve": true} (resolve needs tickets.manage).
GET /ticket-messages/{id}/files/{index}tickets.viewDiscussion attachment (private). ?download=1 to download.
GET /teams—Frontline & Back Office teams with their members; ?type=frontline|backoffice.
GET /teams/{id}—A single team.
GET /contactscontacts.viewContacts. Filters q, label_id, wa_label_id, owner (me/none/id), channel_id, sort (recent/name/created).
POST /contactscontacts.manageAdd a contact: name, phone, email, company, job_title, address, city, language, owner_id, label_ids, custom.
GET /contacts/{id}contacts.viewFull profile: identities, Onix & WhatsApp labels, custom fields, statistics & 12-month activity.
PATCH /contacts/{id}contacts.manageUpdate a contact (only the fields sent). label_ids replaces all labels.
POST /contacts/{id}/labelscontacts.manageAdd an Onix label: {"label_id": 3} (also allowed with interactions.reply).
DELETE /contacts/{id}/labels/{label}contacts.manageRemove an Onix label.
GET /contacts/{id}/timelinecontacts.viewContact timeline, newest first (before = last id).
GET /contacts/{id}/ticketscontacts.viewThe contact's tickets with their status log (only those the caller can see).
GET /contacts/{id}/notescontacts.viewTeam notes.
POST /contacts/{id}/notescontacts.viewAdd a note: {"body": "…"}.
PATCH /contacts/{id}/notes/{note}contacts.viewEdit a note (author only).
DELETE /contacts/{id}/notes/{note}contacts.viewDelete a note (the author or contacts.manage).
GET /labelscontacts.viewOnix labels with their contact counts; meta.colors = available colors.
GET /custom-fieldscontacts.view / interactions.view / tickets.view / leads.viewCustom field definitions per entity: ?entity=contact|interaction|ticket|lead|client (no entity = all). meta.max_per_entity = 30.
GET /contact-fieldscontacts.viewCustom contact field definitions (same as /custom-fields?entity=contact).
GET /wa-label-rulescontacts.manageWhatsApp label automation rules (Pro): {"label_name", "channel", "action", "value", "value_label", "active"}. Also allowed with channels.manage.
GET /conversation-categoriesinteractions.viewConversation categories in tree order (parent, then its subcategories): {"id", "name", "color", "position", "parent_id", "depth", "path_text", "children_count"}. Also allowed with contacts.view.
GET /kb/categorieskb.viewKnowledge base categories (by position) with the number of published articles the caller may see.
POST /kb/categorieskb.manageCreate a category: {"name", "description", "position"}. Names are unique per workspace.
PATCH /kb/categories/{id}kb.manageUpdate a category (fields you don't send stay the same).
DELETE /kb/categories/{id}kb.manageDelete a category; its articles become uncategorized.
GET /kb/articleskb.viewPaginated articles. Filters q (every word must match), category_id (id or none), visibility, status, team_id (team id = that team's team-only articles, none = articles for all teams). Without kb.manage only published articles are returned. Team-only articles are visible only to that team's members (the Owner, admins, "Can view all data", and API keys see everything). Each article has teams (empty = all teams). meta.counts = published & draft counts.
POST /kb/articleskb.manageCreate an article: {"title", "body", "category_id", "visibility", "status", "tags", "team_ids"}. Defaults to Internal + Draft, for all teams. The body is simple Markdown. team_ids = teams who can read it; managers who can't view all data may only choose their own teams (422).
GET /kb/articles/{id}kb.viewOne full article with its body, teams, semantic search processing status (embedding_status, chunk_count, embedded_at_text), and can.insert (published Public articles can be inserted into replies). Another team's team-only article → 404.
PATCH /kb/articles/{id}kb.manageUpdate an article (fields you don't send stay the same; team_ids: [] = all teams). Published + title/body changed → processed again for semantic search.
DELETE /kb/articles/{id}kb.manageDelete an article.
GET /kb/searchkb.viewSearch published articles. mode=keyword (default, all plans) or mode=semantic (Pro/Custom, ai.use, 1 AI request per search). Parameters q, visibility, limit (1–20). Results: {"article", "match", "score", "snippet"}; meta.ai = AI usage this month.
GET /reports/summaryreports.viewSummary cards for the range from–to (YYYY-MM-DD in the caller's time zone, inclusive; default the last 30 days, at most 366 days) and optional channel_id, team_id filters: incoming conversations, messages in/out, first response (average, median, % within target), resolution time, unreplied now, tickets. Formulas: Report formulas.
GET /reports/dailyreports.viewOne row per date in the range: {"date", "conversations", "messages_in", "messages_out"} (0 when empty). Same parameters as the summary.
GET /reports/hourlyreports.view24 rows of messages in per hour 0–23 (caller's time zone): {"hour", "messages_in"}.
GET /reports/breakdownreports.viewtype = channel, label, category, or team: one row per group with incoming conversations (by number also messages & average first response; by team also tickets created/resolved; by category in tree order, with conversations including subcategories and conversations_direct).
GET /kpireports.viewPer-person KPI cards (Pro/Custom; Free → 402): frontline (first response, follow-up replies, resolution, solved per business day, recontacts, escalations, past target now) and backoffice (ticket response, on-time resolution by priority, resolved per business day, reopened, feedback, backlog). Times in business minutes/hours; from/to (workspace time zone, up to 93 days), team_id, channel_type (whatsapp/email/livechat/api: only conversations & tickets from that channel type, with that type's targets — email, live chat, and API have their own). See KPIs & SLA.
GET /kpi/users/{id}—One person's KPIs with daily, breaches, and targets (with source). Yourself always; others need reports.view.
GET /kpi/me—Your own KPIs today & over the last 7 days (the Dashboard "My KPIs" card).
GET /kpi/configsettings.workspaceWorkspace targets, team & personal targets, the metric list (including the email targets email_first_response & email_reply in hours, live chat livechat_first_response & livechat_reply and channel API api_first_response & api_reply in minutes), and the workspace business hours.
GET /reports/agentsreports.viewProductivity of each active member: {"user", "replies", "conversations", "first_responses", "avg_first_response_minutes", "tickets_resolved"}.
POST /interactions/{id}/summaryai.useAI summary of a conversation (Pro/Custom, 1 AI request): 3–6 bullet points, saved and shown in GET /interactions/{id} → ai_summary.
GET /templatesinteractions.replyReply templates. With interaction_id, each template includes rendered (variables filled in). Filters q, category.
GET /templates/{id}interactions.replyOne template.
GET /templates/{id}/attachmentinteractions.replyThe template attachment.
GET /settingssettings.workspaceGeneral settings (workspace time zone, response target, business hours business_hours + open_now, …) with choices in meta.choices.
GET /memberssettings.membersEvery member, including the Owner.
GET /invitationssettings.membersInvitations not yet accepted, including expired ones.
GET /rolessettings.rolesEvery role with its permissions and usage counts (or settings.members).
GET /roles/{id}settings.rolesA single role.
GET /permissionssettings.rolesPermission catalog by menu group; meta.grantable = what this user may grant.
GET /audit-logssettings.auditAudit log, newest first. Filters: category, user, from, to (YYYY-MM-DD, reader's zone), q. 50 per page by default.
GET /pipelinesleads.view / leads.manageVisible pipeline workflows (admins, "can view all data", API keys, and pipeline.manage: all; otherwise your teams' workflows): stages (kind new/progress/won/lost, label, color, probability) and the Back Office teams that handle them. meta = lost reasons, expense categories, color palette.
GET /pipelines/{id}leads.view / leads.manageOne workflow (holders of pipeline.manage also get the lead count per stage).
GET /pipeline/settingsleads.view / leads.manageLost reasons, expense categories, stage color palette, and the In-progress stage limit.
GET /pipeline/dashboardleads.view / leads.managePipeline dashboard: tiles (open, forecast, new in 7 days, won/lost, win rate, average days to close, overdue follow-ups), funnel, weekly (8 weeks), by_assignee, expenses, today's agenda, quota. Filters pipeline_id, from/to (current month by default), team_id, assignee_id. Only leads you can see.
GET /leadsleads.view / leads.manageList leads (data ownership: leads with an owner are only for that owner, without an owner for the lead's team; admins/API keys see all). Filters pipeline_id, stage_id, status (open/won/lost), team_id, assignee (me/none/id), client_id, contact_id, q (LEAD-…, title, contact, client), followup (overdue/today/upcoming/none), created_from/created_to; sort & dir. meta.totals = count, prospect value, deal value.
POST /leadsleads.manageCreate a lead (create_lead; source api): pipeline_id, title, a contact (contact_id, or contact {name, phone, email} matched by phone then email, or interaction_id), client_id/client_name, stage_id, team_id, assignee_id, value_estimate, budget, expected_close, followup_at, note, custom. The monthly lead quota applies (402).
GET /leads/boardleads.view / leads.manageKanban board for one workflow (pipeline_id required): stages + cards for open leads & this month's Won/Lost (max 100 per stage, more), count/value per stage, and a summary.
GET /leads/candidatesleads.view / leads.manageMembers who can own leads, with Back Office team_ids & is_admin.
GET /leads/duplicatesleads.manageOpen leads of a contact in the same workflow (contact_id, pipeline_id) — a warning before creating a lead.
GET /leads/{id}leads.view / leads.manageLead details: workflow & stage, contact, client, source conversation, the 30 latest activities, checklist, events, files, expenses, custom fields, and can.
PATCH /leads/{id}leads.manageChange title, client_id/client_name, value_estimate, value_won (Won leads), budget, expected_close, followup_at/followup_note (null = clear), team_id, assignee_id, lost_reason/lost_note (Lost leads), custom. Returns {"id", "visible": false} when you can no longer see it.
POST /leads/{id}/moveleads.manageMove stage: {"stage_id"}; to Won requires value_won (defaults to the prospect value), to Lost requires a lost_reason from the list (+ lost_note). From Won/Lost back to a New/In-progress stage = reopen.
GET /leads/{id}/activitiesleads.view / leads.managePaged lead timeline (before = last id, kind=note for notes only).
POST /leads/{id}/activitiesleads.manageLog an activity: {"kind": "call|chat|email|meeting|visit|note|other", "body", "happened_at", "duration_minutes"}. @Name sends a notification.
GET /lead-files/{id}leads.view / leads.manageLead file (private). ?download=1 to download.
GET /lead-expenses/{id}/receiptleads.view / leads.manageExpense receipt (private). ?download=1 to download.
GET /interactions/{id}/leadsinteractions.viewThe conversation's Leads tab: ALL leads from this conversation (brief, can_open) + other open leads of its contact that you can see.
GET /contacts/{id}/leadscontacts.viewThe contact's leads you can see (empty without Pipeline access).
GET /clientscontacts.view / leads.viewClient master list (not limited by team): q, industry; each client with contacts_count and a summary of the leads you can see. meta.industries for filters.
GET /clients/searchcontacts.view / leads.viewSearch clients for form pickers: ?q=.
POST /clientsclients.manageAdd a client: {"name", "industry", "phone", "email", "website", "address", "city", "notes", "custom"}. Names are unique per workspace.
GET /clients/{id}contacts.view / leads.viewClient profile, linked contacts, the leads you can see, and a deal summary.
PATCH /clients/{id}clients.manageEdit a client.
GET /segmentsbroadcasts.view / broadcasts.manageContact segments: rules, summary, last_count, and how many broadcasts use them.
GET /segments/fieldsbroadcasts.view / broadcasts.manageField, operator, & value catalog for segment rules (including custom fields cf:{key}).
POST /segments/previewbroadcasts.view / broadcasts.managePreview rules without saving: {"match": "all|any", "rules": [{"field", "op", "value", "value2"}]} → count, with phone, unsubscribed, samples.
GET /segments/{id}broadcasts.view / broadcasts.manageSegment details + recalculated members (preview).
GET /broadcastsbroadcasts.view / broadcasts.manageBroadcast list (status, channel_id, q) with per-status counts (stats).
GET /broadcasts/optionsbroadcasts.view / broadcasts.manageNumbers (broadcast permission), segments, default settings & safety limits, quota, message variables.
GET /broadcasts/dashboardbroadcasts.view / broadcasts.manageFigures for a period (from, to, channel_id; at most 92 days): funnel, daily, hourly, reasons, number usage today, latest & best broadcasts.
GET /broadcasts/{id}broadcasts.view / broadcasts.manageBroadcast details: audience, messages, attachment, settings, counts, skip reasons, risk acknowledgement, wait, can.
GET /broadcasts/{id}/recipientsbroadcasts.view / broadcasts.manageRecipients & per-recipient status (cumulative status, q), including the message sent.
GET /broadcasts/{id}/exportbroadcasts.view / broadcasts.managePer-recipient report ?format=xlsx|csv.
GET /broadcasts/{id}/mediabroadcasts.view / broadcasts.manageThe broadcast attachment (private).
POST /broadcasts/{id}/estimatebroadcasts.view / broadcasts.manageEstimated recipients (skip reasons), quota, finish time, and risk level.
POST /broadcasts/{id}/previewbroadcasts.view / broadcasts.manageFinal text for up to 5 sample contacts (variant, optional text/footer).

Onix app only

These endpoints accept only an app session (cookie + X-CSRF-Token). With an API key they return 403 forbidden.

EndpointPermissionDescription
POST /wa-label-rulescontacts.manageCreate a WhatsApp label rule (Pro): action = contact_label (value = contact label id), assign_team (Frontline team id), or solve. Free → 402.
PATCH /wa-label-rules/{id}contacts.manageUpdate a rule (including active).
DELETE /wa-label-rules/{id}contacts.manageDelete a rule.
POST /conversation-categoriescontacts.manageCreate a conversation category: {"name", "color", "parent_id"} — up to 3 levels and 200 categories; names unique within the same parent.
PATCH /conversation-categories/{id}contacts.manageChange a category's name, color, order, or parent (parent_id, null = top level; not into itself or its subcategories, at most 3 levels).
DELETE /conversation-categories/{id}contacts.manageDelete a category; its subcategories and conversations move to its parent (uncategorized for a top-level category).
PUT /kpi/configsettings.workspaceSave workspace targets (targets) and/or team & personal targets (overrides, replaces all). Business hours now live in PATCH /settings (business_hours is still accepted here).
GET /kpi/exportreports.viewKPI CSV: type = summary, conversations, replies, or tickets; user_id for one person (allowed for yourself without reports.view); channel_type as above. Raw data has a channel_type column at the end.
POST /kb/articles/{id}/embedkb.manage(Re)process a published article for semantic search (Pro/Custom) — the Process now / Reprocess / Try again buttons.
GET /reports/exportreports.viewDownload CSV (UTF-8 + BOM): type = summary, daily, channel, label, category, agents, or tickets, with the same range & filters as the JSON reports.
PATCH /me—Personal language & time zone: {"locale": "en", "timezone": "Asia/Makassar"} ("" = follow the workspace).
POST /me/preferences—Personal preferences, such as tour and sidebar state, and livechat_alert (0/1: sound & blinking tab title when a new live chat is waiting).
GET /me/invitations—Valid invitations for this account's email.
POST /me/invitations/{id}/accept—Accept an invitation; that workspace becomes the active one.
POST /me/workspace—Switch the active workspace: {"workspace_id": 3}.
GET /workspaces—Workspaces where the user is an active member.
POST /workspaces—Create a workspace: {"name": "…", "timezone": "Asia/Jakarta"}. Free/Pro accounts own one workspace; Custom customers as many as their contract allows (409). meta.create on GET /workspaces says whether it is allowed.
GET /workspace/transfer—Ownership transfer (Owner only): the pending request + possible recipients and their eligibility.
POST /workspace/transfer—Request an ownership transfer: to_user_id, confirm_name (the workspace name), stay, stay_role_id, stay_team_ids.
DELETE /workspace/transfer—Cancel the pending request.
GET /me/transfers—Ownership transfer requests waiting for my answer.
GET /me/transfers/{id}—Request details (can_accept, becomes_free).
POST /me/transfers/{id}/accept—Accept: I become the Owner; that workspace becomes my active workspace.
POST /me/transfers/{id}/decline—Decline the request.
PATCH /workspacesettings.workspaceRename the workspace.
POST /workspace/leave—Leave the active workspace (not the Owner).
PATCH /settingssettings.workspaceUpdate some General settings, e.g. {"timezone": "Asia/Jakarta"} or the workspace business hours (all plans) {"business_hours": {"enabled": true, "days": [{"day": 1, "open": true, "from": "08:00", "to": "17:00"}, …]}} — day 1 = Monday.
GET /notifications—The 20 latest notifications and the unread count.
POST /notifications/read—Mark one ({"id": 12}) or all notifications as read.
POST /realtime/token—WebSocket (Centrifugo) token for live updates; {"channels": ["conv:12", "ticket:5"]} to join a conversation/ticket channel.
POST /interactions/{id}/readinteractions.viewMark as read; read receipts are sent to the customer.
POST /interactions/{id}/typinginteractions.replyTyping indicator: {"typing": true}.
POST /interactions/{id}/pickupinteractions.replyPick up a live chat nobody has taken (atomic): whoever is too late gets 409 "Already picked up by {name}". The visitor sees "{name} joined the chat".
POST /interactions/{id}/historyinteractions.viewCopy older history from the phone for this conversation.
POST /channelschannels.manageAdd a WhatsApp number: {"name": "Store CS"} (channel quota is checked). grant_selected_members: true = members with "selected channels" access get access too (also on POST /channels/email, /livechat, /api).
GET /channels/{id}channels.manageChannel details.
PATCH /channels/{id}channels.manageEvery channel type: name, business hours hours (an object like business_hours, or null = follow the workspace hours), auto replies auto_reply (greeting_on, greeting, away_on, away). WhatsApp: groups, reject_calls, call_reply, agent_initial (add the agent's initials at the end of replies). Other email/live chat/API settings use PATCH /channels/{id}/email, /livechat, /api.
DELETE /channels/{id}channels.manageDelete a channel (archived if it already has conversations).
GET /channels/{id}/qrchannels.manageQR code to scan (PNG data URL) + lifetime in seconds.
POST /channels/{id}/pairchannels.managePairing code: {"phone": "0812…"}.
GET /channels/{id}/statuschannels.manageLatest number status.
POST /channels/{id}/logoutchannels.manageLog the number out of Onix.
POST /channels/{id}/reconnectchannels.manageReconnect.
POST /channels/{id}/historychannels.manageImport chat history: {"days": 30} (7/30/90 days).
GET /channels/{id}/labelschannels.manageWhatsApp Business labels (read from the phone).
POST /channels/email/detectchannels.manageGuess the provider from an address: {"address": "[email protected]"} → provider, suggested IMAP/SMTP servers, and a hint (e.g. needs an App Password; Microsoft 365 not supported yet). Signed-in sessions only.
POST /channels/email/testchannels.manageTest IMAP & SMTP sign-in without sending email: address, password, optional username, imap_host/imap_port/imap_security (ssl/starttls), smtp_host/smtp_port/smtp_security, smtp_username/smtp_password. Returns the selectable folders and the detected Sent folder. Errors are explained per field (422).
POST /channels/emailchannels.manageConnect an email address (channel quota checked; an address can be in only one workspace → 409): the test fields above + name, folders (besides Inbox), sent_folder, read_sent, save_sent, history_days (0/3/7/30, default 7), sender_name, sender_with_agent, signature_html, ignore. The password is stored encrypted and never returned.
PATCH /channels/{id}/emailchannels.manageChange email settings (same fields as above, except history_days). Server, username, password, or folder changes are tested again before saving.
GET /channels/{id}/email/folderschannels.manageMailbox folders (signs in again) with a selected flag, plus the Sent folder.
POST /channels/{id}/syncchannels.manageCheck the mailbox now (the pause after failed sign-ins is skipped). Returns {"sync", "channel"}.
POST /channels/{id}/iconchannels.manageUpload a channel icon/logo (multipart icon: square PNG/JPG/WebP, up to 512 KB). Live chat: the same logo appears in the widget header & launcher.
DELETE /channels/{id}/iconchannels.manageGo back to the default icon for the channel type.
POST /channels/livechatchannels.manageCreate a live chat channel (all plans, one channel slot): {"name": "Website Chat", "title", "color", "grant_selected_members"} → a channel with livechat.key & livechat.embed_code. See Live chat.
PATCH /channels/{id}/livechatchannels.manageWidget settings: title, subtitle, color, font, radius, position, launcher_icon, intro, consent, start_label, attachments (off/images/files), email_fallback, allowed_domains; also name, hours, auto_reply.
POST /channels/{id}/livechat/keychannels.manageReplace the public key; the old embed code stops working.
POST /channels/apichannels.manageCreate an API channel (Pro/Custom, one channel slot): {"name", "webhook_url", "events", "retries", "grant_selected_members"} → credentials.key (shown only once) & credentials.webhook_secret. See Channel API.
GET /channels/{id}/apichannels.manageSettings, the inbound message endpoint, the active key (prefix & last used), and the 50 latest webhook deliveries.
PATCH /channels/{id}/apichannels.managewebhook_url (https, public host), events (message.created always; conversation.updated optional), retries (0–5); also name, hours, auto_reply.
POST /channels/{id}/api/keychannels.manageReplace the channel key; the old key is rejected right away (401).
GET /channels/{id}/api/secretchannels.manageShow the webhook secret.
POST /channels/{id}/api/secretchannels.manageReplace the webhook secret.
POST /channels/{id}/api/test-webhookchannels.manageSend a ping event now: {"status", "http_status", "duration_ms", "error"}.
POST /channels/{id}/api/test-messagechannels.manageSimulate an inbound message from your system (shows up in Interaction).
POST /templatestemplates.manageCreate a template: name, shortcut, category, body (JSON or multipart with file).
PATCH /templates/{id}templates.manageUpdate a template.
DELETE /templates/{id}templates.manageDelete a template.
POST /templates/{id}/attachmenttemplates.manageSet/replace the attachment (multipart file).
DELETE /templates/{id}/attachmenttemplates.manageRemove the attachment.
GET /contacts/exportcontacts.manageExport contacts to CSV (same filters as the list).
POST /contacts/importcontacts.manageCSV import (multipart file): returns created, updated, skipped, errors.
DELETE /contacts/{id}contacts.manageDelete a contact with its conversations, messages, media, notes, and timeline (tickets stay, unlinked).
POST /contacts/{id}/mergecontacts.manageMerge duplicates: {"source_id": 9} is merged into contact {id}.
POST /labelscontacts.manageCreate a label: {"name": "VIP", "color": "orange"}.
PATCH /labels/{id}contacts.manageChange a label's name, color, or order.
DELETE /labels/{id}contacts.manageDelete a label (removed from all contacts).
POST /custom-fieldscontacts.manageCreate a custom contact, interaction, or ticket field: {"entity": "interaction", "label": "Invoice number", "type": "text"}. Keys are unique per entity.
PATCH /custom-fields/{id}contacts.manageChange a field's name, options, or order (entity, type, & key stay the same).
DELETE /custom-fields/{id}contacts.manageDelete a custom field; its values are no longer shown.
POST /contact-fieldscontacts.manageCreate a custom contact field: {"label": "Size", "type": "select", "options": ["S", "M", "L"]}.
PATCH /contact-fields/{id}contacts.manageChange a field's name, options, or order.
DELETE /contact-fields/{id}contacts.manageDelete a custom field.
PATCH /members/{id}settings.membersChange a member's role, team_ids, view_all, channel access, or status (active/disabled). Non-admin roles need at least one team. reply_initial (up to 20 characters, empty = automatic from the name, e.g. ^BS) can be set for every member, including the Owner.
POST /invitationssettings.membersInvite a member: email, role_id, team_ids, view_all, channel_access, channel_ids.
POST /teamssettings.membersCreate a team: {"name": "Warehouse", "type": "backoffice", "member_ids": [5, 7]}.
PATCH /teams/{id}settings.membersChange a team's name, description, or members (member_ids = the full list). The type cannot be changed.
DELETE /teams/{id}settings.membersDelete a team; if it still has members or data (including team-only KB articles), ?replacement_id= (a team of the same type) is required.
POST /invitations/{id}/resendsettings.membersResend an invitation with a new link.
DELETE /invitations/{id}settings.membersCancel an invitation.
POST /rolessettings.rolesCreate a role: name, permissions, and is_admin (Owner/admins only).
PATCH /roles/{id}settings.rolesChange a role's name, permissions, and is_admin flag (except Owner).
DELETE /roles/{id}settings.rolesDelete a role nobody uses.
POST /roles/{id}/duplicatesettings.rolesCopy a role.
GET /api-keyssettings.apiActive API keys, without their secret values.
POST /api-keyssettings.apiCreate a key: {"name": "…", "scope": "read"}. The full value is returned only once.
DELETE /api-keys/{id}settings.apiRevoke a key.
GET /billingsettings.billingPlan, usage, invoices, and billing profile.
POST /billing/upgradesettings.billingIssue (or reuse) the upgrade invoice and create a Sassly Pay checkout.
GET /billing/invoices/{number}settings.billingInvoice details with payment attempts.
POST /billing/invoices/{number}/paysettings.billingSassly Pay checkout URL (a still-valid attempt is reused).
PATCH /billing/profilesettings.billingBilling profile for upcoming invoices.
POST /billing/downgrade-choicesettings.billingChoose which channels & members stay active after moving to Free.
GET /billing/returnsettings.billingPayment status after returning from checkout (checked with Sassly Pay on the server).
POST /pipelinespipeline.manageCreate a workflow: {"name", "description", "team_ids"} (Back Office teams); without stages → default stages.
PATCH /pipelines/{id}pipeline.manageChange the name, description, teams, order; {"archived": false} restores it.
PUT /pipelines/{id}/stagespipeline.manageSave the full stage layout: one New, 0–10 In progress, one Won, one Lost (label, color, probability %).
DELETE /pipelines/{id}pipeline.manageDelete a workflow without leads; one that still has leads is archived.
PUT /pipeline/settingspipeline.manageSave lost_reasons & expense_categories.
GET /pipeline/exportreports.viewRaw data CSV: type = leads (all columns + custom fields), activities, or expenses; filters pipeline_id, status, from/to.
GET /leads/import/templateleads.manageImport template ?format=xlsx|csv.
POST /leads/import/previewleads.manageRead an Excel/CSV file (multipart file): columns, sample values, mapping suggestions, a 1-hour token.
POST /leads/importleads.manageCheck (dry_run) or run the import with a column mapping (unmatched columns → new custom fields).
POST /leads/bulkleads.manageBulk actions (max 100): move, assign, followup.
DELETE /leads/{id}leads.manageDelete a lead (admin, owner, or creator); quota is not returned.
PATCH /lead-activities/{id}leads.manageEdit a manual activity (author or admin).
DELETE /lead-activities/{id}leads.manageDelete a manual activity (author or admin).
POST /leads/{id}/filesleads.manageUpload a lead file (multipart file).
DELETE /lead-files/{id}leads.manageDelete a file (uploader, owner, or admin).
POST /leads/{id}/checklistleads.manageAdd a checklist item {"text"}.
PATCH /lead-checklist/{id}leads.manageEdit / tick a checklist item (text, done, position).
DELETE /lead-checklist/{id}leads.manageDelete a checklist item.
POST /leads/{id}/eventsleads.manageAdd an event: {"kind", "title", "starts_at", "ends_at", "location", "notes", "remind_minutes"}.
PATCH /lead-events/{id}leads.manageEdit an event (changing the start time resets the reminder).
DELETE /lead-events/{id}leads.manageDelete an event.
POST /leads/{id}/expensesleads.manageRecord an expense (multipart): spent_on, category, amount, note, spent_by, receipt (photo/PDF).
PATCH /lead-expenses/{id}leads.manageEdit an expense (recorder, payer, or admin).
DELETE /lead-expenses/{id}leads.manageDelete an expense and its receipt.
DELETE /clients/{id}clients.manageDelete a client; contacts & leads stay without the client link.
POST /clients/{id}/contactsclients.manageLink ({"contact_id", "link": true}) or unlink a contact; the contact's leads without a client are linked too.
POST /contacts/{id}/broadcast-opt-outcontacts.manage / broadcasts.manageUnsubscribe ({"opt_out": true}) from or re-subscribe to broadcasts.
POST /segmentsbroadcasts.manageCreate a segment: {"name", "description", "match", "rules"} (at most 15 rules, unique name).
PATCH /segments/{id}broadcasts.manageEdit a segment.
DELETE /segments/{id}broadcasts.manageDelete a segment (refused while a scheduled/running/paused broadcast uses it).
GET /segments/{id}/exportbroadcasts.manageDownload segment members (CSV).
POST /broadcastsbroadcasts.manageCreate a draft (Pro/Custom): {"name", "channel_id", "audience", "messages", "settings"}.
PATCH /broadcasts/{id}broadcasts.manageEdit a draft (only the parts sent).
DELETE /broadcasts/{id}broadcasts.manageDelete a draft, completed, or cancelled broadcast.
POST /broadcasts/{id}/mediabroadcasts.manageAttach a file (multipart file: image, video, document); DELETE to remove it.
POST /broadcasts/{id}/testbroadcasts.manageSend a test to one number {"phone", "variant"} (no quota used, limited per hour).
POST /broadcasts/{id}/schedulebroadcasts.manageStart now/schedule with {"risk_ack": true}; the recipient list is locked.
POST /broadcasts/{id}/pausebroadcasts.managePause. Also /resume, /cancel, /retry (resend failed), /unschedule (back to draft), /duplicate.

Public

EndpointPermissionDescription
GET /public/config—Plan prices & quotas, tax, and retention rules (follows X-Locale).
GET /public/demo-livechat—The sample-data live chat key for the /demo/livechat page (null when there is none).
POST /plan-requests—Custom plan request: name, email, message, optional company, phone, channels, agents.
GET /invite/{token}—Invitation summary from the email link.

Live chat widget (public)

Called by the live chat widget (the livechat.js loader & the Onix iframe) without signing in. {key} = the channel\'s public key. Apart from config and logo, all of them use the visitor token in Authorization: Bearer. Full details: Live chat.

EndpointAccessDescription
GET /livechat/{key}/config—Widget look & texts, business-hours status, form token (CORS *).
GET /livechat/{key}/logo—Widget logo.
POST /livechat/{key}/visitors—Start a chat: name, email, phone (required), page_url, locale, form_token → visitor token.
GET /livechat/{key}/conversation—The visitor's conversation + messages after ?after={id}, queue position, agent.
POST /livechat/{key}/messages—Send a message: text, client_id (safe to resend), optional multipart attachment file.
GET /livechat/{key}/files/{id}—Attachments in that visitor's conversation only.
POST /livechat/{key}/realtime—WebSocket token for the visitor:{session} channel.
POST /livechat/{key}/typing—The visitor is typing.
POST /livechat/{key}/seen—The widget is open & messages up to message_id have been seen.

Channel API (channel key)

For your system sending customer messages to Onix through an API channel, with the channel key onx_ch_… in Authorization: Bearer (not a workspace API key). Agent replies are sent to your webhook. Guide, sample payloads, and signature verification: Channel API.

EndpointAccessDescription
POST /channel-api/messages—Inbound message: {"conversation_id", "contact": {"id", "name", "email", "phone"}, "message": {"id", "text", "attachments", "sent_at"}}. A unique message.id makes resending safe.
POST /channel-api/messages/{id}/status—Mark an agent reply delivered/read.
GET /channel-api/files/{token}—Agent reply attachments (signed link in the webhook, 7 days).

Examples

Who owns this key

curl -s "https://onix.sassly.ai/api/v1/me" \
  -H "Authorization: Bearer $ONIX_API_KEY"
{
  "data": {
    "user": {"id": 12, "name": "Budi Santoso", "email": "[email protected]", "avatar_url": null, "locale": "en", "timezone": null, "last_login_at": "2026-10-08T02:00:00Z"},
    "via": "key",
    "timezone": "Asia/Jakarta",
    "workspace": {
      "id": 3, "name": "Toko Budi", "slug": "toko-budi", "plan": "pro", "pro_until": "2026-11-08T03:15:00Z", "in_grace": false, "timezone": "Asia/Jakarta",
      "limits": {"channels": 4, "users": 10, "tickets_per_month": 200, "replies_per_day": 0, "ai_per_month": 2000, "ai": true, "api": true},
      "created_at": "2026-10-08T02:00:00Z"
    },
    "role": {"id": 1, "name": "Owner"},
    "is_owner": true,
    "is_platform_admin": false,
    "permissions": ["dashboard.view", "interactions.view", "interactions.reply", "interactions.assign", "templates.manage", "channels.manage", "settings.members", "settings.roles", "settings.billing", "settings.audit", "settings.workspace", "settings.api"],
    "channel_ids": null,
    "realtime": true
  }
}

In limits, 0 means unlimited. A null channel_ids means access to every channel. timezone is the effective zone (the user's personal zone, or the workspace zone).

Replying to a WhatsApp customer

Find unanswered conversations, then send a reply (requires a write key). The message goes out through the conversation's WhatsApp number and is recorded under the key's name.

curl -s "https://onix.sassly.ai/api/v1/interactions?tab=unreplied&per_page=5" \
  -H "Authorization: Bearer $ONIX_API_KEY"
curl -s -X POST "https://onix.sassly.ai/api/v1/interactions/42/messages" \
  -H "Authorization: Bearer $ONIX_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"text": "Hi, your order shipped today 🙏"}'
{
  "data": {
    "id": 981, "interaction_id": 42, "direction": "out", "type": "text", "body": "Hi, your order shipped today 🙏",
    "media": null, "reply_to": null, "sender": {"name": "API · CRM", "user_id": 12, "jid": null},
    "status": "sent", "error": null, "reactions": [], "sent_at": "2026-10-09T03:15:00Z", "created_at": "2026-10-09T03:15:00Z"
  },
  "meta": {"replies": {"used": 1, "limit": 0}}
}

Messages rejected by WhatsApp are kept with "status": "failed" and the reason in error; resend them via /retry. At most 30 messages per minute per number (429 beyond that). Attachments: send multipart/form-data with a file field (up to 16 MB).

Member audit log since the start of the month

curl -s "https://onix.sassly.ai/api/v1/audit-logs?category=anggota&from=2026-10-01&per_page=20" \
  -H "Authorization: Bearer $ONIX_API_KEY"
{
  "data": [
    {
      "id": 41, "action": "member.invited", "label": "Invited a member", "category": "anggota", "icon": "user-plus",
      "target": "[email protected]", "details": "Role: Agent",
      "user": {"id": 12, "name": "Budi Santoso", "avatar_url": null},
      "ip": "203.0.113.10", "created_at": "2026-10-08T03:20:00Z"
    }
  ],
  "meta": {"page": 1, "per_page": 20, "total": 1, "pages": 1, "categories": [{"key": "anggota", "label": "Members & invitations"}, "…"], "users": [{"id": 12, "name": "Budi Santoso"}]}
}

JavaScript (Node 18+) and PHP

const res = await fetch("https://onix.sassly.ai/api/v1/members", {
  headers: { Authorization: `Bearer ${process.env.ONIX_API_KEY}` },
});
const { data, error } = await res.json();
if (error) throw new Error(`${error.code}: ${error.message}`);
$ch = curl_init('https://onix.sassly.ai/api/v1/roles');
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . getenv('ONIX_API_KEY')],
]);
$body = json_decode(curl_exec($ch), true);

Real-time events

The app receives events over WebSocket (Centrifugo) on the ws:{workspace_id} and user:{user_id} channels. Each event looks like {"event": "…", "data": {…}, "at": "…Z"}.

EventChannelWhen
notification.createduser:{id}A new notification for the user.
workspace.updatedws:{id}The workspace name changed.
settings.updatedws:{id}General settings changed (e.g. the time zone).
members.changedws:{id}Members or invitations changed.
teams.changedws:{id}Teams or team members changed.
billing.updatedws:{id}The plan changed (Pro activated, moved to Free, set by an admin).
inbox.changedws:{id}Something changed in the inbox: {"interaction_id", "channel_id", "reason"} without message content — reload the list through the API (channel access is still checked).
channel.updatedws:{id}A channel's status or settings changed, including history import progress.
labels.changedws:{id}A channel's WhatsApp Business labels changed.
message.created / message.updatedconv:{id}A new or changed message (delivery status, reactions, edits, unsends, media downloaded). Payload = the message resource.
interaction.updatedconv:{id}The conversation's status, assignment, or summary changed.
typingconv:{id}The customer (WhatsApp/live chat) or another agent is typing.
visitor.onlineconv:{id}The live chat visitor has the widget open (sent by the widget about every 30 seconds).
tickets.changedws:{id}A ticket was created/changed: {"ticket_id", "reason"} without content — reload via the API (visibility is still checked).
ticket.message / ticket.message.updatedticket:{id}New or changed discussion message (e.g. sent as feedback). Payload = ticket message resource.
ticket.eventticket:{id}New ticket log entry (status, priority, due date, participants, feedback, conclusion).
ticket.updatedticket:{id}Ticket data changed — reload its details.
contacts.changedws:{id}A contact was created/updated/merged/deleted/imported: {"contact_id", "reason"}.
contacts.settingsws:{id}Onix labels or custom fields changed.
kb.changedws:{id}A knowledge base category or article changed, including finishing/failing semantic search processing: {"article_id"} or {"category_id"}.
pipeline.changedws:{id}A lead, workflow, or Pipeline setting changed: {"lead_id", "pipeline_id", "reason"} without content — reload via the API (data ownership is still checked).
clients.changedws:{id}A client was created/edited/deleted or a contact was linked: {"client_id"}.
broadcasts.changedws:{id}A broadcast was created/edited/started/paused/completed, sending progressed, or a recipient receipt/reply arrived: {"broadcast_id", "reason"} — reload via the API.
segments.changedws:{id}A segment was created/edited/deleted: {"segment_id"}.

The conv:{id} channel is granted only if the user may view that conversation (channel access + team rules). When a conversation is assigned to another team/person, users who lose access are unsubscribed from its channel automatically.

The ticket:{id} channel is only granted to users who can open that ticket (participants, team members for tickets without an assignee, or those who see all data).

The live chat widget uses a separate visitor:{session} channel (token from POST /livechat/{key}/realtime) with the message, queue, typing, and read events — see Live chat.