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 415unsupported_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 and Server integration. 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.
- Sign up in the console with the 6-digit code sent to your email.
- In the top menu choose API keys → New key → give it a name (for example
laptop .env). - 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.
# .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
.envis in.gitignore. - Do not paste it into an AI chat; conversations are kept as records. Tell the AI "read
CHAT_ACCOUNT_KEYfrom.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_andsk_, 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 theCHAT_ACCOUNT_KEYline 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.
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; waitRetry-Afterseconds 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.
{ "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.
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.
{
"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 newsk_with "Issue a key" below and revoke the old one.
# .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 and
Server integration; for the server calls that handle rooms, messages and users see the
REST API reference; for live connections see the Real-time protocol.
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
{ "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}
{
"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.
{ "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.
POSTqueues the job and returns 202{ "job": PurgeJob }. It cannot be undone.GETreturns this app's jobs, newest first, as{ "jobs": [PurgeJob] }.
{ "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
{ "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 |
{ "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.
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.
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}'{ "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.limitsof thePOST /api/appsresponse. - Rate and size fields (
groupisrateorsize) 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 400invalid_request, with the field infield. - Going above the ceiling is a 400
limit_above_ceiling; below the minimum is a 400limit_below_min.limitcarries 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 and the Real-time protocol.
Reading data
Users, rooms and messages are read-only. To create or change them, call REST with sk_.
GET /api/apps/{id}/users
{ "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
{ "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
{ "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=<seq> is one page older than that; ?after=<seq> 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.
curl https://console.chat.gravex.app/api/apps/$APP_ID/export \
-H "Authorization: Bearer $CHAT_ACCOUNT_KEY" -o export.ndjsonModeration 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.
{ "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.