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.
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 })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)}"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))
}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?}) |
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= |
# 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.
- In the console's Keys screen, press New secret key. The full value appears only in that dialog.
- Replace the
sk_on your backend and deploy. Both keys are live at this point. - Check that a token signed with the new key connects, and that your server API calls go through.
- 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.