๐Ÿ“‹ 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 = JWT sub, dimiliki account-service (otoritas).
  • user_id = chat-local UUID di tabel users. Lazy-created saat

principal pertama kali hit endpoint authenticated.

  • org_id dari JWT org_id claim โ€” strict per-org isolation

(channels, messages, memberships semua scoped by org).

  • principal_type = human atau service (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 familyPermission required
POST /channelschannel:create
PATCH /channels/{id}channel:update
DELETE /channels/{id}channel:archive
POST /channels/{id}/memberschannel:invite
DELETE โ€ฆ/members/{user-id}channel:remove_member
POST /channels/{id}/messageschat: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 events
  • chat: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

LayerStagingProduction
PostgreSQLDB chat, role chat_appDB chat_prod, role chat_app_prod
ScyllaDBkeyspace chat, RF=3keyspace chat_prod, RF=3
Redisdb=5, prefix chat:db=6, prefix chat_prod:
JWT issueraccount-service-devaccount-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 kindVisible 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

1
Login (atau token-exchange) โ–พ

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.

2
Switch org saat user multi-tenant โ–พ

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.

3
Hit endpoint chat โ–พ

Setiap request: Authorization: Bearer <access_token>. First call auto lazy-create chat user row (1:1 ke JWT.sub). Subsequent call lookup existing.

4
Refresh saat 401 โ–พ

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

5
Open WebSocket (realtime) โ–พ

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

POST /api/v1/attachments/sign

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.

Example Response
{
  "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
      }
    ]
  }
}
GET /api/v1/attachments/{media-id}/raw

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

ParamTypeRequiredDescription
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.
GET /api/v1/channels/{channel-id}/attachments

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

ParamTypeRequiredDescription
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.
Example Response
{
  "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..."
  }
}
POST /api/v1/auth/login

Email + password โ†’ access_token + refresh_token. Refresh juga di-set sebagai HttpOnly cookie scoped .ikavia.com untuk SSO cross-subdomain.

Request Body Fields

FieldTypeRequiredDescription
email string required
password string required
Example Response
{
  "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" }]
  }
}
GET /api/v1/organizations

Return semua org yang caller anggota. Dipakai oleh chat-web untuk populate org-switcher dropdown.

Example Response
{
  "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
  }
}
POST /api/v1/organizations/{org_id}/switch

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

FieldTypeRequiredDescription
(empty body) โ€” optional POST dengan body {} cukup. Org_id di path.
Example Response
{
  "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":[ ... ]
  }
}
POST /api/v1/auth/refresh

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

FieldTypeRequiredDescription
refresh_token string optional Optional kalau cookie ada.
POST /api/v1/auth/logout

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.

POST /api/v1/auth/token-exchange

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
Example Response
{
  "success": true,
  "data": {
    "access_token": "eyJhbGciOiJSUzI1NiIโ€ฆ",
    "expires_in": 3600,
    "token_type": "Bearer",
    "principal_type": "service"
  }
}
POST /api/v1/calls/turn-creds

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

FieldTypeRequiredDescription
ttl_seconds int optional Lifetime of credentials in seconds (60..86400, default 3600)
Example Request Body
{ "ttl_seconds": 3600 }
Example Response
{
  "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
  }
}
POST /api/v1/channels/{channel-id}/calls

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

FieldTypeRequiredDescription
type enum("audio"|"video") required audio or video
Example Request Body
{ "type": "video" }
Example Response
{
  "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"
  }
}
POST /api/v1/calls/{call-id}/join

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.

Example Response
{
  "success": true,
  "data": { "id": "...", "status": "active", ... }
}
POST /api/v1/calls/{call-id}/leave

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

FieldTypeRequiredDescription
code enum("hangup"|"disconnect"|"rejected"|"permission_denied") optional Why we're leaving โ€” default 'hangup'
Example Request Body
{ "code": "hangup" }
Example Response
{ "success": true, "data": { "ok": true } }
POST /api/v1/calls/{call-id}/end

Initiator (or moderator) terminates the call for everyone. Fans out call.end. Duration is recorded in milliseconds in the response.

Request Body Fields

FieldTypeRequiredDescription
reason string optional End reason, free-form (defaults to 'hangup')
Example Request Body
{ "reason": "hangup" }
Example Response
{ "success": true, "data": { "ended": true } }
GET /api/v1/channels/{channel-id}/calls

Recent call sessions for the channel. Shows duration, type, status (active / ended / missed / failed). Used by the chat-web "Call history" panel.

Query Parameters

