Resources menu

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 submit sends 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 null is 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.text and an option’s description.
  • 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 responses at all.
  • A person always sees their own answer.
  • visibility, mode and guests can’t change once someone has answered, and results may only show later, never sooner (live to on_close to hidden is fine; the other way answers 422 card_invalid). People answered under the card’s promise.
  • A card closes within a minute of its closes_at, and your app then gets card.closed with closed_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 with multiple), 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 the id from 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 once card.
  • The note is at most 300 characters; blank or null for 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 in details.
  • 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 text always 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 shared card’s stay readable as its results allow, and an anonymous card’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 a shared card’s answers while its results show; a private or anonymous one answers 403 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 text isn’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