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.

sh
npm install @kispi/chat

There 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.com and https://www.example.com are different origins.
  • So are http://localhost:3000 and http://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.

ts
// 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
    })
  })
}
python
# 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)}
go
// 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})
}
kotlin
// 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

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

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

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

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