ParamTypeRequiredDescription
limit int optional Max items, โ‰ค100 (default: 50)
Example Response
{
  "success": true,
  "data": {
    "items": [
      {
        "id": "...", "type": "video",
        "status": "ended", "end_reason": "hangup",
        "started_at": "...", "ended_at": "...",
        "duration_ms": 359, "initiator_id": "..."
      }
    ]
  }
}
GET /api/v1/channels

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

ParamTypeRequiredDescription
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.
Example Response
{
  "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 }
  }
}
GET /api/v1/channels/{channel-id}

Detail single channel. Tidak strict membership check di endpoint ini (public channels visible org-wide); private channel data hanya terlihat kalau caller member.

POST /api/v1/channels

Create channel baru. Creator auto-jadi owner-membership. kind selalu channel โ€” DM / group_dm pakai endpoint dedicated.

Request Body Fields

FieldTypeRequiredDescription
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.
Example Response
{
  "success": true,
  "data": {
    "id":          "fc695580-e54e-4f9f-9101-55a0b078fc58",
    "kind":        "channel",
    "name":        "general",
    "is_private":  false,
    "created_by":  "...",
    ...
  }
}
PATCH /api/v1/channels/{channel-id}

Patch name / topic / description / is_private. kind, e2e_enabled, created_by immutable. DM/group_dm tidak boleh di-rename.

Request Body Fields

FieldTypeRequiredDescription
name string optional
topic string optional
description string optional
is_private boolean optional
DELETE /api/v1/channels/{channel-id}

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.

Example Response
{ "success": true, "data": { "id": "...", "is_archived": true, "archived_at": "..." } }
POST /api/v1/channels/{channel-id}/join

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

Example Response
{
  "success": true,
  "data": { "id": "...", "is_public": true, "name": "public-square" }
}
GET /api/v1/me/export

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.

Example Response
{
  "success": true,
  "data": {
    "user": { "id": "...", "display_name": "...", ... },
    "memberships": [ ... ],
    "messages": [ ... ],
    "reactions": [ ... ],
    "starred": [ ... ],
    "exported_at": "2026-05-20T..."
  }
}
POST /api/v1/me/anonymize

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.

Example Response
{ "success": true, "data": { "anonymized": true } }
POST /api/v1/cs/sessions

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"
}
Example Response
{
  "success": true,
  "data": {
    "session_id": "โ€ฆ", "channel_id": "โ€ฆ", "status": "waiting",
    "queue_position": 2, "estimated_wait_seconds": 240, "priority": "medium"
  }
}
GET /api/v1/cs/sessions/mine

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

GET /api/v1/channels/{channel-id}/cs-session

Sesi CS non-terminal untuk channel tersebut (untuk render banner status di room). Caller harus member channel.

POST /api/v1/channels/{channel-id}/cs-session/handoff

Eskalasi dari bot ke antrian manusia (waiting). Body opsional: { "reason": "butuh agen", "priority": "high" }.

POST /api/v1/channels/{channel-id}/cs-session/feedback

Rating kepuasan 1โ€“5 (+ komentar opsional) setelah sesi resolved. Body: { "rating": 5, "comment": "mantap" }.

GET /api/v1/cs/agent/state

Presence + workload + skills agen (caller), untuk pre-fill UI. Default offline bila agen belum pernah set presence.

Example Response
{
  "success": true,
  "data": { "status": "online", "skills": ["billing"], "max_concurrent": 5, "active_count": 2 }
}
PUT /api/v1/cs/agent/state

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

GET /api/v1/cs/sessions

Daftar sesi berstatus waiting di org, diurutkan priority lalu FIFO (waiting_since). Tiap item menyertakan skill yang diminta.

POST /api/v1/cs/sessions/{session-id}/assign

Tugaskan sesi ke agen. Body { "agent_id": "uuid" } โ€” kosong berarti self-assign (claim oleh caller). Transisi menjadi assigned.

POST /api/v1/cs/sessions/{session-id}/reassign

Transfer sesi assigned ke agen lain (workload ikut pindah). Gated supervisor/admin (cs:manage / *). Body { "agent_id": "uuid" }.

PATCH /api/v1/cs/sessions/{session-id}

Ubah status sesi via state machine (mis. assigned โ†’ resolved, resolved โ†’ closed). Body { "status": "resolved" }. Transisi ilegal ditolak.

GET /api/v1/cs/agents

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

Example Response
{
  "success": true,
  "data": [ { "user_id": "โ€ฆ", "account_id": "โ€ฆ", "display_name": "Budi", "role": "agent" } ]
}
POST /api/v1/cs/agents

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.

DELETE /api/v1/cs/agents/{user-id}

Copot status agen seseorang (by chat user_id). Gated admin.

