Server integration

Your backend has two jobs: sign user tokens, and manage rooms, messages and users over REST. TypeScript has @kispi/chat/server, which wraps both; every other language does the same work directly. Pick your language in the tabs below.

Both jobs use a single sk_.

User token spec

Format A standard JWT. The signing algorithm is fixed to HS256; any other algorithm and none are refused
Signing key The entire sk_ string as UTF-8 bytes. Don't strip the prefix or decode it
Header {"alg":"HS256","typ":"JWT","kid":"…"} — kid is the second segment of sk_<key id>_<token> and is optional

Claims:

Claim
sub required The user id in your service. At most 128 UTF-8 bytes. Refused if it starts with anon:
iat required Issued at, in whole seconds. Refused if later than now + 60s. Don't round it or cache it
exp required Expiry, in whole seconds. If exp - iat exceeds 86400 (24 hours) it is refused, not clamped
name recommended Wins on every connect. Truncated to 256 bytes when stored
avatar optional Truncated to 2048 bytes when stored
meta optional A JSON object. Merged into the user's meta.provider

aud, iss and nbf are not required. Which app you are is decided by the pk_ the browser sends on connect, not by the token. When verification fails the answer is a single 401 unauthorized whatever the reason — it won't tell you whether the signature was wrong or the token expired.

Go uses github.com/golang-jwt/jwt/v5 and Kotlin uses com.auth0:java-jwt. The rest needs nothing beyond the standard library.

ts
import { createChatServer } from '@kispi/chat/server'

const chat = createChatServer({ secretKey: process.env.CHAT_SECRET_KEY! })
const token = chat.token({ userId: 'u_42', name: 'Hanna', ttlSeconds: 3600 })
python
import base64, hmac, hashlib, json, time

def b64u(b): return base64.urlsafe_b64encode(b).rstrip(b"=").decode()

def chat_token(sk, user_id, name, ttl=3600, avatar=None, meta=None):
    parts = sk.split("_")
    header = {"alg": "HS256", "typ": "JWT"}
    if len(parts) >= 3: header["kid"] = parts[1]
    iat = int(time.time())
    claims = {"sub": user_id, "name": name, "iat": iat, "exp": iat + ttl}
    if avatar: claims["avatar"] = avatar
    if meta: claims["meta"] = meta
    enc = lambda o: b64u(json.dumps(o, separators=(",", ":")).encode())
    signing_input = f"{enc(header)}.{enc(claims)}"
    sig = hmac.new(sk.encode(), signing_input.encode(), hashlib.sha256).digest()
    return f"{signing_input}.{b64u(sig)}"
go
func chatToken(sk, userID, name string, ttl time.Duration) (string, error) {
	now := time.Now()
	token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
		"sub":  userID,
		"name": name,
		"iat":  now.Unix(),
		"exp":  now.Add(ttl).Unix(),
	})
	if parts := strings.Split(sk, "_"); len(parts) >= 3 {
		token.Header["kid"] = parts[1]
	}
	return token.SignedString([]byte(sk))
}
kotlin
fun chatToken(sk: String, userId: String, name: String, ttlSeconds: Long = 3600): String {
    val now = Instant.now()
    val builder = JWT.create()
        .withSubject(userId)
        .withClaim("name", name)
        .withIssuedAt(now)
        .withExpiresAt(now.plusSeconds(ttlSeconds))
    sk.split("_").let { if (it.size >= 3) builder.withKeyId(it[1]) }
    return builder.sign(Algorithm.HMAC256(sk))
}

Server API

There is one way in: REST. The TypeScript SDK wraps it, so the three tabs below are three ways of calling the same endpoints.

The whole surface of @kispi/chat/server.

Group Methods
rooms ensure({key?, type, name?, meta?, members?}), ensureDM([a, b]), get, update, delete, custom(roomId, payload), presence(roomId, {full?}), members.list/add/remove
messages send(roomId, {sender, text?, kind?, attachments?, appMeta?, replyTo?, threadId?, clientMessageId?}), list, delete
users list({cursor?, limit?}), update(userId, {name?, avatar?, meta?}), withdraw(userId, {purgeMessages?}), purgeMessages(userId), ban(userId, {until, reason?}), unban(userId), revokeTokens(userId), disconnect(userId, {connectionId?, reason?})
events list({after?, limit?}) — catch up on missed webhooks
webhooks verify(headers, rawBody, {now?, tolerance?})
ts
const chat = createChatServer({ secretKey: process.env.CHAT_SECRET_KEY! })
const room = await chat.rooms.ensure({ key: 'lobby', type: 'public' })
await chat.messages.send(room.id, {
  sender: { id: 'system', name: 'Notice' },
  text: 'Maintenance in 10 minutes'
})

