The app API
The app API is the HTTPS API your app calls, version 1. Every request carries the app’s token, and every call acts as the app, in the channels it was added to, with the scopes an admin gave it. For the token, the base URL and the general rules, see Build an app.
Paths below start at /v1, relative to https://api.buildit.social/functions/v1/app-api.
Endpoints#
| Endpoint | Scope | What it does |
|---|---|---|
GET /v1/meta |
a valid token | The API’s version and deprecations |
GET /v1/me |
a valid token | The app, the org, the installation’s status, scopes, events, delivery, the app’s channels, the token’s hint and dates, and the rate limits in force |
GET /v1/channels?cursor=&limit= |
channels:read |
The channels the app is in, by name (never DMs) |
GET /v1/channels/{id} |
channels:read |
One channel: name, topic, visibility, org-wide or not, archived or not, member count, when the app was added |
GET /v1/channels/{id}/members?cursor=&limit= |
channels:read |
Its people: {id, name, is_guest}, never emails |
GET /v1/channels/{id}/messages?since=&cursor=&limit= |
messages:read |
Top-level messages, newest first, from before the app was added too; next_cursor goes further back, down to since |
GET /v1/messages/{id} |
messages:read |
One message |
GET /v1/messages/{id}/thread?cursor=&limit= |
messages:read |
A thread: its root and its replies, oldest first |
POST /v1/channels/{id}/messages |
messages:write |
Post {text, card?, thread_id?, reply_to_id?, idempotency_key?}: 201 {created, message} |
PATCH /v1/messages/{id} |
messages:write |
Change the app’s own message: {text?, card?, if_version?} (card: null removes the card) |
DELETE /v1/messages/{id} |
messages:write |
Delete the app’s own message: {id, deleted} |
PUT /v1/messages/{id}/reactions/{emoji} |
messages:write |
React, with the emoji URL-encoded: {message_id, emoji, changed} |
DELETE /v1/messages/{id}/reactions/{emoji} |
messages:write |
Take the app’s reaction back |
PUT /v1/messages/{id}/pin |
messages:write |
Pin a message for everyone in the channel: {pinned, pinned_at, count} |
DELETE /v1/messages/{id}/pin |
messages:write |
Unpin it, whoever pinned it |
GET /v1/emoji |
messages:read |
The org’s custom emoji: {items: [{id, name, animated, url, still_url}]} |
POST /v1/messages/{id}/card/close |
messages:write |
Close the app’s own card: {message} |
GET /v1/messages/{id}/card/responses?cursor=&limit= |
none for its own card; messages:read for another sender’s |
Who answered what, with respondents, counts and closed_at |
PATCH /v1/responses/{id} |
messages:write |
Accept or reject one answer: {status, note?, revision?} |
GET /v1/events?cursor=&limit= |
a valid token | Polling apps: up to 100 events, {events, cursor, more} |
POST /v1/events/ack |
a valid token | Polling apps: {cursor} marks every event up to it handled |
GET /v1/items/{key} |
projects:read |
A work item by key (WEB-42) in a project whose channel the app is in |
Cards and their answers are on The card format; events and polling on Events and webhooks.
Where an app reaches#
- The org comes from the token, never from the request. Another org’s ids, DMs and group DMs, and ids that don’t exist all answer the same
404 not_found. - Channels: only those the app was added to. Others in its org answer
403 not_in_channel. Writes in an archived channel answer403 archived. - Messages: an app changes, deletes and closes only its own (
403 not_own_message). - A read-only org (its subscription lapsed): nothing new can be written (
403 org_read_only), but deleting the app’s own messages, removing its reactions and unpinning still work. - A turned-off app: while the app is turned off, paused, or the org’s apps are off, every call answers
403 app_pausedwithdetails.reason(disabled,pausedorapps_off). The token isn’t revoked: it works again when an admin turns the app back on.
Post a message#
curl -sS -X POST "$BUILDIT_API_BASE/channels/$CHANNEL_ID/messages" \
-H "Authorization: Bearer $BUILDIT_APP_TOKEN" \
-H "Content-Type: application/json" \
-d '{"text": "Deployed **web 1.0.0** to production.", "idempotency_key": "00000000-0000-4000-8000-000000000001"}'
The answer is 201 with the new message:
{
"created": true,
"message": {
"id": "00000000-0000-4000-8000-000000000001",
"channel_id": "7e9a1c3b-5d7f-4e9a-8c1d-3f5b7d9e1a3c",
"thread_id": null,
"reply_to_id": null,
"kind": "text",
"text": "Deployed **web 1.0.0** to production.",
"author": {"type": "app", "id": "0d4b6f8a-2c4e-4a6b-8c0d-2e4f6a8b0c2d", "name": "Deploybot"},
"mentions": [],
"card": null,
"card_state": null,
"has_attachment": false,
"reply_count": 0,
"last_reply_at": null,
"created_at": "2026-10-20T15:02:11.482Z",
"edited_at": null,
"deleted": false,
"version": 1,
"reactions": []
}
}
Messages#
- Text: the Markdown people use (bold, italic, code, links, lists, quotes), 1 to 4,000 characters, counted as Unicode code points. No images:
of people who are members of the channel (ids fromGET /v1/channels/{id}/members), at most 20 a message. They notify under each person’s own settings. A mention of an app is kept as text and notifies nobody; any other id answers422 validationontext. Mentions added by an edit don’t notify. - Threads and quotes:
thread_idposts a reply in the thread of that top-level message of the channel;reply_to_idquotes a message of the channel. - Links get the same previews as anyone’s. Work item keys such as
WEB-42link to the item for people who can see it. - Versions: the app’s own messages have a
version(1 when posted, one more for each change). Send it asif_versionwith aPATCHto refuse a change on top of another one (409 conflict, withdetails.current.version). - Authors in what you read:
{"type": "user", "id", "name", "is_guest"}or{"type": "app", "id", "name"}. Files and GIFs show only ashas_attachment: true. A deleted message keeps its place, with no text. - Reactions: the emoji itself in the path, URL-encoded, or
:name:for one of the org’s custom emoji fromGET /v1/emoji(case-sensitive). An app puts at most 20 different reactions on one message, and none on system lines. - Custom emoji in text: write
[:name:](emoji:<id>)with an id fromGET /v1/emoji. - Pins: the app pins as a member does: text messages and replies only, refused where only managers may pin (
403 message_pin_not_allowed), at most 100 per channel (409 message_pin_limit). The channel gets a line saying the app pinned it. - Cards go in
card; with a card,textis its plain-text fallback. See The card format.
Idempotency#
- Posting:
idempotency_keyis a UUID that becomes the new message’s id. A retry with the same key answers200with the same message and"created": false, so a retry never posts twice. The key used for anything else answers409 idempotency_conflict. Make one random UUID per message you mean to post, and reuse it for retries. - Everything else is idempotent by design: a
PATCHwith the same content, aDELETEof a deleted message (deleted: false), a reaction that is already there or already gone (changed: false), the same accept or reject again. Closing a closed card answers409 card_closed.
Paging#
Lists answer {"items": […], "next_cursor": "…"}. Pass next_cursor back as cursor for the next page; it’s null on the last page. Cursors are opaque and valid only with the same query. limit is 1 to 100 (default 50), or 1 to 200 for a channel’s members. A malformed cursor answers 422 validation on cursor.
Polling has its own cursor: see Polling.
Rate limits#
| Limit | Default | Bucket in a 429 |
|---|---|---|
| Reads per app | 120 a minute | app_reads_per_minute |
| Writes per app | 60 a minute and 2,000 a day (UTC) | app_writes_per_minute, app_writes_per_day |
| Posts per app per channel | 1 a second, bursts of 5 | channel_posts |
Changes of one message’s card (a PATCH with card, or a close) |
10 a minute | message_card_updates_per_minute |
| Requests per org, all its apps together | 600 a minute | org_requests_per_minute |
| Polls for events | 12 a minute | app_polls_per_minute |
GET /v1/meta and GET /v1/me |
30 a minute | app_discovery_per_minute |
| Requests per IP address | 600 a minute | (no envelope) |
GET /v1/me gives the limits in force for your app. Every response carries RateLimit-Limit, RateLimit-Remaining and RateLimit-Reset (seconds). A refusal is 429 rate_limited with Retry-After, details.retry_after (seconds) and details.bucket; a refused request counts nothing. The per-address limit answers 429 with only {"message": "…"}: treat it as rate_limited and retry after a minute.
Errors#
Every error is one envelope:
{
"error": {
"code": "not_in_channel",
"message": "The app isn't in this channel. Someone in the channel can add it from the channel's details.",
"hint": "Ask someone in the channel to add the app, or use a channel from GET /v1/channels.",
"details": {"channel_id": "7e9a1c3b-5d7f-4e9a-8c1d-3f5b7d9e1a3c"}
}
}
Read code, hint and details; message is for people.
| Status | Code | When |
|---|---|---|
| 400 | bad_request |
Not JSON, the wrong Content-Type, or an unreadable path or query parameter |
| 401 | token_invalid |
No token, a malformed or unknown one, or a personal access token |
| 401 | token_revoked |
Revoked by an admin, replaced by a rotation whose overlap ended, or its app deleted |
| 401 | token_suspended |
Unused for 90 days; an admin can resume or rotate it |
| 403 | app_paused |
The app turned off, paused, or the org’s apps off (details.reason) |
| 403 | scope_missing |
The app lacks the scope (details.scope, details.granted) |
| 403 | scope_not_confirmed |
This private channel’s members haven’t allowed messages:read yet, or refused (details.scope, details.channel_id) |
| 403 | projects_off |
An item read in an org without projects |
| 403 | not_in_channel |
A channel of the org the app isn’t in |
| 403 | not_own_message |
Changing, deleting or closing another sender’s message; another sender’s private or anonymous card’s answers |
| 403 | archived |
Writing in an archived channel |
| 403 | org_read_only |
The org is read-only |
| 403 | message_pin_not_allowed |
Pinning where only the channel’s managers may pin |
| 404 | not_found |
Nothing the app may see (details.kind: channel, message, item, route, response or emoji) |
| 405 | method_not_allowed |
The path exists, but not with this method (details.allowed) |
| 409 | conflict |
if_version didn’t match (details.current.version) |
| 409 | idempotency_conflict |
The idempotency_key was used for something else |
| 409 | not_poll_mode |
Polling calls from an app whose events go to its webhook |
| 409 | card_closed |
Replacing a closed card with one people could answer, closing it twice, or answering a response on a closed card |
| 409 | message_pin_limit |
The channel already has 100 pins |
| 409 | response_changed |
Accepting or rejecting a revision the person has since changed (details.current.revision) |
| 413 | body_too_large |
The body is over 64 KB |
| 422 | validation |
The body or query breaks the schema or a rule (details.fields[], each with path and message) |
| 422 | card_invalid |
The card breaks the card format (details.errors[], each with path and message) |
| 429 | rate_limited |
A rate limit is used up |
| 500 | internal |
Something failed on the server: retry once, then report the X-Request-Id |
| 503 | unavailable |
The server is busy or being updated: retry in a minute |
Retry 429 after Retry-After, 500 once, and 503. Don’t retry the other 4xx codes unchanged: hint says what to change.