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_publishif 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
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
})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
- 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.rawin Express,await request.text()in SvelteKit). - Delivery is at-least-once. The same delivery can arrive twice. Every delivery has a
deliveryid that stays the same across retries — remember the ones you've processed and respond to repeats with just a 200. - 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.
- 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
deliveryand isn't subject to deduplication. - On
deny, the sender'ssendfails withmoderation_denied; thecodeyou return lands in the client'serr.appCode, andmessagein 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
actionisn'tallowordeny, 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 todeny, 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.