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

  1. Sign up in the console 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 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

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.

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

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

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.