Webhooks

Your backend receives your app's events (publishes, edits, deletes, reactions…) over HTTP. Push notifications, search indexing, audit logs, and syncing with external systems hook in here.

You configure your webhook URL in the console's webhook screen. On the "Webhook" tab next to your app's overview, enter the address to deliver to (and before_publish if you need it), and the screen shows you the signing secret once — the console does not store it, so copy it into your endpoint's verification code right away.

Receiving code

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

const app = express()
const seen = new Set<string>() // in production, keep this outside the process (Redis, a table)

const chat = createChatServer({
  secretKey: process.env.CHAT_SECRET_KEY!,
  webhookSecret: process.env.CHAT_WEBHOOK_SECRET // signing secret the console's webhook screen showed you
})

app.post('/chat-hook', express.raw({ type: 'application/json' }), (req, res) => {
  let payload
  try {
    payload = chat.webhooks.verify(req.headers, req.body.toString('utf8'))
  } catch {
    return res.sendStatus(400) // signature didn't match. Process nothing
  }
  const { event, delivery, data } = payload
  if (seen.has(delivery)) return res.sendStatus(200) // delivery is at-least-once
  seen.add(delivery)
  res.sendStatus(200) // respond within 10 seconds first
  void handle(event, data) // then process asynchronously
})
python
import hmac, hashlib, json, time

# raw_body is the bytes you received. Parsing and re-serializing breaks the signature.
# Returns the parsed body when the signature matches, or None (-> 400) when it doesn't.
def verify(headers, raw_body, secret):
    event = headers.get("X-Chat-Event", "")
    delivery = headers.get("X-Chat-Delivery", "")  # before_publish has no such header
    timestamp, macs = None, []
    for item in headers.get("X-Chat-Signature", "").split(","):
        name, _, value = item.strip().partition("=")
        if name == "t":
            timestamp = value
        elif name == "v1":
            macs.append(value)  # there can be several; one match is enough
    if timestamp is None or not macs:
        return None
    if abs(time.time() - int(timestamp)) > 300:  # 5 minutes both ways; future stamps are rejected too
        return None
    signed = f"{timestamp}\n{event}\n{delivery}\n".encode() + raw_body
    expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
    if not any(hmac.compare_digest(expected, mac) for mac in macs):
        return None
    return json.loads(raw_body)

Four rules

  1. Verify the raw body as-is. The signature covers the bytes you received, so a string you parsed as JSON and serialized again won't match. Make sure your framework's JSON parser doesn't consume the body first by reading this route raw (express.raw in Express, await request.text() in SvelteKit).
  2. Delivery is at-least-once. The same delivery can arrive twice. Every delivery has a delivery id that stays the same across retries — remember the ones you've processed and respond to repeats with just a 200.
  3. Respond with a 2xx within 10 seconds. Taking longer or answering with a non-2xx counts as a failure and starts a backoff (1s → 5s → 25s → 2m → 10m). On failure, that app's deliveries stop at that event and later ones aren't sent ahead of it. Put heavy work on a queue and respond first.
  4. Ordering is guaranteed only within a room. Events for a single message (message.created → reaction.added → message.deleted) never arrive out of order. Events from different rooms can.

Catching up on missed events

If your endpoint was down for a while, read past events with chat.events.list({ after, limit }). Each item is { delivery, event, createdAt, data }, and data is the same as the body the webhook POSTs. Pass the last delivery you processed as after; an empty cursor means that was everything. Dedupe with delivery here too — the same event can arrive through both the webhook and this list. Past events are kept for 7 days, so don't put off catching up for longer than that.

before_publish — intercepting before publish

When enabled, publishes and edits pass through your endpoint synchronously. It's the only webhook that can reject a message or return a rewritten body. Profanity filtering, image moderation, and spam blocking live here.

→ { "event": "before_publish", "op": "create" | "update", "messageId"?: "…",
    "room": "…", "sender": {…}, "kind": "text", "body": {…}, "meta"?: {…} }

← { "action": "allow" | "deny", "body"?: {…}, "appMeta"?: {…},
    "code"?: "…", "message"?: "…" }
  • You verify it the same way. But since it's a question, it has no delivery and isn't subject to deduplication.
  • On deny, the sender's send fails with moderation_denied; the code you return lands in the client's err.appCode, and message in the error message.
  • If you return a body, the message is stored and broadcast with that instead. The rewritten body goes through the size check again.
  • If action isn't allow or deny, it counts as a failure, so a typo can't become a moderation bypass.
  • If your endpoint times out or errors, the default is allow. You can change it to deny, but then if your endpoint goes down, all publishing for that app stops.
  • Messages sent through the server API (sk_) aren't screened.

Keep it fast. The sender's message waits until this call finishes, and the timeout is at most 5 seconds.

Verifying in another language

Three headers arrive with the request.

Header
X-Chat-Event The event name. For before_publish, that value
X-Chat-Delivery The delivery id. before_publish has no such header
X-Chat-Signature t=<unix seconds>,v1=<hex>. There can be several v1 values, and one match is enough

The signing input is four pieces joined with newlines (\n).

<t>
<X-Chat-Event>
<X-Chat-Delivery, or an empty string>
<the raw body bytes you received>

v1 is hex(HMAC-SHA256(webhook secret, signing input)). The secret is not your sk_: it's the separate webhook secret shown once on the console's webhook screen. Reject the delivery if the absolute value of now - t is more than 5 minutes (both directions — a future timestamp is rejected too). Retries refresh t but keep X-Chat-Delivery, so dedupe on that value.