GET /api/v1/cs/skills

Katalog skill aktif (default). Tambahkan ?include_inactive=true untuk menyertakan yang non-aktif (admin only).

Example Response
{
  "success": true,
  "data": [ { "id": "โ€ฆ", "slug": "billing", "label": "Tagihan", "active": true } ]
}
POST /api/v1/cs/skills

Tambah skill ke katalog. Body { "slug": "billing", "label": "Tagihan" }. Slug dinormalisasi lowercase dan divalidasi pola ^[a-z0-9][a-z0-9_-]{0,63}$.

PATCH /api/v1/cs/skills/{skill-id}

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

DELETE /api/v1/cs/skills/{skill-id}

Hapus skill dari katalog. Sesi/agen yang terlanjur memakai slug-nya tetap aman (routing fallback). Gated admin.

GET /api/v1/cs/business-hours

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.

Example Response
{
  "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"
  }
}
PUT /api/v1/cs/business-hours

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

FieldTypeRequiredDescription
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).
Example Request Body
{
  "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."
}
POST /api/v1/audit/dlp

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

FieldTypeRequiredDescription
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
Example Request Body
{
  "rule_ids": ["jwt", "aws_access_key"],
  "channel_id": "61c625f0-7a2e-44af-aa4c-8fefb0ce0ab4",
  "sent_anyway": false,
  "surface": "composer"
}
Example Response
{ "success": true, "data": { "recorded": true } }
GET /api/v1/me

Return chat-side user record. Membuat record baru kalau ini call pertama (lazy-create dari JWT claim).

Example Response
{
  "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 /api/v1/me

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

FieldTypeRequiredDescription
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.
Example Request Body
{
  "mute_keywords": ["lunch", "standup", "deploy"]
}
Example Response
{
  "success": true,
  "data": { "id": "...", "display_name": "Updated Name", "mute_keywords": ["lunch", "standup", "deploy"], "..." : "..." }
}
GET /api/v1/channels/{channel-id}/members

List anggota aktif. Caller harus member juga. Member yang sudah leave (left_at set) tidak ke-include.

Query Parameters

ParamTypeRequiredDescription
page int optional
per-page int optional Default 50, max 100.
Example Response
{
  "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 }
  }
}
POST /api/v1/channels/{channel-id}/members

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

FieldTypeRequiredDescription
user_ids uuid[] optional Chat-side user IDs (bukan account_id).
members object[] optional [{account_id, display_name}] โ€” provision otomatis.
Example Response
{ "success": true, "data": { "added": 3 } }
DELETE /api/v1/channels/{channel-id}/members/{user-id}

Soft-leave target user. left_at di-set tapi row tidak dihapus (for audit + last_read preservation). Self-remove auto-route ke leave endpoint.

POST /api/v1/channels/{channel-id}/leave

Caller leave channel sendiri. Owner channel tidak boleh leave โ€” harus transfer ownership dulu (out of scope Step 3).

PATCH /api/v1/channels/{channel-id}/me

Update notification pref / mute. Per-membership setting.

Request Body Fields

FieldTypeRequiredDescription
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).
POST /api/v1/dm

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

FieldTypeRequiredDescription
user_id uuid required Peer chat-side user_id (bukan account_id).
Example Response
{
  "success": true,
  "data": {
    "channel_id": "6805901e-5912-4a5c-92de-146c1dd35d4f",
    "created":    true
  }
}
GET /api/v1/channels/{channel-id}/messages

Return messages DESC (newest first). Pagination via before / after cursor (message_id), bukan offset (offset di Scylla mahal โ€” pakai cursor).

Query Parameters

ParamTypeRequiredDescription
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).
Example Response
{
  "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"
      }
    ]
  }
}
POST /api/v1/channels/{channel-id}/messages

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

FieldTypeRequiredDescription
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.
Example Request Body
{
  "body": "Deploy ke prod?",
  "metadata": "{\"interactive\":{\"buttons\":[[{\"id\":\"approve\",\"label\":\"โœ… Approve\",\"style\":\"success\"},{\"id\":\"reject\",\"label\":\"โŒ Reject\",\"style\":\"danger\"}]]}}"
}
PATCH /api/v1/channels/{channel-id}/messages/{message-id}

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

FieldTypeRequiredDescription
body string required New body, max 8000 chars.
DELETE /api/v1/channels/{channel-id}/messages/{message-id}

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.

Example Response
{ "success": true, "data": { "deleted": true } }
POST /api/v1/channels/{channel-id}/messages/{message-id}/interactions

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