The address is https://api.chat.gravex.app. Authenticate with Authorization: Bearer sk_…, and send Content-Type: application/json when there's a body.

Purpose Route
Room get-or-create PUT /v1/rooms {key?, type, name?, meta?, members?} — 201 created, 200 existing
DM PUT /v1/rooms {type: "dm", members: [a, b]}
Get, update, delete a room GET, PATCH, DELETE /v1/rooms/{roomId}, GET /v1/rooms/by-key/{key}
Send a message POST /v1/rooms/{roomId}/messages {clientMessageId, sender: {id, name, avatar?}, kind?, body: {text?, attachments?}, appMeta?, replyTo?, threadId?}
History GET /v1/rooms/{roomId}/messages?before=&after=&limit=
Delete a message DELETE /v1/rooms/{roomId}/messages/{messageId}
Control signal (not stored) POST /v1/rooms/{roomId}/custom {payload}
Members GET /v1/rooms/{roomId}/members, PUT, DELETE /v1/rooms/{roomId}/members/{userId} {role?}
presence GET /v1/rooms/{roomId}/presence?full=true — an empty room is {count: 0}
User profile PUT /v1/users/{userId} {name?, avatar?, meta?}
Ban and unban PUT /v1/users/{userId}/ban {until, reason?} (until is unix milliseconds and required), DELETE …/ban
Take down their messages DELETE /v1/users/{userId}/messages
Delete the account DELETE /v1/users/{userId}?purge_messages=true
Invalidate tokens POST /v1/users/{userId}/revoke-tokens
Force disconnect POST /v1/users/{userId}/disconnect
List users GET /v1/users?cursor=&limit=
Missed events GET /v1/events?after=&limit=
bash
# Room get-or-create: 200 if it existed, 201 if it was created, same body either way
curl -X PUT https://api.chat.gravex.app/v1/rooms \
  -H "Authorization: Bearer $CHAT_SECRET_KEY" \
  -H "Content-Type: application/json" \
  -d '{"key": "lobby", "type": "public"}'

# Send a system message. sender and clientMessageId are required
curl -X POST https://api.chat.gravex.app/v1/rooms/$ROOM_ID/messages \
  -H "Authorization: Bearer $CHAT_SECRET_KEY" \
  -H "Content-Type: application/json" \
  -d '{"clientMessageId": "c_1",
       "sender": {"id": "system", "name": "Notice"},
       "body": {"text": "Maintenance in 10 minutes"}}'

sender and clientMessageId are required when you send a message from the server. Query parameters are snake_case (purge_messages); JSON fields are camelCase.

users.* works only after that person has connected at least once; users come into being by connecting. Taking down everything someone sent (DELETE /v1/users/{userId}/messages) is not the same as deleting the account, and it removes up to a cap per call — call it again while purgeCapped is true.

The type you pick when creating a room:

Type Who can read Who can write How to find it
public Anyone Anyone Key or id. Shows up in discovery
private Members only Members only Key or id
channel Anyone owner and admin only Key or id. Shows up in discovery
dm The two participants only The two participants only The pair of users. Has no key

A room's canonical identifier is its id. key is a human-assigned alias (lobby, order-1234) that the browser opens with chat.roomByKey(key). A channel created from the server has no owner, so if someone needs to post announcements with a user token, assign an owner or admin with the members API afterwards.

Rotating keys

Rotate an sk_ in this order: issue new → deploy backend → verify → revoke old.

  1. In the console's Keys screen, press New secret key. The full value appears only in that dialog.
  2. Replace the sk_ on your backend and deploy. Both keys are live at this point.
  3. Check that a token signed with the new key connects, and that your server API calls go through.
  4. Revoke the old key in the console.

Do not revoke first. Turning off the old key stops the backend that signs with it, right then.

After you deploy, browsers still hold tokens signed with the old key. The engine checks the key that a token's kid points at, so those tokens keep working as long as the old key isn't revoked. Once one token lifetime has passed (one hour by default), everything in circulation has been re-signed with the new key — revoke then and nobody is dropped. Revoking sooner is safe too: an old token is replaced on the next call.

Rotating a pk_ has the same shape, plus one thing: allowed origins live on the key, not on the app. A pk_ with an empty list admits no browser page, so a new key has to carry the origins the current one has. If only the origins change, don't rotate the key — edit the list on the same key with Edit origins on the Keys screen.

Revocation takes effect immediately and cannot be undone. Losing a key doesn't lose data: users, rooms and messages belong to the app.

Separate production and development with apps, not keys. The app is the tenant, so the app is the data boundary. Create two apps and label them Production and Development in the console.