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 (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 <JWT> 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 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).
  • 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

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:<root message id> 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 seqs 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

  1. 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.
  • 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.