Resources menu

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 answer 403 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_paused with details.reason (disabled, paused or apps_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: ![ outside code is refused; write \![ for the text.
  • Mentions: [@Name](mention:<user id>) of people who are members of the channel (ids from GET /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 answers 422 validation on text. Mentions added by an edit don’t notify.
  • Threads and quotes: thread_id posts a reply in the thread of that top-level message of the channel; reply_to_id quotes a message of the channel.
  • Links get the same previews as anyone’s. Work item keys such as WEB-42 link 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 as if_version with a PATCH to refuse a change on top of another one (409 conflict, with details.current.version).
  • Authors in what you read: {"type": "user", "id", "name", "is_guest"} or {"type": "app", "id", "name"}. Files and GIFs show only as has_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 from GET /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 from GET /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, text is its plain-text fallback. See The card format.

Idempotency#

  • Posting: idempotency_key is a UUID that becomes the new message’s id. A retry with the same key answers 200 with the same message and "created": false, so a retry never posts twice. The key used for anything else answers 409 idempotency_conflict. Make one random UUID per message you mean to post, and reuse it for retries.
  • Everything else is idempotent by design: a PATCH with the same content, a DELETE of 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 answers 409 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.