๐ Overview
Realtime team-chat service untuk ekosistem ikavia. Backend Go (gin + GORM +
gocql + gorilla/websocket), data berlapis: PostgreSQL untuk metadata
(channels, memberships, audit), ScyllaDB untuk message body (ULID-
ordered), Redis pub/sub untuk multi-instance fanout antar WS gateway.
Auth delegated ke Account Service โ JWT RS256 ditandatangani di
account.ikavia.com + verified via /api/v1/auth/public-key. Setiap
request authenticated lazy-create row di users table chat-side (1:1 ke
JWT sub).
๐ฌ REST + WebSocket
Identical message model via either surface
๐ Identity model
Two-layer โ account_id (JWT) โ chat user.id (local)
๐ Channel kinds
channel | dm | group_dm
๐งพ Standard envelope
Match ikavia pattern (snake_case JSON, kebab-case URL)
๐ Permission model
Org-level RBAC inherited dari account-service grants
๐ก Multi-instance scale-out
Redis pub/sub fan-out, no sticky session needed
๐ Storage layout
Per-stack isolation untuk prod vs staging
๐ Org switching
Multi-org users โ pilih org aktif via account-service
Base URL
Constraints
๐ฌ REST + WebSocket
Same envelope shape ({success, data, request_id}) untuk REST. WS frame ({type, id, data}) untuk realtime events. Send via POST /api/v1/channels/{channel-id}/messages โ persist Scylla โ publish Redis topic chat:ch:{channel_id} โ fan-out ke WS subscribers di instance manapun.
๐ Identity model
account_id= JWTsub, dimiliki account-service (otoritas).user_id= chat-local UUID di tabelusers. Lazy-created saat
principal pertama kali hit endpoint authenticated.
org_iddari JWTorg_idclaim โ strict per-org isolation
(channels, messages, memberships semua scoped by org).
principal_type=humanatauservice(bot/service account).
๐ Channel kinds
channelโ named, public-by-default, owner+admins+members.
Bisa is_private=true untuk invite-only.
dmโ 1:1 direct message. Idempotent canonical pair (sortir
user_id lalu hash โ metadata.dm_pair). POST /api/v1/dm dari AโB vs BโA return channel_id sama.
group_dmโ 3-9 user, tidak bisa di-rename/archive.
๐งพ Standard envelope
Success:
{ "success": true, "data": { ... }, "request_id": "uuid" }Error:
{ "success": false, "message": "...", "request_id": "uuid" }Pagination response shape:
{
"items": [...],
"pagination": {
"page": 1, "per_page": 50,
"total": 123, "total_pages": 3
}
}๐ Permission model
Chat endpoints gate-keep dengan permission claim di JWT. Required:
| Endpoint family | Permission required |
|---|---|
POST /channels | channel:create |
PATCH /channels/{id} | channel:update |
DELETE /channels/{id} | channel:archive |
POST /channels/{id}/members | channel:invite |
DELETE โฆ/members/{user-id} | channel:remove_member |
POST /channels/{id}/messages | chat:write |
GET โฆ/messages | (channel member auto-OK) |
Wildcards * dan chat:* di-honor. Service principals (bot) tetap kena permission gate โ tidak ada bypass.
๐ก Multi-instance scale-out
Setiap chat-service instance bergabung ke Redis pub/sub (db=5 staging, db=6 prod). Topics:
chat:ch:{channel_id}โ message eventschat:user:{user_id}โ per-user events (membership added, badges)chat:org:{org_id}โ org-wide announcements (future)
WS hub di-tiap-instance subscribe Redis untuk topics yang ada conn aktif. Saat user pindah load-balancer node, langganan WS re-build otomatis pakai topic state in-process.
๐ Storage layout
| Layer | Staging | Production |
|---|---|---|
| PostgreSQL | DB chat, role chat_app | DB chat_prod, role chat_app_prod |
| ScyllaDB | keyspace chat, RF=3 | keyspace chat_prod, RF=3 |
| Redis | db=5, prefix chat: | db=6, prefix chat_prod: |
| JWT issuer | account-service-dev | account-service |
Tidak ada cross-stack data bleed โ di-verify via test GET prod-channel via dev-token โ 404 channel not found.
๐ Org switching
JWT bawa satu org_id aktif. User member di banyak org perlu switch via account-service untuk pindah konteks:
POST https://account.ikavia.com/api/v1/organizations/{org_id}/switch
Authorization: Bearer <current_jwt>
Body: {}
โ 200 { access_token, refresh_token, org_id (new) }Chat-service tidak pegang switcher sendiri โ cukup pakai JWT baru di request berikutnya. Lazy-create di users table emit row terpisah per (account_id, org_id) โ tiap context punya user.id sendiri.
Visibility rule saat switch:
| Channel kind | Visible setelah switch ke org B? |
|---|---|
Org A internal (is_public=false) | โ Hilang (membership scoped per-org) |
| Org A private | โ Hilang |
Public (cross-org, is_public=true) | โ Tetap visible |
| DM peer di org B | โ Visible (DM canonical pair tetap aktif) |
| DM peer di org A | โ Hilang sementara |
Frontend chat-web punya dropdown switcher di top-bar yang call endpoint ini, simpan token baru di localStorage, tear-down WS, lalu re-bootstrap chat data untuk org target.
๐ JWT Bearer Authentication Flow
Step-by-step authentication menggunakan JWT Bearer
Human: POST https://account.ikavia.com/api/v1/auth/login dengan email+password โ terima access_token + refresh_token. Service principal: POST /auth/token-exchange dengan X-API-Key โ terima 1-hour JWT.
User member di banyak org โ satu org aktif per JWT. Untuk pindah:
POST https://account.ikavia.com/api/v1/organizations/{org_id}/switch
Authorization: Bearer <current_jwt>Response berisi access_token + refresh_token baru yang scoped ke org target. Replace token lokal, panggil ulang GET /api/v1/me di chat-service untuk lazy-create chat user di org baru, lalu list channels akan berubah ke konteks org tersebut.
List my orgs: GET https://account.ikavia.com/api/v1/organizations โ {items: [{org_id, name, my_role, ...}]} โ sumber data untuk dropdown switcher di UI.
Per-org boundary tetap kuat untuk channel non-public โ kalau org A punya channel secret-stuff, switch ke org B akan hilang dari list. Channel is_public=true tetap visible cross-org.
Setiap request: Authorization: Bearer <access_token>. First call auto lazy-create chat user row (1:1 ke JWT.sub). Subsequent call lookup existing.
Access token TTL ~15 menit. Saat 401, panggil POST /auth/refresh di account-service untuk dapat token baru (atau pakai refresh_token cookie SSO .ikavia.com).
wss://chat.ikavia.com/ws?token=<jwt> atau pakai Authorization header. Subprotocol ikavia-chat-v1. Subscribe ke channel via {"type":"subscribe","data":{"channel_ids":[...]}}.
๐ Service Flow Diagram
Visualisasi alur data antara services
Mint signed URL TTL 1 jam untuk akses satu atau lebih attachment. Membership channel di-verify per item; item invalid masuk hasil dengan ok=false + error (batch tidak fail keseluruhan).
FE biasanya panggil ini saat normalize message list (debounced 100ms), kemudian replace share_url dengan signed_url di render template.
{
"success": true,
"data": {
"items": [
{
"media_id": "abc-uuid",
"signed_url": "https://chat.ikavia.com/api/v1/attachments/abc-uuid/raw?c=27e3...&m=01K...&u=874f...&e=1779189144&a=view&t=11d2f44b...",
"expires_at": "2026-05-19T11:12:24Z",
"ok": true
}
]
}
}
Tidak butuh Bearer. Self-auth via signature di query string.
Verify HMAC + expire โ audit (attachment.viewed atau attachment.downloaded) โ 302 redirect ke share_url permanent media-service.
URL ini boleh embedded langsung di <img src>, <video>, dst โ browser akan follow 302 ke media-service.
Query Parameters
| Param | Type | Required | Description |
|---|---|---|---|
| c | uuid | required | Channel ID. |
| m | ulid | required | Message ID. |
| u | uuid | required | User ID yang minta tiket (audit, bukan validasi). |
| e | int64 | required | Unix timestamp expire. |
| a | string | optional | Action: view (default) atau download. Mempengaruhi audit event name. |
| t | hex | required | HMAC-SHA256 hex dari media_id|c|m|u|e|a pakai server secret. |
List attachment di sebuah channel, latest first. Membership cek + wildcard * bypass. Tiap item sudah include signed_url siap pakai โ FE tidak perlu re-sign.
Query Parameters
| Param | Type | Required | Description |
|---|---|---|---|
| kind | string | optional | Filter: image, video, audio, file, atau kosong (semua). |
| limit | int | optional | Default 50, max 100. |
| before | ulid | optional | Cursor pesan untuk paginate older. |
{
"success": true,
"data": {
"items": [
{
"media_id": "abc-uuid",
"signed_url": "https://chat.ikavia.com/api/v1/attachments/abc-uuid/raw?...",
"expires_at": "2026-05-19T11:12:24Z",
"kind": "image",
"mime": "image/jpeg",
"filename": "ktp.jpg",
"size": 123456,
"channel_id": "27e3...",
"message_id": "01KR...",
"sender_id": "874f...",
"sent_at": "2026-05-19T10:00:00Z"
}
],
"next_cursor": "01KR..."
}
}
Email + password โ access_token + refresh_token. Refresh juga di-set sebagai HttpOnly cookie scoped .ikavia.com untuk SSO cross-subdomain.
Request Body Fields
| Field | Type | Required | Description |
|---|---|---|---|
| string | required | ||
| password | string | required |
{
"success": true,
"data": {
"account_id": "uuid",
"email": "user@example.com",
"display_name": "Jane Doe",
"access_token": "<jwt>",
"refresh_token": "<jwt>",
"expires_in": 900,
"organizations": [{ "id": "uuid", "role": "owner" }]
}
}
Return semua org yang caller anggota. Dipakai oleh chat-web untuk populate org-switcher dropdown.
{
"success": true,
"data": {
"items": [
{
"org_id": "073bebe9-โฆ",
"name": "Acme Inc",
"slug": "acme-inc",
"status": "active",
"owner_id": "uuid",
"member_count": 42,
"my_role": "owner"
}
],
"total": 1, "page": 1, "per_page": 20, "total_pages": 1
}
}
Issue JWT baru yang org_id claim-nya = {org_id} di path. Session ID (sid) preserved โ bukan re-login. Refresh token juga di-rotate (lama otomatis ke-revoke saat next refresh).
Setelah dapat token baru, caller harus tear-down WebSocket lama dan reconnect dengan token baru โ server gateway tidak punya mechanism in-flight untuk re-auth. Frontend juga harus call ulang /api/v1/me chat-service untuk lazy-create chat user di org target.
Request Body Fields
| Field | Type | Required | Description |
|---|---|---|---|
| (empty body) | โ | optional |
POST dengan body {} cukup. Org_id di path.
|
{
"success": true,
"data": {
"account_id": "uuid",
"email": "user@example.com",
"display_name": "Jane Doe",
"access_token": "<new jwt scoped to target org>",
"refresh_token":"<rotated>",
"expires_in": 900,
"organizations":[ ... ]
}
}
Pakai refresh_token (body atau HttpOnly cookie) โ terima access baru. Setiap refresh rotate refresh token; presentasi refresh lama setelah rotation akan revoke seluruh session (reuse detection).
Chat-web kasih ini ke fetch interceptor: pada 401 dari chat, coba refresh sekali โ retry request asli; gagal โ redirect ke login.
Request Body Fields
| Field | Type | Required | Description |
|---|---|---|---|
| refresh_token | string | optional | Optional kalau cookie ada. |
Revoke session (JTI ke Redis revocation list). Stolen access token segera berhenti valid di seluruh service yang verify via JWKS + revocation cache. Cookie refresh juga di-clear.
Chat-service akan tutup WebSocket aktif untuk sid tersebut via error{code: session_revoked} frame + close.
Service principals (bots) send their ak_* key in X-API-Key header and receive a short-lived JWT scoped to the service account's permissions. JWT carries principal_type=service.
Note for chat-service callers: you do NOT need to call this endpoint directly. chat-service accepts X-API-Key natively on every authenticated route โ server exchanges + caches the JWT internally (in-memory cache per replica, 60s pre-exp refresh). See the dedicated Bot integration section in the chat-service README for the one-step pattern.
Use this endpoint when:
- You need the raw JWT for WS auth (
wss://chat.ikavia.com/ws?token=<jwt>) - You're calling a service that does NOT support X-API-Key dual-mode
{
"success": true,
"data": {
"access_token": "eyJhbGciOiJSUzI1NiIโฆ",
"expires_in": 3600,
"token_type": "Bearer",
"principal_type": "service"
}
}
Exchange the caller's JWT for short-lived Cloudflare TURN ICE-server credentials. The browser uses these to negotiate WebRTC. TTL is requested in the body (default 3600s, server clamps to 60..86400).
Response shape matches RTCConfiguration.iceServers so the FE can pass it through to new RTCPeerConnection({ iceServers }) with minimal massaging.
Request Body Fields
| Field | Type | Required | Description |
|---|---|---|---|
| ttl_seconds | int | optional | Lifetime of credentials in seconds (60..86400, default 3600) |
{ "ttl_seconds": 3600 }
{
"success": true,
"data": {
"ice_servers": [
{ "urls": ["stun:stun.cloudflare.com:3478"] },
{
"urls": [
"turn:turn.cloudflare.com:3478?transport=udp",
"turns:turn.cloudflare.com:5349?transport=tcp"
],
"username": "g070e2โฆ",
"credential": "ca7a41โฆ"
}
],
"expires_at": "2026-05-20T13:00:00Z",
"ttl_seconds": 3600
}
}
Initiator opens a new call session in the channel. Server inserts a row with status=dialing, adds initiator as a participant, then fans out call.invite on the channel topic so other members' devices ring.
Request Body Fields
| Field | Type | Required | Description |
|---|---|---|---|
| type | enum("audio"|"video") | required | audio or video |
{ "type": "video" }
{
"success": true,
"data": {
"id": "9bf2bc06-c00c-4cc1-ab06-5ba9fcc98f87",
"org_id": "...",
"channel_id": "...",
"initiator_id": "...",
"type": "video",
"status": "dialing",
"started_at": "2026-05-20T08:13:33.458841Z"
}
}
Invitee accepts. Promotes status from dialing to active on the first non-initiator join. Returns 422 if call has already ended or mesh capacity (4) is full.
{
"success": true,
"data": { "id": "...", "status": "active", ... }
}
Participant graceful hangup. Fans out call.participant_left. If the last active participant leaves, the call transitions to status=ended automatically and call.end is published.
Request Body Fields
| Field | Type | Required | Description |
|---|---|---|---|
| code | enum("hangup"|"disconnect"|"rejected"|"permission_denied") | optional | Why we're leaving โ default 'hangup' |
{ "code": "hangup" }
{ "success": true, "data": { "ok": true } }
Initiator (or moderator) terminates the call for everyone. Fans out call.end. Duration is recorded in milliseconds in the response.
Request Body Fields
| Field | Type | Required | Description |
|---|---|---|---|
| reason | string | optional | End reason, free-form (defaults to 'hangup') |
{ "reason": "hangup" }
{ "success": true, "data": { "ended": true } }
Recent call sessions for the channel. Shows duration, type, status (active / ended / missed / failed). Used by the chat-web "Call history" panel.
Query Parameters
| Param | Type | Required | Description |
|---|---|---|---|
| limit | int | optional | Max items, โค100 (default: 50) |
{
"success": true,
"data": {
"items": [
{
"id": "...", "type": "video",
"status": "ended", "end_reason": "hangup",
"started_at": "...", "ended_at": "...",
"duration_ms": 359, "initiator_id": "..."
}
]
}
}
List channels di org saat ini (resolved dari JWT org_id). Filter optional via query.
Tiap item di-dekorasi per-caller: is_member, last_read_id, dan unread_count = jumlah pesan setelah last_read_id (di-cap 99 โ klien tampilkan "99+" bila โฅ99; 0 = sudah terbaca semua). Dihitung hanya untuk channel yang ada unread (range-scan Scylla ter-bound).
Query Parameters
| Param | Type | Required | Description |
|---|---|---|---|
| scope | string | optional | my-org (channels di org sendiri) \| public (semua public cross-org) \| all (default, union org + public). |
| kind | string | optional | channel | dm | group_dm |
| archived | boolean | optional | true โ only archived; default false. |
| q | string | optional | Partial-match (LIKE %name%) untuk channel name. |
| page | int | optional | Default 1. |
| per-page | int | optional | Default 20, max 100. |
{
"success": true,
"data": {
"items": [
{
"id": "27e3cd2b-b1fe-49dd-ad1a-16ed0617daaf",
"org_id": "0db3bcc1-8e2e-4519-9a41-d4f1d314136a",
"kind": "channel",
"name": "general",
"topic": "All-hands",
"is_private": false,
"is_archived": false,
"e2e_enabled": false,
"created_by": "874f0605-19d7-4e18-8aef-c544d9620157",
"message_count": 4,
"last_message_id":"01KRYQ7K1JQX75GVZ5PT06Y4FY",
"last_message_at":"2026-05-18T23:37:39.634Z",
"is_member": true,
"last_read_id": "01KRYQ7B0000000000000000",
"unread_count": 3,
"created_at": "2026-05-18T23:08:21Z",
"updated_at": "2026-05-18T23:37:39Z"
}
],
"pagination": { "page": 1, "per_page": 20, "total": 2, "total_pages": 1 }
}
}
Detail single channel. Tidak strict membership check di endpoint ini (public channels visible org-wide); private channel data hanya terlihat kalau caller member.
Create channel baru. Creator auto-jadi owner-membership. kind selalu channel โ DM / group_dm pakai endpoint dedicated.
Request Body Fields
| Field | Type | Required | Description |
|---|---|---|---|
| name | string | required | Channel name. Lowercase alphanum + hyphen, max 80 chars. |
| topic | string | optional | Free-form one-liner. |
| description | string | optional | Markdown description, max 4kb. |
| is_private | boolean | optional |
true โ invite-only di org. Default false. Mutex ke is_public.
|
| is_public | boolean | optional |
true โ cross-org public, siapa saja bisa join via POST /join. Default false. Mutex ke is_private.
|
| e2e_enabled | boolean | optional | End-to-end encryption flag. Immutable setelah create (Step 14 future feature). |
| retention_days | int | optional | Override message retention. null = inherit org default. |
{
"success": true,
"data": {
"id": "fc695580-e54e-4f9f-9101-55a0b078fc58",
"kind": "channel",
"name": "general",
"is_private": false,
"created_by": "...",
...
}
}
Patch name / topic / description / is_private. kind, e2e_enabled, created_by immutable. DM/group_dm tidak boleh di-rename.
Request Body Fields
| Field | Type | Required | Description |
|---|---|---|---|
| name | string | optional | |
| topic | string | optional | |
| description | string | optional | |
| is_private | boolean | optional |
Soft-archive โ channel tetap ada tapi is_archived=true dan archived_at di-set. Tidak bisa kirim message baru. DM / group_dm tidak boleh di-archive.
{ "success": true, "data": { "id": "...", "is_archived": true, "archived_at": "..." } }
Self-join โ anyone dengan valid JWT bisa join public channel, tanpa invite. Endpoint ini menolak kalau channel is_public=false (private/org-internal pakai invite flow biasa).
Idempotent: kalau caller sudah member, return 200 dengan channel existing (bukan 409). Caller jadi role member (owner cuma creator).
{
"success": true,
"data": { "id": "...", "is_public": true, "name": "public-square" }
}
Returns a JSON dump of everything chat-service knows about the caller: user row, memberships, messages authored, reactions, stars, pinned, attachments referenced. Streamed for large accounts; gzip-encoded.
{
"success": true,
"data": {
"user": { "id": "...", "display_name": "...", ... },
"memberships": [ ... ],
"messages": [ ... ],
"reactions": [ ... ],
"starred": [ ... ],
"exported_at": "2026-05-20T..."
}
}
Soft-delete user row + replace display_name with (deleted user). Messages remain (for channel-conversation integrity) but author fields are scrubbed of identifying info. Channel memberships are left for audit but left_at set to now.
Irreversible. account-service should be the orchestrator for full-cascade anonymization across services.
{ "success": true, "data": { "anonymized": true } }
Buka sesi CS di sebuah channel (customer harus member). Status awal waiting (masuk antrian), atau bot bila use_bot=true & bot org aktif. skill opsional โ bila diisi harus slug yang ada & aktif di katalog.
Body:
{
"channel_id": "uuid",
"topic": "status pesanan",
"priority": "medium",
"use_bot": false,
"skill": "billing"
}{
"success": true,
"data": {
"session_id": "โฆ", "channel_id": "โฆ", "status": "waiting",
"queue_position": 2, "estimated_wait_seconds": 240, "priority": "medium"
}
}
Sesi support milik caller yang sedang berjalan (bot/waiting/assigned), lintas channel. Dipakai klien untuk pre-check sebelum membuat sesi baru (buka yang ada daripada bikin duplikat).
Sesi CS non-terminal untuk channel tersebut (untuk render banner status di room). Caller harus member channel.
Eskalasi dari bot ke antrian manusia (waiting). Body opsional: { "reason": "butuh agen", "priority": "high" }.
Rating kepuasan 1โ5 (+ komentar opsional) setelah sesi resolved. Body: { "rating": 5, "comment": "mantap" }.
Presence + workload + skills agen (caller), untuk pre-fill UI. Default offline bila agen belum pernah set presence.
{
"success": true,
"data": { "status": "online", "skills": ["billing"], "max_concurrent": 5, "active_count": 2 }
}
Set presence + kapasitas + skills. skills dinormalisasi (lowercase / trim / dedup) dan bersifat lenient โ slug yang tak ada/non-aktif di katalog di-drop diam-diam (bukan ditolak) supaya agen tak terkunci dari update presence.
Body: { "status": "online", "max_concurrent": 5, "skills": ["billing","refund"] } (status โ online|away|busy|offline).
Daftar sesi berstatus waiting di org, diurutkan priority lalu FIFO (waiting_since). Tiap item menyertakan skill yang diminta.
Tugaskan sesi ke agen. Body { "agent_id": "uuid" } โ kosong berarti self-assign (claim oleh caller). Transisi menjadi assigned.
Transfer sesi assigned ke agen lain (workload ikut pindah). Gated supervisor/admin (cs:manage / *). Body { "agent_id": "uuid" }.
Ubah status sesi via state machine (mis. assigned โ resolved, resolved โ closed). Body { "status": "resolved" }. Transisi ilegal ditolak.
Daftar agen org (decorated account_id + display_name + role). Gated admin โ endpoint ini juga dipakai klien untuk mendeteksi apakah caller seorang admin CS (200) atau bukan (403).
{
"success": true,
"data": [ { "user_id": "โฆ", "account_id": "โฆ", "display_name": "Budi", "role": "agent" } ]
}
Jadikan user agen. Pilih salah satu identifier: { "account_id": "uuid", "display_name": "Budi", "role": "agent" } (dari daftar member org account-service โ direkomendasikan) atau { "user_id": "uuid", ... } (chat user_id). role โ agent | supervisor. Gated admin.
Bila pakai account_id dan user belum punya akun chat, server auto-provision user chat (pakai display_name; nama final auto-heal saat user pertama login) โ owner bisa jadikan agen member mana pun walau belum pernah buka chat.
Copot status agen seseorang (by chat user_id). Gated admin.
Katalog skill aktif (default). Tambahkan ?include_inactive=true untuk menyertakan yang non-aktif (admin only).
{
"success": true,
"data": [ { "id": "โฆ", "slug": "billing", "label": "Tagihan", "active": true } ]
}
Tambah skill ke katalog. Body { "slug": "billing", "label": "Tagihan" }. Slug dinormalisasi lowercase dan divalidasi pola ^[a-z0-9][a-z0-9_-]{0,63}$.
Ubah label dan/atau status active. Body { "label": "Tagihan", "active": false }. Menonaktifkan skill membuatnya tak bisa dipakai sesi baru (routing tetap punya fallback ke agen umum).
Hapus skill dari katalog. Sesi/agen yang terlanjur memakai slug-nya tetap aman (routing fallback). Gated admin.
Jam operasional CS org + status terkini. Boleh dibaca semua member (untuk tahu CS buka/tutup). open_now & next_open_at dihitung di server (timezone-aware). enabled=false โ CS dianggap selalu buka.
{
"success": true,
"data": {
"enabled": true,
"timezone": "Asia/Jakarta",
"schedule": "{\"mon\":{\"open\":\"09:00\",\"close\":\"17:00\"}}",
"closed_message": "Kami buka SenโJum 09.00โ17.00 WIB.",
"open_now": false,
"next_open_at": "2026-06-01T02:00:00Z"
}
}
Set jam operasional (gated admin). schedule = objek JSON per-hari (mon|tue|wed|thu|fri|sat|sun; hari tanpa entry = tutup), jam HH:MM 24-jam di timezone. Validasi: timezone valid + close > open.
Di luar jam: sesi waiting tidak di-auto-abandon (tetap antri sampai buka); response sesi customer (POST /cs/sessions, GET /cs/sessions/mine) membawa business_open + next_open_at untuk menampilkan closed_message + ETA.
Request Body Fields
| Field | Type | Required | Description |
|---|---|---|---|
| enabled | boolean | required | |
| timezone | string | optional | IANA tz, default Asia/Jakarta. |
| schedule | object | optional | {day:{open,close}} HH:MM. |
| closed_message | string | optional | Pesan ke customer di luar jam (โค500). |
{
"enabled": true,
"timezone": "Asia/Jakarta",
"schedule": { "mon": {"open":"09:00","close":"17:00"}, "fri": {"open":"09:00","close":"15:00"} },
"closed_message": "Kami buka SenโJum."
}
chat-web calls this fire-and-forget right after the scanner finds a match. Server sanitizes rule IDs (alphanumeric + underscore, โค32 chars), caps the list at 20 rules per call (rate-limit on audit table), writes dlp.flagged to audit_logs.
Surface field disambiguates where the scan ran (composer / edit / reply / annotation caption) so reports can slice by entry point.
Request Body Fields
| Field | Type | Required | Description |
|---|---|---|---|
| rule_ids | []string | required | Detected rule IDs from src/dlp.js (e.g. jwt, aws_access_key, credit_card) |
| channel_id | string | optional | Optional โ channel the scanner ran against |
| sent_anyway | bool | required | true if user clicked 'Kirim tetap', false if cancelled |
| surface | enum("composer"|"edit"|"reply"|"annotation") | optional | Where the scan was triggered |
{
"rule_ids": ["jwt", "aws_access_key"],
"channel_id": "61c625f0-7a2e-44af-aa4c-8fefb0ce0ab4",
"sent_anyway": false,
"surface": "composer"
}
{ "success": true, "data": { "recorded": true } }
Return chat-side user record. Membuat record baru kalau ini call pertama (lazy-create dari JWT claim).
{
"success": true,
"data": {
"id": "874f0605-19d7-4e18-8aef-c544d9620157",
"account_id": "d130939c-f3ce-4cfb-bb6f-aee94e06419a",
"org_id": "0db3bcc1-8e2e-4519-9a41-d4f1d314136a",
"kind": "human",
"display_name": "Chat Tester",
"avatar_url": null,
"status": "active",
"mute_keywords": ["lunch", "standup"],
"created_at": "2026-05-18T23:03:07.367883Z",
"updated_at": "2026-05-18T23:03:07.367883Z"
},
"request_id": "uuid"
}
Patch display_name / avatar_url / mute_keywords. Omitted field = unchanged. kind, account_id, org_id immutable.
display_name forwarding (TIER 2 fix): chat-service relay PATCH ke account-service PUT /api/v1/me dengan caller's bearer. Account-service jadi source of truth permanent. Kalau forward gagal, local PG TIDAK di-update (return 502). Reverse direction (update di account.ikavia.com โ propagate ke chat) ditangani via RabbitMQ event account.account.updated (auto-sync sub-1 detik di prod).
mute_keywords (TIER 2): per-user notif suppression. Server normalize: trim whitespace + lowercase + dedupe. FE filter audio ping + desktop notif kalau body WS message_new match substring. Message tetap di-render (user lihat di chat list, hanya audio/popup yang silent).
Request Body Fields
| Field | Type | Required | Description |
|---|---|---|---|
| display_name | string | optional | Visible name, max 80 chars. |
| avatar_url | string | optional | HTTPS URL ke gambar (validated, max 512 chars). |
| mute_keywords | string[] | optional |
Replace-all. Max 50 entries, max 50 chars each. [] = clear, key absent = no change.
|
{
"mute_keywords": ["lunch", "standup", "deploy"]
}
{
"success": true,
"data": { "id": "...", "display_name": "Updated Name", "mute_keywords": ["lunch", "standup", "deploy"], "..." : "..." }
}
List anggota aktif. Caller harus member juga. Member yang sudah leave (left_at set) tidak ke-include.
Query Parameters
| Param | Type | Required | Description |
|---|---|---|---|
| page | int | optional | |
| per-page | int | optional | Default 50, max 100. |
{
"success": true,
"data": {
"items": [
{
"user_id": "874f0605-19d7-4e18-8aef-c544d9620157",
"channel_id": "27e3cd2b-b1fe-49dd-ad1a-16ed0617daaf",
"role": "owner",
"notification_pref": "all",
"joined_at": "2026-05-18T23:08:21Z"
}
],
"pagination": { "page": 1, "per_page": 50, "total": 1, "total_pages": 1 }
}
}
Batch add (max 100/request). Idempotent โ duplicate di-skip silently (return 200, bukan 409). Untuk private channel, caller harus admin/owner; public channel any member boleh invite.
Dua cara identifikasi (boleh dipakai bersama):
user_idsโ chat-side user IDs (target sudah pernah buka chat).membersโ[{account_id, display_name}]dari daftar member org
(account-service). Server auto-provision akun chat bila target belum pernah buka chat (pakai display_name; nama auto-heal saat user login). Owner bisa undang member mana pun walau belum buka chat.
Request Body Fields
| Field | Type | Required | Description |
|---|---|---|---|
| user_ids | uuid[] | optional | Chat-side user IDs (bukan account_id). |
| members | object[] | optional | [{account_id, display_name}] โ provision otomatis. |
{ "success": true, "data": { "added": 3 } }
Soft-leave target user. left_at di-set tapi row tidak dihapus (for audit + last_read preservation). Self-remove auto-route ke leave endpoint.
Caller leave channel sendiri. Owner channel tidak boleh leave โ harus transfer ownership dulu (out of scope Step 3).
Update notification pref / mute. Per-membership setting.
Request Body Fields
| Field | Type | Required | Description |
|---|---|---|---|
| notification_pref | string | optional |
all | mentions | none.
|
| muted_until | timestamp | optional | RFC3339; null = no mute. |
| clear_mute | boolean | optional |
true โ clear muted_until (alternative to passing null).
|
Idempotent โ kalau DM dengan peer ini sudah ada, return existing channel_id. Kalau belum, create new + bootstrap 2 membership row (caller + peer, both role=member).
Canonical pair key: metadata.dm_pair = "${min(a,b)}:${max(a,b)}", jadi AโB dan BโA return record sama. Self-DM ditolak (422).
Request Body Fields
| Field | Type | Required | Description |
|---|---|---|---|
| user_id | uuid | required | Peer chat-side user_id (bukan account_id). |
{
"success": true,
"data": {
"channel_id": "6805901e-5912-4a5c-92de-146c1dd35d4f",
"created": true
}
}
Return messages DESC (newest first). Pagination via before / after cursor (message_id), bukan offset (offset di Scylla mahal โ pakai cursor).
Query Parameters
| Param | Type | Required | Description |
|---|---|---|---|
| limit | int | optional | Default 50, max 100. |
| before | ulid | optional | Pesan dengan id < before (lebih lama). |
| after | ulid | optional | Pesan dengan id > after (lebih baru). |
{
"success": true,
"data": {
"count": 4,
"items": [
{
"id": "01KRYQ7K1JQX75GVZ5PT06Y4FY",
"channel_id": "27e3cd2b-b1fe-49dd-ad1a-16ed0617daaf",
"org_id": "0db3bcc1-8e2e-4519-9a41-d4f1d314136a",
"author_id": "874f0605-19d7-4e18-8aef-c544d9620157",
"author_kind": "human",
"body": "Fourth message",
"content_type": "text/plain",
"edited_at": null,
"deleted_at": null,
"created_at": "2026-05-18T23:37:39.634Z"
}
]
}
}
Persist + publish. Pipeline:
1. ACL: caller harus active member (atau hold wildcard). 2. ULID di-generate server-side (monotonic + random entropy). 3. INSERT ke Scylla messages (no LWT โ see above). 4. Update channels.last_message_* denormalized (async goroutine). 5. Publish envelope ke Redis topic chat:ch:{channel_id} โ WS subscribers di all instances dapat message_new frame.
Response REST datang sebelum atau bareng WS frame โ client harus dedup by message id kalau optimistic-append.
Request Body Fields
| Field | Type | Required | Description |
|---|---|---|---|
| body | string | required | Max 8000 chars. Optional when attachments OR interactive present. |
| content_type | string | optional |
Default text/plain. Future: text/markdown.
|
| reply_to_id | ulid | optional | Inline reply. Reply count denorm on parent bumped. |
| thread_root_id | ulid | optional | Thread root (Step 7 feature). |
| mentions | uuid[] | optional | User IDs explicitly mentioned. |
| client_nonce | string | optional | Reserved untuk Step 6 idempotency key. |
| expires_in_seconds | int | optional |
Ephemeral TTL (5..86400). When set, sweeper auto-deletes after this many seconds + publishes message_expired.
|
| metadata | json-string | optional |
JSON with attachments[], interactive.buttons (inline keyboard), or forward_from. Max 16 KB.
|
{
"body": "Deploy ke prod?",
"metadata": "{\"interactive\":{\"buttons\":[[{\"id\":\"approve\",\"label\":\"โ
Approve\",\"style\":\"success\"},{\"id\":\"reject\",\"label\":\"โ Reject\",\"style\":\"danger\"}]]}}"
}
Edit body. Hanya author (atau holder chat:moderate) boleh edit. Deleted message tidak bisa di-edit (422). edited_at di-set ke now. Broadcasts message_edit event via WS.
Request Body Fields
| Field | Type | Required | Description |
|---|---|---|---|
| body | string | required | New body, max 8000 chars. |
Soft-delete: body di-kosongkan + deleted_at di-set. Row tetap ada di Scylla untuk ordering + audit. Broadcasts message_delete via WS. Hanya author atau holder chat:moderate.
Cascade cleanup: attachment_refs cleared, pinned/starred rows dropped, ES delete-marked, parent reply_count decremented when the deleted message had a reply_to_id.
{ "success": true, "data": { "deleted": true } }
Record a user click on a callback button in the message's metadata.interactive.buttons grid (Telegram-style inline keyboard). Server validates the button exists + is NOT a link button (url set โ those open the browser directly, no callback). Publishes message.interaction on the channel topic so the bot that owns the message can react (typically by editing the original message via PATCH to acknowledge).
Inline keyboard schema (max 5ร5 grid):
{
"interactive": {
"buttons": [
[
{"id":"approve","label":"โ
Approve","style":"success"},
{"id":"reject","label":"โ Reject","style":"danger"}
],
[{"label":"๐ Logs","url":"https://logs.example.com"}]
]
}
}Styles: default | primary | danger | success. Button id must be unique within a message and โค64 chars; label โค80 chars. Audit message.interaction recorded with actor_kind + action_id.
Request Body Fields
| Field | Type | Required | Description |
|---|---|---|---|
| action_id | string | required | Matches a button.id in the message metadata. |
{ "action_id": "approve" }
{ "success": true, "data": { "recorded": true } }
Member-only. Validates the message exists and is not deleted. Max 50 pins per channel (idempotent โ re-pin returns 200 not 201).
{ "success": true, "data": { "pinned": true } }
Pinner or moderator only. Idempotent โ unpin of a non-pinned message returns 200.
{ "success": true, "data": { "unpinned": true } }
Member-only. Returns up to 50 pins, sorted by pinned_at DESC. Enriched with message body so FE doesn't need to re-fetch.
{
"success": true,
"data": {
"items": [
{
"channel_id": "...", "message_id": "01KS24โฆ",
"pinned_by": "...", "pinned_at": "...",
"body": "Read the runbook before deploy",
"author_id": "...", "is_deleted": false
}
]
}
}
Batch resolve display info. Strict per-org by default โ caller can only resolve users in the same org. Cross-org peers are name- resolved via the channel member-list endpoint (membership proves shared context).
Query Parameters
| Param | Type | Required | Description |
|---|---|---|---|
| ids | csv-uuid | required | Comma-separated user IDs, max 100 |
{
"success": true,
"data": {
"items": [
{
"id": "...", "account_id": "...",
"display_name": "Arian Saputra",
"avatar_url": null,
"kind": "human"
}
]
}
}
Returns online/offline state for a set of user IDs by checking Redis presence keys (TTL 90s).
Query Parameters
| Param | Type | Required | Description |
|---|---|---|---|
| ids | csv-uuid | required | Comma-separated user IDs |
{
"success": true,
"data": {
"items": [
{ "user_id": "...", "online": true },
{ "user_id": "...", "online": false }
]
}
}
Member-only. Updates last_read_id if newer than stored.
Request Body Fields
| Field | Type | Required | Description |
|---|---|---|---|
| message_id | string | required | ULID of the most recently seen message |
{ "message_id": "01KS24XPTNY4SVB6Y7TVESYYYB" }
{ "success": true, "data": { "ok": true } }
Member-only. Adds emoji to message. Server normalizes emoji (max 16 chars). Returns 422 if emoji invalid or message deleted.
Request Body Fields
| Field | Type | Required | Description |
|---|---|---|---|
| emoji | string | required | Single emoji char or shortcode (โค16 chars) |
{ "emoji": "๐" }
{ "success": true, "data": { "added": true } }
Remove your own reaction. Idempotent. Body matches emoji to remove.
Request Body Fields
| Field | Type | Required | Description |
|---|---|---|---|
| emoji | string | required | Same emoji string used in POST |
{ "emoji": "๐" }
{ "success": true, "data": { "removed": true } }
Full-text search across messages the caller can see. Channels filter narrows to specific channels (otherwise org-wide).
Query Parameters
| Param | Type | Required | Description |
|---|---|---|---|
| q | string | required | Search phrase (โค256 chars) |
| channel_id | uuid | optional | Restrict to one channel |
| limit | int | optional | Max hits, โค100 (default: 50) |
{
"success": true,
"data": {
"items": [
{
"id": "01KSโฆ", "channel_id": "...",
"author_id": "...",
"body": "the prod deploy is at 17:00",
"highlight": "the <em>prod deploy</em> is at 17:00",
"created_at": "..."
}
],
"total": 1
}
}
Idempotent. Calling kedua kali tidak fail (409); langsung return OK. Caller harus member channel โ gate ini mencegah user mem-bookmark message yang tidak boleh dia akses (defense-in-depth, source-channel sudah membership-gated).
{ "success": true, "data": { "starred": true } }
Idempotent. Tidak butuh membership check (user boleh hapus bookmark sendiri walaupun sudah left channel).
{ "success": true, "data": { "unstarred": true } }
Sorted by starred_at DESC. Decorate dengan body/author dari Scylla (best-effort). Pagination via before (RFC3339 timestamp).
Query Parameters
| Param | Type | Required | Description |
|---|---|---|---|
| limit | int | optional | Default 50, max 100. |
| before | timestamp | optional | Cursor: paginate items sebelum waktu ini (DESC). |
{
"success": true,
"data": {
"count": 2,
"items": [
{
"channel_id": "61c625f0-...",
"message_id": "01KS1...",
"starred_at": "2026-05-19T17:10:08.494637Z",
"author_id": "71213598-...",
"body": "important message I want to remember",
"created_at": "2026-05-19T16:00:00Z"
}
]
}
}
Frontend paint indicator efficient โ kirim list (channel_id, message_id) untuk message yang visible, dapat map "<channel_id>:<message_id>" โ true untuk yang starred (absent = not starred). Bound max 200 items.
{
"success": true,
"data": {
"items": {
"61c625f0-...:01KS1...": true
}
}
}
Returns the generated secret (one-time visible) used to verify HMAC signatures on subsequent fires. URL must be HTTPS; localhost / RFC1918 / .ikavia.com blocked (anti-SSRF).
Request Body Fields
| Field | Type | Required | Description |
|---|---|---|---|
| url | string | required | Target https:// URL |
| events | string | optional |
Comma-separated event allow-list or '*' for all
default: message.created
|
{
"url": "https://my-bot.example.com/chat-hook",
"events": "message.created, message.interaction"
}
{
"success": true,
"data": {
"id": "...", "channel_id": "...", "url": "...",
"secret": "abc123โฆ",
"events": "message.created, message.interaction",
"is_active": true,
"failure_streak": 0
}
}
Returns currently configured webhooks. secret is NOT returned after create (one-time visibility).
{
"success": true,
"data": {
"items": [
{
"id": "...", "url": "...",
"events": "*",
"is_active": true, "failure_streak": 0,
"last_fired_at": "...", "last_failed_at": null
}
]
}
}
Removes a webhook config + cancels queued retries.
{ "success": true, "data": { "deleted": true } }
Fired on POST /messages.
{
"event": "message.created",
"delivery_id": "1742โฆ",
"data": {
"id": "01KSโฆ",
"channel_id": "...",
"author_id": "...", "author_kind": "human",
"body": "hello", "content_type": "text/plain",
"metadata": "{}",
"reply_to_id": "",
"created_at": "2026-05-20T13:11:00.000Z"
}
}
Fired on PATCH /messages/:id.
{
"event": "message.edited",
"data": {
"id": "01KSโฆ",
"channel_id": "...", "author_id": "...",
"body": "hello (edited)",
"metadata": "{}",
"edited_at": "2026-05-20T13:12:00.000Z"
}
}
Fired on DELETE /messages/:id (user-initiated).
{
"event": "message.deleted",
"data": {
"id": "01KSโฆ",
"channel_id": "...", "author_id": "...",
"deleted_by": "...",
"deleted_at": "2026-05-20T13:13:00.000Z"
}
}
Fired by the ephemeral-message sweeper when a message's TTL elapses. Payload omits body (already cleared from Scylla).
{
"event": "message.expired",
"data": {
"id": "01KSโฆ",
"channel_id": "...", "author_id": "...",
"expired_at": "2026-05-20T13:14:00.000Z"
}
}
Fired when a user clicks a callback button on an inline keyboard attached to a message. Link buttons (with url) do not fire this event โ they open the browser directly.
{
"event": "message.interaction",
"data": {
"channel_id": "...",
"message_id": "01KSโฆ",
"user_id": "...",
"action_id": "approve",
"action_label": "โ
Approve"
}
}
Standard RFC 6455 upgrade. Server reply Sec-WebSocket-Protocol: ikavia-chat-v1 kalau match. First frame yang server kirim adalah hello_ack dengan info session.
Detail protocol frame ada di section Events (di overview page, bawah architecture diagram) โ termasuk subscribe/unsubscribe, message_new, message_edit, message_delete, error.
Query Parameters
| Param | Type | Required | Description |
|---|---|---|---|
| token | string | optional | JWT โ alternative to Authorization header (untuk browser yang tidak bisa set header). |
# First frame yang server kirim setelah upgrade:
{
"type": "hello_ack",
"data": {
"user_id": "874f0605-19d7-4e18-8aef-c544d9620157",
"session_id": "4c6d67b6-228f-40b1-b765-72ea130a34de",
"server_time": "2026-05-19T01:23:01.610Z"
}
}
๐ก subscribe / unsubscribe
Subscribe atau unsubscribe ke realtime events untuk channel. Server check membership untuk setiap channel_id โ non-member dikirim error dengan code not_member. Server reply subscribed dengan list channel_ids yang akhirnya tersubscribed.
User-private topic (chat:user:{user_id}) auto-subscribed saat handshake โ client tidak perlu subscribe manual.
websocket/wssubscribe Client kirim `subscribe` frame
Daftar channel_ids untuk mulai terima realtime events.
Payload
| Field | Type | Required | Description |
|---|---|---|---|
type |
string | yes | subscribe |
id |
string | no | Client correlation id (echoed di reply). |
data.channel_ids |
uuid[] | yes | List channel UUID. |
Example
{
"type": "subscribe",
"id": "client-correlation-1",
"data": { "channel_ids": ["27e3cd2b-โฆ","6805901e-โฆ"] }
}
publish Server reply `subscribed` frame
Echo list channel_ids yang berhasil di-subscribe.
Example
{
"type": "subscribed",
"id": "client-correlation-1",
"data": { "channel_ids": ["27e3cd2b-โฆ"] }
}
๐ก message_new (server push)
Server push untuk message baru di channel yang caller subscribe. Diterima baik dari diri sendiri (race REST response) maupun dari member lain โ client wajib dedup by data.data.id kalau optimistic-append dari REST response.
websocketchat:ch:{channel_id}publish Server โ Client
Frame yang server kirim saat message_new event ke topic.
Example
{
"type": "message_new",
"data": {
"channel_id": "27e3cd2b-โฆ",
"ts": "2026-05-18T23:37:59.522Z",
"data": {
"id": "01KRYQ86F25R3CGYXTTB5PN0FY",
"channel_id": "27e3cd2b-โฆ",
"author_id": "874f0605-โฆ",
"author_kind": "human",
"body": "Realtime fanout via REST",
"content_type": "text/plain",
"reply_to_id": "",
"mentions": null,
"created_at": "2026-05-18T23:37:59.522Z"
}
}
}
๐ก message_edit (server push)
Body & edited_at update. data.data hanya carry field yang berubah (id, body, edited_at), bukan full message envelope.
websocketchat:ch:{channel_id}publish Server โ Client
Example
{
"type": "message_edit",
"data": {
"channel_id": "27e3cd2b-โฆ",
"data": {
"id": "01KRYQ86F25Rโฆ",
"body": "Edited via REST",
"edited_at": "2026-05-18T23:38:00.123Z"
}
}
}
๐ก message_delete (server push)
Soft-delete event. Client render placeholder untuk message_id ini. Row tetap ada di Scylla untuk ordering + audit.
websocketchat:ch:{channel_id}publish Server โ Client
Example
{
"type": "message_delete",
"data": {
"channel_id": "27e3cd2b-โฆ",
"data": {
"channel_id": "27e3cd2b-โฆ",
"message_id": "01KRYQ86F25Rโฆ"
}
}
}
๐ก read_marker (server push)
Broadcast saat seorang member meng-set read marker (REST POST /channels/{channel-id}/read-marker). Dipakai untuk read receipt (โโ): pesan kita dengan id <= data.data.last_read_id (ULID lexicographic) sudah dibaca oleh data.user_id. Untuk DM ini menggerakkan โโ biru; untuk grup, agregasi "dibaca semua" adalah tanggung jawab klien (server hanya menyiarkan per-user). Best-effort โ tak menutup koneksi bila gagal.
websocketchat:ch:{channel_id}publish Server โ Client
Frame yang server kirim saat ada read marker baru di topic.
Example
{
"type": "read_marker",
"data": {
"channel_id": "27e3cd2b-โฆ",
"user_id": "874f0605-โฆ",
"ts": "2026-05-26T16:00:00.000Z",
"data": {
"last_read_id": "01KRYQ86F25R3CGYXTTB5PN0FY"
}
}
}
๐ก ping / pong
Application-level ping (selain WS frame-level ping/pong gorilla yang jalan auto). Berguna untuk client RTT measurement.
websocket/wssubscribe Client โ Server
Example
{ "type": "ping", "id": "rtt-test-1" }
publish Server โ Client (pong echo)
Example
{ "type": "pong", "id": "rtt-test-1" }
๐ก error (server push)
Per-request error reply. Carries stable code untuk client routing plus human-readable message. Tidak menutup koneksi โ sekedar response ke frame yang failed.
Stable error codes:
| Code | Meaning |
|---|---|
invalid_frame | JSON parse fail atau payload shape salah |
unsupported_type | type tidak dikenal |
permission_denied | Authorization fail di JWT permissions |
not_member | Subscribe attempt ke channel bukan member |
rate_limited | Future feature (Step 7) |
session_revoked | Session di-logout di account-service |
max_connections_per_user | Boot saat conn ke-9 (oldest FIFO) |
internal_error | Generic 5xx โ biasanya transient |
websocket/wspublish Server โ Client
Example
{
"type": "error",
"id": "client-correlation-1",
"data": {
"code": "not_member",
"message": "not a member of channel cfa1942f-โฆ"
}
}
๐ก typing (bi-directional)
Client sends typing frame when user starts/stops typing in the composer. Server fans out to other channel subscribers (skip self). Rate-limited per (conn, channel) to 1 broadcast per 3s. Server uses conn.UserID (JWT-validated) not the payload โ anti-spoof.
websocket/wssubscribe Client โ Server
Example
{ "type": "typing", "data": { "channel_id": "cfa1942f-โฆ", "is_typing": true } }
publish Server โ Other subscribers
Example
{
"type": "typing",
"data": {
"channel_id": "cfa1942f-โฆ",
"user_id": "<UUID of typer>",
"is_typing": true
}
}
๐ก presence (server push)
Fired when a user transitions online โ offline. Online = Redis presence key exists (TTL 90s, refreshed every 30s by an active WS conn). Offline = TTL expired OR last connection closed cleanly.
websocket/wspublish Server โ Client
Example
{
"type": "presence",
"data": { "user_id": "...", "status": "online" }
}
๐ก reaction (server push)
Fan-out of reaction add/remove. Companion to REST endpoints in the Reactions section.
websocket/wspublish Server โ Client
Example
{
"type": "reaction",
"data": {
"channel_id": "...",
"message_id": "01KSโฆ",
"user_id": "...",
"emoji": "๐",
"op": "add"
}
}
๐ก mention (per-user push)
Published on the chat:user:{user_id} topic (not channel topic), so the user gets a high-priority notification (toast + ping bypassing mute) even if they're not currently focused on that channel.
websocket/wspublish Server โ Mentioned user only
Example
{
"type": "mention",
"data": {
"channel_id": "...",
"message_id": "01KSโฆ",
"author_id": "...",
"body_preview": "@you check the deploy at 17:00โฆ"
}
}
๐ก member_added / member_removed (server push)
Fired when channel roster changes.
websocket/wspublish Server โ Subscribed clients
Example
{
"type": "member_added",
"data": { "channel_id": "...", "user_id": "..." }
}
๐ก message_pinned / message_unpinned (server push)
Pin/unpin event. Companion to REST endpoints in the Pinned section.
websocket/wspublish Server โ Subscribed clients
Example
{
"type": "message_pinned",
"data": {
"channel_id": "...",
"message_id": "01KSโฆ",
"pinned_by": "..."
}
}
๐ก message_expired (server push)
Fired by the ephemeral-message sweeper when a message's TTL elapses. FE renders distinct "โฑ pesan hilang" placeholder (separate from regular message_delete).
websocket/wspublish Server โ Subscribed clients
Example
{
"type": "message_expired",
"data": { "channel_id": "...", "message_id": "01KSโฆ" }
}
๐ก message.interaction (server push)
Fired when a user clicks an inline-keyboard callback button. The bot that owns the message subscribes to the channel topic to receive these โ typically responds by PATCHing the original message to acknowledge.
websocket/wspublish Server โ Subscribed clients
Example
{
"type": "message.interaction",
"data": {
"channel_id": "...",
"message_id": "01KSโฆ",
"user_id": "<clicker>",
"action_id": "approve",
"action_label": "โ
Approve"
}
}
๐ก call.* events (voice/video signaling)
WebRTC signaling tunneled over WS. Six event types โ three are broadcast on the channel topic (lifecycle: invite, join, leave, end) and three are routed peer-to-peer via the chat:user:{user_id} topic (SDP/ICE exchange).
Server anti-spoof: from_user_id is locked to conn.UserID, payload contents (SDP/ICE) are opaque and forwarded as-is.
websocket/wspublish call.invite (server โ channel)
Example
{
"type": "call.invite",
"data": {
"call_id": "...",
"channel_id": "...",
"initiator_id": "...",
"type": "video",
"status": "dialing",
"started_at": "..."
}
}
publish call.participant_joined (server โ channel)
Example
{
"type": "call.participant_joined",
"data": {
"call_id": "...",
"channel_id": "...",
"user_id": "...",
"status": "active"
}
}
publish call.participant_left (server โ channel)
Example
{
"type": "call.participant_left",
"data": {
"call_id": "...",
"channel_id": "...",
"user_id": "...",
"leave_code": "hangup"
}
}
publish call.end (server โ channel)
Example
{
"type": "call.end",
"data": {
"call_id": "...",
"channel_id": "...",
"end_reason": "hangup",
"duration_ms": 359
}
}
subscribe call.sdp_offer / sdp_answer / ice (client โ server, routed to peer)
Example
{
"type": "call.sdp_offer",
"data": {
"call_id": "...",
"channel_id": "...",
"to_user_id": "<recipient>",
"kind": "sdp_offer",
"payload": { "type": "offer", "sdp": "v=0\\r\\nโฆ" }
}
}
publish call.sdp_offer (server โ recipient peer)
Example
{
"type": "call.sdp_offer",
"data": {
"call_id": "...",
"channel_id": "...",
"from_user_id": "<offerer>",
"to_user_id": "<recipient>",
"kind": "sdp_offer",
"payload": { "type": "offer", "sdp": "v=0\\r\\nโฆ" }
}
}
๐ Credentials
Konfigurasi credentials untuk testing API
Cara Menggunakan
๐ Authentication
Methods and permissions for API access
Authentication Methods
JWT Bearer
Header: Authorization: Bearer <access_token>
Source: account-service
RS256 JWT diterbitkan oleh account-service (prod: account.ikavia.com, staging: account-dev.ikavia.com). Chat-service verify signature via JWKS endpoint dengan cache + auto-refresh saat kid berubah.
API Key
Header: X-API-Key: ak_<random>
Source: account-service (issuing) + chat-service (transparent exchange)
Service-to-service auth for bots / integrations. Sent in X-API-Key header directly to any chat-service endpoint โ server transparently exchanges with account-service and caches the resulting JWT in-memory (per-replica, 60s pre-exp refresh). Bot integration code does NOT need to call /auth/token-exchange manually.
Header precedence: X-API-Key > Authorization: Bearer (kalau both ada). Audit actor_kind="service" otomatis (dari resulting JWT's principal_type).
Note: For WebSocket (no header support after the upgrade handshake), bots
exchange once via /auth/token-exchange and pass JWT in ?token=
query param.
Permissions
| Permission | Description |
|---|