Getting started
Adding ordered chat to your service takes three things: an app created in the console, one backend endpoint that issues tokens, and the SDK in the browser.
npm install @kispi/chatThere are two entry points. The SDK has no runtime dependencies and includes type declarations.
| Entry point | Where | Key it holds |
|---|---|---|
@kispi/chat |
Browser (and Node) | pk_ |
@kispi/chat/server |
Your backend | sk_ |
1. Create an app in the console
Sign up, then click New app. Each app is a tenant — users, rooms, messages, and keys are all scoped to the app. As soon as you create it, you get two keys.
| Key | Where it lives | What it does |
|---|---|---|
pk_… (publishable) |
Frontend code | Identifies the app, nothing more. It's public |
sk_… (secret) |
Backend environment variable | Grants access to all of the app's data. Used to issue tokens and call the server API |
The sk_ is shown only once, when you create it. The console doesn't store it, so move it to your
secrets store right away. If you lose it, issue a new sk_ and revoke the old one
(Rotating keys).
Allowed origins
The allowed origins you enter when creating the app are the origins of pages allowed to use this pk_
in the browser.
- Scheme, host, and port must match exactly.
https://example.comandhttps://www.example.comare different origins. - So are
http://localhost:3000andhttp://127.0.0.1:3000. Add your development origins separately. - Using the key from a page that isn't on the list is refused with
unauthorized. Change the list with Edit origins on the Keys screen.
2. Issue tokens on your backend
The engine doesn't know who anyone is. It verifies the token's signature and trusts the user id inside it at face value. Your service already knows who's logged in, so all you need is an endpoint that issues a token for that person.
// src/routes/api/chat-token/+server.ts (SvelteKit) — the shape is the same in any framework
import { json } from '@sveltejs/kit'
import { createChatServer } from '@kispi/chat/server'
import { env } from '$env/dynamic/private'
const chat = createChatServer({
secretKey: env.CHAT_SECRET_KEY // sk_…
})
export const POST = async ({ locals }) => {
const user = locals.user // your service's session
if (!user) return new Response(null, { status: 401 })
return json({
token: chat.token({
userId: String(user.id), // this person's id
name: user.nickname,
avatar: user.avatarUrl, // optional
ttlSeconds: 3600 // optional. Defaults to 1 hour, max 24 hours
})
})
}# chat_token() lives on the "Server integration" page — this only calls it
@app.post("/api/chat-token")
def issue_chat_token():
user = current_user() # your service's session
if user is None:
return "", 401
return {"token": chat_token(SK, str(user.id), user.nickname, ttl=3600)}// chatToken() lives on the "Server integration" page — this only calls it
func issueChatToken(w http.ResponseWriter, r *http.Request) {
user, ok := currentUser(r) // your service's session
if !ok {
http.Error(w, "", http.StatusUnauthorized)
return
}
token, err := chatToken(sk, user.ID, user.Nickname, time.Hour)
if err != nil {
http.Error(w, "", http.StatusInternalServerError)
return
}
json.NewEncoder(w).Encode(map[string]string{"token": token})
}// chatToken() lives on the "Server integration" page — this only calls it
@PostMapping("/api/chat-token")
fun issueChatToken(session: HttpSession): Map<String, String> {
val user = session.user ?: throw ResponseStatusException(HttpStatus.UNAUTHORIZED)
return mapOf("token" to chatToken(sk, user.id.toString(), user.nickname))
}chat.token() signs locally without a network call, so it's fine to call it on every request — and the
same is true in every other language, since signing never touches the engine. name wins on every
connect. The token spec itself is in Server integration; for visitors who aren't
logged in, Guest users shows how the same endpoint issues them an identity.
3. Send and receive in the browser
// chat.ts
import { createChatClient } from '@kispi/chat'
export const chat = createChatClient({
key: import.meta.env.VITE_CHAT_PK, // pk_…
token: async () => {
const res = await fetch('/api/chat-token', { method: 'POST' })
return (await res.json()).token
}
})
chat.on('state', (s) => setStatus(s)) // connecting | open | reconnecting | closed
chat.connect().catch(() => {})Reconnecting and token refresh are the SDK's job. Your backend creates rooms — opening a room by key
doesn't create it, and subscribing to a key that doesn't exist gives you not_found. rooms.ensure is
get-or-create: it returns the room if it already exists.
// Backend — the createChatServer instance from step 2
await chat.rooms.ensure({ key: 'lobby', type: 'public' })Everything below is back in the browser, on the createChatClient from step 3.
const room = chat.roomByKey('lobby')
await room.subscribe() // live feed + the latest 100 messages
room.on('messages', (all) => render(all)) // the whole list, every time it changes
room.on('message.created', (m) => append(m)) // or one new message at a time
const ack = await room.send({ text: 'Hello' }) // { messageId, seq }
await room.send({ text: 'Check this out', attachments: [{ type: 'image', url, key }] })
await room.react(ack.messageId, '👍')
await room.markRead(room.lastSeq)
room.typing()
const { hasMore } = await room.loadOlder() // as the user scrolls up. false at the beginningroom.messages is always in ascending seq order, with no gaps. It's a new array every time it changes,
so drop it straight into state that compares by reference (Svelte $state.raw, React useState,
Vue shallowRef). The engine doesn't store files — upload them to your own storage (S3 or similar)
and put the address in attachments (url must start with https://).
Always handle these two events.
reset— The SDK discarded the list and refilled it. Throw away what you rendered and redraw fromroom.messages.error— Reading history failed. The room resubscribes itself, so just surface it.
| Persisted? | After reconnecting | |
|---|---|---|
| Messages | Yes | The SDK fills the gaps |
| Reactions | Yes | Come with history. For changes while disconnected, use reload() |
Read cursor (markRead) |
Yes | Reflected in the unread counts from rooms.list() |
| presence | No | Received fresh |
| typing | No | Fine to miss. Clears after 3 seconds |
typing is also sent back to the sender — if you render "X is typing," skip your own userId.
Failures throw a ChatError; if err.code is rate_limited, wait err.retryAfterMs before retrying.
4. Webhooks (optional)
Use these when your backend needs to hear about publishes, edits, and deletes — push notifications, search
indexing, audit logs. There's also before_publish, which steps in before a message is published to
reject it or rewrite its body. The receiving code and signature verification are in
Webhooks.
Next
- Server integration — token spec, server API, key rotation.
- SDK reference — every method and event.