# 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`](https://onix.sassly.ai/docs/openapi.yaml) — import it into Postman, Insomnia, or an SDK generator.

## Authentication

|  | Onix app (browser) | Integrations (your server) |
| --- | --- | --- |
| Identity | Session cookie after signing in with Google | Header `Authorization: Bearer onx_live_…` |
| Requests that change data | Require the session's `X-CSRF-Token` header; the app sends it automatically | No CSRF; the key needs the `write` scope |
| Permissions | Follow the user's role and channel access | The whole workspace, limited by the key's scope |
| Plans | All plans | Pro 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

| HTTP | code | Meaning |
| --- | --- | --- |
| 401 | `unauthorized` | The API key is missing, malformed, or revoked; or the app session has ended. |
| 402 | `plan_required` | The workspace isn't on an active Pro or Custom plan (API keys and Pro-only features). |
| 403 | `forbidden` | Missing permission, an app-only endpoint called with an API key, or a `read` key trying to change data. |
| 404 | `not_found` | The endpoint or data doesn't exist, including data that belongs to another workspace. |
| 405 | `method_not_allowed` | The method isn't supported by this endpoint; see the `Allow` header. |
| 409 | `conflict` / `workspace_required` | The data conflicts with the current state (e.g. a role still in use), or the user has no workspace yet. |
| 419 | `csrf_mismatch` | An app request other than GET without a valid `X-CSRF-Token`. Reload the page and try again. |
| 422 | `validation_error` | Invalid input; per-field details are in `error.fields`. |
| 423 | `workspace_frozen` | The workspace is frozen because its Custom contract ended. Only Plan & Billing, ownership transfer, notifications, and basic read data keep working. |
| 429 | `rate_limited` | Too many requests; wait for `Retry-After`. |
| 502 | `ai_error` | The AI provider is having trouble. Your AI quota isn't used; try again shortly. |
| 503 | `ai_unavailable` | AI features aren't set up on the Onix server yet. Contact the Onix admin. |
| 500 | `server_error` | A 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

