Manifest
The manifest is one JSON document that describes a version of your app. You edit it on the app's Manifest tab. Parsing is strict, so an unknown or misspelled field is an error rather than something silently ignored.
A manifest may be up to 65536 bytes. It must declare a webhook, a web page, or both. Saving stores it as the draft; publishing makes it the version users get, and a published version never changes.
Top-level fields
| Field | Rules |
|---|---|
contract | Required. Always 1. |
name | Required, 3 to 40 characters. Users see the name set on the app's Overview tab. |
description | Up to 200 characters. |
scopes | What the app may learn about users. See Scopes. |
allowedLinkHosts | Up to 10 hosts an open_url card action may point to, such as
example.com, or *.example.com for its subdomains (not the domain itself). |
allowedImageHosts | Up to 10 hosts card images may load from, written the same way. |
webhook.url | Where commands are sent. https, up to 1024 characters, no user
name, password or fragment. Required when the manifest has commands. |
web | Makes the app a web app. See Web apps. |
roomBot | true lets room admins add the app to rooms so it can post on its
own. See Room bots. |
sealing.publicKey | Optional X25519 public key, base64 of its 32 bytes. Phones then encrypt form answers to it. See Sealed answers. |
commands | Up to 20 slash commands. See Commands. |
flows | Up to 20 forms, keyed by flow id. See Flows and views. |
cards | Up to 20 card templates, keyed by template id. See Card templates. |
Scopes
Users see each scope's description and must accept all of them to install the app. Ask only for what you use.
| Scope | Your app receives | Users are told the app will |
|---|---|---|
profile.name | user.name: the name the user shows in the app | see your name |
profile.language | user.language: the user's app language, such as en | know your language |
conversation.ref | conversation.ref: a value that is the same every time your app is
used in the same room, and never reveals which room it is | tell conversations apart (without knowing which room) |
location.coarse | user.location: latitude and longitude rounded to two decimals
(about a kilometre), only when the user switches on "include my location" for that use |
use your approximate location when you choose to share it |
post.cards | Permission for cards with "visibility": "room" and for a web app's
postCard | offer to post cards into rooms (you confirm each one) |
payments | Permission to ask the user for a payment: the payment reply, the
pay card action and banga.requestPayment. Every payment is still confirmed by the user on
their device; see Payments | ask you to pay from your wallet (you confirm each payment on your device) |
Every call carries user.id, the per-app id, whatever the scopes.
When a new version asks for a scope, your app gets that data from each user only after they accept it; until then they keep using the app without it. A scope you drop in a new version stops at once. You get your own data for every declared scope while testing, but testers must install the app before it receives theirs.
Commands
{ "id": "lunch.start", "command": "/lunch", "title": "Start a lunch order", "description": "Pick a restaurant and a time", "roomTypes": ["PRIVATE_GROUP", "PUBLIC_GROUP"], "flow": "start" }
| Field | Rules |
|---|---|
id | Required, unique. Lower-case letters, digits, dots, dashes and underscores, up to 64
characters. Your webhook receives it as command. |
command | Required, unique. What users type: / and a lower-case word, optionally
followed by more words separated by single spaces, up to 32 characters, such as /lunch or
/lunch status. When two installed apps share a command, the phone shows each app's name. |
title | Required, up to 40 characters. Shown in the command list. |
description | Up to 120 characters. |
roomTypes | Where the command may be used: PRIVATE_DIRECT,
PRIVATE_GROUP, PUBLIC_GROUP, PUBLIC_BROADCAST. Empty or absent means
everywhere. In a broadcast room only its admins can run commands. |
flow | Required. The id of the flow the command starts. |
Flows and views
A flow is the form behind a command or a card button. Flow ids use lower-case letters, digits, dashes and underscores, up to 32 characters.
"mode": "submit"(the default): the phone walks the user through every view and sends all answers in one call. Forms with two or more views end with a review page."mode": "dynamic": the phone sends each view's answers as it is completed, and your webhook decides what comes next. See Multi-step forms.- A flow with no views runs at once, without a form. If the app has the
location.coarsescope, the phone first shows only the "include my location" switch.
A flow has up to 10 views. A view is:
| Field | Rules |
|---|---|
id | Required, unique in the flow, same characters as flow ids. |
title | Required, up to 60 characters. |
description | Up to 200 characters. |
submitLabel | Up to 24 characters, the label of the view's button. |
blocks | Up to 20 blocks, drawn in order. |
Blocks
{"type": "text", "text": "..."}: instructions, up to 1000 characters.**bold**and*italic*are the only formatting.{"type": "divider"}: a separator line.{"type": "input", ...}: a question, described below.
Inputs
Every input has these fields. Only the fields that apply to its kind are accepted.
| Field | Rules |
|---|---|
id | Required, unique across the whole flow. Letters, digits and underscores, up to 32
characters. The answer arrives under this key in values. |
label | Required, up to 40 characters. |
input | Required. The kind, from the table below. |
hint | Up to 120 characters, shown under the field. |
placeholder | Up to 80 characters. |
required | true makes an answer mandatory. |
default | The starting value. An optional question left blank is sent as its default, when it has one. |
input | Your webhook receives | Extra fields |
|---|---|---|
text | a string | maxLength 1 to 500 (default 200);
pattern, a regular expression up to 200 characters that the whole answer must match;
multiline. Control characters other than new lines and tabs are refused. |
number | a number; whole numbers arrive as integers | min,
max, step (positive). Decimals and negative numbers are allowed unless
min says otherwise. |
select | the chosen option's value | options: 1 to 50 of
{"value", "label"?, "description"?}, values 1 to 64 characters and unique, labels up to 60,
descriptions up to 120. style: list (default) or chips. |
multiselect | an array of option values | As select, plus
minSelected and maxSelected. |
toggle | true or false | None. |
date | "2026-09-23" | min, max in the same
format. |
time | "12:30" | min, max in the same
format. |
datetime | "2026-09-23T12:30", local time without a zone | min,
max in the same format. |
{ "type": "input", "id": "email", "label": "Email", "input": "text", "required": true,
"maxLength": 120, "pattern": "[^@\\s]+@[^@\\s]+", "placeholder": "you@example.com" }
{ "type": "input", "id": "toppings", "label": "Toppings", "input": "multiselect", "style": "chips",
"minSelected": 1, "maxSelected": 3,
"options": [ { "value": "cheese" }, { "value": "olives" }, { "value": "basil" } ] }
{ "type": "input", "id": "when", "label": "When", "input": "datetime", "min": "2026-01-01T00:00" }
The phone checks answers as the user types, and the platform checks them again before calling your webhook. A value that breaks a rule, a missing required answer, or a key that is not an input of the flow is refused with an error to the user, and your webhook is not called.
A larger example
A lunch order started with /lunch. Its card, from the order_open template, lets everyone
in the room add a dish through a second flow, and lets the organiser close the order. The
open_flow payload pre-fills the order input of that flow.
{
"contract": 1,
"name": "Team Lunch",
"description": "Collect a lunch order from the whole room.",
"scopes": ["profile.name", "conversation.ref", "post.cards"],
"allowedLinkHosts": ["lunch.example.com"],
"allowedImageHosts": ["cdn.lunch.example.com"],
"webhook": { "url": "https://lunch.example.com/banga/hook" },
"commands": [
{ "id": "lunch.start", "command": "/lunch", "title": "Start a lunch order",
"description": "Pick a restaurant and a time", "roomTypes": ["PRIVATE_GROUP", "PUBLIC_GROUP"], "flow": "start" }
],
"flows": {
"start": {
"mode": "submit",
"views": [
{ "id": "where", "title": "Where from?", "blocks": [
{ "type": "input", "id": "restaurant", "label": "Restaurant", "input": "select", "required": true,
"options": [ { "value": "bkk", "label": "Bangkok Kitchen" }, { "value": "sal", "label": "Green Salads" } ] },
{ "type": "input", "id": "time", "label": "Delivery time", "input": "time", "required": true,
"min": "11:30", "max": "14:00" }
] }
]
},
"add": {
"mode": "submit",
"views": [
{ "id": "dish", "title": "Your dish", "submitLabel": "Add", "blocks": [
{ "type": "text", "text": "Add your dish to the order. **One dish** per entry." },
{ "type": "input", "id": "order", "label": "Order number", "input": "text", "required": true, "maxLength": 12 },
{ "type": "input", "id": "dish", "label": "Dish", "input": "text", "required": true, "maxLength": 60 },
{ "type": "input", "id": "qty", "label": "Quantity", "input": "number", "min": 1, "max": 20, "default": 1 },
{ "type": "input", "id": "spicy", "label": "Spicy", "input": "toggle" }
] }
]
}
},
"cards": {
"order_open": {
"blocks": [
{ "type": "title", "text": "Lunch from {{restaurant}}" },
{ "type": "facts", "items": [ { "k": "Delivery", "v": "{{time}}" }, { "k": "Orders so far", "v": "{{count}}" } ] },
{ "type": "status", "text": "Open", "tone": "ok" }
],
"actions": [
{ "type": "open_flow", "label": "Add my dish", "style": "primary", "flowId": "add",
"payload": { "order": "{{order}}" } },
{ "type": "submit", "label": "Close order", "actionId": "close", "payload": { "order": "{{order}}" } },
{ "type": "open_url", "label": "Menu", "url": "https://lunch.example.com/menu" }
]
}
}
}