Events and webhooks
buildIt.social tells your app what happens in its channels through events. Each event is a small JSON envelope, delivered at least once and in no guaranteed order, either as a signed webhook or from a queue your app polls.
Webhook or polling#
An admin picks one when they set up the app, and can switch later. Both read the same queue.
| Webhook | Polling | |
|---|---|---|
| Needs | A public https:// URL on port 443 that answers within 5 seconds |
Nothing but the token |
| Delay | About a second | Your poll interval (at most 12 polls a minute) |
| Authenticity | Verify the signature on every request | The token over HTTPS is enough |
| Answering a card in the reply | Yes (below) | No: use PATCH /v1/responses/{id} |
No events are written for a webhook app until its URL is verified, nor while the app is paused or turned off, or the org’s apps are off. An app gets only the event types it’s subscribed to and has the scope for, only from channels it’s in, and never its own actions.
The envelope#
{
"v": 1,
"id": "3f0c9a7e-1b2d-4e5f-8a6b-7c8d9e0f1a2b",
"type": "message.posted",
"created_at": "2026-10-20T15:02:11.482Z",
"org": {"id": "9b2e4c6d-8f0a-4b1c-9d3e-5f7a9b1c3d5e"},
"app": {"id": "0d4b6f8a-2c4e-4a6b-8c0d-2e4f6a8b0c2d", "installation_id": "5a7c9e1b-3d5f-4b7d-9f1a-3c5e7a9b1d3f"},
"attempt": 1,
"data": {
"channel": {"id": "7e9a1c3b-5d7f-4e9a-8c1d-3f5b7d9e1a3c", "name": "ops"},
"message": {
"id": "1c3e5a7b-9d1f-4c3e-8a5b-7d9f1b3c5e7a",
"thread_id": null,
"reply_to_id": null,
"text": "The **web** build is green.",
"mentions": [],
"has_card": false,
"created_at": "2026-10-20T15:02:11.482Z",
"edited_at": null
},
"user": {"id": "2b4d6f8a-0c2e-4b4d-9f6a-8c0e2a4b6d8f", "name": "Ana Ruiz", "is_guest": false, "locale": "en"}
}
}
| Field | Meaning |
|---|---|
v |
The envelope’s version: 1 |
id |
The event’s id. Deduplicate on it: the same event can arrive more than once |
type |
The event type. New types may be added: ignore those you don’t know |
created_at |
When it happened (UTC, milliseconds) |
org, app |
Your org, your app and its installation |
attempt |
How many times this event has been handed out, from 1 |
data |
What happened: the channel, and the message, person, card, call or item it’s about |
- A person is always
{id, name, is_guest, locale}, never an email.locale(en,es-419,fr-CA,sr-Cyrlorsr-Latn) lets your app answer in their language.userisnullwhen the account has been deleted. - Content is in the event, so you rarely need a second call. It’s checked again when the event is sent: if your app left the channel, lost the scope or was unsubscribed, or the message or item was deleted, the event is dropped.
- Never in an event: emails, DMs, files, a card’s content (a message only says
has_card), and on an anonymous card, who answered.
Event types#
| Type | When | Needs |
|---|---|---|
url_verification |
An admin verifies the webhook URL | nothing (always sent) |
app.added_to_channel, app.removed_from_channel |
Someone adds the app to a channel, or removes it | channels:read |
member.joined, member.left |
A person joins or leaves one of its channels | channels:read |
message.posted |
A message in one of its channels, top level or in a thread (not system lines, a work item’s comments, or its own) | messages:read |
message.edited, message.deleted |
One of those messages changes or is deleted | messages:read |
app.mentioned |
A message mentions the app | mentions:read or messages:read |
reaction.added, reaction.removed |
A reaction on any message of its channels, or only on its own messages without messages:read |
messages:read, or messages:write for its own |
message.pinned, message.unpinned |
Someone pins or unpins a message of its channels, or only its own without messages:read |
messages:read, or messages:write for its own |
card.response |
Someone answers one of its cards | messages:write |
card.closed |
One of its cards closes | messages:write |
call.started, call.ended |
A call in one of its channels | calls:read |
work_item.created, work_item.status_changed, work_item.commented |
In a project whose channel the app is in | projects:read |
An app subscribed to both message.posted and app.mentioned gets both for a message that mentions it.
Payloads#
The data of each type.
Messages#
message.posted, message.edited and app.mentioned carry the message as in the envelope: its Markdown text, thread_id (the thread’s first message, for a reply), reply_to_id (the message it quotes), the ids it mentions (people and apps), has_card, and its times. A message sent by another app has user: null and sender_app: {id, name}.
A mention is a Markdown link to an id, [@Name](mention:<id>), and your app’s own id is in app.id, so strip the mention before reading a command:
{
"channel": {"id": "7e9a1c3b-5d7f-4e9a-8c1d-3f5b7d9e1a3c", "name": "ops"},
"message": {
"id": "1c3e5a7b-9d1f-4c3e-8a5b-7d9f1b3c5e7a",
"thread_id": null,
"reply_to_id": null,
"text": "[@Deploybot](mention:0d4b6f8a-2c4e-4a6b-8c0d-2e4f6a8b0c2d) ship web",
"mentions": ["0d4b6f8a-2c4e-4a6b-8c0d-2e4f6a8b0c2d"],
"has_card": false,
"created_at": "2026-10-20T15:02:11.482Z",
"edited_at": null
},
"user": {"id": "2b4d6f8a-0c2e-4b4d-9f6a-8c0e2a4b6d8f", "name": "Ana Ruiz", "is_guest": false, "locale": "en"}
}
message.deleted:
{
"channel": {"id": "7e9a1c3b-5d7f-4e9a-8c1d-3f5b7d9e1a3c", "name": "ops"},
"message": {
"id": "1c3e5a7b-9d1f-4c3e-8a5b-7d9f1b3c5e7a",
"thread_id": null,
"created_at": "2026-10-20T15:02:11.482Z",
"deleted_at": "2026-10-20T15:09:30.000Z"
},
"user": {"id": "2b4d6f8a-0c2e-4b4d-9f6a-8c0e2a4b6d8f", "name": "Ana Ruiz", "is_guest": false, "locale": "en"}
}
Channels and members#
app.added_to_channel and app.removed_from_channel have the channel and the user who did it. member.joined has added_by when someone added the person, and member.left has removed_by when someone removed them:
{
"channel": {"id": "7e9a1c3b-5d7f-4e9a-8c1d-3f5b7d9e1a3c", "name": "ops"},
"user": {"id": "6c8e0a2b-4d6f-4c8e-8a0b-2d4f6c8e0a2b", "name": "Sam Lee", "is_guest": true, "locale": "fr-CA"},
"added_by": {"id": "2b4d6f8a-0c2e-4b4d-9f6a-8c0e2a4b6d8f", "name": "Ana Ruiz", "is_guest": false, "locale": "en"}
}
Reactions and pins#
reaction.added and reaction.removed have the channel, the message ({id, thread_id}), the reaction and the user. A custom emoji’s reaction has emoji as :name: and reaction.custom: {id, name}; another app’s reaction has user: null and sender_app.
{
"channel": {"id": "7e9a1c3b-5d7f-4e9a-8c1d-3f5b7d9e1a3c", "name": "ops"},
"message": {"id": "1c3e5a7b-9d1f-4c3e-8a5b-7d9f1b3c5e7a", "thread_id": null},
"reaction": {"emoji": "🚀"},
"user": {"id": "2b4d6f8a-0c2e-4b4d-9f6a-8c0e2a4b6d8f", "name": "Ana Ruiz", "is_guest": false, "locale": "en"}
}
message.pinned and message.unpinned have the channel, the message, who did it (a person as user, or another app as app: {id, name}), and pinned_at on a pin.
Cards#
card.response: someone answered one of your cards. card gives the card’s answer settings with their defaults applied, response the answer ({"action": "<button id>"}, or {"inputs": {…}} for a form) with its id and revision, and private your card’s private data (or null). On an anonymous card, user is null and pseudonym (anon_ and 32 hex digits, the same for one person on one card) is all you get.
{
"channel": {"id": "7e9a1c3b-5d7f-4e9a-8c1d-3f5b7d9e1a3c", "name": "ops"},
"message": {"id": "4e6a8c0b-2d4f-4e6a-9c8b-0d2f4a6c8e0b", "thread_id": null},
"card": {"visibility": "private", "results": "on_close", "mode": "once", "confirm": true},
"response": {
"id": "8a0c2e4b-6d8f-4a0c-8e2b-4d6f8a0c2e4b",
"revision": 1,
"answer": {"action": "approve"},
"created_at": "2026-10-20T15:03:40.102Z",
"updated_at": "2026-10-20T15:03:40.102Z"
},
"user": {"id": "2b4d6f8a-0c2e-4b4d-9f6a-8c0e2a4b6d8f", "name": "Ana Ruiz", "is_guest": false, "locale": "en"},
"pseudonym": null,
"private": {"target": "web"}
}
A changed answer sends a new card.response with a higher revision; the same answer again sends nothing.
card.closed: one of your cards closed. closed_by is time (its closing time), app (you closed it), deleted (its message was deleted) or removed (its card was removed). counts are the final ones, by button id or by input id then option id, even when results are hidden from people.
{
"channel": {"id": "7e9a1c3b-5d7f-4e9a-8c1d-3f5b7d9e1a3c", "name": "ops"},
"message": {"id": "4e6a8c0b-2d4f-4e6a-9c8b-0d2f4a6c8e0b", "thread_id": null},
"card": {"visibility": "private", "results": "on_close", "mode": "once", "confirm": true},
"closed_by": "time",
"closed_at": "2026-10-20T16:00:00.000Z",
"respondents": 3,
"counts": {"approve": 2, "reject": 1},
"private": {"target": "web"}
}
Calls#
call.started and call.ended: who started it, when, how long and how many joined. Never media or the names of the people in it. mode is ring (a call) or meeting. call.started has the same call with ended_at and duration_seconds null, and a user.
{
"channel": {"id": "7e9a1c3b-5d7f-4e9a-8c1d-3f5b7d9e1a3c", "name": "ops"},
"call": {
"id": "9d1f3b5c-7e9a-4d1f-8b3c-5e7a9d1f3b5c",
"mode": "meeting",
"with_video": true,
"started_at": "2026-10-20T14:00:02.000Z",
"ended_at": "2026-10-20T14:31:40.000Z",
"duration_seconds": 1898,
"joined_count": 4,
"started_by": {"id": "2b4d6f8a-0c2e-4b4d-9f6a-8c0e2a4b6d8f", "name": "Ana Ruiz", "is_guest": false, "locale": "en"}
}
}
Work items#
work_item.created, work_item.status_changed (with from_status and to_status) and work_item.commented (with the comment). Built-in types and statuses have a key and a null name until someone renames them; category is not_started, started, done or canceled.
{
"channel": {"id": "3a5c7e9b-1d3f-4a5c-8e7b-9d1f3a5c7e9b", "name": "web"},
"project": {"id": "5c7e9a1b-3d5f-4c7e-9a1b-3d5f5c7e9a1b", "key": "WEB"},
"item": {
"id": "7a9c1e3b-5d7f-4a9c-8e3b-5d7f7a9c1e3b",
"key": "WEB-42",
"title": "Ship the new sign-in page",
"type": {"id": "1e3a5c7b-9d1f-4e3a-8c7b-9d1f1e3a5c7b", "key": "task", "name": null},
"status": {"id": "3c5e7a9b-1d3f-4c5e-9a9b-1d3f3c5e7a9b", "key": "in_review", "name": null, "category": "started"},
"created_at": "2026-10-18T10:15:00.000Z"
},
"user": {"id": "2b4d6f8a-0c2e-4b4d-9f6a-8c0e2a4b6d8f", "name": "Ana Ruiz", "is_guest": false, "locale": "en"},
"from_status": {"id": "5e7a9c1b-3d5f-4e7a-8c1b-3d5f5e7a9c1b", "key": "in_progress", "name": null, "category": "started"},
"to_status": {"id": "3c5e7a9b-1d3f-4c5e-9a9b-1d3f3c5e7a9b", "key": "in_review", "name": null, "category": "started"}
}
Webhooks#
Headers#
Each event is a POST of the envelope, with these headers:
| Header | Value |
|---|---|
Content-Type |
application/json |
User-Agent |
buildItSocial-Apps/1.0 |
X-Buildit-Event-Id |
The event’s id (the envelope’s id) |
X-Buildit-Timestamp |
When it was signed, in Unix seconds |
X-Buildit-Signature |
v1= and the lower-case hex HMAC-SHA256 of "v1:" + timestamp + ":" + body, keyed with the signing secret. While a secret is being rotated, two values: v1=<new>,v1=<old> |
Verifying signatures#
Verify every request before you trust anything in it:
- Read the raw body as bytes, before any JSON parsing. The signature is over the exact bytes sent.
- Refuse an
X-Buildit-Timestampthat isn’t whole seconds, or is more than 5 minutes from your clock either way. Keep your clock synced. - Compute
HMAC-SHA256(secret, "v1:" + timestamp + ":" + body)as lower-case hex. - Split
X-Buildit-Signatureon commas and keep thev1=values. Accept the request if any of them equals yours, compared in constant time. Ignore values of other versions. - Otherwise answer
401and do nothing.
In TypeScript with Web Crypto (Node 20 and later, Deno, Bun, Cloudflare Workers):
const encoder = new TextEncoder();
export const TOLERANCE_SECONDS = 300; // 5 minutes, either way
export async function verifyBuilditSignature(
headers: Headers,
body: Uint8Array, // the raw body, exactly as received
secrets: readonly string[], // the signing secret (or the new and the old one while you switch)
nowSeconds = Math.floor(Date.now() / 1000),
): Promise<boolean> {
const timestamp = headers.get("X-Buildit-Timestamp") ?? "";
if (!/^[0-9]{1,12}$/.test(timestamp)) return false;
if (Math.abs(nowSeconds - Number(timestamp)) > TOLERANCE_SECONDS) return false;
const sent = (headers.get("X-Buildit-Signature") ?? "").split(",")
.map((value) => value.trim())
.filter((value) => value.startsWith("v1="))
.map((value) => value.slice(3));
if (sent.length === 0) return false;
const prefix = encoder.encode(`v1:${timestamp}:`);
const payload = new Uint8Array(prefix.length + body.length);
payload.set(prefix);
payload.set(body, prefix.length);
let ok = false;
for (const secret of secrets) {
if (secret === "") continue;
const key = await crypto.subtle.importKey(
"raw", encoder.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"],
);
const expected = toHex(new Uint8Array(await crypto.subtle.sign("HMAC", key, payload)));
for (const value of sent) if (constantTimeEqual(expected, value)) ok = true;
}
return ok;
}
function toHex(bytes: Uint8Array): string {
return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
}
function constantTimeEqual(a: string, b: string): boolean {
if (a.length !== b.length) return false;
let diff = 0;
for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
return diff === 0;
}
In Python, with the standard library only:
import hashlib
import hmac
import re
import time
TOLERANCE_SECONDS = 300 # 5 minutes, either way
def verify_buildit_signature(headers, body, secrets, now=None):
"""headers: a case-insensitive mapping; body: the raw request body as bytes;
secrets: the signing secret (or the new and the old one while you switch)."""
timestamp = headers.get("X-Buildit-Timestamp", "")
if not re.fullmatch(r"[0-9]{1,12}", timestamp):
return False
now = int(time.time()) if now is None else now
if abs(now - int(timestamp)) > TOLERANCE_SECONDS:
return False
values = [v.strip() for v in headers.get("X-Buildit-Signature", "").split(",")]
sent = [v[3:].encode() for v in values if v.startswith("v1=")]
payload = b"v1:" + timestamp.encode() + b":" + body
ok = False
for secret in secrets:
if not secret:
continue
expected = hmac.new(secret.encode(), payload, hashlib.sha256).hexdigest().encode()
for value in sent:
if hmac.compare_digest(expected, value):
ok = True
return ok
The Node example does the same with node:crypto.
Answering#
- Answer
2xxwithin 5 seconds, counted from the connection to the end of your reply. Anything else is a failure: another status, a time-out, a refused connection or a TLS error. Redirects are failures and are never followed. - Do slow work after replying: put the event on a queue, or answer first and carry on in the background.
- The body of your reply is ignored, except for
url_verificationandcard.response. It’s read up to 64 KB.
URL verification#
When an admin verifies the webhook URL, buildIt.social sends a signed url_verification event with a random challenge of 48 hex digits:
{
"v": 1,
"id": "0f2b4d6a-8c0e-4f2b-9d6a-8c0e0f2b4d6a",
"type": "url_verification",
"created_at": "2026-10-20T14:58:03.120Z",
"org": {"id": "9b2e4c6d-8f0a-4b1c-9d3e-5f7a9b1c3d5e"},
"app": {"id": "0d4b6f8a-2c4e-4a6b-8c0d-2e4f6a8b0c2d", "installation_id": "5a7c9e1b-3d5f-4b7d-9f1a-3c5e7a9b1d3f"},
"attempt": 1,
"data": {"challenge": "6f1c0b3e9a8d7c6b5a4f3e2d1c0b9a8f7e6d5c4b3a2f1e0d"}
}
Verify its signature like any event, then answer 2xx within 5 seconds with JSON holding the same challenge:
{"challenge": "6f1c0b3e9a8d7c6b5a4f3e2d1c0b9a8f7e6d5c4b3a2f1e0d"}
Only then is the URL saved and events start, so the app needs its signing secret before the URL is verified. The URL must be https, on port 443, at most 2,000 characters, and resolve to public addresses only. If the check fails, the admin sees only that the URL was unreachable or gave the wrong reply, so the check can’t be used to probe which hosts buildIt.social reaches.
Answering a card in the reply#
To a card.response delivery, your 2xx reply may accept or reject the answer at once, with an optional note that only the person who answered sees:
{"status": "rejected", "note": "That date is a public holiday. Pick another one."}
status is exactly accepted or rejected; note is at most 300 characters (blank or null for none); other keys are ignored. Any other reply (an empty 200, a 204, another shape, a note that’s too long) is ignored, and the event still counts as delivered. The answer applies to the revision the event carried: if the person changed their answer since, it isn’t applied. It does what PATCH /v1/responses/{id} does, without a second call.
Retries and pausing#
- A failed event is tried again after 30 seconds, 2 minutes, 10 minutes, 1 hour, 3 hours, 6 hours and 12 hours, each moved by up to 10% either way: 8 tries over about 22 hours. After the 8th failure it’s dead. Admins see failed events in the app’s deliveries log for 14 days and can send one again.
- Pausing: after 20 failures since the last success, the first of them a day ago, the app is paused and the org’s owners and admins are told. While paused, no new events are written for it and its API calls answer
403 app_paused. An admin resumes it once your endpoint works. - At least once, unordered. A retried event keeps its
id(itsattemptgrows), and an event can arrive after a later one. Deduplicate onid(orX-Buildit-Event-Id), and order by the times indatawhen it matters.
Polling#
An app without a public URL reads the same queue:
GET /v1/events?cursor=&limit=returns up to 100 events aftercursor(limit1 to 100, default 100), oldest first:{"events": […], "cursor": "<the last one's position>", "more": true|false}. Withoutcursor, it starts after the last event you acknowledged. Each event is the same envelope a webhook gets, without a signature.POST /v1/events/ackwith{"cursor": "42"}marks every event up to that position handled, and answers{"acked": <how many>, "cursor": "42"}.- Unacknowledged events come back on the next poll without a cursor, with a higher
attempt. After 24 hours unacknowledged they’re dead. - Poll at most 12 times a minute (
429 rate_limitedotherwise). Whenmoreis true, poll again at once with the cursor you got. - A polling app that hasn’t polled for 7 days is paused.
- These calls from an app whose events go to its webhook answer
409 not_poll_mode.
curl -sS "$BUILDIT_API_BASE/events" -H "Authorization: Bearer $BUILDIT_APP_TOKEN"
# {"events": [ … ], "cursor": "42", "more": false}
curl -sS -X POST "$BUILDIT_API_BASE/events/ack" \
-H "Authorization: Bearer $BUILDIT_APP_TOKEN" -H "Content-Type: application/json" \
-d '{"cursor": "42"}'
A loop: poll, handle each event (deduplicating on id), acknowledge the cursor, and wait 5 seconds when more is false.
The queue’s limits#
- At most 5,000 events wait for an app. Past that, new events for it are dropped (the polling cursor skips them), and the org’s owners and admins are told, at most once an hour.
- One action writes at most 500 events for an app, for example a bulk import of messages; the rest are dropped the same way.
- An event waits at most 3 days. One not delivered or acknowledged within 3 days is dead, and can’t be sent again.
- Content is kept only while it can be sent. Once an event is delivered, acknowledged or dropped, its content is deleted; the deliveries log keeps its type, times, attempts, status and error.
- Deliveries are shared fairly: at most 4 events are in flight to one app at a time, and apps take turns, so one app’s backlog never holds up another’s.