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

FieldRules
contractRequired. Always 1.
nameRequired, 3 to 40 characters. Users see the name set on the app's Overview tab.
descriptionUp to 200 characters.
scopesWhat the app may learn about users. See Scopes.
allowedLinkHostsUp 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).
allowedImageHostsUp to 10 hosts card images may load from, written the same way.
webhook.urlWhere commands are sent. https, up to 1024 characters, no user name, password or fragment. Required when the manifest has commands.
webMakes the app a web app. See Web apps.
roomBottrue lets room admins add the app to rooms so it can post on its own. See Room bots.
sealing.publicKeyOptional X25519 public key, base64 of its 32 bytes. Phones then encrypt form answers to it. See Sealed answers.
commandsUp to 20 slash commands. See Commands.
flowsUp to 20 forms, keyed by flow id. See Flows and views.
cardsUp 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.

ScopeYour app receivesUsers are told the app will
profile.nameuser.name: the name the user shows in the appsee your name
profile.languageuser.language: the user's app language, such as enknow your language
conversation.refconversation.ref: a value that is the same every time your app is used in the same room, and never reveals which room it istell conversations apart (without knowing which room)
location.coarseuser.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.cardsPermission for cards with "visibility": "room" and for a web app's postCardoffer to post cards into rooms (you confirm each one)
paymentsPermission 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 Paymentsask 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" }
FieldRules
idRequired, unique. Lower-case letters, digits, dots, dashes and underscores, up to 64 characters. Your webhook receives it as command.
commandRequired, 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.
titleRequired, up to 40 characters. Shown in the command list.
descriptionUp to 120 characters.
roomTypesWhere 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.
flowRequired. 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.coarse scope, the phone first shows only the "include my location" switch.

A flow has up to 10 views. A view is:

FieldRules
idRequired, unique in the flow, same characters as flow ids.
titleRequired, up to 60 characters.
descriptionUp to 200 characters.
submitLabelUp to 24 characters, the label of the view's button.
blocksUp 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.

FieldRules
idRequired, unique across the whole flow. Letters, digits and underscores, up to 32 characters. The answer arrives under this key in values.
labelRequired, up to 40 characters.
inputRequired. The kind, from the table below.
hintUp to 120 characters, shown under the field.
placeholderUp to 80 characters.
requiredtrue makes an answer mandatory.
defaultThe starting value. An optional question left blank is sent as its default, when it has one.
inputYour webhook receivesExtra fields
texta stringmaxLength 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.
numbera number; whole numbers arrive as integersmin, max, step (positive). Decimals and negative numbers are allowed unless min says otherwise.
selectthe chosen option's valueoptions: 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.
multiselectan array of option valuesAs select, plus minSelected and maxSelected.
toggletrue or falseNone.
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 zonemin, 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" }
      ]
    }
  }
}