App Actions API
This is the wire reference for App Actions: what Roam sends to your Interactivity URL, and what you can send back.
For setting up an app, registering actions, and enabling the commands scope,
start with App Actions.
Delivery
Every app action interaction is a POST to the same Interactivity URL,
with Content-Type: application/json. The initial invocation and every later
modal event all go to that one URL. Block Kit message-button clicks
use it too; they share type with modal block_actions (see Routing).
Requests are signed with your app's signing secret, using the
Standard Webhooks
scheme. The signature is in the webhook-id, webhook-timestamp, and
webhook-signature headers. You should verify over the raw body (see
Signature verification).
Slash command, message action, and modal payloads do not include a
clientId field. If one server backs several Roam apps, give each app its own
Interactivity URL (a distinct path is enough) — the request body alone will not
tell them apart.
Routing
Roam tells you what happened with a type field. slash_command and
message_action are the initial invocation. If you respond with a modal
(a Block Kit form), later events reuse the same URL:
block_actions— an element dispatched: it committed a change and Roam is asking you about it before submit. Inputs do this only if you setdispatch_action; buttons always dispatch.view_submission— the user submitted the form.
Both include a viewId you chose when you opened the modal, and state with
every input's current value. Neither includes the chat.
type | Sent when | Routing key |
|---|---|---|
slash_command | A user runs your command from the composer | actionId |
message_action | A user picks your action from a message's menu | actionId |
block_actions | An element in an open modal dispatched | viewId and actions[0].actionId |
view_submission | A user submits an open modal | viewId |
Block Kit message-button clicks also
use type: "block_actions". A modal interaction has a viewId; a
message-button click has a clientId.
A typical handler first switches on type. Route initial invocations on the
top-level actionId, modal submissions on viewId, and modal element
interactions on viewId plus actions[0].actionId as needed.
app.post("/roam/interactivity", async (req, res) => {
const payload = verify(req); // Standard Webhooks, over the raw body
switch (payload.type) {
case "slash_command":
case "message_action":
return handleAction(payload, res);
case "block_actions":
if (payload.viewId) {
return handleModalAction(payload, res);
}
if (payload.clientId) {
return handleMessageButton(payload, res);
}
return res.status(200).end();
case "view_submission":
return handleSubmit(payload, res);
default:
return res.status(200).end();
}
});
Common fields
| Field | Type | Description |
|---|---|---|
type | string | One of the four interaction types above |
user | object | The invoking user |
user.id | string | The user's address ID |
user.email | string | The user's verified email address |
user.accountId | integer | The account (workspace) the action resolved in |
slash_command
Sent when a user runs your command from the composer.
{
"type": "slash_command",
"actionId": "create-issue",
"user": {
"id": "ad1e9cc0-0ffd-47e5-895c-2630a73327b4",
"email": "alice@example.com",
"accountId": 4821
},
"chat": {
"id": "295155ae-7df5-4ed5-9ebc-89a170559c81"
}
}
| Field | Type | Description |
|---|---|---|
actionId | string | The action's ID, as registered |
chat.id | string | The chat the command was run in |
chat.threadTimestamp | integer | Unix microseconds. Present only when the command was run inside a thread. |
Slash commands carry no arguments. Text typed after the command name filters the autocomplete menu and is not delivered.
message_action
Sent when a user picks your action from a message's overflow menu. Identical to
slash_command, plus the message it was invoked on.
{
"type": "message_action",
"actionId": "create-issue",
"user": {
"id": "ad1e9cc0-0ffd-47e5-895c-2630a73327b4",
"email": "alice@example.com",
"accountId": 4821
},
"chat": {
"id": "295155ae-7df5-4ed5-9ebc-89a170559c81"
},
"message": {
"chatId": "295155ae-7df5-4ed5-9ebc-89a170559c81",
"timestamp": 1765602474760032,
"userId": "7c2f1e40-5a8b-4c31-9d17-2b6e0f9a4c88",
"contentType": "text",
"text": "The deploy is failing on staging again"
}
}
| Field | Type | Description |
|---|---|---|
message.chatId | string | The chat containing the message |
message.timestamp | integer | The message's timestamp, in Unix microseconds. This is the message's identity. |
message.threadTimestamp | integer | The channel thread's parent timestamp. Omitted when the message is not in a channel thread. |
message.userId | string | Address ID of the message's author |
message.contentType | string | "text" or "block" |
message.text | string | Markdown text. Present when contentType is "text". |
message.blocks | array | Block Kit blocks. Present when contentType is "block". |
message.color | string | The block message's color strip, when set |
To post into the same channel thread with chat.post, set
threadTimestamp to message.threadTimestamp ?? message.timestamp. To reply
directly to the selected message in a DM, set replyTimestamp to
message.timestamp instead. Direct replies support text messages only.
block_actions
Sent when an element in an open modal dispatches (see
dispatch_action for which elements dispatch).
{
"type": "block_actions",
"viewId": "create-issue",
"user": {
"id": "ad1e9cc0-0ffd-47e5-895c-2630a73327b4",
"email": "alice@example.com",
"accountId": 4821
},
"actions": [
{
"actionId": "project",
"blockId": "project-block",
"value": ""
}
],
"state": {
"values": {
"project-block": {
"project": { "type": "static_select", "selected_option": { "value": "ROAM" } }
},
"summary-block": {
"summary": { "type": "plain_text_input", "value": "Deploy fails on staging" }
}
}
}
}
| Field | Type | Description |
|---|---|---|
viewId | string | The viewId of the open modal |
actions | array | Always exactly one element |
actions[].actionId | string | The element's action_id |
actions[].blockId | string | The containing block's block_id |
actions[].value | string | Direct value for buttons, text inputs, and datepickers; empty for static selects, radios, and checkboxes |
state | object | Every input's current value — see Reading state |
For static_select, radio_buttons, and checkboxes, actions[].value is an
empty string. Read their selection from state, keyed by block_id then
action_id. A plain_text_input sends its text directly, and a datepicker
sends its YYYY-MM-DD value, but their current values are also present in
state.
view_submission
Sent when the user submits an open modal.
{
"type": "view_submission",
"viewId": "create-issue",
"user": {
"id": "ad1e9cc0-0ffd-47e5-895c-2630a73327b4",
"email": "alice@example.com",
"accountId": 4821
},
"state": {
"values": {
"project-block": {
"project": { "type": "static_select", "selected_option": { "value": "ROAM" } }
},
"summary-block": {
"summary": { "type": "plain_text_input", "value": "Deploy fails on staging" }
}
}
}
}
Reading state
state.values is keyed by block_id, then by the element's
action_id:
state.values[block_id][action_id]
Each value is tagged with the element's type:
| Element | Value shape |
|---|---|
plain_text_input | { "type": "plain_text_input", "value": "…" | null } |
static_select | { "type": "static_select", "selected_option": { "value": "…" } | null } |
radio_buttons | { "type": "radio_buttons", "selected_option": { "value": "…" } | null } |
checkboxes | { "type": "checkboxes", "selected_options": [{ "value": "…" }] } |
datepicker | { "type": "datepicker", "selected_date": "YYYY-MM-DD" | null } |
Always set block_id explicitly on blocks you intend to read. When you omit it,
Roam assigns block-<index> based on the block's position — so reordering
blocks in a modal update silently changes the keys your handler reads.
Responses
Your endpoint must respond within 3 seconds. There are no retries, redirects are not followed, and response bodies are capped at 1 MB.
Respond 200 with a JSON body. The shape you send determines what the user
sees.
To slash_command and message_action
| Body | Result |
|---|---|
| Empty | Nothing happens. A deliberate no-op. |
{"infoMessage": "Done"} | A toast |
| A modal object | The modal opens |
{ "infoMessage": "Started the deploy — I'll post here when it finishes." }
To block_actions
| Body | Result |
|---|---|
| Empty | Nothing happens — the modal stays as it is |
{"responseAction": "update", "view": { … }} | The modal is replaced in place |
{"infoMessage": "…"} | A toast, modal unchanged |
clear or errors | The response action is ignored; an included infoMessage is still shown |
The client does not close a modal or display per-block errors in response to a block interaction.
To view_submission
| Body | Result |
|---|---|
| Empty | The modal closes, with a Done toast |
{"responseAction": "clear", "infoMessage": "…"} | The modal closes, and the message is shown as a toast (default: Done) |
{"responseAction": "update", "view": { … }} | The modal is replaced in place |
{"responseAction": "errors", "errors": { … }} | Per-block errors are shown, and the modal stays open |
{"infoMessage": "…"} | The modal closes, and the message is shown as a toast |
Omitting responseAction on a view_submission behaves as clear. An
explicit successful submission looks like:
{ "responseAction": "clear", "infoMessage": "Created ROAM-482" }
Validation errors
Return errors keyed by block_id:
{
"responseAction": "errors",
"errors": {
"summary-block": "Summary must be under 80 characters",
"due-block": "Pick a date in the future"
}
}
The Roam client enforces required fields, lengths, and offered options as a
convenience. Treat state as untrusted and validate it on your server.
Updating a modal
Return the replacement modal under view:
{
"responseAction": "update",
"view": {
"viewId": "create-issue",
"title": { "type": "plain_text", "text": "Create Issue" },
"blocks": [ … ],
"submitLabel": { "type": "plain_text", "text": "Create" }
}
}
This is how you build dependent fields (pick a project, then re-render with
that project's issue types) and multi-step wizards (each step returns the next
one, with a different viewId).
Input values are preserved across an update where the element is unchanged. Changing an element's shape resets that field.
Modals
The modal object
{
"viewId": "create-issue",
"title": { "type": "plain_text", "text": "Create Issue" },
"blocks": [
{
"type": "input",
"block_id": "summary-block",
"label": { "type": "plain_text", "text": "Summary" },
"element": {
"type": "plain_text_input",
"action_id": "summary",
"placeholder": { "type": "plain_text", "text": "Short description" }
}
}
],
"submitLabel": { "type": "plain_text", "text": "Create" },
"closeLabel": { "type": "plain_text", "text": "Cancel" }
}
| Field | Required | Description |
|---|---|---|
viewId | Yes | Your identifier for this view, up to 255 characters. Returned on every subsequent interaction. |
title | Yes | plain_text only |
blocks | Yes | The modal's content |
submitLabel | No | plain_text. Defaults to Submit. |
closeLabel | No | plain_text. Defaults to Cancel. |
Carrying context through viewId
block_actions and view_submission do not include the chat. If your handler
needs to know where the modal was opened — to post a result with
chat.post, for instance — encode it into viewId
yourself when you open the modal:
// Opening, in response to a slash_command
const viewId = `create-issue:${payload.chat.id}`;
// Submitting
const [action, chatId] = payload.viewId.split(":");
viewId allows 255 characters, which is ample for an action name and a chat
ID. Alternatively, keep server-side state keyed by a viewId you generate.
Blocks
Modals use the same blocks as Block Kit messages —
header, section, context, divider and actions — plus the input
block for collecting values.
The input block
| Field | Required | Description |
|---|---|---|
type | Yes | "input" |
label | Yes | plain_text |
element | Yes | One input element, below |
block_id | No | Recommended. The key you read in state and target with errors. |
optional | No | true to let the user submit without a value. Defaults to false. |
dispatch_action | No | true to receive block_actions when this input commits a change. Defaults to false; see below. |
Input elements
| Element | Notable fields |
|---|---|
plain_text_input | placeholder, initial_value, multiline, min_length (0–3000), max_length (1–3000), focus_on_load |
static_select | placeholder, options (up to 100) or option_groups, initial_option |
radio_buttons | options (up to 10), initial_option |
checkboxes | options (up to 10), initial_options |
datepicker | placeholder, initial_date (YYYY-MM-DD) |
Every input element accepts an action_id (up to 255 characters), which is the
second key under which its value appears in state. Set it explicitly so that
the key remains stable and meaningful to your handler.
{
"type": "input",
"block_id": "project-block",
"label": { "type": "plain_text", "text": "Project" },
"dispatch_action": true,
"element": {
"type": "static_select",
"action_id": "project",
"placeholder": { "type": "plain_text", "text": "Pick a project" },
"options": [
{ "text": { "type": "plain_text", "text": "Roam" }, "value": "ROAM" },
{ "text": { "type": "plain_text", "text": "Platform" }, "value": "PLAT" }
]
}
}
dispatch_action
Input blocks are quiet by default: the user's edits stay client-side until they
submit. Set dispatch_action: true to receive a block_actions request when
that input commits a change. Selection elements and datepickers commit changes immediately. plain_text_input commits changes when it loses focus.
dispatch_action can be used to rebuild a modal in response to a selection.
Interactive buttons always dispatch, whether they are in an actions block
or as a section accessory. They do not need dispatch_action. A button with
a url opens that URL instead and does not dispatch.
Limits
| Constraint | Limit |
|---|---|
| Actions per app | 10 |
| Action ID | 1–32 chars, a–z, 0–9, -, _ |
| Action label | 64 characters |
| Action description | 256 characters |
| Response time | 3 seconds, no retry |
| Response body | 1 MB |
viewId | 255 characters |
| Blocks per modal | 100 |
| Total block content per modal | 256 KB |
| Any single text value | 3,000 characters |
errors entries | 100 |
action_id | 255 characters |
Note that modals are more generous than messages: a Block Kit message is limited to 10 blocks and 8,000 bytes.
Complete example
A /create-issue command that opens a modal, reacts to the project selection,
and confirms on submit. This sample handles modal block_actions only — not
message-button clicks.
import express from "express";
import { Webhook } from "standardwebhooks";
const app = express();
const wh = new Webhook(process.env.ROAM_SIGNING_SECRET);
const PROJECTS = {
ROAM: ["Bug", "Task"],
PLAT: ["Incident", "Chore"],
};
const modal = (chatId, project) => ({
viewId: `create-issue:${chatId}`,
title: { type: "plain_text", text: "Create Issue" },
submitLabel: { type: "plain_text", text: "Create" },
blocks: [
{
type: "input",
block_id: "project-block",
label: { type: "plain_text", text: "Project" },
dispatch_action: true,
element: {
type: "static_select",
action_id: "project",
placeholder: { type: "plain_text", text: "Pick a project" },
options: Object.keys(PROJECTS).map((key) => ({
text: { type: "plain_text", text: key },
value: key,
})),
...(project && {
initial_option: {
text: { type: "plain_text", text: project },
value: project,
},
}),
},
},
...(project
? [
{
type: "input",
block_id: "kind-block",
label: { type: "plain_text", text: "Issue type" },
element: {
type: "static_select",
action_id: "kind",
options: PROJECTS[project].map((kind) => ({
text: { type: "plain_text", text: kind },
value: kind,
})),
},
},
]
: []),
{
type: "input",
block_id: "summary-block",
label: { type: "plain_text", text: "Summary" },
element: { type: "plain_text_input", action_id: "summary", max_length: 80 },
},
],
});
const selected = (state, blockId, actionId) =>
state?.values?.[blockId]?.[actionId]?.selected_option?.value;
app.post(
"/roam/interactivity",
express.raw({ type: "application/json" }),
(req, res) => {
let payload;
try {
payload = wh.verify(req.body.toString("utf8"), {
"webhook-id": req.get("webhook-id"),
"webhook-timestamp": req.get("webhook-timestamp"),
"webhook-signature": req.get("webhook-signature"),
});
} catch {
return res.status(401).end();
}
const chatId = payload.viewId?.split(":")[1] ?? payload.chat?.id;
switch (payload.type) {
case "slash_command":
case "message_action":
return res.json(modal(payload.chat.id));
case "block_actions":
// The project changed — rebuild with that project's issue types.
return res.json({
responseAction: "update",
view: modal(chatId, selected(payload.state, "project-block", "project")),
});
case "view_submission": {
const summary = payload.state.values["summary-block"].summary.value;
if (!summary?.trim()) {
return res.json({
responseAction: "errors",
errors: { "summary-block": "Summary is required" },
});
}
// Do the real work asynchronously, then post back with chat.post.
createIssueLater({ chatId, summary });
return res.json({
responseAction: "clear",
infoMessage: "Creating your issue…",
});
}
default:
return res.status(200).end();
}
}
);
app.listen(3000);