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/jsonon 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 ofBearerdoesn't matter. - Keep the
sk_on the server. Never put it in an app or a browser. - A browser request's
Originmust be in thepk_'s allowed origins (scheme, host and port must match exactly). Otherwise the answer is 401 with the reason in the message. Requests with noOriginheader (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 403forbidden. - 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.
{ "error": { "code": "rate_limited", "message": "rate limit exceeded", "retryAfterMs": 800 } }- Branch on
code.messageis an English sentence for people and may change. retryAfterMsappears only onrate_limited.appCodeappears only onmoderation_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. TheRetry-Afterheader is in seconds and the body'sretryAfterMsis 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'slimits(real-time protocol). - Size limits: message text 4000 characters, 10 attachments, 100
entities,appMeta4 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
/v1response, error responses included, carries CORS headers.Access-Control-Allow-Originechoes the request'sOrigin. - A preflight (
OPTIONS) answers 204. Allowed methods areGET, POST, PUT, PATCH, DELETE, OPTIONSand allowed headers areAuthorization, Content-Type, X-Chat-Key. Retry-Afteris 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
{
"id": "0AAS4P1MM0000", "appId": "0AAS4P1HW0000", "key": "general", "type": "public",
"name": "General", "meta": {}, "lastSeq": 12, "minSeq": 1,
"createdAt": 1792861844890, "updatedAt": 1792861844949
}typeispublic,private,channelordm. Admhas nokey.nameis present only if it was set.lastSeqis theseqof the last message published in the room.minSeqis the oldestseqhistory can still be read from (anafterfar older than that is 410).- A deleted room carries
deletedAt, but the lookup routes answer 404 for a deleted room.
Message
{
"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 emptybody, and emptyreactionsandmyReactions. Itsseqis unchanged. - This object has the same shape as the WebSocket's
message.createdandmessage.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}.
{ "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
nameandmetaare not changed (use 6 to change them). public,privateandchannelare found bykey; admis found by the pair of people.PUT /v1/rooms/{key}is the same call with thekeyin the path. If the body'skeyand the path'skeydiffer it is 400. New code usesPUT /v1/rooms.- A user token can create a room only if the app has client room creation turned on (otherwise 403). A
dmmust include the caller. A room made with a user token has its creator as owner. - A
channelcreated with a server key has no owner. Use 10 to name an owner or an admin who can write.
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
typeyou getpublicandchannel. A user token may ask only forpublicandchannel(browsing public rooms). A server key may also ask forprivateanddm. 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
keyalready in use is 409. Giving akeyto admis 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 itskeyis freed so you can create a room with the samekeyagain. An already deleted room is 404. A user token must be the owner. ?purge=truequeues 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
purgethat is nottrueorfalseis 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:
{ "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.joinedis 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/meis 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/mealways 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),mainis the main timeline only, andthread:<root message id>is that thread only. Any other value is 400.mainandthread:are for display. Don't use them as a sync position.q(server key only) searchesbody.textfor a substring, ignoring case, and never matches deleted messages. A user token that passesqgets 403, and aqthat is only whitespace is 400. Theseqs in search results are not contiguous (messages that didn't match are simply missing).- If
afteris older than the room's retention floor the answer is 410cursor_too_old. You can't attach to it; start again from recent history. - Everything a page needs (
sendernames,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}.
clientMessageIdis the idempotency key. If the same sender sends the same value again (within 24 hours), no new message is made and you get themessageIdandseqof 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.threadIdmeans "this is a thread reply". If you give a reply's id, it is rewritten to that thread's root.replyTois 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 ifuserIdis a real user. An unknown id is left out, not rejected. - If the app has a
before_publishhook, a user token's publish goes through it and can get 403moderation_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 403quota_exceeded.
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
- This is a tombstone: the row stays and its body is emptied (
sequnchanged). The room getsmessage.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 akind:"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}).
nameandavatarare 400. A name and avatar come from the token your backend signs, or are changed with 25.- A top-level
meta.provideris 400; that slot is vouched for by your backend. - If the
metaafter 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:
{
"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": ""
}unreadis counted by the server, notlastSeq - readSeq(main-timeline messages only, my own and deleted ones excluded). It is cut off at 1000 andunreadCapped: truesays so.readSeqis the read position on the main timeline.lastMessageis 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.
nameis at most 256 bytes andavatarat most 2048 bytes (over that is 400 and nothing is written).metais 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
nameandavataroverwrite 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_messagesis 200{"userId": "…", "anonymized": true, "purgedMessages": 40, "scrubbedSenders": 2, "purgeCapped": false}.anonymizedsays whether this call did the anonymizing. purgeCapped: truemeans it couldn't finish in one call. Repeat the same call until it isfalse.- 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=trueanswers 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.idstill holds the same string, so never reuse an old id for a new person.- If either query value is not
trueorfalse, 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?}.untilis 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:
{ "events": [{ "delivery": "0AAS4P1QQ0000", "event": "message.created", "createdAt": 1792861844902, "data": { } }], "cursor": "" }delivery,eventanddatamatch the webhook's headers and body. Receiving and verifying webhooks is in Webhooks.- An
afterthat can't be parsed is 400.cursorappears 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 bydelivery. - 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.
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 everyuser, then for each roomroom,member,message(ascending seq),reaction,read, and finallyend(withcounts). - A file whose last line is not
endis 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
publicorchannelroom 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.