The card format
A card is a small JSON document your app sends with a message: a header, text, facts, and buttons or a short form. buildIt.social draws it with its own widgets, in the org’s theme, on every platform, and keeps the answers itself: a click is recorded and shown at once, even when your app is slow or down, and then sent to your app as card.response.
This is version 1 of the format. To see how people experience cards, read Answering app cards.
Send a card#
Put the card in card when you post (POST /v1/channels/{id}/messages) or change (PATCH /v1/messages/{id}) a message. text is required with a card: it’s the fallback that notifications, search, screen readers and older versions of the app use. It isn’t shown with the card, so make it say the same thing in one line.
{
"text": "Deploy web to production? Approve or reject it in buildIt.social.",
"card": {
"v": 1,
"header": {"title": "Deploy web to production?", "subtitle": "Build 1.4.0 (121) · requested by Ana Ruiz"},
"body": [
{"type": "text", "text": "**3 changes** since the last deploy."},
{"type": "facts", "items": [{"label": "Branch", "value": "main"}, {"label": "Checks", "value": "All passed"}]},
{"type": "context", "text": "Approvals close at 17:00 UTC."}
],
"actions": [
{"type": "button", "id": "approve", "label": "Approve", "style": "primary"},
{
"type": "button",
"id": "reject",
"label": "Reject",
"style": "danger",
"confirm": {"title": "Reject this deploy?", "text": "Web won't be deployed.", "yes": "Reject", "no": "Cancel"}
},
{"type": "link", "label": "Open the build", "url": "https://ci.example.com/builds/121"}
],
"responses": {"confirm": true, "closes_at": "2026-10-20T17:00:00Z"},
"private": {"build": 121, "target": "web"}
}
}
A card that breaks a rule answers 422 card_invalid, and details.errors lists each problem with its path, such as body[2].text or actions[1].confirm.title ("" for the whole card).
Elements#
| Element | Fields | Limits |
|---|---|---|
header |
title, subtitle? |
1 to 150 characters each, one line |
text (body) |
text: Markdown (bold, italic, code, links, mentions; no images or headings) |
1 to 2,000 characters |
facts (body) |
items: [{label, value}] |
1 to 10 items; labels 1 to 40, values 1 to 200 characters, one line |
context (body) |
text: small, muted Markdown |
1 to 300 characters |
divider (body) |
none | |
choice (input) |
id, label, options: [{id, label, description?}], multiple?, required?, min?, max? |
2 to 10 options, labels 1 to 80; descriptions are Markdown like text, 1 to 2,000 characters; min and max only with multiple: true |
text_input (input) |
id, label, placeholder?, multiline?, max_length?, required? |
max_length 1 to 2,000 (2,000 if left out) |
date (input) |
id, label, min?, max?, required? |
YYYY-MM-DD, no time |
button (action) |
id, label, style? (default, primary, danger), confirm? ({title, text, yes?, no?}) |
labels 1 to 30 characters; at most 5 buttons |
link (action) |
label, url |
at most 3; https:// with a host name (never an IP address), no user name or password, at most 2,000 characters |
submit (action) |
label? (our own Submit if left out) |
exactly one on a card with inputs, none without |
Input labels and placeholders are 1 to 150 characters. A button’s confirm takes a title of up to 100 characters, a text of up to 300, and yes and no labels of up to 30.
Rules for the whole card#
- Two kinds of card. Without inputs, each button is an answer, such as approve or reject. With inputs, the card is a form: buttons aren’t allowed, and the one
submitsends every input at once. Links go on either. - Ids of inputs, buttons and options are
[a-z0-9_]{1,40}, unique in the card (options: in their choice). - At most 16 KB of JSON (without
private) and 25 elements (body, inputs and actions; the header and a facts element’s items don’t count). A card needs a header or at least one body element. - Every object is closed: a field not listed here is refused, and
nullis never a value: leave the field out. - Characters are Unicode code points. Texts need a character other than a space and hold no control characters; line feeds are allowed in
text,context,confirm.textand an option’sdescription. - A choice with 6 or more options and no descriptions shows as a dropdown. Options with descriptions show as a list of rows, the label beside its description.
How answers work: responses#
Every field is optional, with these defaults:
| Field | Values | Default |
|---|---|---|
mode |
once: one answer (a rejected one may be given again); changeable: people can change their answer until the card closes |
once |
visibility |
shared: people see who answered what; private: they see counts only; anonymous: counts only, and your app gets a pseudonym instead of the person |
private |
results |
live: counts show as answers come in; on_close: when the card closes; hidden: only your app sees them |
buttons only: on_close; a form: live |
closes_at |
A UTC time, YYYY-MM-DDTHH:MM:SSZ (fractions of a second allowed), in the future |
none |
guests |
Whether guests may answer | true |
confirm |
Whether the person waits for your app to accept or reject their answer | false |
- A card without buttons or inputs takes no
responsesat all. - A person always sees their own answer.
visibility,modeandguestscan’t change once someone has answered, andresultsmay only show later, never sooner (livetoon_closetohiddenis fine; the other way answers422 card_invalid). People answered under the card’s promise.- A card closes within a minute of its
closes_at, and your app then getscard.closedwithclosed_by: "time".
Visibility and anonymity#
visibility |
Other people see | Your app gets |
|---|---|---|
shared |
Who answered what | The person: {id, name, is_guest, locale} |
private |
Counts only, when results allows, once 3 people have answered or the card closed |
The person |
anonymous |
Counts only, when results allows, once 3 people have answered or the card closed |
A pseudonym (anon_ and 32 hex digits), the same for one person on one card and unrelated across cards |
Anonymous is anonymous to people and to your app. buildIt.social still keeps who answered, so that each person answers once; org admins can’t read it either.
Private data#
private is any JSON except null, up to 2 KB, that you want back with each answer: an internal id, a build number, the thing being approved. It stays on the server: it never reaches anyone’s device, and it comes back in every card.response and in card.closed. Replacing the card replaces it.
Everything else in a card reaches every device in the channel. Never put a token, key or password in an id, a label or a URL: the API refuses strings that look like secrets (422 card_invalid), as a safety net, not a guarantee. And never ask people for passwords, sign-in codes or other secrets in a card: link to your own sign-in page instead.
Answers#
When someone answers, buildIt.social checks the answer against the card (the button exists, required inputs are given, option ids, lengths, dates in range), records it, updates the counts, and sends you card.response:
- a button card’s answer is
{"action": "<button id>"}; - a form’s is
{"inputs": {"<input id>": <value>}}: an option id (a list of option ids withmultiple), the text, or a date (YYYY-MM-DD). Inputs left empty aren’t there.
The answer counts at once. Your app may then accept or reject it, update the card, or close it.
Accept or reject an answer#
Accept or reject an answer, with an optional note that only the person who answered sees:
- in the webhook’s reply, within the same 5 seconds:
{"status": "accepted", "note": "…"}(see Answering a card in the reply); - or later, with
PATCH /v1/responses/{id}and theidfrom the event. Polling apps always do this.
{"status": "rejected", "note": "That date is a public holiday. Pick another one.", "revision": 1}
- A rejected answer is taken out of the counts, and the person may answer again, even on a
oncecard. - The note is at most 300 characters; blank or
nullfor none. It replaces an earlier note. revision, from the event, refuses an answer the person has since changed:409 response_changed, with the current revision indetails.- When the person changes their answer, your note is dropped.
- Accepting or rejecting an answer on a closed card answers
409 card_closed.
With confirm: true, the person sees Waiting for Deploybot… until you answer. After 10 seconds it becomes Recorded. Deploybot hasn’t answered yet, and the answer counts anyway.
Update a card#
Replace the whole card for everyone with PATCH /v1/messages/{id} (card: null removes it). Answers already given stay. The usual way to show an outcome is a card without buttons or inputs, which nobody can answer:
{
"text": "Deploy web to production: approved.",
"card": {
"v": 1,
"header": {"title": "Deploy web to production", "subtitle": "Build 1.4.0 (121)"},
"body": [
{"type": "facts", "items": [
{"label": "Outcome", "value": "Approved"},
{"label": "By", "value": "Ana Ruiz"}
]},
{"type": "context", "text": "Deploy started. [Follow it](https://ci.example.com/builds/121/deploy)."}
]
}
}
- A closed card can still be replaced by a card without buttons or inputs, or removed. Replacing it with one people could answer again answers
409 card_closed. It stays closed. - Changing only the
textalways works, and keeps the card. - At most 10 card changes a minute per message.
- When a result card replaces a card that has answers, the answers keep the replaced card’s settings: a
sharedcard’s stay readable as itsresultsallow, and ananonymouscard’s stay pseudonymous.
Close a card#
POST /v1/messages/{id}/card/close closes your card now: it takes no more answers, its results show as results says, and you get card.closed with the final counts (closed_by: "app"). A card also closes at its closes_at, when its message is deleted, and when its card is removed. Closing a closed card answers 409 card_closed.
Read the answers#
GET /v1/messages/{id}/card/responses?cursor=&limit= lists who answered what, oldest first, with respondents (answers that count), counts and closed_at:
- on your own card: every answer, your notes and the counts, hidden results included; no scope needed;
- on another sender’s card (a person’s poll, another app’s card), with
messages:read: only what members see, so asharedcard’s answers while its results show; aprivateoranonymousone answers403 not_own_message.
counts are by button id ({"approve": 2, "reject": 1}), or for a form by input id, then option id ({"lunch": {"pizza": 2, "salad": 0}}). Rejected answers aren’t counted.
Examples#
A form: a time-off request that only your app sees the answers to.
{
"v": 1,
"header": {"title": "Request time off"},
"body": [{"type": "text", "text": "Only HR Bot sees your answers. You'll get a note here when it's approved."}],
"inputs": [
{"type": "choice", "id": "kind", "label": "Kind", "required": true, "options": [
{"id": "vacation", "label": "Vacation"},
{"id": "sick", "label": "Sick leave"},
{"id": "other", "label": "Other"}
]},
{"type": "date", "id": "from", "label": "First day", "min": "2026-10-21", "required": true},
{"type": "date", "id": "to", "label": "Last day", "min": "2026-10-21", "required": true},
{"type": "text_input", "id": "reason", "label": "Anything we should know?", "multiline": true, "max_length": 300}
],
"actions": [{"type": "submit", "label": "Send request"}],
"responses": {"mode": "changeable", "visibility": "private", "results": "hidden", "confirm": true},
"private": {"form": "time_off_v2"}
}
Its answer arrives as {"inputs": {"kind": "vacation", "from": "2026-11-02", "to": "2026-11-06", "reason": "Family trip"}}.
An anonymous pulse check with several choices, showing results when it closes:
{
"v": 1,
"header": {"title": "How did this sprint feel?"},
"inputs": [
{"type": "choice", "id": "mood", "label": "Pick one or two", "multiple": true, "min": 1, "max": 2, "options": [
{"id": "calm", "label": "Calm"},
{"id": "busy", "label": "Busy"},
{"id": "stuck", "label": "Stuck"},
{"id": "great", "label": "Great"}
]}
],
"actions": [{"type": "submit"}],
"responses": {"mode": "changeable", "visibility": "anonymous", "results": "on_close", "closes_at": "2026-10-24T16:00:00Z"}
}
A question with the context of each choice, using option descriptions:
{
"v": 1,
"header": {"title": "A question about the export"},
"body": [{"type": "text", "text": "Which database should the **export** read from?\nIt runs every night at 02:00."}],
"inputs": [
{"type": "choice", "id": "db", "label": "Database", "required": true, "options": [
{"id": "primary", "label": "Primary", "description": "Always current.\nThe export **slows the app** while it runs."},
{"id": "replica", "label": "Read replica", "description": "Up to a minute behind; no load on the app"},
{"id": "snapshot", "label": "Last night's snapshot"}
]}
],
"actions": [{"type": "submit", "label": "Send"}],
"responses": {"mode": "once", "visibility": "private", "results": "hidden", "confirm": true}
}
A notice with a link and no answers:
{
"v": 1,
"header": {"title": "Incident INC-207 resolved"},
"body": [
{"type": "facts", "items": [{"label": "Duration", "value": "42 minutes"}, {"label": "Impact", "value": "Sign-in slow for some people"}]},
{"type": "divider"},
{"type": "context", "text": "The write-up is due Friday."}
],
"actions": [{"type": "link", "label": "Read the timeline", "url": "https://status.example.com/incidents/207"}]
}
How cards look#
- The card takes the message’s place in every timeline, with your app’s name, picture and APP badge above it. The fallback
textisn’t shown. - Your text is shown as you wrote it. Our parts (Submit, Closed, closing times, counts, Waiting for Deploybot…, your note) are in each reader’s language, in a footer under the card.
- Buttons sit in a row that wraps; on phones, 3 or more stack full width, primary first and danger last. A danger button is outlined, with an ×. Links go on their own lines, each with its domain.
- Results show as bars with counts and percentages for choices, and as chips for button cards.
- Offline, an app’s card dims with Needs a connection and takes no answers.
- Cards can’t set colours, fonts or images, so they never look like buildIt.social’s own screens. A text input says it’s sent to your app.
- An unknown version or element, on an older version of the app, shows the fallback text with a prompt to update.
Limits#
| What | Limit |
|---|---|
| The card | 16 KB of JSON (without private), 25 elements |
private |
2 KB |
The message’s text |
1 to 4,000 characters |
| Buttons, links, choice options | 5, 3, 2 to 10 |
| A choice option’s label, description | 80, 2,000 characters |
| A text input’s answer | 2,000 characters (max_length may set fewer) |
| A note on an answer | 300 characters |
| Card changes and closes | 10 a minute per message |