FieldTypeRequiredDescription
action_id string required Matches a button.id in the message metadata.
Example Request Body
{ "action_id": "approve" }
Example Response
{ "success": true, "data": { "recorded": true } }
POST /api/v1/channels/{channel-id}/messages/{message-id}/pin

Member-only. Validates the message exists and is not deleted. Max 50 pins per channel (idempotent โ€” re-pin returns 200 not 201).

Example Response
{ "success": true, "data": { "pinned": true } }
DELETE /api/v1/channels/{channel-id}/messages/{message-id}/pin

Pinner or moderator only. Idempotent โ€” unpin of a non-pinned message returns 200.

Example Response
{ "success": true, "data": { "unpinned": true } }
GET /api/v1/channels/{channel-id}/pinned

Member-only. Returns up to 50 pins, sorted by pinned_at DESC. Enriched with message body so FE doesn't need to re-fetch.

Example Response
{
  "success": true,
  "data": {
    "items": [
      {
        "channel_id": "...", "message_id": "01KS24โ€ฆ",
        "pinned_by": "...", "pinned_at": "...",
        "body": "Read the runbook before deploy",
        "author_id": "...", "is_deleted": false
      }
    ]
  }
}
GET /api/v1/users/lookup

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

ParamTypeRequiredDescription
ids csv-uuid required Comma-separated user IDs, max 100
Example Response
{
  "success": true,
  "data": {
    "items": [
      {
        "id": "...", "account_id": "...",
        "display_name": "Arian Saputra",
        "avatar_url": null,
        "kind": "human"
      }
    ]
  }
}
GET /api/v1/users/presence

Returns online/offline state for a set of user IDs by checking Redis presence keys (TTL 90s).

Query Parameters

ParamTypeRequiredDescription
ids csv-uuid required Comma-separated user IDs
Example Response
{
  "success": true,
  "data": {
    "items": [
      { "user_id": "...", "online": true },
      { "user_id": "...", "online": false }
    ]
  }
}
POST /api/v1/channels/{channel-id}/read-marker

Member-only. Updates last_read_id if newer than stored.

Request Body Fields

FieldTypeRequiredDescription
message_id string required ULID of the most recently seen message
Example Request Body
{ "message_id": "01KS24XPTNY4SVB6Y7TVESYYYB" }
Example Response
{ "success": true, "data": { "ok": true } }
POST /api/v1/channels/{channel-id}/messages/{message-id}/reactions

Member-only. Adds emoji to message. Server normalizes emoji (max 16 chars). Returns 422 if emoji invalid or message deleted.

Request Body Fields

FieldTypeRequiredDescription
emoji string required Single emoji char or shortcode (โ‰ค16 chars)
Example Request Body
{ "emoji": "๐Ÿ‘" }
Example Response
{ "success": true, "data": { "added": true } }
DELETE /api/v1/channels/{channel-id}/messages/{message-id}/reactions

Remove your own reaction. Idempotent. Body matches emoji to remove.

Request Body Fields

FieldTypeRequiredDescription
emoji string required Same emoji string used in POST
Example Request Body
{ "emoji": "๐Ÿ‘" }
Example Response
{ "success": true, "data": { "removed": true } }
GET /api/v1/search

Full-text search across messages the caller can see. Channels filter narrows to specific channels (otherwise org-wide).

Query Parameters

ParamTypeRequiredDescription
q string required Search phrase (โ‰ค256 chars)
channel_id uuid optional Restrict to one channel
limit int optional Max hits, โ‰ค100 (default: 50)
Example Response
{
  "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
  }
}
POST /api/v1/channels/{channel-id}/messages/{message-id}/star

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

Example Response
{ "success": true, "data": { "starred": true } }
DELETE /api/v1/channels/{channel-id}/messages/{message-id}/star

Idempotent. Tidak butuh membership check (user boleh hapus bookmark sendiri walaupun sudah left channel).

Example Response
{ "success": true, "data": { "unstarred": true } }
GET /api/v1/me/starred

Sorted by starred_at DESC. Decorate dengan body/author dari Scylla (best-effort). Pagination via before (RFC3339 timestamp).

Query Parameters

ParamTypeRequiredDescription
limit int optional Default 50, max 100.
before timestamp optional Cursor: paginate items sebelum waktu ini (DESC).
Example Response
{
  "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"
      }
    ]
  }
}
POST /api/v1/me/starred/bulk-check

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.

Example Response
{
  "success": true,
  "data": {
    "items": {
      "61c625f0-...:01KS1...": true
    }
  }
}
POST /api/v1/channels/{channel-id}/webhooks

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

