Developer docs
Outgoing webhooks
Send signed events from a hub or a whole organization to your own service: the event catalog, payload schemas, signature checks in JavaScript, Python and Go, retries and redelivery.
An outgoing webhook sends a signed HTTPS request to your service when something happens. Hubs and organizations use the same system, the same signatures and the same retry rules.
| Scope | Where it is managed | Who | Events |
|---|---|---|---|
| Hub | Manage, Developer tools | The hub owner | Messages posted, edited and deleted in 1 to 20 chosen channels |
| Organization | Organization admin, Integrations | The owner and managers | Every event in the catalog below, across every hub of the organization or the ones you pick |
Adding a webhook
- Choose a name, a public HTTPS address, the events and, for an organization, which hubs. Adding one asks for your password and second factor, because events leave Hubs.
- The signing secret (
whsec_...) is shown once. Hubs keeps the address and the secret sealed and shows only the host and the secret's last four characters. - Addresses must be public HTTPS on port 443, with no user name or password and no literal IP address. Redirects are never followed and each request has 10 seconds.
What a webhook sees
A webhook sees only what the person who made it can see, checked when the event happens and again right before every attempt. Direct messages are never sent. Automated and imported posts are left out, so an incoming webhook's post never loops back out. Message text is read when the event is sent, so an edit or delete before then cancels the stale event.
Event catalog
Organization webhooks send every event in an envelope, version 1. Each event's data follows the schema below.
message.createdMessage posted
Someone posts in a channel. Includes the text and the author.
Sent to organization and hub webhooks.
data
| Field | Type | Rules |
|---|---|---|
messagerequired | object | |
message.idrequired | string | 1 to 200 characters |
message.contentrequired | string | |
message.createdAtrequired | timestamp | |
message.editedAtrequired | timestamp or null | |
message.authorrequired | object | |
message.author.idrequired | string | 1 to 200 characters |
message.author.handlerequired | string | |
message.author.displayNamerequired | string | |
message.author.botrequired | boolean |
message.editedMessage edited
Someone changes a message. Includes the new text.
Sent to organization and hub webhooks.
data
| Field | Type | Rules |
|---|---|---|
messagerequired | object | |
message.idrequired | string | 1 to 200 characters |
message.contentrequired | string | |
message.createdAtrequired | timestamp | |
message.editedAtrequired | timestamp or null | |
message.authorrequired | object | |
message.author.idrequired | string | 1 to 200 characters |
message.author.handlerequired | string | |
message.author.displayNamerequired | string | |
message.author.botrequired | boolean |
message.deletedMessage deleted
A message is deleted. Only ids, never the text.
Sent to organization and hub webhooks.
data
| Field | Type | Rules |
|---|---|---|
messagerequired | object | |
message.idrequired | string | 1 to 200 characters |
reaction.addedReaction added
Someone reacts to a message.
Sent to organization webhooks.
data
| Field | Type | Rules |
|---|---|---|
reactionrequired | object | |
reaction.messageIdrequired | string | 1 to 200 characters |
reaction.emojirequired | string | at least 1 character |
reaction.userrequired | object | |
reaction.user.idrequired | string | 1 to 200 characters |
reaction.user.handlerequired | string | |
reaction.user.displayNamerequired | string | |
reaction.user.botrequired | boolean |
reaction.removedReaction removed
Someone takes a reaction back.
Sent to organization webhooks.
data
| Field | Type | Rules |
|---|---|---|
reactionrequired | object | |
reaction.messageIdrequired | string | 1 to 200 characters |
reaction.emojirequired | string | at least 1 character |
reaction.userrequired | object | |
reaction.user.idrequired | string | 1 to 200 characters |
reaction.user.handlerequired | string | |
reaction.user.displayNamerequired | string | |
reaction.user.botrequired | boolean |
member.joinedMember joined
Someone joins one of the organization's hubs.
Sent to organization webhooks.
data
| Field | Type | Rules |
|---|---|---|
memberrequired | object | |
member.idrequired | string | 1 to 200 characters |
member.handlerequired | string | |
member.displayNamerequired | string | |
member.botrequired | boolean |
member.leftMember left
Someone leaves or is removed from a hub.
Sent to organization webhooks.
data
| Field | Type | Rules |
|---|---|---|
memberrequired | object | |
member.idrequired | string | 1 to 200 characters |
member.handlerequired | string | |
member.displayNamerequired | string | |
member.botrequired | boolean |
channel.createdChannel created
A new channel is made.
Sent to organization webhooks.
data
| Field | Type | Rules |
|---|---|---|
channelrequired | object | |
channel.idrequired | string | 1 to 200 characters |
channel.namerequired | string | |
channel.kindrequired | string |
channel.archivedChannel archived
A channel is archived.
Sent to organization webhooks.
data
| Field | Type | Rules |
|---|---|---|
channelrequired | object | |
channel.idrequired | string | 1 to 200 characters |
channel.namerequired | string | |
channel.kindrequired | string |
canvas.updatedCanvas updated
A canvas is saved, renamed or restored. Never the drawing itself.
Sent to organization webhooks.
data
| Field | Type | Rules |
|---|---|---|
canvasrequired | object | |
canvas.idrequired | string | 1 to 200 characters |
canvas.titlerequired | string | |
canvas.revisionrequired | integer | at least 1 |
canvas.channelIdrequired | string or null | |
canvas.updatedAtrequired | timestamp | |
actorrequired | object or null |
meeting.startedMeeting started
Not sent yet: meetings belong to their host, not to a hub or organization.
Not sent yet. The schema is fixed so you can prepare.
data
| Field | Type | Rules |
|---|---|---|
meetingrequired | object | |
meeting.idrequired | string | 1 to 200 characters |
meeting.titlerequired | string | |
meeting.startedAtrequired | timestamp |
meeting.endedMeeting ended
Not sent yet: meetings belong to their host, not to a hub or organization.
Not sent yet. The schema is fixed so you can prepare.
data
| Field | Type | Rules |
|---|---|---|
meetingrequired | object | |
meeting.idrequired | string | 1 to 200 characters |
meeting.titlerequired | string | |
meeting.startedAtrequired | timestamp | |
meeting.endedAtrequired | timestamp |
webhook.testTest event
Sent when you choose Send test event.
Sent to organization and hub webhooks.
data
| Field | Type | Rules |
|---|---|---|
messagerequired | string | |
webhookIdrequired | string | 1 to 200 characters |
The envelope
{
"id": "8c5b1f9e-2f0e-4d0a-9d55-1f0c8b1c2a10",
"type": "message.created",
"version": 1,
"createdAt": "2026-10-11T16:00:00.000Z",
"organizationId": "a1b2c3d4-0000-4000-8000-000000000001",
"hubId": "a1b2c3d4-0000-4000-8000-000000000002",
"channelId": "a1b2c3d4-0000-4000-8000-000000000003",
"data": {
"message": {
"id": "a1b2c3d4-0000-4000-8000-000000000004",
"content": "Deploy finished.",
"createdAt": "2026-10-11T16:00:00.000Z",
"editedAt": null,
"author": {
"id": "a1b2c3d4-0000-4000-8000-000000000005",
"handle": "river",
"displayName": "River",
"bot": false
}
}
}
}version changes only when a field is removed or changes meaning. New fields can arrive in the same version, so ignore fields you do not know.
Hub webhooks keep their original body for the three message events, because existing receivers read it: { id, type, createdAt, hubId, channelId, message }, where message is { id, content, createdAt, editedAt, author: { id, handle, displayName } } (only id for message.deleted). Their test event uses the envelope above with organizationId: null.
Headers
| Header | Value |
|---|---|
X-Hubs-Event-Id | The event id. The same on retries and redeliveries |
Idempotency-Key | The same event id |
X-Hubs-Timestamp | Unix seconds when this attempt was signed |
X-Hubs-Signature | v1= and the hex HMAC-SHA256 of "<timestamp>.<raw body>" |
X-Hubs-Delivery-Id | Organization webhooks: this attempt's delivery id |
Verify the signature
Check the signature on the raw bytes before you parse them, compare in constant time, refuse timestamps more than five minutes away, and remember each event id so you act on it once. These samples are tested against the signing code Hubs runs.
import { createHmac, timingSafeEqual } from "node:crypto";
// rawBody is the exact bytes Hubs sent, before any JSON parsing.
export function verifyHubsSignature(secret, headers, rawBody, now = Date.now()) {
const timestamp = headers["x-hubs-timestamp"] ?? "";
const signature = headers["x-hubs-signature"] ?? "";
if (!/^\d+$/.test(timestamp)) return false;
// Refuse deliveries signed more than five minutes ago (or ahead).
if (Math.abs(now / 1000 - Number(timestamp)) > 300) return false;
const expected =
"v1=" +
createHmac("sha256", secret)
.update(timestamp + ".")
.update(rawBody)
.digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(signature);
return a.length === b.length && timingSafeEqual(a, b);
}
Delivery and retries
- Delivery is at least once. Answer with any 2xx after you have stored the event.
- A network failure, 408, 429 or 5xx is tried again with backoff starting at 15 seconds and growing to at most an hour, 5 attempts in all. Any other answer, and any redirect, fails at once.
- A webhook with 500 events waiting is paused and its waiting events cancelled; add it again once your receiver is back.
- The delivery log shows the latest 50 deliveries with their state, attempts, the last status and a short reason. Your service's answers are never stored.
Test events, redelivery and new secrets
- Send test event queues a signed
webhook.testevent right away. - Redeliver sends a delivered, failed or cancelled event again with the same event id, after checking what the webhook may see now.
- New secret shows a new secret once; the old one stops working at once and waiting events are signed with the new one.
- Remove stops the webhook and cancels what is waiting.
Every add, new secret, redelivery, pause and removal is written to the organization audit log, or for hubs to the hub audit trail.