Skip to main content

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).

caution

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 set dispatch_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.

typeSent whenRouting key
slash_commandA user runs your command from the composeractionId
message_actionA user picks your action from a message's menuactionId
block_actionsAn element in an open modal dispatchedviewId and actions[0].actionId
view_submissionA user submits an open modalviewId
caution

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

FieldTypeDescription
typestringOne of the four interaction types above
userobjectThe invoking user
user.idstringThe user's address ID
user.emailstringThe user's verified email address
user.accountIdintegerThe 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"
}
}
FieldTypeDescription
actionIdstringThe action's ID, as registered
chat.idstringThe chat the command was run in
chat.threadTimestampintegerUnix 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"
}
}
FieldTypeDescription
message.chatIdstringThe chat containing the message
message.timestampintegerThe message's timestamp, in Unix microseconds. This is the message's identity.
message.threadTimestampintegerThe channel thread's parent timestamp. Omitted when the message is not in a channel thread.
message.userIdstringAddress ID of the message's author
message.contentTypestring"text" or "block"
message.textstringMarkdown text. Present when contentType is "text".
message.blocksarrayBlock Kit blocks. Present when contentType is "block".
message.colorstringThe 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" }
}
}
}
}
FieldTypeDescription
viewIdstringThe viewId of the open modal
actionsarrayAlways exactly one element
actions[].actionIdstringThe element's action_id
actions[].blockIdstringThe containing block's block_id
actions[].valuestringDirect value for buttons, text inputs, and datepickers; empty for static selects, radios, and checkboxes
stateobjectEvery input's current value — see Reading state
caution

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:

ElementValue 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 }
tip

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

BodyResult
EmptyNothing happens. A deliberate no-op.
{"infoMessage": "Done"}A toast
A modal objectThe modal opens
{ "infoMessage": "Started the deploy — I'll post here when it finishes." }

To block_actions

BodyResult
EmptyNothing happens — the modal stays as it is
{"responseAction": "update", "view": { … }}The modal is replaced in place
{"infoMessage": "…"}A toast, modal unchanged
clear or errorsThe 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

BodyResult
EmptyThe 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" }
}
FieldRequiredDescription
viewIdYesYour identifier for this view, up to 255 characters. Returned on every subsequent interaction.
titleYesplain_text only
blocksYesThe modal's content
submitLabelNoplain_text. Defaults to Submit.
closeLabelNoplain_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 messagesheader, section, context, divider and actions — plus the input block for collecting values.

The input block

FieldRequiredDescription
typeYes"input"
labelYesplain_text
elementYesOne input element, below
block_idNoRecommended. The key you read in state and target with errors.
optionalNotrue to let the user submit without a value. Defaults to false.
dispatch_actionNotrue to receive block_actions when this input commits a change. Defaults to false; see below.

Input elements

ElementNotable fields
plain_text_inputplaceholder, initial_value, multiline, min_length (0–3000), max_length (1–3000), focus_on_load
static_selectplaceholder, options (up to 100) or option_groups, initial_option
radio_buttonsoptions (up to 10), initial_option
checkboxesoptions (up to 10), initial_options
datepickerplaceholder, 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

ConstraintLimit
Actions per app10
Action ID1–32 chars, az, 09, -, _
Action label64 characters
Action description256 characters
Response time3 seconds, no retry
Response body1 MB
viewId255 characters
Blocks per modal100
Total block content per modal256 KB
Any single text value3,000 characters
errors entries100
action_id255 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);