FieldTypeRequiredDescription
url string required Target https:// URL
events string optional Comma-separated event allow-list or '*' for all default: message.created
Example Request Body
{
  "url": "https://my-bot.example.com/chat-hook",
  "events": "message.created, message.interaction"
}
Example Response
{
  "success": true,
  "data": {
    "id": "...", "channel_id": "...", "url": "...",
    "secret": "abc123โ€ฆ",
    "events": "message.created, message.interaction",
    "is_active": true,
    "failure_streak": 0
  }
}
GET /api/v1/channels/{channel-id}/webhooks

Returns currently configured webhooks. secret is NOT returned after create (one-time visibility).

Example Response
{
  "success": true,
  "data": {
    "items": [
      {
        "id": "...", "url": "...",
        "events": "*",
        "is_active": true, "failure_streak": 0,
        "last_fired_at": "...", "last_failed_at": null
      }
    ]
  }
}
DELETE /api/v1/channels/{channel-id}/webhooks/{webhook-id}

Removes a webhook config + cancels queued retries.

Example Response
{ "success": true, "data": { "deleted": true } }
POST <your URL>

Fired on POST /messages.

Example Request Body
{
  "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"
  }
}
POST <your URL>

Fired on PATCH /messages/:id.

Example Request Body
{
  "event": "message.edited",
  "data": {
    "id": "01KSโ€ฆ",
    "channel_id": "...", "author_id": "...",
    "body": "hello (edited)",
    "metadata": "{}",
    "edited_at": "2026-05-20T13:12:00.000Z"
  }
}
POST <your URL>

Fired on DELETE /messages/:id (user-initiated).

Example Request Body
{
  "event": "message.deleted",
  "data": {
    "id": "01KSโ€ฆ",
    "channel_id": "...", "author_id": "...",
    "deleted_by": "...",
    "deleted_at": "2026-05-20T13:13:00.000Z"
  }
}
POST <your URL>

Fired by the ephemeral-message sweeper when a message's TTL elapses. Payload omits body (already cleared from Scylla).

Example Request Body
{
  "event": "message.expired",
  "data": {
    "id": "01KSโ€ฆ",
    "channel_id": "...", "author_id": "...",
    "expired_at": "2026-05-20T13:14:00.000Z"
  }
}
POST <your URL>

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.

Example Request Body
{
  "event": "message.interaction",
  "data": {
    "channel_id": "...",
    "message_id": "01KSโ€ฆ",
    "user_id": "...",
    "action_id": "approve",
    "action_label": "โœ… Approve"
  }
}
GET /ws

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

ParamTypeRequiredDescription
token string optional JWT โ€” alternative to Authorization header (untuk browser yang tidak bisa set header).
Example Response
# 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.

Protocol: websocket
Address: /ws

Client kirim `subscribe` frame

Daftar channel_ids untuk mulai terima realtime events.

Payload

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

Protocol: websocket
Address: chat: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.

Protocol: websocket
Address: chat: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.

Protocol: websocket
Address: chat: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.

Protocol: websocket
Address: chat: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.

Protocol: websocket
Address: /ws

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:

CodeMeaning
invalid_frameJSON parse fail atau payload shape salah
unsupported_typetype tidak dikenal
permission_deniedAuthorization fail di JWT permissions
not_memberSubscribe attempt ke channel bukan member
rate_limitedFuture feature (Step 7)
session_revokedSession di-logout di account-service
max_connections_per_userBoot saat conn ke-9 (oldest FIFO)
internal_errorGeneric 5xx โ€” biasanya transient
Protocol: websocket
Address: /ws

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

Protocol: websocket
Address: /ws

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.

Protocol: websocket
Address: /ws

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

Protocol: websocket
Address: /ws

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

Protocol: websocket
Address: /ws

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

Protocol: websocket
Address: /ws

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

Protocol: websocket
Address: /ws

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

Protocol: websocket
Address: /ws

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

Protocol: websocket
Address: /ws

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

Protocol: websocket
Address: /ws

publish 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
  }
}

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

Authorization: Bearer <value>
๐Ÿ’ก Info: Credentials tersimpan per environment di browser Anda (localStorage) dan tidak dikirim ke server.

Cara Menggunakan

1
Isi credentials di atas
2
Klik "Endpoints" di sidebar untuk memilih API
3
Klik "Try It" pada endpoint yang ingin di-test
4
Pilih metode autentikasi dan klik Send

๐Ÿ” 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.

Token contains: sub (account_id UUID), sid (session_id, kosong untuk service principal), org_id (current organization), permissions[] (chat-relevant: chat:write, chat:read, channel:*), principal_type (human|service), kid (JWKS key reference), exp, iat, iss, jti

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

PermissionDescription