| Endpoint | Permission | Description |
| --- | --- | --- |
| 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 `/dashboard` | `dashboard.view` | Getting-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 `/interactions` | `interactions.view` | Inbox: 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.view` | One 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}/messages` | `interactions.reply` | From 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}/notes` | `interactions.view` | The 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}/messages` | `interactions.view` | Messages oldest → newest. Cursors `before`/`after` (message id), `limit` up to 100. |
| POST `/interactions/{id}/messages` | `interactions.reply` | Send 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.reply` | Change `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.reply` | Edit your own text message (within 15 minutes of sending). |
| DELETE `/interactions/{id}/messages/{message}` | `interactions.reply` | Unsend a message (delete for everyone), within 2 days. |
| POST `/interactions/{id}/messages/{message}/retry` | `interactions.reply` | Resend a failed message. |
| POST `/interactions/{id}/messages/{message}/reaction` | `interactions.reply` | Emoji reaction: `{"emoji": "👍"}`; empty = remove. |
| GET `/interactions/{id}/assignees` | `interactions.reply` | Members who can be assigned to this conversation (with `team_ids`) and the Frontline teams with their members in `meta.teams`. |
| GET `/messages/{id}/media` | `interactions.view` | A message's media file (private). Supports `Range`; `?download=1` to download. |
| GET `/messages/{id}/email` | `interactions.view` | The 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.view` | Email attachment number `index` (from 0, in the order of the message's `email.attachments`). `?download=1` to download. |
| GET `/contacts/{id}/avatar` | `interactions.view` | The contact's WhatsApp profile photo. |
| GET `/groups/{id}/avatar` | `interactions.view` | The WhatsApp group photo. |
| POST `/interactions` | `interactions.reply` | Start 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-seen` | `interactions.view` | Mark the ticket feedback/conclusion in a conversation as read. |
| GET `/groups/{id}` | `interactions.view` | Group page: info, members (matched to contacts), and the group's conversations. |
| GET `/tickets` | `tickets.view` | Tickets 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 `/tickets` | `tickets.create` | Create 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/candidates` | `tickets.create` | Members who can be assigned or mentioned on tickets, with their Back Office `team_ids` & `is_admin`. |
| GET `/tickets/{id}` | `tickets.view` | Ticket 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.manage` | Change `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}/assignees` | `tickets.manage` | Add an assignee (a member of the ticket's team, or an admin): `{"user_id": 5}`. |
| DELETE `/tickets/{id}/participants/{user}` | `tickets.manage` | Remove a participant (followers may unfollow themselves). Without an assignee, the ticket is visible to the whole team again. |
| GET `/tickets/{id}/messages` | `tickets.view` | Ticket discussion + log in `meta.events`; `after_message`/`after_event` for new items only. |
| POST `/tickets/{id}/messages` | `tickets.view` | Post 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}/feedback` | `tickets.view` | Send a discussion message as feedback to the original conversation (internal note). |
| POST `/tickets/{id}/conclusion` | `tickets.view` | Conclusion for the agent: `{"conclusion": "…", "resolve": true}` (`resolve` needs `tickets.manage`). |
| GET `/ticket-messages/{id}/files/{index}` | `tickets.view` | Discussion 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 `/contacts` | `contacts.view` | Contacts. Filters `q`, `label_id`, `wa_label_id`, `owner` (`me`/`none`/id), `channel_id`, `sort` (`recent`/`name`/`created`). |
| POST `/contacts` | `contacts.manage` | Add a contact: `name`, `phone`, `email`, `company`, `job_title`, `address`, `city`, `language`, `owner_id`, `label_ids`, `custom`. |
| GET `/contacts/{id}` | `contacts.view` | Full profile: identities, Onix & WhatsApp labels, custom fields, statistics & 12-month activity. |
| PATCH `/contacts/{id}` | `contacts.manage` | Update a contact (only the fields sent). `label_ids` replaces all labels. |
| POST `/contacts/{id}/labels` | `contacts.manage` | Add an Onix label: `{"label_id": 3}` (also allowed with `interactions.reply`). |
| DELETE `/contacts/{id}/labels/{label}` | `contacts.manage` | Remove an Onix label. |
| GET `/contacts/{id}/timeline` | `contacts.view` | Contact timeline, newest first (`before` = last id). |
| GET `/contacts/{id}/tickets` | `contacts.view` | The contact's tickets with their status log (only those the caller can see). |
| GET `/contacts/{id}/notes` | `contacts.view` | Team notes. |
| POST `/contacts/{id}/notes` | `contacts.view` | Add a note: `{"body": "…"}`. |
| PATCH `/contacts/{id}/notes/{note}` | `contacts.view` | Edit a note (author only). |
| DELETE `/contacts/{id}/notes/{note}` | `contacts.view` | Delete a note (the author or `contacts.manage`). |
| GET `/labels` | `contacts.view` | Onix labels with their contact counts; `meta.colors` = available colors. |
| GET `/custom-fields` | `contacts.view / interactions.view / tickets.view / leads.view` | Custom field definitions per entity: `?entity=contact\|interaction\|ticket\|lead\|client` (no entity = all). `meta.max_per_entity` = 30. |
| GET `/contact-fields` | `contacts.view` | Custom contact field definitions (same as `/custom-fields?entity=contact`). |
| GET `/wa-label-rules` | `contacts.manage` | WhatsApp label automation rules (Pro): `{"label_name", "channel", "action", "value", "value_label", "active"}`. Also allowed with `channels.manage`. |
| GET `/conversation-categories` | `interactions.view` | Conversation 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/categories` | `kb.view` | Knowledge base categories (by position) with the number of published articles the caller may see. |
| POST `/kb/categories` | `kb.manage` | Create a category: `{"name", "description", "position"}`. Names are unique per workspace. |
| PATCH `/kb/categories/{id}` | `kb.manage` | Update a category (fields you don't send stay the same). |
| DELETE `/kb/categories/{id}` | `kb.manage` | Delete a category; its articles become uncategorized. |
| GET `/kb/articles` | `kb.view` | Paginated 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/articles` | `kb.manage` | Create 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.view` | One 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.manage` | Update 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.manage` | Delete an article. |
| GET `/kb/search` | `kb.view` | Search 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/summary` | `reports.view` | Summary 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](https://onix.sassly.ai/en/docs/laporan). |
| GET `/reports/daily` | `reports.view` | One row per date in the range: `{"date", "conversations", "messages_in", "messages_out"}` (0 when empty). Same parameters as the summary. |
| GET `/reports/hourly` | `reports.view` | 24 rows of messages in per hour 0–23 (caller's time zone): `{"hour", "messages_in"}`. |
| GET `/reports/breakdown` | `reports.view` | `type` = `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 `/kpi` | `reports.view` | Per-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](https://onix.sassly.ai/en/docs/kpi). |
| 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/config` | `settings.workspace` | Workspace 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/agents` | `reports.view` | Productivity of each active member: `{"user", "replies", "conversations", "first_responses", "avg_first_response_minutes", "tickets_resolved"}`. |
| POST `/interactions/{id}/summary` | `ai.use` | AI summary of a conversation (Pro/Custom, 1 AI request): 3–6 bullet points, saved and shown in `GET /interactions/{id}` → `ai_summary`. |
| GET `/templates` | `interactions.reply` | Reply templates. With `interaction_id`, each template includes `rendered` (variables filled in). Filters `q`, `category`. |
| GET `/templates/{id}` | `interactions.reply` | One template. |
| GET `/templates/{id}/attachment` | `interactions.reply` | The template attachment. |
| GET `/settings` | `settings.workspace` | General settings (workspace time zone, response target, business hours `business_hours` + `open_now`, …) with choices in `meta.choices`. |
| GET `/members` | `settings.members` | Every member, including the Owner. |
| GET `/invitations` | `settings.members` | Invitations not yet accepted, including expired ones. |
| GET `/roles` | `settings.roles` | Every role with its permissions and usage counts (or `settings.members`). |
| GET `/roles/{id}` | `settings.roles` | A single role. |
| GET `/permissions` | `settings.roles` | Permission catalog by menu group; `meta.grantable` = what this user may grant. |
| GET `/audit-logs` | `settings.audit` | Audit log, newest first. Filters: `category`, `user`, `from`, `to` (YYYY-MM-DD, reader's zone), `q`. 50 per page by default. |
| GET `/pipelines` | `leads.view / leads.manage` | Visible 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.manage` | One workflow (holders of `pipeline.manage` also get the lead count per stage). |
| GET `/pipeline/settings` | `leads.view / leads.manage` | Lost reasons, expense categories, stage color palette, and the In-progress stage limit. |
| GET `/pipeline/dashboard` | `leads.view / leads.manage` | Pipeline 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 `/leads` | `leads.view / leads.manage` | List 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 `/leads` | `leads.manage` | Create 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/board` | `leads.view / leads.manage` | Kanban 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/candidates` | `leads.view / leads.manage` | Members who can own leads, with Back Office `team_ids` & `is_admin`. |
| GET `/leads/duplicates` | `leads.manage` | Open leads of a contact in the same workflow (`contact_id`, `pipeline_id`) — a warning before creating a lead. |
| GET `/leads/{id}` | `leads.view / leads.manage` | Lead details: workflow & stage, contact, client, source conversation, the 30 latest activities, checklist, events, files, expenses, custom fields, and `can`. |
| PATCH `/leads/{id}` | `leads.manage` | Change `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}/move` | `leads.manage` | Move 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}/activities` | `leads.view / leads.manage` | Paged lead timeline (`before` = last id, `kind=note` for notes only). |
| POST `/leads/{id}/activities` | `leads.manage` | Log 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.manage` | Lead file (private). `?download=1` to download. |
| GET `/lead-expenses/{id}/receipt` | `leads.view / leads.manage` | Expense receipt (private). `?download=1` to download. |
| GET `/interactions/{id}/leads` | `interactions.view` | The 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}/leads` | `contacts.view` | The contact's leads you can see (empty without Pipeline access). |
| GET `/clients` | `contacts.view / leads.view` | Client 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/search` | `contacts.view / leads.view` | Search clients for form pickers: `?q=`. |
| POST `/clients` | `clients.manage` | Add a client: `{"name", "industry", "phone", "email", "website", "address", "city", "notes", "custom"}`. Names are unique per workspace. |
| GET `/clients/{id}` | `contacts.view / leads.view` | Client profile, linked contacts, the leads you can see, and a deal summary. |
| PATCH `/clients/{id}` | `clients.manage` | Edit a client. |
| GET `/segments` | `broadcasts.view / broadcasts.manage` | Contact segments: rules, summary, `last_count`, and how many broadcasts use them. |
| GET `/segments/fields` | `broadcasts.view / broadcasts.manage` | Field, operator, & value catalog for segment rules (including custom fields `cf:{key}`). |
| POST `/segments/preview` | `broadcasts.view / broadcasts.manage` | Preview rules without saving: `{"match": "all\|any", "rules": [{"field", "op", "value", "value2"}]}` → count, with phone, unsubscribed, samples. |
| GET `/segments/{id}` | `broadcasts.view / broadcasts.manage` | Segment details + recalculated members (`preview`). |
| GET `/broadcasts` | `broadcasts.view / broadcasts.manage` | Broadcast list (`status`, `channel_id`, `q`) with per-status counts (`stats`). |
| GET `/broadcasts/options` | `broadcasts.view / broadcasts.manage` | Numbers (broadcast permission), segments, default settings & safety limits, quota, message variables. |
| GET `/broadcasts/dashboard` | `broadcasts.view / broadcasts.manage` | Figures 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.manage` | Broadcast details: audience, messages, attachment, settings, counts, skip reasons, risk acknowledgement, `wait`, `can`. |
| GET `/broadcasts/{id}/recipients` | `broadcasts.view / broadcasts.manage` | Recipients & per-recipient status (cumulative `status`, `q`), including the message sent. |
| GET `/broadcasts/{id}/export` | `broadcasts.view / broadcasts.manage` | Per-recipient report `?format=xlsx\|csv`. |
| GET `/broadcasts/{id}/media` | `broadcasts.view / broadcasts.manage` | The broadcast attachment (private). |
| POST `/broadcasts/{id}/estimate` | `broadcasts.view / broadcasts.manage` | Estimated recipients (skip reasons), quota, finish time, and risk level. |
| POST `/broadcasts/{id}/preview` | `broadcasts.view / broadcasts.manage` | Final 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`.

