# gravex chat — full documentation > A chat API service: developers add real-time chat to an app with a few lines of SDK, and we run the servers, message storage and reconnects. Sign up in the console and get an account key (ck_); after that, code can create apps and issue keys (pk_ and sk_) through the Console API. The only official SDK is JS/TS (`@kispi/chat`), but the REST API and the real-time (WebSocket) protocol are public, so a client can be built in any language. - When a user asks you to build chat, check "What is and is not supported" below first. Do not say a feature exists if it is missing there or listed under "Not supported". - Signing up and issuing the first account key (`ck_`) is done by a person in the console. After that, creating apps, issuing keys and setting allowed origins and webhooks can be done by calling the "Console API" with the `CHAT_ACCOUNT_KEY` in the user's `.env`. `pk_` is a public value for the frontend; `sk_`, the webhook secret and `ck_` live only in server environment variables or a local `.env`. Never ask the user to paste an `sk_` or `ck_` into the conversation, and do not print an `sk_` received from a Console API response — write it straight into `.env`. - Every API detail you need to build is in the single file llms-full.txt. The per-document sources are linked below. - To build a client in a language other than JS/TS (Swift, Kotlin, Dart, Python, Go and so on), follow "REST API reference" and "Real-time protocol" instead of the SDK reference. The checklist at the end of "Real-time protocol" is everything the SDK does for you. This file joins the public documentation at https://chat.gravex.app and the SDK reference (`@kispi/chat` 0.7.2) into one file. It is rebuilt from the documents each time the site is deployed. --- # Pricing, terms and SLA summary Answer questions about operating conditions from this summary. The full text is at each link. ## Pricing ([Pricing](https://chat.gravex.app/en/pricing)) - There is one plan, Basic, and it is free. No card and no trial period. - Pricing sets no cap on MAU or concurrent connections. Each app has safety limits (publish rate, 10 concurrent connections per user and so on), published on the pricing page. ## Terms ([Terms](https://chat.gravex.app/en/terms), [Privacy](https://chat.gravex.app/en/privacy)) - An app's users, rooms and messages are the customer's data; the operator processes them only to run the service. - At any time the whole app can be downloaded as one NDJSON file, from the console button or by calling `GET /v1/export` with a secret key. - After an app is deleted, running "permanent delete" erases its users, rooms and messages from the engine, and they cannot be recovered. - The `sk_` (secret key) is shown once when issued and is not stored. ## SLA ([SLA](https://chat.gravex.app/en/sla)) - The monthly uptime target is 99.5% (about 3 hours 36 minutes of downtime allowed over 30 days). It is an operating target with no compensation or credits if missed (the Terms do not guarantee availability). - Messages are kept until the app deletes them. The engine does not pick a period and delete them automatically. - Backups are a disk snapshot of the database server once a day (every day at 06:00 KST). In the worst case up to the previous 24 hours of data can be lost. - Paid plans or price changes are announced on the pricing page and by email to the account at least 90 days before they apply. A shutdown is announced at least 180 days ahead, with export guaranteed for that whole period. - Questions and outage reports: admin@gravex.app. There is no separate status page and no response-time promise. - The chat in three services the operator (GravEx) built itself (coinsect.io, everymaple.com, real.gravex.app) runs on the same engine. --- # What is and is not supported When a user asks "can it do this?", answer from this table. Never invent a yes for anything that is missing from the table or listed under "Not supported". Read the document in the right-hand column for how to build it. The engine handles **delivery, ordering, storage, presence and webhooks.** Login, file storage, sending push notifications and judging content belong to the user's backend; the engine opens hooks and events for them. Live services run their chat on this engine: Coinsect (https://coinsect.io, a crypto-streamer community chat), Yeogi Eolma (https://real.gravex.app, chat on an apartment sale-price map) and EveryMaple (https://everymaple.com, a game community chat). ## Supported | Feature | How | Document | |---|---|---| | Real-time chat rooms | The backend creates a room with `chat.rooms.ensure({key, type})` (get-or-create); the browser opens it with `chat.roomByKey(key)` or `chat.room(id)`, then `subscribe()` and `send({text})` | Getting started | | Room types | `public` (anyone reads and writes), `private` (members only), `channel` (anyone reads, only owner and admin write), `dm` (two people) | Server integration | | Members | The backend signs and issues a user JWT (`chat.token({userId, name})`). The engine trusts the signature | Getting started, Server integration | | Visitors (guests) | The backend's `chat.guest()` issues a signed credential. Never sign an id the browser sends | Guest users | | History | Subscribing loads the latest 100; scrolling up is `room.loadOlder()`. `seq` has no gaps and the SDK fills them after a disconnect | SDK reference | | Thread replies | `send({text, threadId: root message id})`. The root gets `thread: {count, lastSeq, lastAt}` and a `thread.updated` event arrives. Read one thread with `room.history({view: {thread: id}})` | SDK reference | | Quote replies | `send({text, replyTo: message id})`. Separate from threads | SDK reference | | Emoji reactions | `room.react(messageId, emoji)` / `room.unreact(...)`; counts arrive as `reaction.added` and `reaction.removed` events; who pressed it is `room.reactionsOf(...)`. At most 20 distinct emojis per message | SDK reference | | Mentions | `send({text, entities: [{type: 'mention', userId, offset, length}]})`. Mentions are counted in `unreadMentions` | SDK reference | | Read and unread | `room.markRead(seq)`, `chat.unread()`, `chat.rooms.list()` (unread count and last message per room); new messages in other rooms arrive as a `notification` event | SDK reference | | Typing | Call `room.typing()`; others get a `typing` event (find the name in `room.presence.users`) | SDK reference | | Presence | `room.presence` = `{count, users?}` and a `presence` event | SDK reference | | 1:1 conversations | The backend's `chat.rooms.ensureDM([a, b])` returns the room id | Server integration | | Announcement-only rooms | `type: 'channel'` plus `members.add(roomId, userId, 'admin')` from the backend | Server integration | | System messages | The backend's `chat.messages.send(roomId, {kind: 'system', sender, text})`. It does not go through the review hook | Server integration | | Edit and delete messages | REST `PATCH` and `DELETE` (the browser SDK's `Room` has no edit method). Delete is a tombstone that empties the body | Server integration | | Attachments (images and so on) | A message carries `attachments: [{type, url, key?, mime?, w?, h?, name?, size?}]`. **Only the URL (https) and metadata.** Up to 10 | Image uploads and moderation | | Word and image checks | The synchronous `before_publish` webhook refuses (`deny`) a publish or edit, or lets it through with a changed body | Webhooks, Image uploads and moderation | | Webhooks | Signed `message.created`, `message.deleted`, `reaction.*` and more to the user's server. At-least-once; catch up on missed ones with `chat.events.list` | Webhooks | | Bans and forced disconnects | `chat.users.ban`, `unban`, `revokeTokens`, `disconnect`, and removing messages with `purgeMessages` | Server integration | | Account and privacy deletion | Anonymize with `withdraw`, delete permanently with `purge` | Server integration | | Message search | REST `q` (substring match on text within a room). **With the server key (`sk_`) only** | Server integration | | Data export | `GET /v1/export` (NDJSON): users, rooms, members, messages, reactions and read positions. **No attachment files** | Server integration | | Clients in other languages | iOS (Swift), Android (Kotlin), Flutter (Dart), Python, Go and more. Implement the REST and WebSocket protocol as documented. The checklist "Building a client without the SDK" is everything the SDK does for you | REST API reference, Real-time protocol | | Separate apps | One app is one tenant. Create two apps for production and development | Server integration | | Automating app and key creation | With an account key (`ck_`), call the Console API (`/api/apps/**`) to create apps (`pk_` and `sk_` come in the same response), issue and revoke more keys, set allowed origins, webhooks and limits, delete and restore apps, read users, rooms and messages and moderate. A person signs up, creates the first `ck_` in the console and puts it in `.env` | Console API | Default limits: 4000 characters of body, 10 attachments, 5 publishes per second per user (burst 10), 10 concurrent connections per user. ## Not supported | Feature | Why and what to do | |---|---| | Sending push notifications | The engine delivers in real time only to open connections. For people whose app is closed, receive the `message.created` webhook and send through FCM, web push or email from the user's server | | Storing or converting files | The engine does not accept files. Upload to the user's bucket and put the URL in the attachment | | Official native SDKs (iOS, Android, Flutter) | The official SDK is JS/TS only, for browsers and Node (`@kispi/chat`, and `@kispi/chat/server` for servers). But the REST API and the real-time (WebSocket) protocol are public, so a client in another language can be built from the documentation ("Clients in other languages" under "Supported" above) | | Login and member management | The engine does not know who anyone is. The user's backend signs the token | | Automatic judging of words and images | The engine opens only a hook. The user builds the judgment (word lists, Rekognition and so on) | | Message search with a user token | Search is `sk_` only. Put a search API on the user's backend | | Signing up and issuing the first account key | A person signs up in the console with an email code and creates a `ck_` on the **API keys** screen. An account key cannot create account keys | | Voice and video calls, screen sharing | None | | Storing push tokens and notification settings | None. Keep them in the user's database | ## UI checklist When the chat screens are done, check them against this list one by one. These are the things the SDK does not do for you and that are easy to miss. - **Redraw on `reset`.** Throw away the rendered list and draw again from `room.messages`. Appending to the old list leaves a gap. - **Keep the reading position when loading the past.** Correct the scroll position by what `loadOlder()` added on top, and follow new messages down only when the user is already at the bottom. - **Leave thread replies out of the main timeline.** `room.messages` also holds rows that have a `threadId`. - **Skip your own typing events and clear the indicator after about 5 seconds.** `typing` also reaches the sender, and there is no "stopped typing" event. - **Fix reactions yourself on rows read with `history()`.** The SDK only updates rows inside `room.messages`. For a list drawn from `history()`, such as a thread panel, use `count` and `userId` from `reaction.added` / `reaction.removed` to fix the count and `myReactions`. - **Draw reaction counts from the server.** `react()` changes `myReactions` first and fixes the count when the event arrives; until then, if there is no count, draw just the emoji. Treat a row without `myReactions` as one you have not reacted to. - **Insert the body as plain text.** Never insert `text` as HTML (`innerHTML`, `dangerouslySetInnerHTML`, `{@html}`). Highlight mentions by slicing the text and wrapping the pieces. - **Wait on `rate_limited`.** Block sending for the `ChatError`'s `retryAfterMs` and show the time left. ## When you answer - If you are not sure it is supported, say you don't know and that it is not in this table or the documents. Do not promise a similar-looking feature in its place. - `pk_` is a public value that may sit in the browser; `sk_`, the webhook secret and the guest secret stay on the server. Never tell the user to paste an `sk_` into the conversation. - The account key `ck_` has full power over the account. Keep it only on a server or in a local `.env`, never in frontend code, a git repository or the conversation, and tell the user to revoke it when done. Apps already created and their `pk_` and `sk_` keep working after it is revoked; after revoking, also delete the `CHAT_ACCOUNT_KEY` line from `.env`. Do not print an `sk_` received from a Console API response to the screen or a log; write it straight into `.env`. - Every document uses the same environment variable names: frontend `VITE_CHAT_PK` (`pk_`); server `CHAT_SECRET_KEY` (`sk_`), `CHAT_WEBHOOK_SECRET` and `CHAT_GUEST_SECRET`; the app-creating script `CHAT_ACCOUNT_KEY` (`ck_`). Vite puts only variables that start with `VITE_` into the browser bundle. **Never widen Vite's `envPrefix` to `CHAT_`** — that ships `sk_` and `ck_` into the bundle. Outside Vite, put that framework's public prefix on the `pk_` only (Next.js: `NEXT_PUBLIC_CHAT_PK`). - For the allowed origin, use the address the dev server actually runs on (scheme, host and port). If the port changes, fix it with `PATCH /api/apps/{id}/keys/{keyId}`; it replaces the whole list, so send the existing values too. - For a project with no login, do not invent a demo login; issue guest identities with `chat.guest()`. --- # Getting started Adding chat to your service takes three things: **an app created in the console**, **one backend endpoint that issues tokens**, and **the SDK in the browser**. ```sh npm install @kispi/chat ``` There are two entry points. The SDK has no runtime dependencies and includes type declarations. | Entry point | Where | Key it uses | |---|---|---| | `@kispi/chat` | Browser (and Node) | publishable key (`pk_`) | | `@kispi/chat/server` | Your backend | secret key (`sk_`) | ## 1. Create an app in the console [Sign up](https://console.chat.gravex.app/login), then click **New app**. Each app is a tenant — users, rooms, messages, and keys are all scoped to the app. As soon as you create it, you get two keys. | Key | Where it lives | What it does | |---|---|---| | Publishable key (`pk_…`) | Frontend code | Identifies the app, nothing more. It's public | | Secret key (`sk_…`) | Backend environment variable | Grants access to all of the app's data. Used to issue tokens and call the server API | **The `sk_` is shown only once, when you create it.** The console doesn't store it, so move it to your secrets store right away. If you lose it, issue a new `sk_` and revoke the old one ([Rotating keys](https://chat.gravex.app/en/docs/server)). If you would rather create the app from a script or an AI than by clicking, one account key (`ck_`) is enough. See the [Console API](https://chat.gravex.app/en/docs/console-api). ### Allowed origins The **allowed origins** you enter when creating the app are the origins of pages allowed to use this `pk_` in the browser. - Scheme, host, and port must match **exactly**. `https://example.com` and `https://www.example.com` are different origins. - So are `http://localhost:3000` and `http://127.0.0.1:3000`. Add your development origins separately. - Using the key from a page that isn't on the list is refused with `unauthorized`. Change the list with **Edit origins** on the **Keys** screen. ## 2. Issue tokens on your backend The engine doesn't know who anyone is. It verifies the token's signature and trusts the user id inside it **at face value**. Your service already knows who's logged in, so all you need is an endpoint that issues a token for that person. ```ts TypeScript // src/routes/api/chat-token/+server.ts (SvelteKit) — the shape is the same in any framework import { json } from '@sveltejs/kit' import { createChatServer } from '@kispi/chat/server' import { env } from '$env/dynamic/private' const chat = createChatServer({ secretKey: env.CHAT_SECRET_KEY // sk_… }) export const POST = async ({ locals }) => { const user = locals.user // your service's session if (!user) return new Response(null, { status: 401 }) return json({ token: chat.token({ userId: String(user.id), // this person's id name: user.nickname, avatar: user.avatarUrl, // optional ttlSeconds: 3600 // optional. Defaults to 1 hour, max 24 hours }) }) } ``` ```python Python # chat_token() lives on the "Server integration" page — this only calls it @app.post("/api/chat-token") def issue_chat_token(): user = current_user() # your service's session if user is None: return "", 401 return {"token": chat_token(SK, str(user.id), user.nickname, ttl=3600)} ``` ```go Go // chatToken() lives on the "Server integration" page — this only calls it func issueChatToken(w http.ResponseWriter, r *http.Request) { user, ok := currentUser(r) // your service's session if !ok { http.Error(w, "", http.StatusUnauthorized) return } token, err := chatToken(sk, user.ID, user.Nickname, time.Hour) if err != nil { http.Error(w, "", http.StatusInternalServerError) return } json.NewEncoder(w).Encode(map[string]string{"token": token}) } ``` ```kotlin Kotlin // chatToken() lives on the "Server integration" page — this only calls it @PostMapping("/api/chat-token") fun issueChatToken(session: HttpSession): Map { val user = session.user ?: throw ResponseStatusException(HttpStatus.UNAUTHORIZED) return mapOf("token" to chatToken(sk, user.id.toString(), user.nickname)) } ``` `chat.token()` signs locally without a network call, so it's fine to call it on every request — and the same is true in every other language, since signing never touches the engine. `name` wins on every connect. The token spec itself is in [Server integration](https://chat.gravex.app/en/docs/server); for visitors who aren't logged in, [Guest users](https://chat.gravex.app/en/docs/guests) shows how the same endpoint issues them an identity. ## 3. Send and receive in the browser ```ts // chat.ts import { createChatClient } from '@kispi/chat' export const chat = createChatClient({ key: import.meta.env.VITE_CHAT_PK, // pk_… token: async () => { const res = await fetch('/api/chat-token', { method: 'POST' }) return (await res.json()).token } }) chat.on('state', (s) => setStatus(s)) // connecting | open | reconnecting | closed chat.connect().catch(() => {}) ``` Reconnecting and token refresh are the SDK's job. **Your backend creates rooms** — opening a room by key doesn't create it, and subscribing to a key that doesn't exist gives you `not_found`. `rooms.ensure` is get-or-create: it returns the room if it already exists. ```ts // Backend — the createChatServer instance from step 2 await chat.rooms.ensure({ key: 'lobby', type: 'public' }) ``` A room `key` uses only ASCII letters, digits, and `_` `.` `:` `-`, 1 to 128 characters (`^[A-Za-z0-9_.:-]{1,128}$`). Anything else, such as Korean text or a space, gets `400 invalid`, so put the human-readable title in `name` and keep `key` an ASCII alias like `order-1234`. Everything below is back in the browser, on the `createChatClient` from step 3. ```ts const room = chat.roomByKey('lobby') await room.subscribe() // live feed + the latest 100 messages room.on('messages', (all) => render(all)) // the whole list, every time it changes room.on('message.created', (m) => append(m)) // or one new message at a time const ack = await room.send({ text: 'Hello' }) // { messageId, seq } await room.send({ text: 'Check this out', attachments: [{ type: 'image', url, key }] }) await room.react(ack.messageId, '👍') await room.markRead(room.lastSeq) room.typing() const { hasMore } = await room.loadOlder() // as the user scrolls up. false at the beginning ``` `room.messages` is always in ascending seq order, with no gaps. It's a **new array** every time it changes, so drop it straight into state that compares by reference (Svelte `$state.raw`, React `useState`, Vue `shallowRef`). **The engine doesn't store files** — upload them to your own storage (S3 or similar) and put the address in `attachments` (`url` must start with `https://`). Always handle these two events. - **`reset`** — The SDK discarded the list and refilled it. Throw away what you rendered and redraw from `room.messages`. - **`error`** — Reading history failed. The room resubscribes itself, so just surface it. | | Persisted? | After reconnecting | |---|---|---| | Messages | Yes | The SDK fills the gaps | | Reactions | Yes | Come with history. For changes while disconnected, use `reload()` | | Read cursor (`markRead`) | Yes | Reflected in the unread counts from `rooms.list()` | | presence | No | Received fresh | | typing | No | Fine to miss. It arrives at most once every 3 seconds per person, so show it for about 5 seconds after the last one. A room announces at most 3 people per 3-second window, so in a crowded room someone can be typing without being announced | `typing` **is also sent back to the sender** — if you render "X is typing," skip your own `userId`. Failures throw a `ChatError`; if `err.code` is `rate_limited`, wait `err.retryAfterMs` before retrying. ### Limits Rate and size limits are per app. The defaults are below; see and change them per app on the **Limits** tab in the console. | Limit | Default | Counted per | |---|---|---| | Sending messages | 5 per second, burst of 10 | user | | Reactions | 10 per second | user | | `typing` | 20 per second | user | | Editing messages | 2 per second | user | | Connecting | 10 per minute | user | | Concurrent connections | 10 | user | | Connection setup | 60 per minute | `pk_` + IP | | Subscriptions | 100 | connection | | Message length | 4000 characters | message | | Attachments | 10 | message | | Distinct reactions | 20 | message | | Frame size | 64 KB | connection | | Stored messages | unlimited | app | | Edit window | unlimited | message | - Messages sent through the server API (`sk_`) are not subject to the rate limits or the edit window. - **Stored messages** counts deleted messages (tombstones) too. When an app that has a cap is full, new messages are refused with `quota_exceeded` (REST 403). Waiting doesn't clear it, so don't retry. - You can raise rates and sizes up to the app's ceiling. Lowering works too, but **the frame size can't go below the 64 KB default.** The send queue (`sendQueueSize`) is fixed at the same value for every app. - Changes reach open connections within 30 seconds. Values fixed at connect time (connection rates, concurrent connections, subscriptions, frame size) apply from the next connection. The current values also arrive in `hello.limits` when you connect. ## 4. Webhooks (optional) Use these when your backend needs to hear about publishes, edits, and deletes — push notifications, search indexing, audit logs. There's also `before_publish`, which steps in **before** a message is published to reject it or rewrite its body. The receiving code and signature verification are in [Webhooks](https://chat.gravex.app/en/docs/webhooks). ## Next - [Server integration](https://chat.gravex.app/en/docs/server) — token spec, server API, key rotation. - [SDK reference](https://chat.gravex.app/en/docs/sdk) — every method and event. - [Architecture and benchmarks](https://chat.gravex.app/en/docs/architecture) — how much it holds, how we measured, and what we did not measure. Source: https://chat.gravex.app/en/docs --- # Server integration Your backend has two jobs: **sign user tokens**, and **manage rooms, messages and users over REST**. TypeScript has `@kispi/chat/server`, which wraps both; every other language does the same work directly. Pick your language in the tabs below. Both jobs use a single `sk_`. ## User token spec | | | |---|---| | Format | A standard JWT. The signing algorithm is **fixed to HS256**; any other algorithm and `none` are refused | | Signing key | The **entire `sk_` string** as UTF-8 bytes. Don't strip the prefix or decode it | | Header | `{"alg":"HS256","typ":"JWT","kid":"…"}` — `kid` is optional. Set it only when the **second segment of `sk__` is 13 Crockford Base32 characters** (`[0-9A-HJKMNP-TV-Z]{13}`); otherwise leave it out. Without a `kid`, the engine tries every live `sk_` of the app | Claims: | Claim | | | |---|---|---| | `sub` | **required** | The user id in your service. At most 128 UTF-8 bytes. Refused if it starts with `anon:` | | `iat` | **required** | Issued at, in whole seconds. Refused if later than `now + 60s`. Don't round it or cache it | | `exp` | **required** | Expiry, in whole seconds. If `exp - iat` exceeds 86400 (24 hours) it is **refused, not clamped** | | `name` | recommended | Overwrites the stored name on every connect. Truncated to 256 bytes when stored | | `avatar` | optional | Truncated to 2048 bytes when stored | | `meta` | optional | A JSON object. Merged into the user's `meta.provider` | `aud`, `iss` and `nbf` are not required. **Which app you are is decided by the `pk_`** the browser sends on connect, not by the token. When verification fails the answer is a single **401 `unauthorized`** whatever the reason — it won't tell you whether the signature was wrong or the token expired. Go uses `github.com/golang-jwt/jwt/v5` and Kotlin uses `com.auth0:java-jwt`. The rest needs nothing beyond the standard library. ```ts TypeScript import { createChatServer } from '@kispi/chat/server' const chat = createChatServer({ secretKey: process.env.CHAT_SECRET_KEY! }) const token = chat.token({ userId: 'u_42', name: 'Hanna', ttlSeconds: 3600 }) ``` ```python Python import base64, hmac, hashlib, json, re, time KID = re.compile(r"[0-9A-HJKMNP-TV-Z]{13}") # 13 Crockford Base32 characters def b64u(b): return base64.urlsafe_b64encode(b).rstrip(b"=").decode() def chat_token(sk, user_id, name, ttl=3600, avatar=None, meta=None): parts = sk.split("_") header = {"alg": "HS256", "typ": "JWT"} if len(parts) >= 3 and KID.fullmatch(parts[1]): header["kid"] = parts[1] iat = int(time.time()) claims = {"sub": user_id, "name": name, "iat": iat, "exp": iat + ttl} if avatar: claims["avatar"] = avatar if meta: claims["meta"] = meta enc = lambda o: b64u(json.dumps(o, separators=(",", ":")).encode()) signing_input = f"{enc(header)}.{enc(claims)}" sig = hmac.new(sk.encode(), signing_input.encode(), hashlib.sha256).digest() return f"{signing_input}.{b64u(sig)}" ``` ```go Go var kidPattern = regexp.MustCompile(`^[0-9A-HJKMNP-TV-Z]{13}$`) // 13 Crockford Base32 characters func chatToken(sk, userID, name string, ttl time.Duration) (string, error) { now := time.Now() token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ "sub": userID, "name": name, "iat": now.Unix(), "exp": now.Add(ttl).Unix(), }) if parts := strings.Split(sk, "_"); len(parts) >= 3 && kidPattern.MatchString(parts[1]) { token.Header["kid"] = parts[1] } return token.SignedString([]byte(sk)) } ``` ```kotlin Kotlin private val KID = Regex("^[0-9A-HJKMNP-TV-Z]{13}$") // 13 Crockford Base32 characters fun chatToken(sk: String, userId: String, name: String, ttlSeconds: Long = 3600): String { val now = Instant.now() val builder = JWT.create() .withSubject(userId) .withClaim("name", name) .withIssuedAt(now) .withExpiresAt(now.plusSeconds(ttlSeconds)) sk.split("_").let { if (it.size >= 3 && KID.matches(it[1])) builder.withKeyId(it[1]) } return builder.sign(Algorithm.HMAC256(sk)) } ``` ## Server API There is one way in: REST. The TypeScript SDK wraps it, so the three tabs below are **three ways of calling the same endpoints.** Every route's request, response and errors are in the [REST API reference](https://chat.gravex.app/en/docs/rest-api). **TypeScript** The whole surface of `@kispi/chat/server`. Editing messages, search (`q`), user-list filters, and permanent deletion (`purge`) have no SDK method, so call REST directly. | Group | Methods | |---|---| | Top level | `token({userId, name, avatar?, meta?, ttlSeconds?})`, `guest({credential?, name, avatar?, meta?, ttlSeconds?})` — both sign locally | | `rooms` | `list({type?, cursor?, limit?})`, `ensure({key?, type, name?, meta?, members?})`, `ensureDM([a, b])`, `get`, `update`, `delete`, `custom(roomId, payload)`, `presence(roomId, {full?})`, `members.list/add/remove` | | `messages` | `send(roomId, {sender, text?, kind?, attachments?, appMeta?, replyTo?, threadId?, clientMessageId?})`, `list(roomId, {before?, after?, limit?})`, `delete` | | `users` | `list({cursor?, limit?})`, `update(userId, {name?, avatar?, meta?})`, `withdraw(userId, {purgeMessages?})`, `purgeMessages(userId)`, `ban(userId, {until, reason?})`, `unban(userId)`, `revokeTokens(userId)`, `disconnect(userId, {connectionId?, reason?})` | | `events` | `list({after?, limit?})` — catch up on missed webhooks | | `webhooks` | `verify(headers, rawBody, {now?, tolerance?})` | ```ts const chat = createChatServer({ secretKey: process.env.CHAT_SECRET_KEY! }) const room = await chat.rooms.ensure({ key: 'lobby', type: 'public' }) await chat.messages.send(room.id, { sender: { id: 'system', name: 'Notice' }, text: 'Maintenance in 10 minutes' }) ``` **REST** The address is `https://api.chat.gravex.app`. Authenticate with `Authorization: Bearer sk_…`, and send `Content-Type: application/json` when there's a body. | Purpose | Route | |---|---| | Room get-or-create | `PUT /v1/rooms` `{key?, type, name?, meta?, members?}` — 201 created, 200 existing | | DM | `PUT /v1/rooms` `{type: "dm", members: [a, b]}` | | List rooms | `GET /v1/rooms?type=&cursor=&limit=` — without `type` you get `public` and `channel`; an `sk_` can also pass `private` and `dm`, comma-separated (`type=private,dm`). Newest first, `limit` defaults to 50, max 100 | | Get, update, delete a room | `GET`, `PATCH`, `DELETE /v1/rooms/{roomId}`, `GET /v1/rooms/by-key/{key}` | | Permanently delete a room | `DELETE /v1/rooms/{roomId}?purge=true` — 202 | | Send a message | `POST /v1/rooms/{roomId}/messages` `{clientMessageId, sender: {id, name, avatar?}, kind?, body: {text?, attachments?}, appMeta?, replyTo?, threadId?}` | | History and search | `GET /v1/rooms/{roomId}/messages?before=&after=&limit=&view=&q=` | | Edit a message | `PATCH /v1/rooms/{roomId}/messages/{messageId}` `{body: {text?, attachments?}}` — 200 + the message object | | Delete a message | `DELETE /v1/rooms/{roomId}/messages/{messageId}` — 204 | | Control signal (not stored) | `POST /v1/rooms/{roomId}/custom` `{payload}` | | Members | `GET /v1/rooms/{roomId}/members`, `PUT`, `DELETE /v1/rooms/{roomId}/members/{userId}` `{role?}` | | presence | `GET /v1/rooms/{roomId}/presence?full=true` — an empty room is `{count: 0}` | | User profile | `PUT /v1/users/{userId}` `{name?, avatar?, meta?}` | | Ban and unban | `PUT /v1/users/{userId}/ban` `{until, reason?}` (`until` is unix milliseconds and required), `DELETE …/ban` | | Take down their messages | `DELETE /v1/users/{userId}/messages` | | Withdraw (anonymize) | `DELETE /v1/users/{userId}`, add `?purge_messages=true` to take their messages down too | | Permanently delete a user | `DELETE /v1/users/{userId}?purge=true` — withdrawn users only, 202 | | Invalidate tokens | `POST /v1/users/{userId}/revoke-tokens` | | Force disconnect | `POST /v1/users/{userId}/disconnect` | | List users | `GET /v1/users?cursor=&limit=&q=&status=&last_seen_after=&last_seen_before=&created_after=&created_before=&bots=` | | Missed events | `GET /v1/events?after=&limit=` | | Download the whole app | `GET /v1/export` — an NDJSON stream with users, rooms, members, messages, reactions and read positions; attachment files are not included. A file whose last line is not `{"type":"end",…}` is incomplete. One at a time per app (409), 429 when the server is busy. `sk_` only | **curl** ```bash curl # Room get-or-create: 200 if it existed, 201 if it was created, same body either way curl -X PUT https://api.chat.gravex.app/v1/rooms \ -H "Authorization: Bearer $CHAT_SECRET_KEY" \ -H "Content-Type: application/json" \ -d '{"key": "lobby", "type": "public"}' # Send a system message. sender and clientMessageId are required curl -X POST https://api.chat.gravex.app/v1/rooms/$ROOM_ID/messages \ -H "Authorization: Bearer $CHAT_SECRET_KEY" \ -H "Content-Type: application/json" \ -d '{"clientMessageId": "c_1", "sender": {"id": "system", "name": "Notice"}, "body": {"text": "Maintenance in 10 minutes"}}' ``` `sender` and `clientMessageId` are required when you send a message from the server. Query parameters are snake_case (`purge_messages`); JSON fields are camelCase. `users.*` works only **after that person has connected at least once**; users come into being by connecting. ### Reading and searching history - The response is in ascending seq order and `limit` is at most 100. `before` and `after` are seq values. - Without `view` you get the whole room including thread replies; `main` gives the main timeline only, and `thread:` gives that thread only. Any other value is a 400. - `q` searches message text (`body.text`) inside that room. It's a substring match, case-insensitive, and deleted messages don't match. Only `sk_` may pass `q` (a user token gets 403), and a `q` of only whitespace is a 400. **The `seq` values in search results are not contiguous** — messages that didn't match are simply missing, so don't use them for gap detection. ### Editing and deleting messages `PATCH` replaces a message's whole `body` and stamps `editedAt`. `seq` doesn't move and the room receives `message.updated`. A missing `body` is a 400, and an already-deleted message is a 404. - Called with an `sk_`, it edits any message (system messages included) and the edit window doesn't apply. - Called with a user token, it edits **only the caller's own messages**, and only inside the app's **edit window** (`editWindowMs`, unlimited by default). A room owner or admin can't edit someone else's message, only delete it. A banned user gets `banned`. - The rate is 2 per second per user (`editPerSecond`). `sk_` calls aren't counted. The browser SDK's `Room` has no edit or delete methods, and the server SDK has only `messages.delete`. Call REST directly to edit. Do system-message and moderation edits from your backend with an `sk_`; for a screen where users edit their own messages, the browser can call the route with the user token (send `Authorization: Bearer ` and `X-Chat-Key: pk_…`). The engine checks the author and the edit window. `DELETE` is a tombstone: the row stays and the text is emptied. `seq` is unchanged and the room receives `message.deleted`. Deleting an already-deleted message is 204 again. A user token can delete the caller's own messages, and a room owner or admin can delete others' as moderation. ### Kinds of deletion | What you want | Call | What remains | |---|---|---| | Delete one message | `DELETE /v1/rooms/{roomId}/messages/{messageId}` | The row, its `seq`, and the sender's name. Only the text is emptied | | Take down everything one person sent | `DELETE /v1/users/{userId}/messages` | Same as above. The account stays | | Withdraw | `DELETE /v1/users/{userId}` | The name becomes "탈퇴한 사용자" (withdrawn user) and the avatar and `meta` are cleared. The user row and external id remain, and so do the messages | | Withdraw + take down messages | `DELETE /v1/users/{userId}?purge_messages=true` | The account stays withdrawn; message text and the name stamped on each message are removed | | Permanently delete a user | `DELETE /v1/users/{userId}?purge=true` | The user row, reactions, read cursors, memberships, and bans are gone, and the names on messages are blanked. Message rows and `sender.id` remain | | Delete a room | `DELETE /v1/rooms/{roomId}` | The room is no longer visible and its `key` is freed, so you can create a room with the same `key` again. Message rows remain | | Permanently delete a room | `DELETE /v1/rooms/{roomId}?purge=true` | The room and every message in it are gone | - **Withdrawing (`withdraw`) only anonymizes; it doesn't delete.** The user row stays, and on its own a withdrawal leaves message text and the name stamped on each message. This is the SDK's `users.withdraw(userId, {purgeMessages})`. - **A personal-data deletion request goes all the way to `purge`.** First call `DELETE /v1/users/{userId}?purge_messages=true` until the response's `purgeCapped` is `false`, then call `DELETE /v1/users/{userId}?purge=true`. The second call returns 409 for a user who hasn't withdrawn. - A `purge` **cannot be undone.** The request is accepted with 202 and the engine deletes in batches. It needs an `sk_`; a user token gets 403. If a job for the same target is already in progress you get that job back. After a user `purge` the external id can be used again, and old messages still carry the same `sender.id` string, so **don't reuse an old id for a new person.** - A user `purge` has no SDK method, so call REST directly. - A `purge` value other than `true` or `false` is a 400. ### User list filters `GET /v1/users` accepts these optional conditions. A value that can't be parsed is a 400, and when you change a filter, drop the `cursor` and start from the first page again. | Parameter | Value | |---|---| | `q` | Prefix match on `externalId`, or substring match on `name`. Case-insensitive | | `status` | `live`, `withdrawn`, `all` (default) | | `last_seen_after`, `last_seen_before` | RFC3339. A range on the last-seen time | | `created_after`, `created_before` | RFC3339. A range on the sign-up time | | `bots` | `include` (default), `exclude`, `only` | `*_after` is inclusive and `*_before` is exclusive. Withdrawn users appear in the list, marked by `deletedAt`. The type you pick when creating a room: | Type | Who can read | Who can write | How to find it | |---|---|---|---| | `public` | Anyone | Anyone | Key or id. Shows up in discovery | | `private` | Members only | Members only | Key or id | | `channel` | Anyone | owner and admin only | Key or id. Shows up in discovery | | `dm` | The two participants only | The two participants only | The pair of users. Has no key | A room's canonical identifier is its `id`. `key` is a human-assigned alias (`lobby`, `order-1234`) that the browser opens with `chat.roomByKey(key)`. A `key` must be 1 to 128 characters of ASCII letters, digits, and `_` `.` `:` `-` (`^[A-Za-z0-9_.:-]{1,128}$`). Korean text or a space makes `PUT /v1/rooms` and `PATCH` answer 400, so put the human-readable title in `name`. A `dm` room has no `key`. A `channel` created from the server has no owner, so if someone needs to post announcements **with a user token**, assign an owner or admin with the members API afterwards. ## Rotating keys Rotate an `sk_` in this order: **issue new → deploy backend → verify → revoke old.** 1. In the console's **Keys** screen, press **New secret key**. The full value appears only in that dialog. 2. Replace the `sk_` on your backend and **deploy.** Both keys are live at this point. 3. Check that a token signed with the new key connects, and that your server API calls go through. 4. **Revoke** the old key in the console. **Do not revoke first.** Turning off the old key stops the backend that signs with it, right then. After you deploy, browsers still hold tokens signed with the old key. The engine checks the key that a token's `kid` points at, so **those tokens keep working as long as the old key isn't revoked.** Once one token lifetime has passed (one hour by default), everything in circulation has been re-signed with the new key — revoke then and nobody is dropped. Revoking sooner is safe too: an old token is replaced on the next call. Rotating a `pk_` has the same shape, plus one thing: **allowed origins live on the key, not on the app.** A `pk_` with an empty list admits no browser page, so a new key has to carry the origins the current one has. If only the origins change, don't rotate the key — edit the list on the same key with **Edit origins** on the **Keys** screen. Revocation takes effect immediately and cannot be undone. Losing a key doesn't lose data: users, rooms and messages belong to the app. Separate production and development **with apps, not keys.** The app is the tenant, so the app is the data boundary. Create two apps and label them **Production** and **Development** in the console. Source: https://chat.gravex.app/en/docs/server --- # Guest users (not logged in) How to let visitors who aren't logged in talk in chat rooms. Use this for services where **jumping into a conversation without signing up** matters: community open chats, live-stream chat, customer support widgets. > This page comes down to one point: **a guest's identity travels as a signed credential, not an id.** > The chat engine simply trusts tokens your backend signs, so deciding who is who is your backend's job. > `chat.guest()` from `@kispi/chat/server` is the tool for making that decision safely. ## A common mistake — signing an id the browser sends ```ts // ❌ Don't do this export const POST = async ({ request }) => { const { guestId } = await request.json() // value the browser pulled from localStorage and sent const userId = isValidGuestId(guestId) ? guestId : `g_${crypto.randomUUID()}` return json({ userId, token: chat.token({ userId, name: 'Guest' }) }) } ``` A guest id isn't a secret. It's attached to every message as `sender.id` and delivered to everyone in the room. So the endpoint above signs that guest's token for anyone who copies the id from their messages — impersonation, editing and deleting their messages, and evading bans all open up. **Format checks like `g_` + UUID don't prevent this.** ## The safe way — `chat.guest()` Send the browser a **credential** instead of an id. A credential looks like `g1..`, and the signature is made with a `guestSecret` only your backend knows. It can't be forged from a copied id. ### 1. Create a secret ```bash openssl rand -base64 48 # → CHAT_GUEST_SECRET (at least 32 characters) ``` Keep it only in your backend's environment variables. **It must be different from your `sk_`** — every rotation of the `sk_` shouldn't turn all your guests into new people. ### 2. Split logged-in users and guests in your token endpoint ```ts TypeScript import { createChatServer } from '@kispi/chat/server' const chat = createChatServer({ secretKey: env.CHAT_SECRET_KEY, guestSecret: env.CHAT_GUEST_SECRET, }) // SvelteKit example. Express, Nuxt (Nitro), and Next look the same. export const POST = async ({ locals, cookies }) => { const user = locals.user if (user) { return json({ token: chat.token({ userId: String(user.id), name: user.nickname }) }) } const guest = chat.guest({ credential: cookies.get('chat_guest'), // if missing or forged, a new guest is created name: 'Guest', }) // httpOnly: so page scripts (XSS) can't read it cookies.set('chat_guest', guest.credential, { path: '/', httpOnly: true, secure: true, sameSite: 'lax', maxAge: 60 * 60 * 24 * 400, }) return json({ token: guest.token }) } ``` ```python Python import base64, hmac, hashlib, os, secrets SECRET = os.environ["CHAT_GUEST_SECRET"].encode() LABEL = b"kispi-chat/guest/v1\n" # the SDK's domain label; without it a TS backend can't read your credentials, nor you its def b64u(b): return base64.urlsafe_b64encode(b).rstrip(b"=").decode() def mac(guest_id): return b64u(hmac.new(SECRET, LABEL + guest_id.encode(), hashlib.sha256).digest()) def verified_id(cookie): parts = (cookie or "").split(".") if len(parts) != 3 or parts[0] != "g1" or not parts[1].startswith("g_"): return None # compare_digest: no timing side channel to guess the signature byte by byte return parts[1] if hmac.compare_digest(mac(parts[1]), parts[2]) else None # Resume the guest if the cookie verifies, otherwise mint a new one def guest(cookie): guest_id = verified_id(cookie) created = guest_id is None if created: guest_id = "g_" + b64u(secrets.token_bytes(16)) # 22 chars # Send the cookie back httpOnly and secure; sign the token with chat_token() from /en/docs/server return guest_id, f"g1.{guest_id}.{mac(guest_id)}", created ``` That `g1..` shape is **this backend's own choice, not a contract with the engine.** The engine only looks at the user id inside a signed token. `guest()` returns `{ userId, credential, token, created }`. It uses no network and no database. If `created` is `true`, the guest was just created (you can use it to show a welcome message). ### 3. The browser stores nothing The client code is the same as for logged-in users. The browser sends the cookie on its own. ```ts const chat = createChatClient({ key: import.meta.env.VITE_CHAT_PK, // pk_… token: async () => (await fetch('/api/chat-token', { method: 'POST' }).then(r => r.json())).token, }) ``` Delete any code that stored the guest id in `localStorage`. ### In another language The Python tab above is all of it. A session table or a signed cookie of your own is fine, as long as two things hold: **never sign a userId the browser sent back**, and **rate-limit this endpoint per IP.** ## Rules to follow | | | |---|---| | **Split the id space** | Guest ids start with `g_`. As long as your logged-in users' ids never start with `g_`, the two spaces won't overlap | | **Rate limiting** | Issuing guests is a door that's open without authentication. Rate-limit your token endpoint per IP. The engine has no idea who is creating how many guests | | **Secret rotation** | Pass an array like `guestSecret: [new, old]`. It signs with the new one and verifies with all of them, and credentials verified with the old one are re-signed with the same id and returned. Once your visitors have cycled through, remove the old one | | **Bans** | A guest ban applies to that `userId`. Clearing cookies makes a new guest, so if guest abuse is a problem, combine it with IP-based limits or a "guests can only read" policy | ## Behind a CDN or proxy If CloudFront, Cloudflare or nginx sits in front of your token endpoint (something like `/api/chat-token`), check three things. All three are **your infrastructure's settings**, not the engine's, and getting them wrong fails silently. | | Symptom | What to do | |---|---|---| | **Forward cookies** | Every reload is a new guest | Check that the CDN forwards cookies to the origin. CloudFront does not by default — attach a cache/origin-request policy that forwards cookies on the token path only | | **Do not cache** | Different visitors become the same guest | Token responses are per person. Do not cache the token path, and send `Cache-Control: no-store` | | **Visitor IP** | A per-IP limit lands on a handful of edge servers, blocking everyone or no one | The request IP is the proxy in front of you, not the visitor. Trust **only a header your own proxy sets** (`CloudFront-Viewer-Address` on CloudFront; on nginx, `real_ip_header` plus `set_real_ip_from` narrowed to the ranges you trust) | **Anyone who reaches the origin directly can forge the visitor-IP header.** Lock the origin to the CDN (a security group limited to CDN ranges, or a secret header the CDN adds and the origin checks); if you cannot, ignore the header and limit on the IP you actually received. The browser's connection to the engine (`api.chat.gravex.app`) is unaffected — there is no proxy of yours on that path. Source: https://chat.gravex.app/en/docs/guests --- # Webhooks Your backend receives your app's events (publishes, edits, deletes, reactions…) over HTTP. Push notifications, search indexing, audit logs, and syncing with external systems hook in here. > **You configure your webhook URL in the console's webhook screen.** On the "Webhook" tab next to your app's > overview, enter the address to deliver to (and `before_publish` if you need it), and the screen shows you > the signing secret once — the console does not store it, so copy it into your endpoint's verification code > right away. ## Receiving code ```ts TypeScript import express from 'express' import { createChatServer } from '@kispi/chat/server' const app = express() const seen = new Set() // in production, keep this outside the process (Redis, a table) const chat = createChatServer({ secretKey: process.env.CHAT_SECRET_KEY!, webhookSecret: process.env.CHAT_WEBHOOK_SECRET // signing secret the console's webhook screen showed you }) app.post('/chat-hook', express.raw({ type: 'application/json' }), (req, res) => { let payload try { payload = chat.webhooks.verify(req.headers, req.body.toString('utf8')) } catch { return res.sendStatus(400) // signature didn't match. Process nothing } const { event, delivery, data } = payload if (seen.has(delivery)) return res.sendStatus(200) // delivery is at-least-once seen.add(delivery) res.sendStatus(200) // respond within 10 seconds first void handle(event, data) // then process asynchronously }) ``` ```python Python import hmac, hashlib, json, time # raw_body is the bytes you received. Parsing and re-serializing breaks the signature. # Returns the parsed body when the signature matches, or None (-> 400) when it doesn't. def verify(headers, raw_body, secret): event = headers.get("X-Chat-Event", "") delivery = headers.get("X-Chat-Delivery", "") # before_publish has no such header timestamp, macs = None, [] for item in headers.get("X-Chat-Signature", "").split(","): name, _, value = item.strip().partition("=") if name == "t": timestamp = value elif name == "v1": macs.append(value) # there can be several; one match is enough if timestamp is None or not macs: return None if abs(time.time() - int(timestamp)) > 300: # 5 minutes both ways; future stamps are rejected too return None signed = f"{timestamp}\n{event}\n{delivery}\n".encode() + raw_body expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest() if not any(hmac.compare_digest(expected, mac) for mac in macs): return None return json.loads(raw_body) ``` ## Events you receive The request body (`data`) is the content of the event; the event name is in the `X-Chat-Event` header and the delivery id in `X-Chat-Delivery`. `chat.webhooks.verify()` bundles the three as `{ event, delivery, data }`. Ids are 13-character strings and times are unix milliseconds. | Event | When | `data` | |---|---|---| | `message.created` | A message is stored (thread replies included) | A message object (below) | | `message.updated` | A message's text is edited | A message object with `editedAt` set | | `message.deleted` | A message is deleted (by its author, by moderation, or when a user's messages are taken down) | `{id, seq}` | | `reaction.added` | A reaction is pressed | `{messageId, emoji, count, userId}` — `count` is that emoji's current count | | `reaction.removed` | A reaction is removed | Same as above | | `thread.updated` | A reply is added to or removed from a thread root | `{rootId, count, lastSeq, lastAt}` | | `read` | A member's read cursor moves forward | `{userId, seq, threadId?}` | | `member.joined` | A member joins a room | `{userId, role}` | | `member.left` | A member leaves a room | `{userId}` | | `room.updated` | A room's `key`, `name` or `meta` changes | A room object `{id, appId, key?, type, name?, meta, lastSeq, minSeq, createdAt, updatedAt}` | | `room.deleted` | A room is deleted | `{roomId}` | ```json { "roomId": "0K3M9X2T5Q8RA", "id": "0K3M9X4B7N1HC", "appId": "0K3M8Y1P6D5WE", "seq": 42, "sender": { "id": "u_42", "name": "Hanna" }, "kind": "text", "body": { "text": "Hello" }, "clientMsgId": "c_1", "createdAt": 1760000000000 } ``` A message object also carries, when they apply, `replyTo`, `threadId`, `mentions` (an array of mentioned users' external ids), `meta`, `appMeta`, `editedAt` and `deletedAt`, and `body` can hold `attachments` and `entities`. Reaction and thread aggregates are not included — they arrive separately as `reaction.*` and `thread.updated`. - A room id appears only as `roomId` in `message.created`, `message.updated` and `room.deleted`, and as `id` in `room.updated`. The payloads of the other events don't carry one. - **The console has no setting for choosing which events to receive, so you receive all of the above.** `read` in particular arrives once for every move of every member's read cursor, which makes it the most frequent event. Filter on `X-Chat-Event` (or `event`) and answer 200 to the ones you don't need. - `typing`, `presence`, `custom`, `notification.message` and `user.presence` aren't stored events, so they are not delivered by webhook. ## Four rules 1. **Verify the raw body as-is.** The signature covers the **bytes** you received, so a string you parsed as JSON and serialized again won't match. Make sure your framework's JSON parser doesn't consume the body first by reading this route raw (`express.raw` in Express, `await request.text()` in SvelteKit). 2. **Delivery is at-least-once.** The same delivery can arrive twice. Every delivery has a `delivery` id that **stays the same across retries** — remember the ones you've processed and respond to repeats with just a 200. 3. **Respond with a 2xx within 10 seconds.** Taking longer or answering with a non-2xx counts as a failure and starts a backoff (1s → 5s → 25s → 2m → 10m). On failure, that app's deliveries **wait at that event** and later ones aren't sent ahead of it. If this goes on for over an hour, delivery stops (see "When delivery stops" below). Put heavy work on a queue and respond first. 4. **Ordering is guaranteed only within a room.** Events for a single message (`message.created` → `reaction.added` → `message.deleted`) never arrive out of order. Events from different rooms can. ## When delivery stops If your endpoint keeps failing for **more than an hour from the first failure**, the engine stops that app's deliveries. The stop **does not clear itself** — even after your endpoint recovers, no event is sent until a person resumes delivery. Queued events are not skipped; they stay in the queue. 1. The console's **Webhook** screen for the app shows "Webhook delivery has stopped for this app". Before that, a "Webhook deliveries are behind" warning appears first, and the undelivered count and the age of the oldest event are on the same screen. 2. Fix the endpoint first, so it answers 2xx. 3. Press **Resume delivery** in the notice, then **Resume** in the confirmation dialog. Delivery continues in order from where it stopped. ## Catching up on missed events If your endpoint was down for a while, read past events with `chat.events.list({ after, limit })`. Each item is `{ delivery, event, createdAt, data }`, and `data` is the same as the body the webhook POSTs. Pass the last `delivery` you processed as `after`; an empty `cursor` means that was everything. Dedupe with `delivery` here too — the same event can arrive through both the webhook and this list. Delivered events are kept for 7 days, so don't put off catching up for longer than that. This list **only accumulates for apps that have a webhook URL set.** Whatever happens while no URL is set is not on the list, even if you set a URL later. ## `before_publish` — intercepting before publish When enabled, **publishes and edits pass through your endpoint synchronously.** It's the only webhook that can reject a message or return a rewritten body. Profanity filtering, image moderation, and spam blocking live here. ``` → { "event": "before_publish", "op": "create" | "update", "messageId"?: "…", "room": "…", "sender": {…}, "kind": "text", "body": {…}, "meta"?: {…} } ← { "action": "allow" | "deny", "body"?: {…}, "appMeta"?: {…}, "code"?: "…", "message"?: "…" } ``` - You verify it the same way. But since it's a question, it has no `delivery` and isn't subject to deduplication. - On `deny`, the sender's `send` fails with `moderation_denied`; the `code` you return lands in the client's `err.appCode`, and `message` in the error message. - If you return a `body`, **the message is stored and broadcast with that instead.** The rewritten body goes through the size check again. - If `action` isn't `allow` or `deny`, it counts as a failure, so a typo can't become a moderation bypass. - If your endpoint times out or errors, the default is **`allow`**. You can change it to `deny`, but then if your endpoint goes down, all publishing for that app stops. - Messages sent through the server API (`sk_`) aren't screened. Keep it fast. The sender's message waits until this call finishes, and the timeout is 1.5 seconds. ## Receiving locally The webhook URL must be **https** and reachable from the internet. `localhost` and private IPs are refused. To receive on a local dev server, start a tunnel such as cloudflared (`cloudflared tunnel --url http://localhost:3000`) or ngrok and put the https URL it gives you in the Webhook screen or in [Console API](https://chat.gravex.app/en/docs/console-api) `PATCH /api/apps/{id}/webhook`. Tunnel URLs can change on every restart, so update it each time. A tunnel adds hundreds of milliseconds per round trip. With `before_publish` on, that easily exceeds the 1.5-second timeout, and a timeout passes as **`allow`** by default — if moderation seems to do nothing locally, suspect the tunnel latency first. While testing, set `beforePublishOnError` to `deny` so timeouts surface as `moderation_denied`. ## Verifying in another language Three headers arrive with the request. | Header | | |---|---| | `X-Chat-Event` | The event name. For `before_publish`, that value | | `X-Chat-Delivery` | The delivery id. **`before_publish` has no such header** | | `X-Chat-Signature` | `t=,v1=`. The engine sends one `v1` today. Write your verifier to read several `v1` values and **accept on any one match** | The signing input is four pieces joined with newlines (`\n`). ``` ``` `v1` is `hex(HMAC-SHA256(webhook secret, signing input))`. The secret is not your `sk_`: it's the separate **webhook secret** shown once on the console's webhook screen. Reject the delivery if the absolute value of `now - t` is more than **5 minutes** (both directions — a future timestamp is rejected too). Retries refresh `t` but keep `X-Chat-Delivery`, so dedupe on that value. ## Rotating the secret Press **Rotate secret** on the app's **Webhook** screen in the console and confirm; the new webhook secret is shown there once. **There is no overlap between the old secret and the new one.** Every delivery is signed with a single secret (one `v1`), so from the moment you rotate, all deliveries are signed with the new secret — queued events and events being retried included — and the old secret no longer verifies. 1. Press **Rotate secret** and copy the new secret. 2. Put it in your endpoint's webhook secret (for example `CHAT_WEBHOOK_SECRET`) and **deploy right away.** 3. Deliveries that arrive before the deploy finishes fail signature verification, are recorded as failures, and come back on the backoff. If you deploy within an hour, no event is lost. If it takes longer and delivery has stopped, press **Resume delivery** on the **Webhook** screen. Source: https://chat.gravex.app/en/docs/webhooks --- # Image uploads and moderation How to let people post photos in chat. **The engine does not accept files.** A message carries only a URL and metadata; receiving, storing and checking the file is the job of your backend and your bucket. This page is the recommended structure on AWS (S3 + Rekognition). > One idea holds it together: **a file is in a private prefix until it passes the check, and only files that > pass are moved to a public prefix.** Then all the `before_publish` hook has to do is confirm, with a string > comparison, that a URL is this sender's file under your public prefix. ## What the engine does and does not do | | | |---|---| | Does | Stores `body.attachments[]` and delivers it to the room. Up to 10 per message by default, and `url` must start with `https://` | | Does not | Receive files. It does not open the URL, convert the format, inspect the file or delete it | | Attachment shape | `{ type: "image", url, key?, mime?, w?, h?, name?, size? }` — the engine reads only `url`; the rest is for your UI | | Export | `GET /v1/export` contains this metadata only, **no files.** You take the files from your own bucket | ## The flow ```text 1 browser ──→ your backend ask to upload → presigned POST (type, size and expiry bound into the signature) 2 browser ──→ S3 incoming/ POST the file straight to S3 (private prefix, never through your server) 3 browser ──→ your backend "I'm done uploading" 4 your backend ←→ S3, Rekognition read → re-encode → DetectModerationLabels pass: write to public/ and delete incoming/ / fail: delete incoming/, 422 5 browser ──→ chat engine room.send({ attachments: [the attachment step 4 returned] }) 6 chat engine ──→ your backend before_publish: is the URL under your CDN's public/{sender}/ ? ``` Why each choice: | Choice | Reason | |---|---| | Two prefixes | `incoming/` is not behind the CDN, so nobody can see a file before it is checked. Only your backend writes `public/` | | Bind type and size into the signature | If the browser breaks the policy, S3 refuses the upload itself. A presigned **PUT** cannot bind the size, so use a POST policy | | User id in the key | The hook can check "only your own files" as a string, and on account deletion you can remove `public/{userId}/` in one sweep | | The server reads the bytes from S3 itself | Having the server fetch a URL the browser supplied is an SSRF path to internal addresses | | Check after re-encoding | EXIF (shooting location and so on) is gone, files disguised as images are filtered out, and **the bytes you checked are the bytes you serve** | | The hook never calls AWS | The hook is a synchronous call that blocks the publish (1.5 seconds by default, 5 at most). Slow checks finish on the upload side | ## 1. Bucket and permissions Bucket `my-chat-uploads` **blocks all public access**. The browser POSTs straight to S3, so it needs CORS. ```json [ { "AllowedOrigins": ["https://app.example.com"], "AllowedMethods": ["POST"], "AllowedHeaders": ["*"], "MaxAgeSeconds": 3000 } ] ``` The minimum permissions for your backend's role. Don't put keys in code; give them through the IAM role of the environment you run in (EC2, ECS, Lambda). ```json { "Version": "2012-10-17", "Statement": [ { "Sid": "Incoming", "Effect": "Allow", "Action": ["s3:PutObject", "s3:GetObject", "s3:DeleteObject"], "Resource": "arn:aws:s3:::my-chat-uploads/incoming/*" }, { "Sid": "Public", "Effect": "Allow", "Action": ["s3:PutObject", "s3:DeleteObject"], "Resource": "arn:aws:s3:::my-chat-uploads/public/*" }, { "Sid": "ListPublicForCleanup", "Effect": "Allow", "Action": "s3:ListBucket", "Resource": "arn:aws:s3:::my-chat-uploads", "Condition": { "StringLike": { "s3:prefix": ["public/*"] } } }, { "Sid": "Moderation", "Effect": "Allow", "Action": "rekognition:DetectModerationLabels", "Resource": "*" } ] } ``` Files that fail the check, or that a user never finishes (step 3), stay only in `incoming/`, so sweep that prefix with a lifecycle rule. ```json { "Rules": [ { "ID": "expire-incoming", "Status": "Enabled", "Filter": { "Prefix": "incoming/" }, "Expiration": { "Days": 1 } } ] } ``` ## 2. Backend: issuing the upload ticket Issue it only to signed-in users. Put the **exact key, content type, size range and expiry** in the policy, and let the server choose the key. ```sh npm install @aws-sdk/client-s3 @aws-sdk/s3-presigned-post @aws-sdk/client-rekognition sharp ``` ```ts // uploads.ts import { randomUUID } from 'node:crypto' import { S3Client } from '@aws-sdk/client-s3' import { createPresignedPost } from '@aws-sdk/s3-presigned-post' export const s3 = new S3Client({}) // region and credentials come from the environment export const BUCKET = process.env.UPLOAD_BUCKET! // my-chat-uploads export const MAX_BYTES = 5_000_000 const TYPES = new Set(['image/jpeg', 'image/png', 'image/webp']) // no SVG (it can carry script) // userId is your service's user id. It goes into the key, so it must be limited to [A-Za-z0-9_-]. export async function issueUpload(userId: string, contentType: string) { if (!TYPES.has(contentType)) throw new HttpError(400, 'invalid_content_type') const id = randomUUID() const { url, fields } = await createPresignedPost(s3, { Bucket: BUCKET, Key: `incoming/${userId}/${id}`, Fields: { 'Content-Type': contentType }, Conditions: [ ['eq', '$Content-Type', contentType], // this one type only ['content-length-range', 1, MAX_BYTES] // at least 1 byte, at most MAX_BYTES ], Expires: 60 // seconds. Keep it short }) return { id, url, fields } } ``` ```ts // Route example (Express). Authentication is your service's session app.post('/api/uploads', requireLogin, async (req, res) => { res.json(await issueUpload(req.user.id, String(req.body.contentType))) }) ``` Replace `HttpError` with your service's error type. Rate-limit tickets per user here too (see "Things to keep in mind" below). ## 3. Browser: upload, notify, send In an S3 form the **`file` field must come last.** Fill the other fields first and append the file at the end. ```ts import { ChatError } from '@kispi/chat' import { chat } from './chat' // the createChatClient from Getting started const room = chat.roomByKey('lobby') await room.subscribe() async function sendImage(file: File, text?: string) { // 1. Ticket const ticket = await api('/api/uploads', { contentType: file.type }) // 2. Upload straight to S3 const form = new FormData() for (const [name, value] of Object.entries(ticket.fields)) form.append(name, value as string) form.append('file', file) // last const up = await fetch(ticket.url, { method: 'POST', body: form }) if (!up.ok) throw new Error('upload_failed') // 3. Check and make public (backend). Returns the attachment object if it passes const attachment = await api(`/api/uploads/${ticket.id}/complete`) // 4. Publish to the chat engine. Send the server's object as it is try { await room.send({ text, attachments: [attachment] }) } catch (err) { if (err instanceof ChatError && err.code === 'moderation_denied') { showError(err.message) // the hook refused. You can branch on err.appCode } else throw err } } // A small helper: POST JSON, read JSON. Throws on non-2xx async function api(path: string, body?: unknown) { const res = await fetch(path, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body ?? {}) }) if (!res.ok) throw new Error((await res.json()).code ?? 'request_failed') return res.json() } ``` ## 4. Backend: check, then move to public The endpoint step 3 calls. It does **read → re-encode → check → store publicly → delete the original**, in that order. ```ts // uploads.ts (continued) import { GetObjectCommand, PutObjectCommand, DeleteObjectCommand } from '@aws-sdk/client-s3' import { RekognitionClient, DetectModerationLabelsCommand } from '@aws-sdk/client-rekognition' import sharp from 'sharp' const rekognition = new RekognitionClient({}) export const CDN = process.env.CDN_ORIGIN! // https://cdn.example.com export const UUID = '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}' const MIN_CONFIDENCE = 80 // Rekognition returns no label below this confidence // Choose by your service's policy. Take names from the moderation label list (L1, L2) in the AWS docs const BLOCKED = new Set([ 'Explicit', 'Graphic Violence', 'Visually Disturbing', 'Hate Symbols' ]) const drop = (Key: string) => s3.send(new DeleteObjectCommand({ Bucket: BUCKET, Key })) export async function completeUpload(userId: string, id: string) { if (!new RegExp(`^${UUID}$`).test(id)) throw new HttpError(400, 'invalid_id') const inKey = `incoming/${userId}/${id}` // someone else's key cannot be built // 1) Read. The policy capped the size, but the server looks once more const obj = await s3.send(new GetObjectCommand({ Bucket: BUCKET, Key: inKey })) if ((obj.ContentLength ?? 0) > MAX_BYTES) { await drop(inKey) throw new HttpError(413, 'too_large') } const raw = Buffer.from(await obj.Body!.transformToByteArray()) // 2) Re-encode. If it will not decode it is not an image. sharp drops EXIF and GPS from the output let out: { data: Buffer; info: sharp.OutputInfo } try { out = await sharp(raw, { limitInputPixels: 40_000_000 }) .rotate() // apply the EXIF orientation to the pixels first (the metadata is dropped) .resize({ width: 2048, height: 2048, fit: 'inside', withoutEnlargement: true }) .flatten({ background: '#ffffff' }) // background when a transparent PNG becomes a JPEG .jpeg({ quality: 85 }) .toBuffer({ resolveWithObject: true }) } catch { await drop(inKey) throw new HttpError(422, 'invalid_image') } // 3) Check. Send the bytes you will serve (the re-encoded result) let labels try { const res = await rekognition.send( new DetectModerationLabelsCommand({ Image: { Bytes: out.data }, MinConfidence: MIN_CONFIDENCE }) ) labels = res.ModerationLabels ?? [] } catch { // If the check did not run, do not let it through. incoming/ stays, so the same request can be retried throw new HttpError(502, 'check_failed') } const hit = labels.find( (l) => BLOCKED.has(l.Name ?? '') || BLOCKED.has(l.ParentName ?? '') ) if (hit) { await drop(inKey) console.warn('image rejected', { userId, label: hit.Name, confidence: hit.Confidence }) throw new HttpError(422, 'image_rejected') } // 4) Store publicly. Only this function writes this prefix const key = `public/${userId}/${id}.jpg` await s3.send( new PutObjectCommand({ Bucket: BUCKET, Key: key, Body: out.data, ContentType: 'image/jpeg', CacheControl: 'public, max-age=31536000, immutable' // the key is unique, so caching forever is fine }) ) await drop(inKey) return { type: 'image', url: `${CDN}/${key}`, key, mime: 'image/jpeg', w: out.info.width, h: out.info.height, size: out.info.size } } ``` ```ts app.post('/api/uploads/:id/complete', requireLogin, async (req, res) => { res.json(await completeUpload(req.user.id, req.params.id)) }) ``` - Rekognition accepts **JPEG and PNG only.** Converting to JPEG first, as above, lets you accept WebP too. - Labels are hierarchical. When a child label (L2) matches, its parent (L1) comes back with it, so look for the name in both `Name` and `ParentName`. AWS recommends filtering on L1 and L2. A lower `MIN_CONFIDENCE` catches more (more false positives) and a higher one catches less (more misses) — choose for your service; the default is 50. - Show users a generic message for a rejection, not the label name. ## 5. The hook: confirm the attachment is your public file The engine checks only that the URL is `https://`. So **without a hook, a client can send any URL as an attachment** (an unchecked outside image, a tracking pixel). Turn on `before_publish` in the console's webhook screen, and if you already receive webhooks at an endpoint, branch on `event` in the same route. ```ts import express from 'express' import { createChatServer } from '@kispi/chat/server' import { CDN, UUID } from './uploads' const chat = createChatServer({ secretKey: process.env.CHAT_SECRET_KEY!, webhookSecret: process.env.CHAT_WEBHOOK_SECRET }) type BeforePublish = { op: 'create' | 'update' sender: { id: string; name: string } body: { text?: string; attachments?: { type?: string; url?: unknown }[] } } // Must be under the sender's own public prefix, in the file-name shape we generate const fileName = new RegExp(`^${UUID}\\.jpg$`) const isMine = (url: unknown, senderId: string) => { const prefix = `${CDN}/public/${senderId}/` return typeof url === 'string' && url.startsWith(prefix) && fileName.test(url.slice(prefix.length)) } function decide({ sender, body }: BeforePublish) { for (const a of body.attachments ?? []) { if (a.type !== 'image' || !isMine(a.url, sender.id)) { return { action: 'deny', code: 'attachment_not_allowed', message: 'That attachment is not allowed' } } } return { action: 'allow' } } app.post('/chat-hook', express.raw({ type: 'application/json' }), (req, res) => { let hook try { hook = chat.webhooks.verify(req.headers, req.body.toString('utf8')) } catch { return res.sendStatus(400) // the signature is wrong } if (hook.event === 'before_publish') return res.json(decide(hook.data as BeforePublish)) // Handle the other events (message.created and so on) as in the receiving code on the webhooks page res.sendStatus(200) }) ``` - The hook covers **both publishes and edits**, so editing in someone else's URL is stopped by the same code. - The `code` you return reaches the client as `err.appCode`, and `message` as the error text. - If your UI also turns **image URLs inside the message text** into pictures, the hook has to check the URLs in `body.text` as well. It is easier to render attachments only. - Messages sent with a server key (`sk_`) skip the hook. When your own backend posts an image, use the step 4 result as it is. ## 6. Serving through CloudFront (optional, recommended) Keep the bucket private and let CloudFront read it. Make the bucket the distribution's origin with **origin access control (OAC)** on, and open only one path pattern, `public/*`, in the behavior. `incoming/` is unreachable through the CDN. ```json { "Version": "2012-10-17", "Statement": [ { "Sid": "CloudFrontReadPublicOnly", "Effect": "Allow", "Principal": { "Service": "cloudfront.amazonaws.com" }, "Action": "s3:GetObject", "Resource": "arn:aws:s3:::my-chat-uploads/public/*", "Condition": { "StringEquals": { "AWS:SourceArn": "arn:aws:cloudfront::111122223333:distribution/EXXXXXXXXXXXXX" } } } ] } ``` Add `X-Content-Type-Options: nosniff` in a response headers policy, and attach your own domain (`cdn.example.com`); that becomes `CDN_ORIGIN` in step 4. This address is the front part of the attachment `url`, so it must be the same value as `CDN` in the hook. ## Text moderation Filter text in the same hook. The engine does not escape the body — filtering words is the hook's job, and escaping when you render is the renderer's job. ```ts const BAD = /badword1|badword2/gi // your service's word list // call this from decide() after the attachment checks pass async function decideText(body: BeforePublish['body']) { const text = body.text ?? '' const masked = text.replace(BAD, (m) => '*'.repeat(m.length)) if (masked !== text) return { action: 'allow', body: { ...body, text: masked } } // allow, with the change return { action: 'allow' } } ``` - The `body` you return is the **whole body.** Send only part of it and the rest disappears. - If you call an external moderation service, put a **timeout shorter than the hook's time limit** on it yourself (the hook defaults to 1.5 seconds and tops out at 5). Cut it off with something like `AbortSignal.timeout(1000)` and decide in code whether to allow or refuse when that happens. - When the hook itself times out or errors, the engine follows `before_publish_on_error`, which defaults to **`allow`.** If you rely on the hook for attachment checks, that means "the checks are off while the hook is down." Switching to `deny` is safe, but every publish in that app stops while the hook is down — keep the hook light and monitor it. ## Things to keep in mind - **Size.** Limit it in three layers: the signed condition (`content-length-range`), the server's recheck and a pixel cap (`limitInputPixels`). Rekognition also has an image size limit, so check AWS's limits page (Guidelines and quotas) and keep `MAX_BYTES` inside it. - **Formats.** Accept JPEG, PNG and WebP only. SVG can carry script, so refuse it. Animated GIFs are easy to get around if you check only the first frame, so it is safer not to accept them. - **EXIF.** Re-encoding removes it. If you must serve the original, add a step that strips the GPS data from EXIF. - **Rate limits.** Limit ticket issuing and `complete` per user. Every `complete` call costs a Rekognition request. - **Cost.** Rekognition bills per image. Prices change, so open the Amazon Rekognition pricing page on AWS, find Image Moderation, and multiply by your expected uploads. S3 storage and CloudFront transfer cost extra. - **Limits of the check.** AWS states that Rekognition does not detect whether an image is illegal content such as CSAM. Put a report button, human review and the legal duties your service carries (reporting and takedown procedures) in place yourself. - **Deletion.** Deleting a message (`message.deleted`) **does not delete the file in your bucket.** To delete the file too, record `body.attachments[].key` from the `message.created` webhook together with the message `id` in your DB, and when `message.deleted` (`{id, seq}`) arrives, delete that key's object. For removals after a report, tie `chat.messages.delete`, the object deletion and, if needed, a ban into one flow. For account deletion and privacy deletion requests, list everything under `public/{userId}/` and delete it. - **Export.** The whole-app export (`GET /v1/export`) does not include attachment files. Back up and migrate the files on the bucket side (replication, `aws s3 sync`). - **Keys.** Keep `sk_`, the webhook secret and AWS credentials out of the browser and out of the repository. Take AWS access from the IAM role of the environment you run in. Source: https://chat.gravex.app/en/docs/uploads --- # REST API reference Every REST route the engine serves. Any language that can speak HTTP and JSON can call them. Real-time events are not REST; they belong to the [real-time protocol](https://chat.gravex.app/en/docs/realtime) (WebSocket). This page is the **ask-and-receive** half. - Base URL: `https://api.chat.gravex.app` (every path starts with `/v1`) - Bodies are JSON (UTF-8). Send `Content-Type: application/json` on any request with a body. - Every timestamp is **UTC epoch milliseconds as an integer** (`createdAt: 1792861844890`). - Room and message ids are **13-character strings** made by the server (Crockford Base32, e.g. `0AAS4P1MM0000`). Treat them as opaque strings. - User ids are the strings your service chose (the token's `sub`). URL-encode them when they go in a path. - Ignore response fields you don't know. Fields are only ever added; they are never renamed or removed. ## Authentication There are two kinds of caller. The **Auth** column in the tables below says which one a route accepts. | Caller | Headers | Use | |---|---|---| | **Server key** (`sk`) | `Authorization: Bearer sk_…` | Your backend. It acts for the service, so room membership checks are skipped | | **User token** (`user`) | `Authorization: Bearer ` and `X-Chat-Key: pk_…` | Apps and browsers. It acts as one user and gets the same access rules as the WebSocket | - A user token is a JWT your backend signs with the `sk_`. See "User token spec" in [Server integration](https://chat.gravex.app/en/docs/server) for how to make one. - If both arrive, the `sk_` wins. The case of `Bearer` doesn't matter. - Keep the `sk_` on the server. Never put it in an app or a browser. - A browser request's `Origin` must be in the `pk_`'s **allowed origins** (scheme, host and port must match exactly). Otherwise the answer is 401 with the reason in the message. **Requests with no `Origin` header (iOS, Android, Flutter, servers) skip this check.** - A failed authentication is 401 `unauthorized`. A caller that is authenticated but may not do the thing gets 403 `forbidden`. - A room id that belongs to another app is **404**, not 403. - A bad signature, an expired token and a revoked token all give the same 401. Find the cause by checking your token against the spec, not by reading the response. - When a user token expires, the answer is 401. Fetch a fresh token from your backend and retry **once**. A second 401 is not expiry. ## Common rules ### Error format A failure comes with an HTTP status and JSON in this shape. ```json { "error": { "code": "rate_limited", "message": "rate limit exceeded", "retryAfterMs": 800 } } ``` - Branch on `code`. `message` is an English sentence for people and may change. - `retryAfterMs` appears only on `rate_limited`. `appCode` appears only on `moderation_denied`. - A success with no body is 204. | `code` | HTTP | Meaning | Retry | |---|---|---|---| | `unauthorized` | 401 | The key or token is missing or wrong | Fetch a new token and retry once | | `forbidden` | 403 | This caller may not do this | No | | `banned` | 403 | A banned user tried to publish or react | After the ban ends | | `moderation_denied` | 403 | Your `before_publish` hook refused it. `message` is your wording and `appCode` is your machine-readable code | No | | `quota_exceeded` | 403 | The app's stored-message quota is full | No | | `not_found` | 404 | No such resource, or it was deleted, or it belongs to another app | No | | `invalid` | 400 | The request is wrong (format, a required field, a value not allowed). A body over 1 MB or not JSON is also 400 | After fixing it | | `invalid` | 409 | The room `key` is already in use | With another `key` | | `too_large` | 413 | The message text is over `bodyTextMaxLen` | After shortening it | | `cursor_too_old` | 410 | The requested `after` is older than what is kept | See "History" below | | `rate_limited` | 429 | Over a limit. `Retry-After` (seconds) and `retryAfterMs` come with it | After `retryAfterMs` | | `conflict` | 409 | Collides with the current state (an export already running, a permanent delete of a user who hasn't withdrawn, a permanent delete already running) | After the state changes | | `internal` | 500 | Server error | With backoff | **Treat HTTP 409 as "collides with the current state", whatever the `code` is.** Retry 5xx and network errors with exponential backoff. ### Limits - Publishing (`POST …/messages`) is 5 per second per user, with a burst of 10. Editing (`PATCH …/messages/…`) is 2 per second. Creating rooms with a user token is 50 per day. - **Calls made with a server key (`sk_`) are not counted against these limits.** - When one is hit the answer is 429 `rate_limited`. The `Retry-After` header is in seconds and the body's `retryAfterMs` is in milliseconds; they are the same wait. Wait that long and send the same request again. - Limit values can differ per app. The effective values are in the WebSocket `hello`'s `limits` ([real-time protocol](https://chat.gravex.app/en/docs/realtime#limits)). - Size limits: message text 4000 characters, 10 attachments, 100 `entities`, `appMeta` 4 KB, request body 1 MB. ### Pagination | Style | Where | Rules | |---|---|---| | **cursor** | room list, members, my rooms, users, reads, reactions, events | `?cursor=&limit=`. `limit` defaults to 50, at most 100. Pass the response's `cursor` back **unchanged**. An empty string (`""`) is the last page. A cursor is opaque; don't parse it. A cursor that can't be parsed is 400 | | **seq window** | message history | `?before=&after=&limit=`. See "History" below | | none | `GET /v1/me/unread`, `GET …/presence` | Cut off at a cap and say so with `roomsCapped` / `capped` | With cursor paging, if a `cursor` is present **there is definitely a next page.** New items appearing between requests never shift pages you already passed. ### Calling from a browser (CORS) - Every `/v1` response, error responses included, carries CORS headers. `Access-Control-Allow-Origin` echoes the request's `Origin`. - A preflight (`OPTIONS`) answers 204. Allowed methods are `GET, POST, PUT, PATCH, DELETE, OPTIONS` and allowed headers are `Authorization, Content-Type, X-Chat-Key`. - `Retry-After` is exposed so browsers can read it. - WebSocket is not subject to CORS. Native apps and servers don't deal with CORS at all. ## All routes at a glance In the Auth column, `sk` is a server key and `user` is a user token. Parentheses are extra conditions. | # | Method and path | Auth | What it does | |---|---|---|---| | 1 | `PUT /v1/rooms` | sk, user (app setting) | Get-or-create a room | | 2 | `PUT /v1/rooms/{key}` | sk, user (app setting) | Alias of 1 (key in the path) | | 3 | `GET /v1/rooms` | sk, user | List rooms | | 4 | `GET /v1/rooms/by-key/{key}` | sk, user | Get a room by alias | | 5 | `GET /v1/rooms/{roomId}` | sk, user | Get a room | | 6 | `PATCH /v1/rooms/{roomId}` | sk, user (owner, admin) | Update a room | | 7 | `DELETE /v1/rooms/{roomId}` | sk, user (owner) | Delete a room; `?purge=true` deletes permanently | | 8 | `POST /v1/rooms/{roomId}/join` | user | Join a public room | | 9 | `GET /v1/rooms/{roomId}/members` | sk, user | List members | | 10 | `PUT /v1/rooms/{roomId}/members/{userId}` | sk, user (owner, admin) | Add a member or change a role | | 11 | `DELETE /v1/rooms/{roomId}/members/me` | user | Leave | | 12 | `DELETE /v1/rooms/{roomId}/members/{userId}` | sk, user (owner, admin) | Remove a member | | 13 | `GET /v1/rooms/{roomId}/messages` | sk, user (`q` is sk only) | History and search | | 14 | `POST /v1/rooms/{roomId}/messages` | sk, user | Publish a message | | 15 | `PATCH /v1/rooms/{roomId}/messages/{messageId}` | sk, user (author) | Edit a message | | 16 | `DELETE /v1/rooms/{roomId}/messages/{messageId}` | sk, user (author, owner, admin) | Delete a message | | 17 | `GET /v1/rooms/{roomId}/messages/{messageId}/reactions` | sk, user | Who reacted | | 18 | `GET /v1/rooms/{roomId}/reads` | sk, user | Read positions | | 19 | `GET /v1/rooms/{roomId}/presence` | sk, user | Who is in the room now | | 20 | `POST /v1/rooms/{roomId}/custom` | sk | An unstored control signal | | 21 | `PATCH /v1/me` | user | Update my `meta` | | 22 | `GET /v1/me/rooms` | user | My rooms, with unread counts | | 23 | `GET /v1/me/unread` | user | Unread totals | | 24 | `GET /v1/users` | sk | List users | | 25 | `PUT /v1/users/{userId}` | sk | Update a user's profile | | 26 | `DELETE /v1/users/{userId}` | sk | Withdraw (anonymize); `?purge=true` deletes permanently | | 27 | `DELETE /v1/users/{userId}/messages` | sk | Take back all of a user's messages | | 28 | `PUT /v1/users/{userId}/ban` | sk | Ban | | 29 | `DELETE /v1/users/{userId}/ban` | sk | Lift a ban | | 30 | `POST /v1/users/{userId}/disconnect` | sk | Force-close connections | | 31 | `POST /v1/users/{userId}/revoke-tokens` | sk | Invalidate every issued token | | 32 | `GET /v1/events` | sk | Catch up on missed events | | 33 | `GET /v1/export` | sk | Download all of the app's data | (Rows 9, 13, 18 and 19 are the four paths `GET /v1/rooms/{roomId}/{members|messages|reads|presence}`. Any other name is 404.) **There is no REST route to add a reaction, write a read position, or announce typing.** Those three are WebSocket frames only (`react`, `read`, `typing`). Only the reading side (rows 17, 18 and the unread counts) is REST. ## Objects ### Room ```json { "id": "0AAS4P1MM0000", "appId": "0AAS4P1HW0000", "key": "general", "type": "public", "name": "General", "meta": {}, "lastSeq": 12, "minSeq": 1, "createdAt": 1792861844890, "updatedAt": 1792861844949 } ``` - `type` is `public`, `private`, `channel` or `dm`. A `dm` has no `key`. `name` is present only if it was set. - `lastSeq` is the `seq` of the last message published in the room. `minSeq` is the oldest `seq` history can still be read from (an `after` far older than that is 410). - A deleted room carries `deletedAt`, but the lookup routes answer 404 for a deleted room. ### Message ```json { "id": "0AAS4P1P40800", "seq": 12, "kind": "text", "sender": { "id": "u-1", "name": "alice", "avatar": "https://example.com/a.png" }, "body": { "text": "hi @bob", "entities": [{ "type": "mention", "userId": "u-2", "offset": 3, "length": 4 }] }, "replyTo": "0AAS4P1P40000", "threadId": "0AAS4P1P40000", "mentions": ["u-2"], "meta": {}, "appMeta": {}, "reactions": [{ "emoji": "👍", "count": 2 }], "thread": { "count": 3, "lastSeq": 20, "lastAt": 1792861900000 }, "myReactions": ["👍"], "createdAt": 1792861844902, "editedAt": 1792861850000 } ``` | Field | Notes | |---|---| | `id`, `seq` | `seq` is a number that **goes up by 1 inside a room with no gaps.** Thread replies use a `seq` too | | `kind` | `text` or `system` (only a server key can send `system`) | | `sender` | `{id, name, avatar?}`. `id` is your service's user id | | `body` | `{text?, attachments?, entities?}`. An attachment is `{type, url, key?, mime?, w?, h?, name?, size?}`; `type` is `image`, `video`, `audio` or `file`, and `url` must be `https`. The engine stores no files | | `replyTo` | The id of the message being quoted | | `threadId` | For a thread reply, the **root** message id. Absent on main-timeline messages | | `mentions` | Array of mentioned user ids (your service's ids) | | `meta` | A JSON object the sender attached | | `appMeta` | A JSON object only a server key can attach (up to 4 KB) | | `reactions` | Aggregate `[{emoji, count}]` | | `thread` | `{count, lastSeq, lastAt}`, only on a root that has replies | | `myReactions` | Present **only when read with a user token.** The emoji I pressed. An empty array means "pressed nothing"; an absent field means "there is no viewer" (server key) | | `editedAt`, `deletedAt` | If present, the message was edited / deleted | - **A deleted message (tombstone) keeps its row.** It has `deletedAt`, an empty `body`, and empty `reactions` and `myReactions`. Its `seq` is unchanged. - This object has the **same shape** as the WebSocket's `message.created` and `message.updated`. Only which fields are filled differs. - A message object has no `roomId`. The request path tells you which room it is. ### User (my profile) The response of `PATCH /v1/me` and `PUT /v1/users/{userId}`. ```json { "id": "u-1", "name": "alice", "avatar": "https://example.com/a.png", "meta": { "plan": "pro" } } ``` An item of `GET /v1/users` has more: `externalId`, `name`, `avatarUrl?`, `meta`, `createdAt`, `updatedAt`, `lastSeenAt?`, `deletedAt?` (a withdrawn user), `bannedUntil?` (live bans only), `isBot`, `lastUserAgent`. ## Rooms ### 1 and 2. `PUT /v1/rooms`, `PUT /v1/rooms/{key}`: get-or-create a room Request body: `{key?, type?, name?, meta?, members?}` | Field | Notes | |---|---| | `type` | `public` (default), `private`, `channel`, `dm` | | `key` | **Required** for `public`, `private` and `channel`. `^[A-Za-z0-9_.:-]{1,128}$`. Giving one to a `dm` is 400 | | `members` | **Required** for `dm`. **Exactly two** of your service's user ids. Both must have connected at least once (otherwise 404) | | `name`, `meta` | Used only when the room is created | - A new room is **201**; an existing one is **200**. Both return the room object. An existing room's `name` and `meta` are not changed (use 6 to change them). - `public`, `private` and `channel` are found by `key`; a `dm` is found by the pair of people. - `PUT /v1/rooms/{key}` is the same call with the `key` in the path. If the body's `key` and the path's `key` differ it is 400. New code uses `PUT /v1/rooms`. - A **user token** can create a room only if the app has client room creation turned on (otherwise 403). A `dm` must include the caller. A room made with a user token has its creator as owner. - **A `channel` created with a server key has no owner.** Use 10 to name an owner or an admin who can write. ```bash curl -X PUT https://api.chat.gravex.app/v1/rooms \ -H "Authorization: Bearer $CHAT_SECRET_KEY" -H "Content-Type: application/json" \ -d '{"key":"lobby","type":"public","name":"Lobby"}' ``` ### 3. `GET /v1/rooms`: list rooms Query: `type` (comma-separated), `cursor`, `limit`. - Without `type` you get `public` and `channel`. **A user token may ask only for `public` and `channel`** (browsing public rooms). **A server key may also ask for `private` and `dm`.** Any other value is 400. - Newest rooms first. Response: `{"rooms": [room…], "cursor": ""}` ### 4 and 5. `GET /v1/rooms/by-key/{key}`, `GET /v1/rooms/{roomId}`: get a room Returns a room object. A room's identity is its `id`. A `key` is an alias that can change, and only `by-key` looks a room up by it. A user token must be able to read the room (see "Access rules"). ### 6. `PATCH /v1/rooms/{roomId}`: update a room Request body: `{key?, name?, meta?}`. Fields you leave out are untouched. The response is the room object, and the room gets `room.updated`. - A user token must be the room's **owner or an admin**. - A `key` already in use is 409. Giving a `key` to a `dm` is 400. ### 7. `DELETE /v1/rooms/{roomId}`: delete a room - By default this is a soft delete. It answers 204 and the room gets `room.deleted`. Messages stay; the room is 404 for reading, joining and publishing, and its `key` is freed so you can create a room with the same `key` again. An already deleted room is 404. A user token must be the **owner**. - **`?purge=true` queues a permanent delete.** **Only a server key** may do it; a user token gets 403 (even the room's owner). A live room is soft-deleted first. The answer is **202** `{"job": {…}}`, and if the room already has a job in progress you get that same job with 202. It cannot be undone. - A `purge` that is not `true` or `false` is 400. ## Members The read and write rules per room type are in "Access rules" below. Membership means "the room is in my room list and I get notifications". ### 8. `POST /v1/rooms/{roomId}/join`: join a public room User token only. You become a member of a `public` or `channel` room. If you are already a member it is still 200, with `{"roomId": "…", "role": "member"}`. A `private` or `dm` room is 403. `member.joined` is sent to the room only when you actually joined just now. ### 9. `GET /v1/rooms/{roomId}/members`: list members Query: `cursor`, `limit`. Response: ```json { "members": [{ "userId": "u-1", "name": "alice", "role": "owner", "joinedAt": 1792861844892 }], "cursor": "" } ``` A withdrawn user's row stays as `{"role": "member", "joinedAt": …, "withdrawn": true}` (no `userId`, and `name` is an anonymous label). ### 10. `PUT /v1/rooms/{roomId}/members/{userId}`: add a member or change a role Request body: `{role?}`, one of `owner`, `admin`, `member` (default). It adds the member if absent and changes the role if present. 204. The user must have connected at least once (otherwise 404). - A user token must be an owner or an admin. **Only an owner changes an owner's role.** The last owner of a room cannot be demoted (400). - `member.joined` is sent only when something actually changed. ### 11 and 12. `DELETE /v1/rooms/{roomId}/members/me`, `…/members/{userId}`: leave and remove Both answer 204. `member.left` is sent only when a row was actually removed. - `members/me` is **for a user token only** and removes the caller. With a server key it is 403. - `members/{userId}` takes a server key, or an owner or admin. - **Never create a user whose id is `me`.** `members/me` always wins. ## Messages ### 13. `GET /v1/rooms/{roomId}/messages`: history and search Query: `before`, `after`, `limit` (default and maximum 100), `view`, `q`. Response: `{"messages": [message…]}`, **always in ascending seq order.** | Call | Result | |---|---| | with nothing | The most recent `limit` messages | | `before=N` | The most recent `limit` messages with `seq` below N (scrolling up) | | `after=N` | The **oldest** `limit` messages with `seq` above N (filling a gap) | | `after=N&before=M` | Between N and M | - `view`: omitted means the whole room including thread replies (`all`), `main` is the main timeline only, and `thread:` is that thread only. Any other value is 400. **`main` and `thread:` are for display. Don't use them as a sync position.** - `q` (server key only) searches `body.text` for a substring, ignoring case, and never matches deleted messages. A user token that passes `q` gets 403, and a `q` that is only whitespace is 400. **The `seq`s in search results are not contiguous** (messages that didn't match are simply missing). - If `after` is older than the room's retention floor the answer is **410 `cursor_too_old`**. You can't attach to it; start again from recent history. - Everything a page needs (`sender` names, `reactions`, `thread`, `myReactions`) is filled in by the server in one go. ### 14. `POST /v1/rooms/{roomId}/messages`: publish Request body: | Field | Required | Notes | |---|---|---| | `clientMessageId` | **yes** | The dedup key. 400 if missing. See below | | `body` | yes | `{text?, attachments?, entities?}`. Up to 4000 characters of `text`, 10 attachments, 100 `entities` | | `kind` | | `text` (default). `system` only with a server key | | `meta` | | A JSON object | | `replyTo`, `threadId` | | Ids of messages in the same room. See below | | `sender` | **yes** with a server key | `{id, name}`. A server key has no user to fall back to, so a missing or empty `sender.id` is 400. A user token that sends it gets 403 | | `appMeta` | | Server key only (a JSON object up to 4 KB). A user token that sends it gets 403 | The response is **201** `{"messageId": "…", "seq": 13, "createdAt": 1792861844902}`. - **`clientMessageId` is the idempotency key.** If the same sender sends the same value again (within 24 hours), no new message is made and you get the `messageId` and `seq` of the one stored first. If you didn't get a response, **send it again with the same value.** A new value makes two messages. UUIDv4 is recommended. - `threadId` means "this is a thread reply". If you give a reply's id, it is rewritten to that thread's **root**. `replyTo` is a quote and doesn't open a thread. If the quoted message is inside a thread, the new message becomes a reply in that thread. - A mention in `entities` (`{type:"mention", userId, offset, length}`) counts only if `userId` is a real user. An unknown id is left out, not rejected. - If the app has a `before_publish` hook, a user token's publish goes through it and can get 403 `moderation_denied`. A server key doesn't go through the hook. If the hook changes the text, **the changed text is what gets stored.** - A banned user gets 403 `banned`, and a full storage quota gets 403 `quota_exceeded`. ```bash curl -X POST https://api.chat.gravex.app/v1/rooms/$ROOM_ID/messages \ -H "Authorization: Bearer $USER_TOKEN" -H "X-Chat-Key: $VITE_CHAT_PK" \ -H "Content-Type: application/json" \ -d '{"clientMessageId":"cm-6f1c","body":{"text":"hello"}}' ``` ### 15. `PATCH /v1/rooms/{roomId}/messages/{messageId}`: edit Request body: `{body}`. It is required and **replaces the whole body.** Without it the answer is 400. The response is 200 with the message object, and the room gets `message.updated`. `editedAt` is set and `seq` does not move. - A user token edits **only its own messages**. Even a room owner or admin can't edit someone else's message (they can only delete it). It works only inside the app's edit window (`editWindowMs`, unlimited by default). - A server key edits anyone's message. - An already deleted message is 404. There is a limit of 2 per second per user (not counted for a server key). ### 16. `DELETE /v1/rooms/{roomId}/messages/{messageId}`: delete 204. This is a tombstone: the row stays and its body is emptied (`seq` unchanged). The room gets `message.deleted`. Deleting twice is still 204. A user token may delete its own messages, and an owner or admin may delete anyone's as moderation. A message id from another room is 404. ### 17. `GET /v1/rooms/{roomId}/messages/{messageId}/reactions`: who reacted Query: `emoji` (only that emoji), `cursor`, `limit`. Response: `{"reactions": [{"userId": "u-1", "emoji": "👍"}], "cursor": ""}`. A row is a (user, emoji) pair, so one person can take several lines. A withdrawn user stays as `withdrawn: true` with no `userId`. A deleted message gives an empty page, not 404. ### 18. `GET /v1/rooms/{roomId}/reads`: read positions Query: `userId` (only that user's rows), `cursor`, `limit`. Response: `{"reads": [{"userId": "u-1", "seq": 12, "threadId": "…"}], "cursor": ""}`. A row is a (user, scope) pair. The main timeline has no `threadId`, and each thread has its own line. A withdrawn user is `withdrawn: true` with no `userId`. A `userId` that names no user gives an empty page, not 404. **To get my own read positions back**, pass my id as `userId`. ### 19. `GET /v1/rooms/{roomId}/presence`: who is in the room now Query: `full=true`. By default you get `{"count": 3}`, and with `full=true` you get `{"count": 3, "users": [{"id": "u-1", "name": "alice", "avatar": "…"}]}`. `users` is cut off at 1000 and `capped: true` says so. An empty room is `{"count": 0}`, not 404. ### 20. `POST /v1/rooms/{roomId}/custom`: an unstored control signal Server key only (a user token gets 403). Request body: `{"payload": {…}}`, a JSON **object** of 16 KiB or less (otherwise 400). It sends `payload` unchanged as a `custom` event to the connections watching the room, and answers 204. - It is not stored. It doesn't go to webhooks or `GET /v1/events`, and it isn't replayed to connections that missed it. 204 means "sent", not "received". A signal that must be stored goes as a `kind:"system"` message. ## My profile (user token only) With a server key these are 403. ### 21. `PATCH /v1/me`: update my `meta` Request body: `{meta}`. `meta` is **merged shallowly at the top level**, and a key whose value is `null` is deleted. A nested object is replaced whole, not merged. The response is the user object (`{id, name, avatar, meta}`). - `name` and `avatar` are 400. A name and avatar come from the token your backend signs, or are changed with 25. - A top-level `meta.provider` is 400; that slot is vouched for by your backend. - If the `meta` **after** merging exceeds 4 KB or 64 top-level keys it is 400 and nothing is written. ### 22. `GET /v1/me/rooms`: my rooms Query: `cursor`, `limit`. Ordered by **last activity** (rooms with no messages go last). Response: ```json { "rooms": [{ "room": { "id": "0AAS4P1MM0000", "type": "public", "key": "general", "lastSeq": 12, "minSeq": 1, "meta": {}, "createdAt": 1, "updatedAt": 2 }, "lastSeq": 12, "readSeq": 10, "unread": 2, "unreadMentions": 1, "lastMessage": { "id": "0AAS4P1P40800", "seq": 12, "sender": { "id": "u-1", "name": "alice" }, "kind": "text", "body": { "text": "hi" }, "createdAt": 1792861844902 } }], "cursor": "" } ``` - `unread` is **counted by the server**, not `lastSeq - readSeq` (main-timeline messages only, my own and deleted ones excluded). It is cut off at 1000 and `unreadCapped: true` says so. - `readSeq` is the read position on the main timeline. `lastMessage` is present only when the room has a message. ### 23. `GET /v1/me/unread`: unread totals Response: `{"rooms": [{"roomId": "…", "unread": 2, "unreadMentions": 1}], "unread": 5, "unreadMentions": 1, "roomsCapped": false}`. Per-room numbers for the rooms I am a member of, and their sum. Past 500 rooms it counts only 500 and says `roomsCapped: true` (the total is the sum of a subset). It is the same computation as the WebSocket `hello.unread`. ## User management (server key only) A user exists **only after they have connected once.** A user id that has never connected is 404. With a user token every route here is 403. ### 24. `GET /v1/users`: list users Query: `cursor`, `limit`, and optional filters. **Newest user id first**, and withdrawn users are included. A value that can't be parsed is 400. When you change a filter, drop the `cursor` and start from the first page. | Parameter | Value | |---|---| | `q` | Prefix of `externalId` or a substring of `name`, ignoring case | | `status` | `live`, `withdrawn`, `all` (default) | | `last_seen_after`, `last_seen_before` | RFC3339. A range on the last connection time (`after` is inclusive, `before` is exclusive) | | `created_after`, `created_before` | RFC3339. A range on the signup time | | `bots` | `include` (default), `exclude`, `only` | Response: `{"users": [user…], "cursor": ""}` ### 25. `PUT /v1/users/{userId}`: update a profile Request body: `{name?, avatar?, meta?}`. The response is the user object. - `name` is at most 256 bytes and `avatar` at most 2048 bytes (over that is 400 and nothing is written). `meta` is the same **shallow merge** as 21, with the same limits. - A withdrawn user is 404. If the user is connected, the name on that connection changes immediately. - The next time they connect, the token's `name` and `avatar` overwrite these values again. ### 26. `DELETE /v1/users/{userId}`: withdraw, or delete permanently | Query | Result | |---|---| | none | **Withdrawal = anonymization.** The name becomes "탈퇴한 사용자" (the withdrawn-user label), and the avatar and `meta` are emptied. The user row and id stay, and so do the messages. The user's connections are closed | | `purge_messages=true` | Withdrawal, plus every message of the user becomes a tombstone and the name stamped on those messages is erased | | `purge=true` | Queues a job that **permanently deletes** a withdrawn user's row (it cannot be undone) | - The response to withdrawal and `purge_messages` is **200** `{"userId": "…", "anonymized": true, "purgedMessages": 40, "scrubbedSenders": 2, "purgeCapped": false}`. `anonymized` says whether this call did the anonymizing. - **`purgeCapped: true` means it couldn't finish in one call.** Repeat the same call until it is `false`. - An already withdrawn user is 404. The exception is `purge_messages=true`, which is accepted for an already withdrawn user (it carries on taking messages back). - `purge=true` answers **202** `{"job": {…}}`. A user who has not withdrawn is **409**. If a job is already in progress for the user you get that same job with 202. After it, the user id can be used again, but old messages' `sender.id` still holds the same string, so never reuse an old id for a new person. - If either query value is not `true` or `false`, it is 400. ### 27. `DELETE /v1/users/{userId}/messages`: take back messages Turns every live message the user sent into a tombstone. **It does not withdraw the user.** The response is **200** `{"userId": "…", "purgedMessages": 40, "purgeCapped": false}`. If `purgeCapped: true`, call it again (it skips what is already gone, so it is idempotent, and it ends with `purgedMessages: 0`). Each message sends `message.deleted`. The sender's name stays. **Ban first**, so new messages don't keep arriving. A user who doesn't exist, and a withdrawn user, are 404. ### 28 and 29. `PUT` and `DELETE /v1/users/{userId}/ban`: ban and lift - The ban request body is `{until, reason?}`. `until` is **required** (UTC epoch milliseconds) and 400 if absent. For an indefinite ban give a far-future time. 204. - A banned user's publishes and reactions are refused with `banned`. Reading is not blocked. - Lifting is 204, even if there was no ban. ### 30. `POST /v1/users/{userId}/disconnect`: force-close connections Optional request body: `{connectionId?, reason?}`. With a `connectionId` it closes that one connection; without one it closes all of the user's connections (get a `connectionId` from `hello`; a malformed one is 400). `reason` goes out as the WebSocket close reason (default `disconnected`). **204 means "I told them to", not "I closed this many".** If a connection was missed, call again (idempotent). A user who doesn't exist is 404. It also works on a withdrawn user. ### 31. `POST /v1/users/{userId}/revoke-tokens`: invalidate tokens Invalidates **every token issued so far** for the user and closes their connections (close reason `tokens_revoked`). 204. The cutoff is rounded down to the second, so a token **issued one second later** is the first that surely works. A withdrawn user is 404. ## Events and export (server key only) ### 32. `GET /v1/events`: catch up on missed events Query: `after` (an event id), `limit` (default 50, maximum 100). Response: ```json { "events": [{ "delivery": "0AAS4P1QQ0000", "event": "message.created", "createdAt": 1792861844902, "data": { } }], "cursor": "" } ``` - `delivery`, `event` and `data` match the webhook's headers and body. Receiving and verifying webhooks is in [Webhooks](https://chat.gravex.app/en/docs/webhooks). - An `after` that can't be parsed is 400. `cursor` appears only when the page is full. - Before you decide you have caught up, **read the last page once more**, or re-read from a slightly earlier `after`. Filter duplicates by `delivery`. - Events the app does not subscribe to are not here either. ### 33. `GET /v1/export`: download the whole app No query. It streams users, rooms, members, messages, reactions and read positions as **one NDJSON file** (`Content-Type: application/x-ndjson`). The attachment files themselves are not included. ```bash curl -sS -H "Authorization: Bearer $CHAT_SECRET_KEY" https://api.chat.gravex.app/v1/export -o export.ndjson tail -n 1 export.ndjson # must be {"type":"end",…} for a complete file ``` - One JSON per line, each with a `type`. Order: `export`, then every `user`, then for each room `room`, `member`, `message` (ascending seq), `reaction`, `read`, and finally `end` (with `counts`). - **A file whose last line is not `end` is incomplete.** The status was already 200, so a failure midway can't be reported. Download again from the start (there is no resuming). - One at a time per app. If one is already running it is 409, and if the server is busy it is 429 (`Retry-After`). - It is not a snapshot. Writes made while you download may or may not be in the file. ## Access rules A user-token call gets **the same rules** as the WebSocket. A server key skips these checks. | Room type | Read | Write | Appears in browsing | |---|---|---|---| | `public` | Anyone | Anyone | Yes | | `channel` | Anyone | Only members who are owner or admin | Yes | | `private` | Members only | Members only | No | | `dm` | The two people only | The two people only | No | - A `public` or `channel` room can be read without being a member, but you must become a member with 8 to get it into **my room list and notifications.** - A room you can't read is 403 `forbidden`. A room id from another app, or a deleted room, is 404. | Role | Read | Write | Manage members | Edit room | Delete room | |---|---|---|---|---|---| | `owner` | yes | yes | yes | yes | yes | | `admin` | yes | yes | yes | yes | no | | `member` | yes | depends on room type | no | no | no | ## The routes an app uses A mobile or frontend app gets a user token from the backend and calls with the user token only. The routes it normally uses are 3 to 5, 8 and 9, 11, 13 to 19, and 21 to 23. The rest (creating rooms, managing members, managing users, events, export) are the backend's job with a server key. Live connections, reconnecting and gap filling follow the [real-time protocol](https://chat.gravex.app/en/docs/realtime). Source: https://chat.gravex.app/en/docs/rest-api --- # Real-time protocol (WebSocket) How to build a real-time chat client without the JS SDK. iOS (Swift), Android (Kotlin), Flutter (Dart), Python, Go, or **any language that can do WebSocket, JSON and HTTP** can do the same work. Implement the rules on this page and your client behaves like the official SDK. The checklist "Building a client without the SDK" near the end lists everything. - Events the server sends (new messages, reactions, typing, who is online…) arrive over **WebSocket**. - History, filling the gap after a disconnect, and unread counts come from **[REST](https://chat.gravex.app/en/docs/rest-api)**. Neither replaces the other. A subscription doesn't replay what already happened, and REST doesn't push what happens next. - Sending a message works over both. Reactions, read positions and typing notices work **over WebSocket only.** ## At a glance 1. Get a **user token** (JWT) from your backend. Signing with the `sk_` is the backend's job ([Server integration](https://chat.gravex.app/en/docs/server)). 2. Open a WebSocket to `wss://api.chat.gravex.app/v1/ws?v=1`. 3. Send **`auth` as the first frame.** If it isn't sent within 5 seconds you are disconnected. 4. When `hello` arrives you are ready. Send a `ping` every 25 seconds. 5. Send `subscribe` for each room you are showing, and fill the difference between the `lastSeq` you get back and the last `seq` you hold using REST history. 6. Apply the events that arrive as `push` frames to your screen state. 7. When the connection drops, reconnect with backoff and repeat from step 5. Every frame is **one JSON text frame.** Binary frames are not used. ## Connecting ```text wss://api.chat.gravex.app/v1/ws?v=1 ``` - `?v=1` is the protocol version. If it is missing or different, the handshake is refused with HTTP 400. - The key and token go in **the first frame, not the URL or an HTTP header** (URLs end up in logs). - A browser's `Origin` must be in the `pk_`'s allowed origins. **A native client that sends no `Origin` header skips this check.** - While the server is being replaced the handshake answers HTTP 503. Retry with backoff. - Compression extensions and subprotocols are not used. ## Frame format Every frame is this envelope. `id` and `data` may be absent. ```json { "type": "publish", "id": "c7", "data": { } } ``` **Frames the client sends** - `type` is one of the 8 below. Anything else is `invalid`. - Always attach an `id`. Any string that is unique within the connection will do (`"c1"`, `"c2"`…). - The server answers with the same `id` and **exactly one of `ack` or `error`.** That is how requests and responses are paired. You may send several frames in a row; replies are not guaranteed to come back in order. - **An `id` is not an idempotency key.** The only dedup key is `publish`'s `clientMessageId`. - The server ignores fields it doesn't know. **Frames the server sends** | `type` | Shape | Meaning | |---|---|---| | `hello` | `{data: {…}}` | Authentication succeeded | | `ack` | `{id, data}` | The request with that `id` succeeded. `data` depends on the request | | `error` | `{id?, data: {code, message, retryAfterMs?, appCode?}}` | The request with that `id` failed. **No `id` means a connection-level error** (the frame was too big or not JSON, so no `id` could be read) | | `push` | `{data: {roomId?, event, data}}` | An event the server starts. Room events have a `roomId`; user events don't | | `pong` | `{}` | The reply to `ping` | | `reconnect` | `{data: {reason, delayMs}}` | The server is about to go down. Reconnect after `delayMs` | - The protocol only **adds** fields. Ignore fields you don't know and `push` events you don't know (don't treat them as errors). - A frame is at most 64 KB by default. Over that is `too_large`, and the connection stays up. After `hello` the ceiling is `hello.limits.frameSizeBytes`. - Error `code`s are the same as REST: `unauthorized`, `forbidden`, `not_found`, `invalid`, `too_large`, `rate_limited`, `banned`, `moderation_denied`, `cursor_too_old`, `quota_exceeded`, `internal`. They mean what the table in the [REST API](https://chat.gravex.app/en/docs/rest-api#error-format) says. A failed frame **does not close the connection** (only the authentication step is an exception). ## Authentication: `auth` then `hello` Send this as the first frame within 5 seconds of opening. ```json { "type": "auth", "id": "auth", "data": { "key": "pk_…", "token": "" } } ``` On success a `hello` comes back on the same connection. ```json { "type": "hello", "data": { "connectionId": "0AAS4P1MM0001", "user": { "id": "u-1", "name": "alice", "avatar": "https://example.com/a.png" }, "mode": "token", "serverTime": 1792861844000, "heartbeatMs": 25000, "limits": { "frameSizeBytes": 65536, "bodyTextMaxLen": 4000, "publishPerSecond": 5, "…": 0 }, "unread": { "total": 3, "mentions": 1 }, "warnings": [] } } ``` | Field | Notes | |---|---| | `connectionId` | This connection's id. Used for support and to target a [forced disconnect](https://chat.gravex.app/en/docs/rest-api#30-post-v1usersuseriddisconnect-force-close-connections) | | `user` | Me, as the server sees it. `id` is the token's `sub`. **Messages go out with this as their `sender`** | | `mode` | Always `"token"` | | `serverTime` | Server time (epoch milliseconds). Use it to correct a wrong device clock | | `heartbeatMs` | How often to send `ping`, in milliseconds | | `limits` | The effective limits for this app. See [Limits](#limits) | | `unread` | The **unread total** across every room I am a member of: `{total, mentions, roomsCapped?}`. It is not a per-room list. **The field may be absent, and absent is not 0** — if it is missing, ask `GET /v1/me/unread`. Don't assume 0 and clear the badge | | `warnings` | An array of strings. Usually empty | ### When authentication fails An `error` comes first (its `id` is the `auth` frame's `id`) and then the server closes the connection. The close reason equals the error `code`. | `code` | Cause | What to do | |---|---|---| | `unauthorized` | The key or token is wrong, expired or revoked; the `Origin` isn't allowed; the first frame wasn't `auth`; no `auth` within 5 seconds | **Don't retry with the same token.** If you can get a new token from your backend, do and try once more; if not, stop and tell the user | | `forbidden` | This key isn't allowed | Stop | | `invalid` | The `auth` data isn't JSON | Fix your code | | `rate_limited` | The connect limit (10 per minute per user, 60 per minute per key and IP) or the concurrent-connection limit | Wait `retryAfterMs` if present, otherwise back off, then reconnect | | `internal` | Server error | Reconnect with backoff | Get the token from your backend **on every connect.** Don't fetch it once and reuse it. A token expires within 24 hours, and a connection that is already open stays open regardless of expiry. ## Keeping the connection alive - **Send `{"type":"ping","id":"p1"}` every `heartbeatMs` (25 seconds).** The server answers `{"type":"pong"}`. **If no client frame arrives for 60 seconds the server closes the connection.** Send this JSON `ping`; a WebSocket library's own ping control frame is not a substitute. - When a mobile app returns from the background or the network changes, send a `ping` right away. If there is no answer the socket is dead, so reconnect immediately. - When the server is going down for a deploy it first sends `{"type":"reconnect","data":{"reason":"shutdown","delayMs":3200}}`. **Wait `delayMs`** and then connect again (the server spread those times out so clients don't arrive all at once). - If you read the events too slowly the server closes the connection (reason `slow_consumer`). Don't do slow work in the read loop. Reconnect and fill the gap. ### Close reasons A connection the server closes uses WebSocket close code 1008 (rarely 1000) with one of these reason strings. | Reason | Meaning | Client | |---|---|---| | `unauthorized`, `forbidden`, `invalid`, `rate_limited`, `internal`, `auth timeout` | Refused during authentication | The table above | | `disconnected` (or the wording your service chose) | Your service force-closed this user's connections | You may reconnect with the token you hold | | `tokens_revoked` | All of this user's tokens were invalidated | Get a **new token** from your backend. Reconnecting with the same one comes back as `unauthorized` | | `app_deleted` | The app was deleted | Reconnecting becomes `unauthorized`. Stop | | `slow_consumer` | The events were read too slowly | Reconnect and fill the gap | If your library doesn't expose the reason, the rule is the same: reconnect when closed, and stop if authentication answers `unauthorized`, `forbidden` or `invalid`. ## Watching a room: `subscribe` and `unsubscribe` **Watching (a subscription)** means "this connection receives every event of that room." Subscribe only to the rooms that are open on screen right now (`maxSubscriptions` per connection, 100 by default). ```json { "type": "subscribe", "id": "c2", "data": { "key": "lobby", "since": 12 } } ``` | Field | Notes | |---|---| | `roomId` or `key` | **Exactly one.** Even if you open a room by `key`, every later `push` carries only `roomId`. Remember the `roomId` from the ack | | `since` | The **top `seq` of the unbroken run** of messages you hold. Leave it out for a room you haven't seen | The answer: ```json { "type": "ack", "id": "c2", "data": { "roomId": "0AAS4P1MM0000", "lastSeq": 15, "minSeq": 1, "presence": { "count": 2, "users": [{ "id": "u-1", "name": "alice" }, { "id": "u-2", "name": "bob" }] } } } ``` | ack field | Notes | |---|---| | `lastSeq` | The room's last `seq`. If what you hold is lower, there is a gap | | `minSeq` | The oldest `seq` history can be read from | | `presence` | Who is in the room now: `{count, users?, capped?}`. `users` is present **only when the room has 100 people or fewer.** If the lookup fails the field is missing altogether (different from 0 people) | - Subscribing **does not deliver past events.** Fetch history over REST ([Reconnecting and filling gaps](#reconnecting-and-filling-gaps)). - If `since` is lower than `minSeq - 1` you get a **`cursor_too_old`** error. You can't attach, so drop what you hold, subscribe again without `since`, and load recent history. - Subscribing again to a room you already watch changes nothing and returns the same ack. - A subscription **belongs to the connection.** When the socket drops they all vanish, so send them again after `hello`. - The moment you subscribe, the user joins the room's presence list. It leaves when you `unsubscribe` or the connection drops. - A room you can't subscribe to is an error: missing or another app's room `not_found`, a room you can't read `forbidden`, over the limit `too_large`, a withdrawn user `unauthorized`. - `publish`, `react`, `read` and `typing` can be sent to a room you haven't subscribed to (permission is all you need). You only receive that room's events if you subscribe. ```json { "type": "unsubscribe", "id": "c3", "data": { "roomId": "0AAS4P1MM0000" } } ``` `unsubscribe` always succeeds with an empty `ack` (`{}`), even for a room you weren't watching. **A new message in a room you are not watching** arrives, if you are a member, as the user event `notification.message` (for unread badges). That room's `message.created` does not arrive. On one connection you get one or the other. ## Sending a message: `publish` ```json { "type": "publish", "id": "c4", "data": { "roomId": "0AAS4P1MM0000", "clientMessageId": "cm-6f1c9a1e", "body": { "text": "hi @bob", "entities": [{ "type": "mention", "userId": "u-2", "offset": 3, "length": 4 }] }, "replyTo": "0AAS4P1P40000", "threadId": "0AAS4P1P40000", "meta": { "client": "ios" } } } ``` | Field | Required | Notes | |---|---|---| | `roomId` | yes | | | `clientMessageId` | **yes** | See "Idempotency" below | | `body` | yes | `{text?, attachments?, entities?}`. An attachment is `{type, url, key?, mime?, w?, h?, name?, size?}` and `url` must be `https` | | `replyTo` | | The id of the message to quote. Must be in the same room | | `threadId` | | For a thread reply, the original message's id. If you give a reply's id it is rewritten to that **root** | | `meta` | | A JSON object | | `kind` | | Leave it out (`text`). `system` is sent only with a server key through [REST](https://chat.gravex.app/en/docs/rest-api#14-post-v1roomsroomidmessages-publish) | The ack: `{"messageId": "0AAS4P1P40800", "seq": 16, "createdAt": 1792861844902}` - **A message you got an ack for is stored.** If you watch the room, your own message also comes back as `message.created`. The ack and `message.created` arrive in no fixed order, so use `id` or `seq` to drop the duplicate and draw it once. - Drawing the message on screen at the moment of sending (an optimistic display) is not recommended. The app's `before_publish` hook can change or refuse the text, so the stored form is final only after the ack. - Failure `code`s: `forbidden` (a room you can't write to, a plain member of a `channel`), `banned`, `moderation_denied` (your wording is in `message` and `data.appCode`), `rate_limited` (`retryAfterMs`), `too_large` (text over `bodyTextMaxLen`), `invalid` (no `clientMessageId`, an attachment `url` that isn't https, more than 100 `entities`), `quota_exceeded` (the app's storage quota), `not_found`. - The engine does not escape the text. A client that renders HTML or markdown escapes at render time. The same goes for display names. - A mention in `entities` counts only if `userId` is a real user. An unknown id is left out rather than rejected, and the entity stays in the text. ### Idempotency: `clientMessageId` - **Make a new value for every message** (UUIDv4 recommended, e.g. `"cm-" + uuid`). - If the same sender sends the same value again **within 24 hours**, no new message is made and the ack carries the **`messageId` and `seq` of the one stored first.** - If the connection drops before the ack and you don't know whether it was stored, **send again with the same `clientMessageId`.** A new value would post the same words twice. - When the user taps "send again", keep the value. New content gets a new value. ## Reactions: `react` ```json { "type": "react", "id": "c5", "data": { "roomId": "0AAS4P1MM0000", "messageId": "0AAS4P1P40800", "emoji": "👍", "op": "add" } } ``` - `op` is `"add"` or `"remove"`. **There is no default.** If it is missing or anything else it is `invalid`. - It is idempotent. Sending `add` for an emoji you already pressed is not an error. - `emoji` is an opaque string the app chooses, **at most 64 bytes** (bytes, not characters). - A message can have up to `maxDistinctEmojiPerMessage` (20 by default) different emoji. **Pressing an emoji that is already on the message is exempt from this limit.** Going over it fails with `invalid`. To block it in the UI first, compare the row's `reactions` length with `limits` from `hello`. - The ack: `{"messageId": "…", "emoji": "👍", "count": 3, "changed": true}`. `count` is the value after your press, and `changed: false` means nothing changed. Even if you missed the broadcast, use this `count` to catch up. - A deleted message gets `not_found`. A banned user gets `banned`. ## Read position: `read` ```json { "type": "read", "id": "c6", "data": { "roomId": "0AAS4P1MM0000", "seq": 16 } } ``` - `seq` is 1 or more. Send the `seq` of the last message you have seen. - **The position only moves forward.** A value lower than the current one is ignored without an error. A value past the room's `lastSeq` is cut down to `lastSeq`. - The ack: `{"roomId": "…", "seq": 16, "advanced": true}`. These are the values the server really wrote. `advanced: false` means it was ignored (a value going backwards) or there was nothing to store (a room with no messages). - With `threadId` it is that thread's position (a reply's id becomes its root). The ack's `threadId` is the root it landed on. Without it, it is the main timeline, and **reading a thread does not clear the room badge.** - To load my own positions again, use `GET /v1/rooms/{roomId}/reads?userId=`. - The server counts unread. Don't compute it as `lastSeq - readSeq` (thread replies, my own messages and deleted messages make that wrong). ## Typing: `typing` ```json { "type": "typing", "id": "c7", "data": { "roomId": "0AAS4P1MM0000", "threadId": "0AAS4P1P40000" } } ``` Give `threadId` only while writing a thread reply. Sending side: - Call it on every input event, but **send at most once per second per room** (cut it down in the client). Skipping this wastes frames. - The server broadcasts **at most once per 3 seconds** for one user in one room. A frame that gets cut still gets a normal `ack`, with empty `data`. - The room also caps how many people it announces per window (3 seconds), so in a busy room some typists are never announced. - Write permission is required. A plain member of a `channel` can read but gets `forbidden`. A banned or withdrawn user's `typing` is silently ignored. - You don't have to wait for the `typing` ack. A failure shouldn't block typing. Receiving side (the `typing` event): - **There is no "stopped typing" event.** Show it for **at least 5 seconds** after the last `typing` event, then clear it. - **Your own `typing` comes back to you.** Skip it when the event's `userId` is your id. - The event has no name. Look it up in the room's online list (`presence.users`) or in a recent message's `sender`. - With a `threadId` it is typing in that thread (a root message id); without it, the main timeline. ## Events the server sends (`push`) ```json { "type": "push", "data": { "roomId": "0AAS4P1MM0000", "event": "message.created", "data": { } } } ``` Room events arrive **while you are watching** that room. User events (`notification.message`, `user.presence`) arrive regardless of subscriptions. A room event's `roomId` tells you which room. | `event` | Scope | `data` | |---|---|---| | `message.created` | room | A [message object](https://chat.gravex.app/en/docs/rest-api#message). Thread replies are this event too. **`thread` is absent, `reactions` is an empty array and `myReactions` is absent** (it is brand new) | | `message.updated` | room | A message object. For an edited message **`reactions` and `thread` are real values.** `myReactions` is absent | | `message.deleted` | room | `{id, seq}`. `seq` is the value from when it was published | | `reaction.added`, `reaction.removed` | room | `{messageId, emoji, count, userId}`. `count` is the value after the change | | `thread.updated` | room | `{rootId, count, lastSeq, lastAt}`. When a reply is added or deleted. On a delete it comes **after** `message.deleted` | | `presence` | room | `{count, joined?, left?, users?}`. See "Who is online" below | | `typing` | room | `{userId, threadId?}`. See "Typing" above | | `read` | room | `{userId, seq, threadId?}`. Someone's read position moved forward | | `member.joined`, `member.left` | room | `{userId, role?}` | | `room.updated` | room | A [room object](https://chat.gravex.app/en/docs/rest-api#room) | | `room.deleted` | room | `{roomId}`. After this frame your subscription to that room is gone. The connection stays | | `custom` | room | The JSON object your service backend sent with [`POST …/custom`](https://chat.gravex.app/en/docs/rest-api#20-post-v1roomsroomidcustom-an-unstored-control-signal), unchanged | | `notification.message` | user | `{roomId, seq, unread?, unreadMentions?, unreadCapped?, preview?}`. A new message arrived in a room where I am a member but **not watching.** `unread` is my unread count including this message; if the calculation fails the field is missing (it isn't 0). `preview` is `{sender: {id, name, avatar?}, text, createdAt}` | | `user.presence` | user | `{userId, status: "online" \| "offline", lastSeenAt?}`. The state of the other person in a `dm` or `private` room I am a member of. It comes only when that user's connection count goes from 0 to 1 or from 1 to 0. `lastSeenAt` is present only for offline | - Message events (`message.*`) are recovered from REST history after a reconnect if you missed them. The transient events (`presence`, `typing`, `custom`, `notification.message`, `user.presence`) may be missed and are not recovered. Missed reaction, thread-count, read and membership changes are only corrected through the aggregates on the messages in the history you load. - If you missed `notification.message`, read `GET /v1/me/rooms` again for per-room unread counts. ### Rules for applying events to screen state | Event | Rule | |---|---| | `message.created` | Insert it at its `seq`. If that `seq` already exists, overwrite it (but don't revive one you marked deleted). **If its `seq` is not your top `seq` + 1, there is a gap** → [fill the gap](#reconnecting-and-filling-gaps) | | `message.updated` | Replace that `id`'s fields with the new values. Ignore it if the row is already deleted | | `message.deleted` | Don't remove the row. **Empty its body and mark it deleted** (keep the `seq` slot). If you don't hold the row yet, remember the `id` and empty it when the row arrives | | `reaction.added`, `reaction.removed` | **Set** that emoji's count to `count` (don't add or subtract). A `count` of 0 or less removes the emoji. If `userId` is my id, add or remove it in my `myReactions` | | `thread.updated` | Set the root message's `thread` to `{count, lastSeq}` | | `room.deleted` | Close that room | - Draw only messages without a `threadId` on the main timeline. Thread replies go in the thread view. - **The sync position (`seq`) is about the room's whole stream.** Even if you draw only the main timeline, use every message including thread replies when computing the position. ## Who is online (presence) A room's presence is the users with a connection **subscribed to that room.** The first value comes from the `subscribe` ack's `presence`, and later you update it from `presence` events. - A `presence` event's `joined` and `left` are **increments.** Add `joined` to the list you hold (replace any entry with the same `id`) and remove `left` (an array of user ids). - When `users` arrives, **replace the whole list** with it. - `joined`/`left` and `users` never arrive in the same frame. - A room of **100 people or fewer** gets `joined`/`left` immediately as people come and go. A larger room gets, every 5 seconds, **only `count`**, and no `joined` or `users`. The room goes into that mode at 100 and comes out at 90. - If an event carries only `count` and none of `joined`, `left` or `users`, **the list you hold can no longer be trusted.** Drop the list and use only `count`. For the whole list, call `GET /v1/rooms/{roomId}/presence?full=true` (cut off at 1000). - When the room returns to a small one, a `{count, users: everyone}` snapshot arrives once. That repairs the stale list. - When you hold no list, don't build one from `joined` alone (it would show only the people who arrived after you). - A person may open several connections and still appears as one line. `joined` comes at that user's first subscription and `left` when the last one is released. ## Reconnecting and filling gaps Disconnects are normal. A subway, the background and a deploy are all disconnects. **However many events you missed, `seq` tells you.** A room's `seq` goes up from 1 **with no gaps**, so if the numbers you hold are not contiguous, you fetch what lies between them over REST. ### The reconnect loop ```text attempt = 0 loop: token = fetch a fresh token from your backend # on every connect; if that fails, back off and retry open the ws, send auth on hello: attempt = 0, start the ping timer, run resubscribe below when the ws closes (or dies without an ack): fail every pending request (see "Resending" below) if authentication was refused (unauthorized/forbidden/invalid) stop wait = the value the server gave (reconnect.delayMs, rate_limited's retryAfterMs) if there is one otherwise 500ms + random() × (min(30000, 500ms × 2^attempt) − 500ms) # exponential backoff + jitter attempt += 1, wait, then loop ``` - Don't drop the jitter. When a server goes down, every client is cut at the same instant and comes back at the same instant. - A send the user pressed may skip the backoff and try to reconnect at once. A wait the server gave is not skipped. - If `hello`'s `user.id` differs from the previous connection's (a different account after logging out), **throw away all room state** and load it fresh. That keeps the previous user's `myReactions` off the new user's screen. ### Resubscribe (each time you get hello, the first time too) Do this per room. A room's state is `messages` (a list sorted by `seq`) and `contiguousSeq`. ```text contiguousSeq = the top value of the run, starting at the first message, where seq goes +1 (or stays equal). 0 if the list is empty send subscribe { roomId or key, since: contiguousSeq (leave out if 0) } ├─ cursor_too_old error → drop the list, subscribe again without since → load recent history (A below) → tell the UI "the list was rebuilt" └─ ack {lastSeq, minSeq} ├─ since was sent (you hold something) → catchUp(lastSeq) (B below) └─ no since → if lastSeq > 0, load recent history (A) ``` **A. Recent history**: one `GET /v1/rooms/{roomId}/messages?limit=100`. Replace the list with the page, but don't delete messages that arrived by push during the request and whose `seq` is above the page's top `seq`. **B. catchUp(upTo)**: fill from your `contiguousSeq` up to `upTo`. ```text fill(after, before): # the range after `after` and before `before`, both excluded cursor = after while cursor < before - 1: page = GET /v1/rooms/{roomId}/messages?after={cursor}&limit=100 # leave out view (whole room) stop if the page is empty insert every message of the page at its seq stop if the page's top seq <= cursor cursor = the page's top seq catchUp(upTo): fill(contiguousSeq, upTo + 1) ``` `after=N` returns up to 100 messages with `seq > N`, **oldest first.** If it doesn't all arrive at once, pass the last `seq` as `after` and continue. If you are already contiguous (`upTo <= contiguousSeq`), don't call REST at all. ### Gaps during live use If a `message.created`'s `seq` is more than your top `seq` + 1 (you hold up to 15 and 18 arrives), insert 18 first and then `fill(15, 18)` to get 16 and 17. If the list is empty, skip the gap check. ### Scrolling up (older history) If your top `seq` is above `minSeq`, older messages exist. Fetch them with `GET …/messages?before={your top seq}&limit=100` and put them in front. A response shorter than `limit` means there is no more. ### Pitfalls - **The results of `view=main`, `thread:…` and `q` (search) have non-contiguous `seq`s.** Don't use their last `seq` as a sync position. Always sync without `view` (whole room). - A deleted message still has a row with its `seq` (a tombstone). Deleting never leaves a hole in `seq`. - A 401 from REST may mean the token expired. Retry **once** with a new token. The socket authenticates only at connect, so you can have a healthy socket while only REST gets 401. - Missed reactions, thread counts, read positions and membership changes are corrected only for messages that are in the history page. After a long time away, the honest fix is to load recent history again for the range on screen. ### Resending A request whose connection dropped before its ack is one the server may or may not have received. Handle it like this. | Request | May you send it again? | |---|---| | `publish` | Send it again **with the same `clientMessageId`.** It won't duplicate | | `react` | Send it again (idempotent) | | `read` | Send it again (idempotent) | | `typing` | Don't resend (transient) | | `subscribe` | Resubscribing on the new connection takes its place | One resend is enough. If the connection doesn't come back, tell the user it failed after a timeout (15 seconds recommended). ## Limits Limits are **per app**, and the effective values for your app are in `hello.limits`. Don't hard-code the numbers below; read `hello.limits`. A key that is missing has its default. | `limits` key | Default | When hit | |---|---|---| | `frameSizeBytes` | 65536 | `too_large` (frame) | | `bodyTextMaxLen` | 4000 | `too_large` (text length) | | `maxAttachments` | 10 | `invalid` | | `publishPerSecond` / `publishBurst` | 5 / 10 | `rate_limited` (per user; a burst of 10 plus an average of 5 over 10 seconds) | | `reactPerSecond` | 10 | `rate_limited` | | `typingPerSecond` | 20 | `rate_limited` | | `editPerSecond` | 2 | `rate_limited` (REST edits) | | `connectPerMinute` | 10 | `rate_limited` on connect (per user) | | `connectionEstablishPerMinute` | 60 | `rate_limited` on connect (same `pk_` and same IP) | | `maxConcurrentConnections` | 10 | `rate_limited` on connect. **There is no `retryAfterMs`** (it can't know when a slot frees up) | | `maxSubscriptions` | 100 | `subscribe` gets `too_large` | | `roomCreatePerDay` | 50 | `rate_limited` (creating rooms with a user token) | | `maxDistinctEmojiPerMessage` | 20 | `invalid` | | `notificationMemberCap` | 1000 | A room with more members doesn't send `notification.message`. Read `GET /v1/me/rooms` periodically | | `editWindowMs` | 0 (no limit) | The time allowed for editing | | `maxStoredMessages` | absent (unlimited) | The app's storage quota. When it is full, `quota_exceeded` | - `hello.limits` may hold keys not in this table. Ignore keys you don't know. - `hello.limits` is the value **at the moment you connected.** If limits change, an open connection isn't told again. The next connect's `hello` has them. - Limits are counted in **fixed windows** (not sliding). At a window edge you can briefly get twice as much. - **Wait `retryAfterMs`** from a `rate_limited` error and then send the same request again. Don't shorten it and retry sooner. - A server key's REST calls don't count against the publish and edit limits. ## Building a client without the SDK The complete list of what the official SDK (JS/TS) does for you. **Implement this list and you have the same client as the SDK.** Build it top to bottom. **Connection** 1. Have a function that gets a user token from your backend. Call it **on every connect, and whenever REST returns 401.** 2. Connect to `wss://api.chat.gravex.app/v1/ws?v=1` and send `auth` when it opens. After `hello` you are `open`. Keep a state: `connecting`, `open`, `reconnecting`, (`closed`). 3. Make frame `id`s, and keep a table of requests waiting for a reply by `id`. An `ack` resolves it, an `error` fails it. A timeout (15 seconds) and a dropped connection fail the whole table. 4. Send `ping` every `heartbeatMs`. When the app comes back to the foreground or the network returns, send a `ping` at once and reconnect if it fails. 5. On a drop, reconnect with exponential backoff (500ms to 30s) and jitter. Prefer `reconnect.delayMs` and `rate_limited.retryAfterMs` when present. Stop and tell the user on an authentication-step `unauthorized`, `forbidden` or `invalid`. 6. If `hello.user.id` changes, throw away all room state. **Rooms** 7. Keep per room the `messages` (a list in seq order) and `contiguousSeq`. 8. Each time you get `hello`, send `subscribe` (`since = contiguousSeq`) again for every room you are watching. 9. On `cursor_too_old`, drop the list, subscribe again without `since`, and load recent history. 10. Fill the gap up to the ack's `lastSeq` using REST `GET …/messages?after=`. Fill a gap that a live `message.created` reveals the same way. 11. Fetch older history for scrolling up with `before=`. 12. Apply events to the list by the "Rules for applying events" above. Don't remove a deleted row, empty it. Set reaction counts to the server's `count`. 13. Merge `presence` as increments (`joined`/`left`) and replacements (`users`), and drop the list when only `count` arrives. **Sending** 14. Make a new `clientMessageId` for every `publish`. If it drops before the ack, send it again **with the same value** once. 15. Resend `react` and `read` if they drop before the ack. Don't resend `typing`. 16. Send `typing` at most once per second per room, and on the receiving side show it for at least 5 seconds after the last event, then clear it. Skip your own `userId`. 17. Send `read` only forward, with the last `seq` that was really visible on screen. Don't send it too often. **Unread and notifications** 18. Right after connecting, use `hello.unread` for the badge if it is there, and **if it is absent** ask `GET /v1/me/unread`. 19. For rooms you aren't watching, raise the room-list badge from `notification.message`'s `unread`, and if you may have missed some, read `GET /v1/me/rooms` again. **Finishing** 20. Branch on error `code` (wait on `rate_limited`, show `banned` and `moderation_denied` to the user, don't retry `quota_exceeded` or `forbidden`). 21. Escape message text and names when you draw them. The engine does not. 22. (Optional) When the user presses a reaction, change my `myReactions` first and roll back if the ack fails. **Use only the server's value for counts.** Push notifications are not this connection's job. For people whose app is closed, the `message.created` [webhook](https://chat.gravex.app/en/docs/webhooks) reaches your service server, which sends through FCM or APNs. ## Example frame conversation `→` is what the client sent and `←` is what the server sent. One line is one frame. ```text → {"type":"auth","id":"auth","data":{"key":"pk_…","token":"eyJ…"}} ← {"type":"hello","data":{"connectionId":"0AAS4P1MM0001","user":{"id":"u-1","name":"alice"},"mode":"token","serverTime":1792861844000,"heartbeatMs":25000,"limits":{"frameSizeBytes":65536,"bodyTextMaxLen":4000},"unread":{"total":0,"mentions":0},"warnings":[]}} → {"type":"subscribe","id":"c1","data":{"key":"lobby"}} ← {"type":"ack","id":"c1","data":{"roomId":"0AAS4P1MM0000","lastSeq":15,"minSeq":1,"presence":{"count":1,"users":[{"id":"u-1","name":"alice"}]}}} (then over REST: GET /v1/rooms/0AAS4P1MM0000/messages?limit=100) → {"type":"publish","id":"c2","data":{"roomId":"0AAS4P1MM0000","clientMessageId":"cm-6f1c9a1e","body":{"text":"hello"}}} ← {"type":"ack","id":"c2","data":{"messageId":"0AAS4P1P40800","seq":16,"createdAt":1792861844902}} ← {"type":"push","data":{"roomId":"0AAS4P1MM0000","event":"message.created","data":{"id":"0AAS4P1P40800","seq":16,"kind":"text","sender":{"id":"u-1","name":"alice"},"body":{"text":"hello"},"mentions":[],"reactions":[],"createdAt":1792861844902}}} → {"type":"react","id":"c3","data":{"roomId":"0AAS4P1MM0000","messageId":"0AAS4P1P40800","emoji":"👍","op":"add"}} ← {"type":"ack","id":"c3","data":{"messageId":"0AAS4P1P40800","emoji":"👍","count":1,"changed":true}} ← {"type":"push","data":{"roomId":"0AAS4P1MM0000","event":"reaction.added","data":{"messageId":"0AAS4P1P40800","emoji":"👍","count":1,"userId":"u-1"}}} → {"type":"publish","id":"c4","data":{"roomId":"0AAS4P1MM0000","clientMessageId":"cm-77aa","body":{"text":"quickly"}}} ← {"type":"error","id":"c4","data":{"code":"rate_limited","message":"rate limit exceeded","retryAfterMs":640}} → {"type":"ping","id":"p1"} ← {"type":"pong"} ``` Source: https://chat.gravex.app/en/docs/realtime --- # Console API (account keys) Everything you would click in the console — creating apps, issuing keys, setting allowed origins, webhooks and limits, deleting and restoring apps — can be done over HTTP. With **one account key (`ck_…`)**, a script or an AI can do everything that comes after signing up. - Base URL: `https://console.chat.gravex.app` (every path starts with `/api/apps`. This is the **console address**, not the docs site) - Bodies are JSON (UTF-8); send `Content-Type: application/json`. Anything else is a 415 `unsupported_media_type`. - Times are ISO 8601 strings (`2026-10-25T08:30:00.000Z`), unlike the epoch milliseconds in the REST API reference. - This API handles **apps and keys.** Creating rooms and sending messages is done with the `sk_` you get here, through the [REST API](https://chat.gravex.app/en/docs/rest-api) and [Server integration](https://chat.gravex.app/en/docs/server). The console API does not create rooms. ## Get an account key An account key is **created once, by a person.** An AI cannot sign up or issue the first key for you. 1. [Sign up in the console](https://console.chat.gravex.app/login) with the 6-digit code sent to your email. 2. In the top menu choose **API keys** → **New key** → give it a name (for example `laptop .env`). 3. The full key (`ck_…`) is shown **once.** Copy it right away into your project's `.env`. The console stores only a hash, so it cannot show the key again. ```sh # .env — you fill in the value CHAT_ACCOUNT_KEY=ck_... ``` An account can have at most 10 active keys. Revoke the ones you no longer use on the **API keys** screen. ### Keeping it safe **A `ck_` key has full power over your account.** With it you can issue new keys for all your apps, delete apps and change webhooks. - Keep it only on a server or in a local `.env`. **Never put it in frontend code, a browser or a mobile app.** - Do not commit it to git. Check first that `.env` is in `.gitignore`. - Do not paste it into an AI chat; conversations are kept as records. Tell the AI "read `CHAT_ACCOUNT_KEY` from `.env`" instead. - Revoke it when you are done; you can always create another. Revoke it immediately if you suspect it leaked. Apps you already created, their `pk_` and `sk_`, and their webhook settings keep working after you revoke it — the account key is only the credential for calling this API. After revoking it, also delete the `CHAT_ACCOUNT_KEY` line from `.env`. - What a key does is written to the audit log together with the key id. ## Authentication Add one header to every request. No cookie is needed. ```sh curl https://console.chat.gravex.app/api/apps \ -H "Authorization: Bearer $CHAT_ACCOUNT_KEY" ``` | Path | Account key | |---|---| | `/api/apps/**` — every route in this document | Works | | `/api/account/keys/**` — creating, listing and revoking account keys | **Does not work.** Console screen (signed-in session) only. A key cannot create keys | | `/api/auth/**` — sign-in and sign-out | Does not work | | Operator-only paths | Do not work, even with an operator account's key | - A missing, wrong or revoked key is a 401 `unauthorized`. The reason is not given. - Each key is limited to 120 requests per minute. Beyond that you get 429 `rate_limited`; wait `Retry-After` seconds and retry. Repeatedly sending wrong keys from one address is also answered with 429. - It is not meant to be called from a browser (CORS is not opened). Call it from a server or a local script. - The id of someone else's app, a missing app or a deleted app is always 404 `not_found`; a 403 would reveal that the app exists. ## Error format A failure is an HTTP status code with JSON of this shape. Branch on `code`. ```json { "code": "limit_reached", "limit": 10 } ``` | `code` | HTTP | Meaning | |---|---|---| | `unauthorized` | 401 | The account key is missing, wrong or revoked | | `not_found` | 404 | Not your app, or the app, key, room or user does not exist | | `invalid_request` | 400 | The body or query is wrong. For limit changes, `field` (and `limit`) come with it | | `invalid_name` | 400 | An app name is 1 to 100 characters | | `invalid_origins` | 400 | An origin is malformed. `invalid` lists the bad values | | `invalid_env` | 400 | `env` is `production` or `development` | | `limit_above_ceiling` | 400 | Above this app's limit ceiling. `limit` carries the ceiling | | `limit_below_min` | 400 | Below the minimum. `limit` carries the minimum | | `limit_reached` | 409 | Up to 10 apps per account and 5 active keys per kind per app. `limit` carries it | | `conflict` | 409 | Collides with the current state (for example a permanent deletion already running) | | `export_in_progress` | 409 | An export of this app is already running | | `unsupported_media_type` | 415 | The body was not sent as `application/json` | | `rate_limited` | 429 | Too many requests. Retry after `Retry-After` | | `export_busy` | 429 | The engine is busy with other exports. Retry in a few seconds | | `engine_error` | 502 | The chat engine returned an error | | `engine_unavailable` | 503 | The chat engine could not be reached. Retry shortly | | `unknown` | 500 | Server error | Retry 5xx with exponential backoff. Retry 429 and 409 only after the state has changed, as described above. ## Create an app and get its keys With an account key, this is the whole sequence. It is the same one to give an AI. ### 1. Create the app Put the address you develop on in the allowed origins. If you omit `env` it is `development`. ```sh curl -X POST https://console.chat.gravex.app/api/apps \ -H "Authorization: Bearer $CHAT_ACCOUNT_KEY" \ -H "Content-Type: application/json" \ -d '{"name": "my-chat", "origins": ["http://localhost:5173"], "env": "development"}' ``` The response is 201, and **the full `pk_` and `sk_` values come in this same response.** ```json { "app": { "id": "0AAS4P1MM0000", "name": "my-chat", "createdAt": "2026-10-25T08:30:00.000Z", "webhook": { "url": null, "beforePublishEnabled": false, "beforePublishOnError": "allow", "stopped": false, "stoppedAt": null, "hasSecret": false }, "limits": { "effective": {}, "overrides": {}, "defaults": {}, "max": {}, "min": {}, "fixed": [] } }, "publishableKey": { "id": "0AAS4P1MM0001", "appId": "0AAS4P1MM0000", "kind": "pk", "value": "pk_...", "label": null, "allowedOrigins": ["http://localhost:5173"], "revokedAt": null, "createdAt": "2026-10-25T08:30:00.000Z" }, "secretKey": { "id": "0AAS4P1MM0002", "appId": "0AAS4P1MM0000", "kind": "sk", "value": "sk_...", "label": null, "allowedOrigins": [], "revokedAt": null, "createdAt": "2026-10-25T08:30:00.000Z" } } ``` ### 2. Fill both keys into `.env` - `publishableKey.value` (`pk_…`) is the public value that goes in the frontend. - `secretKey.value` (`sk_…`) is **shown only in this response.** The console does not store it. Have the code that receives the response write it straight into `.env`, and never print it to a screen, a log or a chat. If you missed it, issue a new `sk_` with "Issue a key" below and revoke the old one. ```sh # .env VITE_CHAT_PK=pk_... CHAT_SECRET_KEY=sk_... ``` The frontend reads only `VITE_CHAT_PK` (Vite puts only variables that start with `VITE_` into the browser bundle). `CHAT_SECRET_KEY` and `CHAT_ACCOUNT_KEY` are read only on the server. **Never widen Vite's `envPrefix` to `CHAT_`** — that ships `sk_` and `ck_` into the bundle. With a frontend other than Vite, put that framework's public prefix on the `pk_` only (for Next.js, `NEXT_PUBLIC_CHAT_PK`). ### 3. Set allowed origins and the webhook To change origins later — when the dev server's port changes, for example — use `PATCH /api/apps/{id}/keys/{keyId}` (see "Keys" below); for the webhook use `PATCH /api/apps/{id}/webhook` (see "Webhook"). The response that first sets a webhook URL carries the signing secret (`secret`) once. Write it to `CHAT_WEBHOOK_SECRET` in `.env` right away. ### 4. Then, the references Once the app and keys exist, this API's job is done. To add chat, follow [Getting started](https://chat.gravex.app/en/docs) and [Server integration](https://chat.gravex.app/en/docs/server); for the server calls that handle rooms, messages and users see the [REST API reference](https://chat.gravex.app/en/docs/rest-api); for live connections see the [Real-time protocol](https://chat.gravex.app/en/docs/realtime). Your backend creates rooms with `sk_` (`PUT /v1/rooms/{key}`). ## Routes at a glance `{id}` in a path is the app id (`app.id`) and `{keyId}` is the key id (`id`). Every route works with an account key. | Route | What it does | |---|---| | `GET /api/apps` | List my apps | | `POST /api/apps` | Create an app (issues `pk_` and `sk_` together) | | `GET /api/apps/{id}` | One app with its statistics | | `PATCH /api/apps/{id}` | Change the environment label (`env`) | | `DELETE /api/apps/{id}` | Delete the app (revokes keys, ends connections) | | `POST /api/apps/{id}/restore` | Restore a deleted app (new `pk_` and `sk_`) | | `GET /api/apps/{id}/purge` | Check permanent-deletion jobs | | `POST /api/apps/{id}/purge` | Permanently delete a deleted app's data | | `GET /api/apps/{id}/keys` | List the app's keys | | `POST /api/apps/{id}/keys` | Issue a new `pk_` or `sk_` | | `PATCH /api/apps/{id}/keys/{keyId}` | Replace a `pk_`'s allowed origins | | `DELETE /api/apps/{id}/keys/{keyId}` | Revoke a key (`?purge=true` deletes a revoked key) | | `PATCH /api/apps/{id}/webhook` | Set the webhook URL and `before_publish`, rotate the secret | | `POST /api/apps/{id}/webhook/resume` | Resume a stopped webhook delivery | | `PATCH /api/apps/{id}/limits` | Change the app's limits | | `GET /api/apps/{id}/users` | List users | | `DELETE /api/apps/{id}/users/{userId}` | Permanently delete a withdrawn user (`?purge=true` required) | | `PUT /api/apps/{id}/users/{userId}/ban` | Ban a user | | `DELETE /api/apps/{id}/users/{userId}/ban` | Lift a ban | | `DELETE /api/apps/{id}/users/{userId}/messages` | Remove every message a user sent | | `GET /api/apps/{id}/rooms` | List rooms | | `DELETE /api/apps/{id}/rooms/{roomId}` | Permanently delete a room (`?purge=true` required) | | `GET /api/apps/{id}/rooms/{roomId}/messages` | Read or search a room's messages | | `DELETE /api/apps/{id}/rooms/{roomId}/messages/{messageId}` | Delete one message | | `GET /api/apps/{id}/export` | Download all of the app's data (NDJSON) | ## Apps ### `GET /api/apps` ```json { "apps": [ { "appId": "0AAS4P1MM0000", "name": "my-chat", "env": "development", "createdAt": "2026-10-25T08:30:00.000Z" } ] } ``` Deleted apps are left out. Newest first. ### `POST /api/apps` | Field | Required | Description | |---|---|---| | `name` | yes | 1 to 100 characters | | `origins` | no | An array of origins of pages allowed to use the `pk_`. Like `https://example.com`: no path, and scheme, host and port exact. Up to 50 | | `env` | no | `production` or `development` (default). Only a label in the console list; the engine behaves the same. Make a separate app for production and for development | 201 with the response shown in "Create an app and get its keys". An account can have 10 live apps; beyond that is a 409 `limit_reached`. ### `GET /api/apps/{id}` ```json { "app": { "appId": "0AAS4P1MM0000", "name": "my-chat", "env": "development", "createdAt": "2026-10-25T08:30:00.000Z" }, "stats": { "users": 12, "rooms": 3, "messages": 480, "activeUsers7d": 5, "activeUsers30d": 9, "bots": 0, "activeBots7d": 0, "activeBots30d": 0, "botsIncluded": false, "approximate": [], "webhook": { "pending": 0, "oldestPendingSeconds": null, "stopped": false } } } ``` Statistics exclude bots by default. A number the engine cannot give is `null`; do not read it as 0. Fields listed in `approximate` are estimates. ### `PATCH /api/apps/{id}` Body `{ "env": "production" }`. Returns the changed `app` (same shape as `app` in `GET` above). This is a console-label change and does not call the engine. ### `DELETE /api/apps/{id}` Soft-deletes the app. The engine revokes every key, closes connections and stops the webhook. The data stays. ```json { "appId": "0AAS4P1MM0000", "name": "my-chat", "deletedAt": "2026-10-25T09:00:00.000Z" } ``` If the engine call fails halfway, just send it again. ### `POST /api/apps/{id}/restore` Works **only for a deleted app** (a live app or someone else's is a 404). The old keys do not come back; **new `pk_` and `sk_` values come once in the response**, in the same shape as `POST /api/apps`. An app whose permanent deletion has already started is a 409. ### `GET /api/apps/{id}/purge`, `POST /api/apps/{id}/purge` A job that **permanently** deletes a deleted app's data (users, rooms, messages). Both routes work only for a deleted app. - `POST` queues the job and returns 202 `{ "job": PurgeJob }`. It cannot be undone. - `GET` returns this app's jobs, newest first, as `{ "jobs": [PurgeJob] }`. ```json { "job": { "id": "...", "kind": "app", "targetId": "0AAS4P1MM0000", "state": "pending", "requestedAt": "2026-10-25T09:05:00.000Z", "startedAt": null, "finishedAt": null, "error": null, "deleted": {}, "attempts": 0 } } ``` `state` goes `pending` → `running` → `done`. `failed` is not final: the job is picked up again after 5 minutes. ## Keys A `pk_` shows its full value in lists and an `sk_` is masked (`sk_…abcd`). **The full `sk_` appears only in the issuing response.** An app can have 5 active keys of each kind. ### `GET /api/apps/{id}/keys` ```json { "keys": [ { "id": "0AAS4P1MM0001", "appId": "0AAS4P1MM0000", "kind": "pk", "value": "pk_...", "label": null, "allowedOrigins": ["http://localhost:5173"], "revokedAt": null, "createdAt": "2026-10-25T08:30:00.000Z" } ] } ``` ### `POST /api/apps/{id}/keys` Body `{ "kind": "pk" | "sk", "origins": [...] }`. `origins` only matters for `pk`. Returns 201 and one key (same shape as `publishableKey` above); for `sk`, `value` carries the full value. Beyond the per-kind limit is a 409 `limit_reached`. ### `PATCH /api/apps/{id}/keys/{keyId}` Replaces a `pk_`'s allowed origins as a whole. Body `{ "origins": ["https://example.com", "http://localhost:5173"] }`. An empty array is valid (no browser is allowed). Returns the changed key. To add or remove one origin, read the current list with `GET /api/apps/{id}/keys` and send the whole edited list — any origin you leave out is removed. ### `DELETE /api/apps/{id}/keys/{keyId}` Revokes a key. It takes effect immediately and cannot be undone. Returns the revoked key (`revokedAt` is set). **Before revoking the last key of a kind, issue a new one and deploy it.** With `?purge=true` it **deletes the record of a revoked key** instead. It cannot be used on a live key, answers 204, and cannot be repeated (404). `purge` accepts only `true` or `false`. ## Webhook ### `PATCH /api/apps/{id}/webhook` Only the fields you mention change. One route takes several kinds of instruction. | Body | Effect | |---|---| | `{ "url": "https://example.com/chat-webhook" }` | Set the receiving URL (https) | | `{ "url": null }` | Turn the webhook off | | `{ "beforePublishEnabled": true, "beforePublishOnError": "deny" }` | Turn on `before_publish` and choose what happens if the hook fails (`allow` or `deny`) | | `{ "rotateSecret": true }` | Rotate the signing secret | ```json { "url": "https://example.com/chat-webhook", "beforePublishEnabled": false, "beforePublishOnError": "allow", "stopped": false, "stoppedAt": null, "hasSecret": true, "secret": "..." } ``` **`secret` comes only in the response where the secret was newly made** — the first time you set a `url` on an app that had no secret, and on `rotateSecret`. It cannot be read again. Write it to `CHAT_WEBHOOK_SECRET` in `.env` right away and keep it out of logs. For events and signature verification see [Webhooks](https://chat.gravex.app/en/docs/webhooks). ### `POST /api/apps/{id}/webhook/resume` Restarts a delivery that the drainer gave up on (`stopped: true`). No body. It is a 200 even for an app that is not stopped. Returns the settings in the shape above. ## Limits ### `PATCH /api/apps/{id}/limits` The body is `{ "field": integer | null }`. `null` resets to the default. If any field is wrong, nothing changes. ```sh curl -X PATCH https://console.chat.gravex.app/api/apps/$APP_ID/limits \ -H "Authorization: Bearer $CHAT_ACCOUNT_KEY" \ -H "Content-Type: application/json" \ -d '{"publishPerSecond": 3}' ``` ```json { "rows": [ { "field": "publishPerSecond", "group": "rate", "effective": 3, "default": 5, "max": 100, "min": 1, "override": 3, "ceiling": 5, "ceilingSet": false, "ownerUpper": 5, "ownerCanReset": true } ] } ``` The response is the rows of the fields that changed. `group` says who can change that field. - Field names and current values are in `app.limits` of the `POST /api/apps` response. - Rate and size fields (`group` is `rate` or `size`) can only be raised up to this app's console ceiling (`ownerUpper`). If the operator has not set a ceiling, the engine default is the ceiling, so going above the default means asking the operator. Policy fields (`policy`) can be changed freely. Operator fields (`ops`), fixed fields (`fixed`) and unknown fields are a 400 `invalid_request`, with the field in `field`. - Going above the ceiling is a 400 `limit_above_ceiling`; below the minimum is a 400 `limit_below_min`. `limit` carries that value. - When a changed value takes effect depends on the field. Open connections pick it up within 30 seconds for some; others apply from the next connection. - The effective limits are the same values as the limits in the [REST API reference](https://chat.gravex.app/en/docs/rest-api#limits) and the [Real-time protocol](https://chat.gravex.app/en/docs/realtime#limits). ## Reading data Users, rooms and messages are **read-only.** To create or change them, call REST with `sk_`. ### `GET /api/apps/{id}/users` ```json { "items": [ { "externalId": "u_42", "name": "Hannah", "avatarUrl": null, "lastSeenAt": "2026-10-25T08:00:00.000Z", "deletedAt": null, "createdAt": "2026-10-01T00:00:00.000Z", "bannedUntil": null, "isBot": false, "lastUserAgent": "Mozilla/5.0 ..." } ], "cursor": "abc", "botsSupported": true } ``` A page is 50 users. A `null` `cursor` is the last page; otherwise pass it as `?cursor=` to get the next page. Filter with the query: `q` (name search), `status` (`live`, `withdrawn`, `all`), `bots` (`include`, `exclude`, `only`), `last_seen_after`, `last_seen_before`, `created_after`, `created_before` (`YYYY-MM-DD`). When a filter changes, drop the cursor and read from the first page. ### `GET /api/apps/{id}/rooms` ```json { "items": [ { "id": "0AAS4P1MM0010", "key": "lobby", "type": "public", "name": "Lobby", "lastSeq": 128, "createdAt": "2026-10-25T08:31:00.000Z" } ], "cursor": null } ``` `public`, `channel`, `private` and `dm` rooms all appear. Paging is the same as users (50 per page, `?cursor=`). ### `GET /api/apps/{id}/rooms/{roomId}/messages` ```json { "messages": [ { "id": "0AAS4P1MM0020", "seq": 128, "kind": "text", "sender": { "id": "u_42", "name": "Hannah" }, "text": "Hello", "attachmentCount": 0, "threadId": null, "createdAt": "2026-10-25T08:40:00.000Z", "editedAt": null, "deletedAt": null } ], "hasMore": false } ``` By default it returns the latest 50. `?before=` is one page older than that; `?after=` is the messages newer than it, oldest first. `?q=term` searches the room's message text, and the `seq` values of the results are not contiguous. `after` cannot be combined with `before` or `q` (400). ### `GET /api/apps/{id}/export` Downloads the app's users, rooms, members, messages, reactions, read positions and attachment metadata as one NDJSON file (not the attachment files themselves). It is not a snapshot and cannot be resumed. **If the last line is not `{"type":"end", …}` the file is incomplete.** Only one export per app can run at a time (409 `export_in_progress`). The format is the same as `GET /v1/export` in the [REST API reference](https://chat.gravex.app/en/docs/rest-api). ```sh curl https://console.chat.gravex.app/api/apps/$APP_ID/export \ -H "Authorization: Bearer $CHAT_ACCOUNT_KEY" -o export.ndjson ``` ## Moderation and permanent deletion ### `PUT /api/apps/{id}/users/{userId}/ban`, `DELETE /api/apps/{id}/users/{userId}/ban` `userId` is the id your service chose (`externalId`). The ban body is `{ "until": 1793000000000, "reason": "spam" }`. `until` is **required** and is UTC epoch milliseconds (use a far-future time for a permanent ban). `reason` is optional. Both answer 204, and lifting a ban answers 204 even if there was none. ### `DELETE /api/apps/{id}/users/{userId}/messages` Turns every live message the user sent into a tombstone. It does not withdraw the user. ```json { "purgedMessages": 40, "purgeCapped": true } ``` There is a cap on how many one call removes. If `purgeCapped` is `true`, some remain; call again until it is `false`. ### `DELETE /api/apps/{id}/rooms/{roomId}/messages/{messageId}` Turns one message into a tombstone. 204, and idempotent. The row stays and `seq` does not move. ### `DELETE /api/apps/{id}/users/{userId}?purge=true`, `DELETE /api/apps/{id}/rooms/{roomId}?purge=true` Queues a job that **permanently** deletes a withdrawn user's row, or a room and its messages. Returns 202 `{ "job": PurgeJob }` (same shape as an app's permanent deletion). Without `?purge=true` it is a 400. It cannot be undone. Source: https://chat.gravex.app/en/docs/console-api --- # @kispi/chat Client SDK for the chat server. Many rooms over one socket, history in the right order. **Zero runtime dependencies**, both ESM and CJS, type declarations included. There are two entry points. `@kispi/chat` is the client for the browser and Node; `@kispi/chat/server` is for the consumer backend that holds the `sk_`. ```sh npm install @kispi/chat ``` This document covers **the entire surface**. Anything not here is internal and changes without notice. --- ## `@kispi/chat` — client ### `createChatClient(options): ChatClient` | Option | Type | | |---|---|---| | `key` | `string` | **Required.** A `pk_`. It is public, so it is fine to put in the browser | | `token` | `() => string \| Promise` | **Required.** A user token signed by your backend. Called again on every connect and whenever REST gets a 401, so an expired one is never reused. A throw is retried with backoff; **throwing a `ChatError` means do not retry** (see below) | | `WebSocket` | `new (url) => WebSocketLike` | Implementation to use instead of the global | | `fetch` | `typeof fetch` | Same as above | Do not create `token` in the browser. Signing requires the `sk_`, and if the `sk_` is in the browser, the whole app is open. ### `ChatClient` | Property | | |---|---| | `state` | `'connecting' \| 'open' \| 'reconnecting' \| 'closed'` | | `user` | The connected user. `{id, name, avatar?}` — **the identity the server accepted from the token** (what `hello` carried). Use this instead of parsing your token response. Set once `connect()`/`reconnect()` resolves | | `connectionId` | Identifier used for support requests and forced disconnects | | `hello` | Everything the server sent on connect: `limits` (effective), `unread` (`{total, mentions, roomsCapped?}` — **absent is not zero, it means the count could not be read**, so recover with `unread()`), `warnings`, `serverTime`, `heartbeatMs` | | Method | | |---|---| | `connect()` | Connects. If already connected or connecting, it joins that attempt. Rejects on failure, but **retries continue in the background** (see below) | | `close()` | Disconnects. This one does not reconnect. Call `connect()` to come back | | `reconnect()` | **Re-authenticates**: calls `token()` again and replaces the socket. Room handles, subscriptions and `messages` are kept (**when the user changes, `messages` is dropped and refilled**), and the state goes `open → reconnecting → open` (never `closed`). Use it after a nickname or avatar change, and **when logging in or out switches the user** (below) | | `room(roomId)` | Room handle. Same id, same object | | `roomByKey(key)` | Handle for a room opened by key. The server tells you the id when you subscribe. **Does not create the room** — for a key that doesn't exist, `subscribe()` rejects with `not_found`. Rooms are created by the backend with `rooms.ensure()` | | `rooms.list({cursor?, limit?})` | Rooms I'm a member of + unread + last message | | `rooms.discover({type?, cursor?, limit?})` | Browse public rooms | | `rooms.members(roomId).list/add/remove` | Member management | | `unread()` | `{total, mentions, roomsCapped?, rooms}` — the **same field names** as `chat.hello?.unread` | | `updateMe(meta)` | My `meta`. **`name` and `avatar` come from the token claims** | | Event | | |---|---| | `state` | The four values above | | `error` | The client stopped at `closed` on its own, and why (a `ChatError`): an authentication refusal, or a `ChatError` from `token()`. Not emitted for `close()` | | `notification` | A new message arrived in a room I'm a member of, but I'm not viewing that room | | `user.presence` | Presence changes of my other connections | | `frame` | Every received frame. For debugging | `on` returns an unsubscribe function. ```ts const off = chat.on('state', s => setStatus(s)) off() ``` ### `Room` | Property | | |---|---| | `messages` | Ascending by seq. **The SDK handles ordering and gap filling.** A new array on every change. **Thread replies are included** (rows with a `threadId`) — to draw the main timeline only, keep the rows without one | | `id` / `key` | Room identifiers. If opened by key, `id` is absent until you subscribe | | `lastSeq` | The room's last seq as reported by the server | | `presence` | `{count, users?, capped?}` — who is in this room now. **The SDK folds** the subscribe ack and the increments of `presence` events into it. `users` is there **only for rooms of 100 or fewer**; larger, or unknown, it is absent — use `presenceList()` then | | `hasOlder` | Whether `loadOlder()` has more past to read. Lets you decide whether to draw "load earlier" before the first `loadOlder()`. The same test as `loadOlder()`'s `hasMore`; `false` while the list is empty. Re-read it on `messages` | | Method | | |---|---| | `subscribe()` | Turns on the live feed and reads the latest 100. Does nothing if already caught up. **Fine to call before connecting** — it waits until connected (rejects with `closed` on `close()`) | | `unsubscribe()` | Stops viewing this room. It is not resubscribed on reconnect | | `send({text, attachments?, entities?, replyTo?, threadId?, meta?}, {clientMessageId?})` | Publish. Acked with `{messageId, seq}`. If a subscription is in progress, it waits for it. **While reconnecting it waits for the connection, then sends** (below) | | `loadOlder({limit?})` | Reads the page before the oldest message (default 100) and **prepends it to `messages`.** Returns `{messages, hasMore}`. Use it when scrolling up. If the list is empty (before subscribing), it reads nothing | | `history({before?, after?, limit?, view?})` | Reads the past directly. **Independent of `messages`.** `view` is `'main'`, `'all'`, or `{thread: id}` | | `reload()` | Re-reads the latest page | | `react(messageId, emoji)` / `unreact(...)` | Reactions. Idempotent. Updates that row's `myReactions` **before the frame goes out**, and rolls it back if the server refuses. The count is not moved optimistically; **it is set to the server's value when the `reaction.*` event arrives** (below) | | `reactionsOf(messageId, {emoji?, cursor?, limit?})` | Who reacted. **Each row is a (user, emoji) pair**, so one person can appear on several rows. `limit` defaults to 50, max 100; the last page's `cursor` is an empty string | | `markRead(seq, {threadId?})` | Read cursor | | `reads({userId?, cursor?, limit?})` | Read cursors, paged. `{reads: [{userId?, seq, threadId?, withdrawn?}], cursor}`. **`userId` narrows to one person** -- `reads({userId: chat.user.id})` recovers your own per-thread cursors. Stop when `cursor` is `''` | | `typing({threadId?})` | Typing indicator. Does not throw. **Call it on every input event** — the SDK sends at most once per second per room. Pass `threadId` while writing a thread reply | | `join()` / `leave()` | Membership | | `presenceList()` | Who is in this room right now | Event names are **exactly the wire names**, and the payloads are in the type declarations. | Event | Payload | |---|---| | `message.created` / `message.updated` | `Message`. On `created`, `thread` is absent, `reactions` is empty, and **the SDK fills `myReactions` with `[]`** (the wire carries none) | | `message.deleted` | `{id, seq}`. `seq` is the one the message got **when it was published** | | `reaction.added` / `reaction.removed` | `{messageId, emoji, count, userId?}`. The SDK has already updated that row's `reactions` count in `messages` (and `myReactions` if it was yours). **Rows outside the list**, such as ones read with `history()`, are the consumer's to update | | `thread.updated` | `{rootId, count, lastSeq?, lastAt?}`. The SDK has already updated **the root row's `thread`** (`count`, `lastSeq`, `lastAt`) in `messages`. A root outside the list is the consumer's to update | | `presence` | `{count, joined?, left?, users?, capped?}`. `joined`/`left` are incremental, `users` replaces | | `typing` | `{userId, threadId?}` -- `threadId` is the thread **root** id, absent for the main timeline | | `read` | `{userId, seq, threadId?}` | | `member.joined` / `member.left` | `{userId, role?}` | | `room.updated` | The room object (`unknown` — the server's own representation) | | `room.deleted` | `{roomId}` | | `custom` | Exactly the object `rooms.custom(roomId, payload)` sent | The SDK emits three more that have no wire counterpart. | Event | When | |---|---| | `messages` | Every time the list changes. **A new array each time; an emitted array is never touched again** | | `error` | Reading history or filling a gap failed. **The room resubscribes itself.** The retry button for a person is `subscribe()` | | `reset` | The list was discarded and refilled — the cursor was older than the retention period, or **the client reconnected as a different user** (below). **You must discard what you rendered** | ### Things to know **`messages` is a new array every time, and only changed rows are new objects.** Put it straight into state that compares by reference. ```ts // Svelte 5 let messages = $state.raw([]) room.on('messages', m => (messages = m)) // React const [messages, setMessages] = useState([]) useEffect(() => room.on('messages', setMessages), [room]) // Vue const messages = shallowRef([]) room.on('messages', m => (messages.value = m)) ``` - **Don't ignore `reset`.** If you keep appending to the old list, you're left with a gap that nobody fills. - **Attach the past to the same list with `loadOlder()`.** `history()` is a direct read that doesn't touch the list, so merging and deduplicating fall to you. - **Draw reaction chips from `reactions` and `myReactions` together.** `react()` moves `myReactions` first and waits for `reaction.added` for the count, so an emoji you are the first to add sits briefly in `myReactions` only. Draw that chip as **the highlighted emoji alone** (no count) rather than inventing a `1` — someone else may have pressed it in between. - **Each message has a cap on distinct emoji** (`hello.limits.maxDistinctEmojiPerMessage`, 20 by default). A new emoji past it is refused with `invalid` and the SDK rolls `myReactions` back. Pressing an emoji already on the message is never capped. To disable a picker, check that `reactions.length` has reached the cap and the emoji is not among them. - **A thread panel merges two sources.** `messages` holds replies only for the range the SDK has read (it is the whole room's stream, so an old thread's replies are not in it). Read past replies with `history({view: {thread: rootId}})`, pick new ones from `messages` by `threadId === rootId`, and **merge by id.** Rows read with `history()` are outside the list, so **you apply** `reaction.added`/`removed` (the count, and `myReactions` when `userId` is yours), `message.updated` and `message.deleted` to them yourself — miss it and only the older replies' reactions look frozen. Read again on `reset`. - **`typing` is also delivered to the sender.** If you render "X is typing", skip your own `userId`. - **`typing` carries no name.** Look the name up in `room.presence.users` — whoever is watching the room is there. Collecting names from message senders misses anyone who has not spoken yet. If `users` is absent (a room over 100), fetch it once with `presenceList()`, and fall back to a placeholder if the name is still missing. - **`typing` arrives at most once per person per room every three seconds.** There is no "stopped" event, so keep the indicator for **five seconds or more** after the last one — even someone typing steadily can be broadcast more than four seconds apart, and anything shorter blinks between events. - **Each room announces only a few typists at a time** (three today). Every broadcast goes out once per person in the room, and a screen draws "A, B and C are typing" at most anyway. In a busy room some people typing are never announced — don't write code that depends on the number (the engine may change it). ```ts room.on('typing', ({ userId }) => { if (userId === chat.user?.id) return const name = room.presence?.users?.find(u => u.id === userId)?.name showTyping(userId, name ?? 'Someone') // clearing it after ~5 seconds is yours to do }) ``` - **The connection comes back on its own.** It reconnects with exponential backoff, so don't write your own retry loop. - **What you send while reconnecting waits, then goes out.** `send`, `react`, `unreact` and `markRead` skip the backoff when the state is `connecting`/`reconnecting`, wait for the connection, and send. If the socket drops before the ack, **the same frame is sent once more** — the server dedups on `clientMessageId`, and reactions and reads are idempotent, so nothing lands twice. Waiting plus the ack is capped at 15 seconds, then `timeout`. `closed` now comes only from a **stopped client** (`close()`, an auth refusal, or never `connect()`ed). `typing()` does not wait; it is dropped. - **The SDK handles token expiry.** When REST gets a 401 it calls `token()` again and retries once. There's no need to reconnect periodically. - **Throwing a `ChatError` from `token()` stops the client.** That is how you say a failure, like a ban, is not worth retrying. - **After a nickname or avatar change, `reconnect()`.** The server reads those values from the token claims once, when the socket authenticates. - **Logging in or out is `reconnect()` too.** When `token()` returns a different user the SDK notices: every room drops the list it held, reads recent history again as the new user and emits `reset` (the rows' `myReactions` were the previous user's). A send, reaction or read still waiting for the previous user is rejected with `closed` instead of going out under the new name, so send as the new user after `reconnect()` resolves. `close()` then `connect()` behaves the same. There is no need to create a new client. A room the new user cannot subscribe to (a private room only the previous user was in) emits `error` instead of `reset` and stays empty. **Call `reconnect()` whenever the login state changes** — the REST path that refreshes a token on a 401 does not check whether the user changed. ### `text` is untrusted plain text Message `text`, and user-supplied display names/nicknames (`name`), are **plain text the engine does not HTML-escape or sanitize.** This is by design — escaping depends on the output context you render into (an HTML text node, an attribute, some other format), which the engine cannot decide on your behalf. The engine only **validates** (size, shape). So consumers must render it as text — `textContent`, or your framework's text binding (React's `{text}`, Svelte's `{text}`, Vue's `{{ text }}`). **Never pass it straight into `innerHTML` or `v-html`.** If you add linkification or markup, don't build an HTML string with a regex replace — build DOM from text nodes instead. ```ts // Don't — builds an HTML string with a regex el.innerHTML = text.replace(urlRegex, '$1') // Do — builds DOM from text nodes for (const part of splitByUrl(text)) { el.appendChild(part.isUrl ? makeAnchor(part.text) : document.createTextNode(part.text)) } ``` Bad-word filtering and similar moderation is not escaping — it's a **tenant policy**. The engine only opens the mechanism (the `before_publish` hook); the consumer decides (see the `before_publish` section in the webhooks guide). ### Using it in the browser You **must add the page's origin** to the `pk_`'s allowed origins. That's the only thing — the server emits CORS headers itself, so there's no proxy to touch. Origins are edited on the key screen in the console. Scheme, host and port must match **exactly**. `https://example.com` and `https://www.example.com`, `http://localhost:3000` and `http://127.0.0.1:3000` are different origins. Add development origins separately. From an origin that isn't on the list it isn't just REST that fails — **the connection itself is a 401.** An authentication refusal is a failure the SDK doesn't retry, so the client stops at `closed`, and the reason arrives on `chat.on('error')`. **On Node (bots, tests) there is no origin to add.** The server accepts a request with **no** `Origin` header without an origin check — Node's global `WebSocket` and `fetch` don't send one, so they connect as is. If you set the header yourself, its value must be on the allowed list. The origin list only stops another site from using your `pk_` in a visitor's browser (a `pk_` is public). A user token is required everywhere. ### Errors Failures are `ChatError`. ```ts try { await room.send({ text }) } catch (err) { if (err instanceof ChatError && err.code === 'rate_limited') { retryAfter(err.retryAfterMs) } } ``` | Field | | |---|---| | `code` | A union of the known values (`ChatErrorCode`), so a typo fails to compile. From the server: `unauthorized`, `forbidden`, `not_found`, `invalid`, `too_large`, `rate_limited`, `banned`, `moderation_denied`, `cursor_too_old`, `quota_exceeded`, `internal`. From the SDK: `timeout`, `closed`. A code the server adds later still type-checks | | `retryAfterMs` | Wait time reported by the server when you hit a limit | | `appCode` | Consumer-side code attached by a `before_publish` webhook when rejecting | | `status` | HTTP status, if the error came from a REST call | `quota_exceeded` means the app's storage quota (`limits.maxStoredMessages`) is full. Only new messages are refused (403 over REST); edits, deletes and reactions still work. Deleting messages does not free space (deleted messages are still counted) -- it clears when the app operator raises the quota or purges rooms. Waiting does not clear it, so there is no `retryAfterMs` and the SDK does not resend -- show "storage is full" instead of retrying. --- ## `@kispi/chat/server` — backend Uses the `sk_`. **Never put it in the browser.** ```ts import { createChatServer } from '@kispi/chat/server' const chat = createChatServer({ secretKey: process.env.CHAT_SECRET_KEY!, // sk_… webhookSecret: process.env.CHAT_WEBHOOK_SECRET, guestSecret: process.env.CHAT_GUEST_SECRET, // only if you use guest() }) ``` ### `chat.token(input): string` A local signature that doesn't touch the network. | Field | | |---|---| | `userId` | **Required.** Becomes the JWT `sub`. Your service's user id | | `name` | **Required.** This value wins on every connect | | `avatar`, `meta` | Optional | | `ttlSeconds` | Default 1 hour, max 24 hours. Exceeding it is rejected, not truncated | ### `chat.guest({credential?, name, avatar?, meta?, ttlSeconds?})` Identity for a visitor who isn't logged in. Returns `{userId, credential, token, created}`. Uses neither the network nor storage. ```ts app.post('/api/chat-token', (req, res) => { if (req.user) return res.json({ token: chat.token({ userId: req.user.id, name: req.user.name }) }) const g = chat.guest({ credential: req.cookies.chat_guest, name: 'Guest' }) res.cookie('chat_guest', g.credential, { httpOnly: true, secure: true, sameSite: 'lax', maxAge: 400 * 864e5 }) res.json({ token: g.token }) }) ``` **Never sign a `userId` handed back by the browser as-is.** `guest()` has the browser hold a **credential** instead of the id — if it verifies, it signs with the id inside it; if it's missing or invalid, it signs a new guest (`created: true`). Keep it in an `httpOnly` cookie where you can. | | | |---|---| | `guestSecret` | At least 32 characters. Keep it on the backend only. **Don't derive it from the `sk_`** | | Rotation | Pass an array: `[new, old]`. Signs with the first and verifies with all. A credential verified with an old one is re-signed with the same `userId` and returned, so it migrates on the next visit | Guest ids start with `g_`. As long as logged-in users' ids don't start with `g_`, the two spaces don't overlap. ### The rest | Group | Methods | |---|---| | `rooms` | `list({type?, cursor?, limit?})`, `ensure({key?, type, name?, meta?, members?})`, `ensureDM([a, b])`, `get`, `update`, `delete`, `custom(roomId, payload)`, `presence(roomId, {full?})`, `members.list/add/remove` | | `messages` | `send(roomId, {sender, text?, kind?, attachments?, appMeta?, replyTo?, threadId?, clientMessageId?})`, `list`, `delete` | | `users` | `list({cursor?, limit?})`, `update(userId, {name?, avatar?, meta?})`, `withdraw(userId, {purgeMessages?})`, `purgeMessages(userId)`, `ban(userId, {until, reason?})`, `unban(userId)`, `revokeTokens(userId)`, `disconnect(userId, {connectionId?, reason?})` | | `events` | `list({after?, limit?})` — catches up on missed webhook events | | `webhooks` | `verify(headers, rawBody, {now?, tolerance?})` — `tolerance` is in milliseconds | `rooms.list` returns the app's rooms, newest first. With no `type` it is the public listing (`public` and `channel`); pass e.g. `type: ['private', 'dm']` for private rooms and DMs. `limit` defaults to 50 (max 100), an empty `cursor` is the last page, and **the cursor does not carry `type`** — start over when the filter changes. `rooms.ensure` is get-or-create — it returns the room as-is if it exists, so it's fine to call on every page load. `rooms.presence(roomId)` is `{count}`; with `{full: true}` it adds `users` (at most 1000, `capped: true` when cut). An empty room is `{count: 0}`, not a 404. `rooms.custom(roomId, payload)` is a **control signal that is not stored**. `payload` is a JSON object and arrives as-is at `room.on('custom', ...)`. It leaves no history, no unread and no webhook, so send a signal that must survive a reconnect with `messages.send(roomId, {kind:'system', ...})` instead. `users.ban`'s `until` is **unix milliseconds and required.** For an indefinite ban, pick a far-future time yourself. `users.purgeMessages(userId)` clears that user's messages **without withdrawing them.** It does a bounded amount per call, so call again while `purgeCapped` is `true`, and for spam **ban first**. ```ts await chat.users.ban(userId, { until: Date.now() + 7 * 86_400_000, reason: 'spam' }) while ((await chat.users.purgeMessages(userId)).purgeCapped) {} ``` `users.list` is newest first, `limit` defaults to 50 and maxes at 100, and `cursor` is an opaque string where an empty string marks the last page. **Withdrawn users are included too, and `deletedAt` marks them.** `users.*` works only **after the person has connected at least once** — users come into existence by connecting. ### Webhooks ```ts app.post('/chat-hook', express.raw({ type: 'application/json' }), (req, res) => { const { event, delivery, data } = chat.webhooks.verify(req.headers, req.body.toString('utf8')) if (seen.has(delivery)) return res.sendStatus(200) // it's at-least-once res.sendStatus(200) // acknowledge first void handle(event, data) // process asynchronously }) ``` **It must be the raw body.** Something parsed and re-serialized is a different string and fails verification. Keep three things. `verify` checks the signature and the timestamp (default 5 minutes), **deduplication is on you** (by `delivery`), and you must respond with a 2xx within 10 seconds. Going over counts as a failure and backoff begins. The signature spec for other languages is in the [webhooks guide](https://chat.gravex.app/docs/webhooks). Source: https://chat.gravex.app/en/docs/sdk