Resources menu

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-Cyrl or sr-Latn) lets your app answer in their language. user is null when 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:

  1. Read the raw body as bytes, before any JSON parsing. The signature is over the exact bytes sent.
  2. Refuse an X-Buildit-Timestamp that isn’t whole seconds, or is more than 5 minutes from your clock either way. Keep your clock synced.
  3. Compute HMAC-SHA256(secret, "v1:" + timestamp + ":" + body) as lower-case hex.
  4. Split X-Buildit-Signature on commas and keep the v1= values. Accept the request if any of them equals yours, compared in constant time. Ignore values of other versions.
  5. Otherwise answer 401 and 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 2xx within 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_verification and card.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 (its attempt grows), and an event can arrive after a later one. Deduplicate on id (or X-Buildit-Event-Id), and order by the times in data when it matters.

Polling#

An app without a public URL reads the same queue:

  • GET /v1/events?cursor=&limit= returns up to 100 events after cursor (limit 1 to 100, default 100), oldest first: {"events": […], "cursor": "<the last one's position>", "more": true|false}. Without cursor, it starts after the last event you acknowledged. Each event is the same envelope a webhook gets, without a signature.
  • POST /v1/events/ack with {"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_limited otherwise). When more is 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.