Outbound webhooks
A webhook POSTs workspace events to a URL you choose. It is the opposite direction from bots: bots put things into Slick, webhooks let other systems react to it.
Creating one
Section titled “Creating one”Admin only. The secret is returned once and never again.
curl -X POST https://chat.example.com/api/workspaces/$WS/webhooks \ -H "authorization: Bearer $TOKEN" -H 'content-type: application/json' \ -d '{"url":"https://example.com/hook","events":["message.created"]}'# => { "id": "01J…", "url": "…", "secret": "whsec_…" }Subscribing means from now on. A new webhook starts at the current end of the event log, so configuring one does not open by replaying a week of history at your receiver.
The payload
Section titled “The payload”Deliberately fat. Most webhook integrations are miserable because a bare event forces the receiver to authenticate and re-fetch everything before it can decide whether it cares.
{ "id": "01J…-4821", "seq": 4821, "type": "message.created", "workspace": { "id": "01J…", "slug": "acme", "name": "Acme" }, "channel": { "id": "01J…", "name": "eng", "type": "public", "topic": "builds and breakages", "encrypted": false }, "actor": { "id": "01J…", "displayName": "Dana" }, "data": { "type": "message.created", "message": { "id": "01J…", "content": "shipping the migration", "…": "…" }, "threadRoot": { "id": "01J…", "content": "the original question" } }, "occurredAt": 1785600000000}The author arrives as a name, the channel with its topic, and a reply carries the message it is replying to.
Verifying a delivery
Section titled “Verifying a delivery”Every request is signed with HMAC-SHA256 over the exact bytes sent:
x-slick-signature: sha256=<hex>x-slick-event: message.createdx-slick-delivery: 01J…-4821Compare in constant time, against the raw body before any JSON parsing:
import { createHmac, timingSafeEqual } from "node:crypto";
function verify(rawBody: string, header: string, secret: string): boolean { const expected = Buffer.from(`sha256=${createHmac("sha256", secret).update(rawBody).digest("hex")}`); const provided = Buffer.from(header); return expected.length === provided.length && timingSafeEqual(expected, provided);}What is never sent
Section titled “What is never sent”Private channels are opt-in, off by default, via includePrivate: true.
Encrypted channels send no content. The server holds only ciphertext, so
the delivery says an encrypted message happened, with content: null and
encrypted: true, and stops there.
Plain http:// is refused except on localhost. Deliveries are signed but
not encrypted in transit, and the payload carries message text.
Delivery and failure
Section titled “Delivery and failure”Delivery is a cursor over the same durable event log clients resume from, not
a separate queue. A restart mid-delivery costs a repeat, not a loss, so treat
deliveries as at least once and make your receiver idempotent. The
id field is stable per event and per webhook, which is what to deduplicate
on.
A failed delivery is retried and the cursor stays put, so events after it are not skipped. After 20 consecutive failures the webhook is disabled and the last error recorded, because retrying a receiver that has been gone for a day is how you get rate-limited by someone else’s infrastructure.
# after fixing the receivercurl -X POST .../webhooks/$ID/enable -H "authorization: Bearer $TOKEN"Re-enabling resumes from now, not from where it stopped: a receiver that has been down for a day does not want the day.
Events
Section titled “Events”message.created · message.updated · message.deleted ·
channel.created · channel.updated · channel.archived ·
member.joined · member.left · workspace_member.added ·
workspace_member.removed