| Endpoint | Permission | Description |
| --- | --- | --- |
| POST `/wa-label-rules` | `contacts.manage` | Create 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.manage` | Update a rule (including `active`). |
| DELETE `/wa-label-rules/{id}` | `contacts.manage` | Delete a rule. |
| POST `/conversation-categories` | `contacts.manage` | Create 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.manage` | Change 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.manage` | Delete a category; its subcategories and conversations move to its parent (uncategorized for a top-level category). |
| PUT `/kpi/config` | `settings.workspace` | Save 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/export` | `reports.view` | KPI 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}/embed` | `kb.manage` | (Re)process a published article for semantic search (Pro/Custom) — the Process now / Reprocess / Try again buttons. |
| GET `/reports/export` | `reports.view` | Download 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 `/workspace` | `settings.workspace` | Rename the workspace. |
| POST `/workspace/leave` | — | Leave the active workspace (not the Owner). |
| PATCH `/settings` | `settings.workspace` | Update 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}/read` | `interactions.view` | Mark as read; read receipts are sent to the customer. |
| POST `/interactions/{id}/typing` | `interactions.reply` | Typing indicator: `{"typing": true}`. |
| POST `/interactions/{id}/pickup` | `interactions.reply` | Pick 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}/history` | `interactions.view` | Copy older history from the phone for this conversation. |
| POST `/channels` | `channels.manage` | Add 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.manage` | Channel details. |
| PATCH `/channels/{id}` | `channels.manage` | Every 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.manage` | Delete a channel (archived if it already has conversations). |
| GET `/channels/{id}/qr` | `channels.manage` | QR code to scan (PNG data URL) + lifetime in seconds. |
| POST `/channels/{id}/pair` | `channels.manage` | Pairing code: `{"phone": "0812…"}`. |
| GET `/channels/{id}/status` | `channels.manage` | Latest number status. |
| POST `/channels/{id}/logout` | `channels.manage` | Log the number out of Onix. |
| POST `/channels/{id}/reconnect` | `channels.manage` | Reconnect. |
| POST `/channels/{id}/history` | `channels.manage` | Import chat history: `{"days": 30}` (7/30/90 days). |
| GET `/channels/{id}/labels` | `channels.manage` | WhatsApp Business labels (read from the phone). |
| POST `/channels/email/detect` | `channels.manage` | Guess the provider from an address: `{"address": "cs@yourshop.com"}` → `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/test` | `channels.manage` | Test 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/email` | `channels.manage` | Connect 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}/email` | `channels.manage` | Change email settings (same fields as above, except `history_days`). Server, username, password, or folder changes are tested again before saving. |
| GET `/channels/{id}/email/folders` | `channels.manage` | Mailbox folders (signs in again) with a `selected` flag, plus the Sent folder. |
| POST `/channels/{id}/sync` | `channels.manage` | Check the mailbox now (the pause after failed sign-ins is skipped). Returns `{"sync", "channel"}`. |
| POST `/channels/{id}/icon` | `channels.manage` | Upload 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}/icon` | `channels.manage` | Go back to the default icon for the channel type. |
| POST `/channels/livechat` | `channels.manage` | Create 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](https://onix.sassly.ai/en/docs/livechat). |
| PATCH `/channels/{id}/livechat` | `channels.manage` | Widget 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/key` | `channels.manage` | Replace the public key; the old embed code stops working. |
| POST `/channels/api` | `channels.manage` | Create 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](https://onix.sassly.ai/en/docs/api-channel). |
| GET `/channels/{id}/api` | `channels.manage` | Settings, the inbound message endpoint, the active key (prefix & last used), and the 50 latest webhook deliveries. |
| PATCH `/channels/{id}/api` | `channels.manage` | `webhook_url` (https, public host), `events` (`message.created` always; `conversation.updated` optional), `retries` (0–5); also `name`, `hours`, `auto_reply`. |
| POST `/channels/{id}/api/key` | `channels.manage` | Replace the channel key; the old key is rejected right away (401). |
| GET `/channels/{id}/api/secret` | `channels.manage` | Show the webhook secret. |
| POST `/channels/{id}/api/secret` | `channels.manage` | Replace the webhook secret. |
| POST `/channels/{id}/api/test-webhook` | `channels.manage` | Send a `ping` event now: `{"status", "http_status", "duration_ms", "error"}`. |
| POST `/channels/{id}/api/test-message` | `channels.manage` | Simulate an inbound message from your system (shows up in Interaction). |
| POST `/templates` | `templates.manage` | Create a template: `name`, `shortcut`, `category`, `body` (JSON or multipart with `file`). |
| PATCH `/templates/{id}` | `templates.manage` | Update a template. |
| DELETE `/templates/{id}` | `templates.manage` | Delete a template. |
| POST `/templates/{id}/attachment` | `templates.manage` | Set/replace the attachment (multipart `file`). |
| DELETE `/templates/{id}/attachment` | `templates.manage` | Remove the attachment. |
| GET `/contacts/export` | `contacts.manage` | Export contacts to CSV (same filters as the list). |
| POST `/contacts/import` | `contacts.manage` | CSV import (multipart `file`): returns `created`, `updated`, `skipped`, `errors`. |
| DELETE `/contacts/{id}` | `contacts.manage` | Delete a contact with its conversations, messages, media, notes, and timeline (tickets stay, unlinked). |
| POST `/contacts/{id}/merge` | `contacts.manage` | Merge duplicates: `{"source_id": 9}` is merged into contact {id}. |
| POST `/labels` | `contacts.manage` | Create a label: `{"name": "VIP", "color": "orange"}`. |
| PATCH `/labels/{id}` | `contacts.manage` | Change a label's name, color, or order. |
| DELETE `/labels/{id}` | `contacts.manage` | Delete a label (removed from all contacts). |
| POST `/custom-fields` | `contacts.manage` | Create a custom contact, interaction, or ticket field: `{"entity": "interaction", "label": "Invoice number", "type": "text"}`. Keys are unique per entity. |
| PATCH `/custom-fields/{id}` | `contacts.manage` | Change a field's name, options, or order (entity, type, & key stay the same). |
| DELETE `/custom-fields/{id}` | `contacts.manage` | Delete a custom field; its values are no longer shown. |
| POST `/contact-fields` | `contacts.manage` | Create a custom contact field: `{"label": "Size", "type": "select", "options": ["S", "M", "L"]}`. |
| PATCH `/contact-fields/{id}` | `contacts.manage` | Change a field's name, options, or order. |
| DELETE `/contact-fields/{id}` | `contacts.manage` | Delete a custom field. |
| PATCH `/members/{id}` | `settings.members` | Change 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 `/invitations` | `settings.members` | Invite a member: `email`, `role_id`, `team_ids`, `view_all`, `channel_access`, `channel_ids`. |
| POST `/teams` | `settings.members` | Create a team: `{"name": "Warehouse", "type": "backoffice", "member_ids": [5, 7]}`. |
| PATCH `/teams/{id}` | `settings.members` | Change a team's name, description, or members (`member_ids` = the full list). The type cannot be changed. |
| DELETE `/teams/{id}` | `settings.members` | Delete 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}/resend` | `settings.members` | Resend an invitation with a new link. |
| DELETE `/invitations/{id}` | `settings.members` | Cancel an invitation. |
| POST `/roles` | `settings.roles` | Create a role: `name`, `permissions`, and `is_admin` (Owner/admins only). |
| PATCH `/roles/{id}` | `settings.roles` | Change a role's name, permissions, and `is_admin` flag (except Owner). |
| DELETE `/roles/{id}` | `settings.roles` | Delete a role nobody uses. |
| POST `/roles/{id}/duplicate` | `settings.roles` | Copy a role. |
| GET `/api-keys` | `settings.api` | Active API keys, without their secret values. |
| POST `/api-keys` | `settings.api` | Create a key: `{"name": "…", "scope": "read"}`. The full value is returned only once. |
| DELETE `/api-keys/{id}` | `settings.api` | Revoke a key. |
| GET `/billing` | `settings.billing` | Plan, usage, invoices, and billing profile. |
| POST `/billing/upgrade` | `settings.billing` | Issue (or reuse) the upgrade invoice and create a Sassly Pay checkout. |
| GET `/billing/invoices/{number}` | `settings.billing` | Invoice details with payment attempts. |
| POST `/billing/invoices/{number}/pay` | `settings.billing` | Sassly Pay checkout URL (a still-valid attempt is reused). |
| PATCH `/billing/profile` | `settings.billing` | Billing profile for upcoming invoices. |
| POST `/billing/downgrade-choice` | `settings.billing` | Choose which channels & members stay active after moving to Free. |
| GET `/billing/return` | `settings.billing` | Payment status after returning from checkout (checked with Sassly Pay on the server). |
| POST `/pipelines` | `pipeline.manage` | Create a workflow: `{"name", "description", "team_ids"}` (Back Office teams); without `stages` → default stages. |
| PATCH `/pipelines/{id}` | `pipeline.manage` | Change the name, description, teams, order; `{"archived": false}` restores it. |
| PUT `/pipelines/{id}/stages` | `pipeline.manage` | Save the full stage layout: one New, 0–10 In progress, one Won, one Lost (label, color, probability %). |
| DELETE `/pipelines/{id}` | `pipeline.manage` | Delete a workflow without leads; one that still has leads is archived. |
| PUT `/pipeline/settings` | `pipeline.manage` | Save `lost_reasons` & `expense_categories`. |
| GET `/pipeline/export` | `reports.view` | Raw data CSV: `type` = `leads` (all columns + custom fields), `activities`, or `expenses`; filters `pipeline_id`, `status`, `from`/`to`. |
| GET `/leads/import/template` | `leads.manage` | Import template `?format=xlsx\|csv`. |
| POST `/leads/import/preview` | `leads.manage` | Read an Excel/CSV file (multipart `file`): columns, sample values, mapping suggestions, a 1-hour token. |
| POST `/leads/import` | `leads.manage` | Check (`dry_run`) or run the import with a column mapping (unmatched columns → new custom fields). |
| POST `/leads/bulk` | `leads.manage` | Bulk actions (max 100): `move`, `assign`, `followup`. |
| DELETE `/leads/{id}` | `leads.manage` | Delete a lead (admin, owner, or creator); quota is not returned. |
| PATCH `/lead-activities/{id}` | `leads.manage` | Edit a manual activity (author or admin). |
| DELETE `/lead-activities/{id}` | `leads.manage` | Delete a manual activity (author or admin). |
| POST `/leads/{id}/files` | `leads.manage` | Upload a lead file (multipart `file`). |
| DELETE `/lead-files/{id}` | `leads.manage` | Delete a file (uploader, owner, or admin). |
| POST `/leads/{id}/checklist` | `leads.manage` | Add a checklist item `{"text"}`. |
| PATCH `/lead-checklist/{id}` | `leads.manage` | Edit / tick a checklist item (`text`, `done`, `position`). |
| DELETE `/lead-checklist/{id}` | `leads.manage` | Delete a checklist item. |
| POST `/leads/{id}/events` | `leads.manage` | Add an event: `{"kind", "title", "starts_at", "ends_at", "location", "notes", "remind_minutes"}`. |
| PATCH `/lead-events/{id}` | `leads.manage` | Edit an event (changing the start time resets the reminder). |
| DELETE `/lead-events/{id}` | `leads.manage` | Delete an event. |
| POST `/leads/{id}/expenses` | `leads.manage` | Record an expense (multipart): `spent_on`, `category`, `amount`, `note`, `spent_by`, `receipt` (photo/PDF). |
| PATCH `/lead-expenses/{id}` | `leads.manage` | Edit an expense (recorder, payer, or admin). |
| DELETE `/lead-expenses/{id}` | `leads.manage` | Delete an expense and its receipt. |
| DELETE `/clients/{id}` | `clients.manage` | Delete a client; contacts & leads stay without the client link. |
| POST `/clients/{id}/contacts` | `clients.manage` | Link (`{"contact_id", "link": true}`) or unlink a contact; the contact's leads without a client are linked too. |
| POST `/contacts/{id}/broadcast-opt-out` | `contacts.manage / broadcasts.manage` | Unsubscribe (`{"opt_out": true}`) from or re-subscribe to broadcasts. |
| POST `/segments` | `broadcasts.manage` | Create a segment: `{"name", "description", "match", "rules"}` (at most 15 rules, unique name). |
| PATCH `/segments/{id}` | `broadcasts.manage` | Edit a segment. |
| DELETE `/segments/{id}` | `broadcasts.manage` | Delete a segment (refused while a scheduled/running/paused broadcast uses it). |
| GET `/segments/{id}/export` | `broadcasts.manage` | Download segment members (CSV). |
| POST `/broadcasts` | `broadcasts.manage` | Create a draft (Pro/Custom): `{"name", "channel_id", "audience", "messages", "settings"}`. |
| PATCH `/broadcasts/{id}` | `broadcasts.manage` | Edit a draft (only the parts sent). |
| DELETE `/broadcasts/{id}` | `broadcasts.manage` | Delete a draft, completed, or cancelled broadcast. |
| POST `/broadcasts/{id}/media` | `broadcasts.manage` | Attach a file (multipart `file`: image, video, document); `DELETE` to remove it. |
| POST `/broadcasts/{id}/test` | `broadcasts.manage` | Send a test to one number `{"phone", "variant"}` (no quota used, limited per hour). |
| POST `/broadcasts/{id}/schedule` | `broadcasts.manage` | Start now/schedule with `{"risk_ack": true}`; the recipient list is locked. |
| POST `/broadcasts/{id}/pause` | `broadcasts.manage` | Pause. Also `/resume`, `/cancel`, `/retry` (resend failed), `/unschedule` (back to draft), `/duplicate`. |

### Public

| Endpoint | Permission | Description |
| --- | --- | --- |
| 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](https://onix.sassly.ai/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](https://onix.sassly.ai/en/docs/livechat).

| Endpoint | Access | Description |
| --- | --- | --- |
| 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](https://onix.sassly.ai/en/docs/api-channel).

| Endpoint | Access | Description |
| --- | --- | --- |
| 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": "budi@tokobudi.id", "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": "rina@tokobudi.id", "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"}`.

| Event | Channel | When |
| --- | --- | --- |
| `notification.created` | `user:{id}` | A new notification for the user. |
| `workspace.updated` | `ws:{id}` | The workspace name changed. |
| `settings.updated` | `ws:{id}` | General settings changed (e.g. the time zone). |
| `members.changed` | `ws:{id}` | Members or invitations changed. |
| `teams.changed` | `ws:{id}` | Teams or team members changed. |
| `billing.updated` | `ws:{id}` | The plan changed (Pro activated, moved to Free, set by an admin). |
| `inbox.changed` | `ws:{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.updated` | `ws:{id}` | A channel's status or settings changed, including history import progress. |
| `labels.changed` | `ws:{id}` | A channel's WhatsApp Business labels changed. |
| `message.created` / `message.updated` | `conv:{id}` | A new or changed message (delivery status, reactions, edits, unsends, media downloaded). Payload = the message resource. |
| `interaction.updated` | `conv:{id}` | The conversation's status, assignment, or summary changed. |
| `typing` | `conv:{id}` | The customer (WhatsApp/live chat) or another agent is typing. |
| `visitor.online` | `conv:{id}` | The live chat visitor has the widget open (sent by the widget about every 30 seconds). |
| `tickets.changed` | `ws:{id}` | A ticket was created/changed: `{"ticket_id", "reason"}` without content — reload via the API (visibility is still checked). |
| `ticket.message` / `ticket.message.updated` | `ticket:{id}` | New or changed discussion message (e.g. sent as feedback). Payload = ticket message resource. |
| `ticket.event` | `ticket:{id}` | New ticket log entry (status, priority, due date, participants, feedback, conclusion). |
| `ticket.updated` | `ticket:{id}` | Ticket data changed — reload its details. |
| `contacts.changed` | `ws:{id}` | A contact was created/updated/merged/deleted/imported: `{"contact_id", "reason"}`. |
| `contacts.settings` | `ws:{id}` | Onix labels or custom fields changed. |
| `kb.changed` | `ws:{id}` | A knowledge base category or article changed, including finishing/failing semantic search processing: `{"article_id"}` or `{"category_id"}`. |
| `pipeline.changed` | `ws:{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.changed` | `ws:{id}` | A client was created/edited/deleted or a contact was linked: `{"client_id"}`. |
| `broadcasts.changed` | `ws:{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.changed` | `ws:{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](https://onix.sassly.ai/en/docs/livechat).
