Modals
Modals are a focused surface to collect data from users or display dynamic and interactive information. To users, modals appear as focused surfaces inside of Slack enabling brief, yet deep interactions with apps. Modals can be assembled using the visual and interactive components found in Block Kit.
Slack app configuration
The first step to use modals in your app is to enable interactive components. Visit the Slack app settings page, choose the app you're working on, and go to Features > Interactivity & Shortcuts on the left pane. There are three things to do on the page.
- Turn on the feature
- Set the Request URL to
https://{your app's public URL domain}/slack/events
(this step is not required for Socket Mode apps) - Click the Save Changes button at the bottom
What your bolt app does
There are three patterns to handle on modals. As always, your app has to respond to the request within 3 seconds by ack()
method. Otherwise, the user will see the timeout error on Slack.
block_actions
requests
When someone uses an interactive component in your app's modal views, the app will receive a block_actions
payload. To handle the block_actions
request:
- Verify requests from Slack
- Parse the request body, and check if the
type
isblock_actions
and theaction_id
in a block is the one you'd like to handle - Modify/push a view via API and/or update the modal to hold the sent data as
private_metadata
- Respond to the Slack API server with
200 OK
as an acknowledgment
view_submission
requests
When a modal view is submitted, you'll receive a view_submission
payload. To handle the view_submission
request:
- Verify requests from Slack
- Parse the request body, and check if the
type
isview_submission
and thecallback_id
is the one you'd like to handle - Extract the form data from
view.state.values
- Do whatever logic you'd like, such as input validations, storing the values in a database, talking to external services, etc.
- Respond with
200 OK
as the acknowledgment by doing either of the followings:
- Sending an empty body means closing only the modal
- Sending a body with
response_action
(possible values areerrors
,update
,push
,clear
)
view_closed
requests (only when notify_on_close
is true
)
Your app can optionally receive view_closed
payloads whenever a user clicks on the cancel or x buttons. These buttons are standard, not blocks, in all app modals. To receive the view_closed
payload when this happens, set notify_on_close
to true
when creating a view with views.open and views.push methods.
To handle the "view_closed"
request:
- Verify requests from Slack
- Parse the request body, and check if the
type
isview_closed
and thecallback_id
is the one you'd like to handle - Handle whatever logic you'd like to do at the timing
- Respond with
200 OK
as the acknowledgment
Modal development tips
In general, there are a few things to know when working with modals:
- You need a
trigger_id
in user interaction payloads to open a modal view - Only the inputs in
"type": "input"
blocks will be included in aview_submission
'sview.state.values
field - Each input/selection in non-
input
typed blocks, such assection
oractions
, is sent as ablock_actions
request - Use
callback_id
to identify a modal, a pair ofblock_id
andaction_id
to identify each input inview.state.values
- Use the
view.private_metadata
property to hold the internal state and/orblock_actions
results on the modal - Respond to
view_submission
requests withresponse_action
to determine the next state of a modal - The
views.update
andviews.push
API methods should be used when receivingblock_actions
requests in modals, NOT forview_submission
requests
Examples
If you're a beginner to using Bolt for Slack app development, consult Getting Started with Bolt first.
Let's start with opening a modal.
{
"type": "modal",
"callback_id": "meeting-arrangement",
"notify_on_close": true,
"title": { "type": "plain_text", "text": "Meeting Arrangement" },
"submit": { "type": "plain_text", "text": "Submit" },
"close": { "type": "plain_text", "text": "Cancel" },
"private_metadata": "{\"response_url\":\"https://hooks.slack.com/actions/T1ABCD2E12/330361579271/0dAEyLY19ofpLwxqozy3firz\"}",
"blocks": [
{
"type": "section",
"block_id": "category-block",
"text": { "type": "mrkdwn", "text": "Select a category of the meeting!" },
"accessory": {
"type": "static_select",
"action_id": "category-selection-action",
"placeholder": { "type": "plain_text", "text": "Select a category" },
"options": [
{ "text": { "type": "plain_text", "text": "Customer" }, "value": "customer" },
{ "text": { "type": "plain_text", "text": "Partner" }, "value": "partner" },
{ "text": { "type": "plain_text", "text": "Internal" }, "value": "internal" }
]
}
},
{
"type": "input",
"block_id": "agenda-block",
"element": { "action_id": "agenda-action", "type": "plain_text_input", "multiline": true },
"label": { "type": "plain_text", "text": "Detailed Agenda" }
}
]
}
The slack-api-client
offers a smooth DSL for building blocks and views. The following code generates View
objects in a type-safe way.
import com.slack.api.model.view.View;
import static com.slack.api.model.block.Blocks.*;
import static com.slack.api.model.block.composition.BlockCompositions.*;
import static com.slack.api.model.block.element.BlockElements.*;
import static com.slack.api.model.view.Views.*;
View buildView() {
return view(view -> view
.callbackId("meeting-arrangement")
.type("modal")
.notifyOnClose(true)
.title(viewTitle(title -> title.type("plain_text").text("Meeting Arrangement").emoji(true)))
.submit(viewSubmit(submit -> submit.type("plain_text").text("Submit").emoji(true)))
.close(viewClose(close -> close.type("plain_text").text("Cancel").emoji(true)))
.privateMetadata("{\"response_url\":\"https://hooks.slack.com/actions/T1ABCD2E12/330361579271/0dAEyLY19ofpLwxqozy3firz\"}")
.blocks(asBlocks(
section(section -> section
.blockId("category-block")
.text(markdownText("Select a category of the meeting!"))
.accessory(staticSelect(staticSelect -> staticSelect
.actionId("category-selection-action")
.placeholder(plainText("Select a category"))
.options(asOptions(
option(plainText("Customer"), "customer"),
option(plainText("Partner"), "partner"),
option(plainText("Internal"), "internal")
))
))
),
input(input -> input
.blockId("agenda-block")
.element(plainTextInput(pti -> pti.actionId("agenda-action").multiline(true)))
.label(plainText(pt -> pt.text("Detailed Agenda").emoji(true)))
)
))
);
}
If you need to carry information to the modal, set private_metadata
. The private_metadata
field is a single string with a maximum of 3000 characters. If you have multiple values, serialize them into a string format.
import com.slack.api.bolt.util.JsonOps;
class PrivateMetadata {
String responseUrl;
String commandArgument;
}
app.command("/meeting", (req, ctx) -> {
PrivateMetadata data = new PrivateMetadata();
data.responseUrl = ctx.getResponseUrl();
data.commandArgument = req.getPayload().getText();
return view(view -> view.callbackId("meeting-arrangement")
.type("modal")
.notifyOnClose(true)
.privateMetadata(JsonOps.toJsonString(data))
// omitted ...
A trigger_id
is required to open a modal. You can access it in payloads sent by user interactions, such as slash command invocations, by clicking a button. In Bolt, you can acquire the value by calling the Request.getPayload().getTriggerId()
method. It's also possible to get it by calling the Context.getTriggerId()
method. These methods are defined only when trigger_id
exists in a payload.
import com.slack.api.methods.response.views.ViewsOpenResponse;
app.command("/meeting", (req, ctx) -> {
ViewsOpenResponse viewsOpenRes = ctx.client().viewsOpen(r -> r
.triggerId(ctx.getTriggerId())
.view(buildView()));
if (viewsOpenRes.isOk()) return ctx.ack();
else return Response.builder().statusCode(500).body(viewsOpenRes.getError()).build();
});
The same code in Kotlin is below. (New to Kotlin? Getting Started in Kotlin may be helpful.)
app.command("/meeting") { req, ctx ->
val res = ctx.client().viewsOpen { it
.triggerId(ctx.triggerId)
.view(buildView())
}
if (res.isOk) ctx.ack()
else Response.builder().statusCode(500).body(res.error).build()
}
In Kotlin, it's much easier to embed multi-line string data in source code. It may be simpler to use the viewAsString(String)
method instead.
// Build a view using string interpolation
val commandArg = req.payload.text
val modalView = """
{
"type": "modal",
"callback_id": "meeting-arrangement",
"notify_on_close": true,
"title": { "type": "plain_text", "text": "Meeting Arrangement" },
"submit": { "type": "plain_text", "text": "Submit" },
"close": { "type": "plain_text", "text": "Cancel" },
"private_metadata": "${commandArg}"
"blocks": [
{
"type": "input",
"block_id": "agenda-block",
"element": { "action_id": "agenda-action", "type": "plain_text_input", "multiline": true },
"label": { "type": "plain_text", "text": "Detailed Agenda" }
}
]
}
""".trimIndent()
val res = ctx.client().viewsOpen { it
.triggerId(ctx.triggerId)
.viewAsString(modalView)
}
Alternatively, you can use the Block Kit DSL in conjunction with the Java Builder to construct your view. The Java example above would look like this in Kotlin:
import com.slack.api.model.kotlin_extension.view.blocks
import com.slack.api.model.view.Views.*
fun buildView(): View {
return view { thisView -> thisView
.callbackId("meeting-arrangement")
.type("modal")
.notifyOnClose(true)
.title(viewTitle { it.type("plain_text").text("Meeting Arrangement").emoji(true) })
.submit(viewSubmit { it.type("plain_text").text("Submit").emoji(true) })
.close(viewClose { it.type("plain_text").text("Cancel").emoji(true) })
.privateMetadata("""{"response_url":"https://hooks.slack.com/actions/T1ABCD2E12/330361579271/0dAEyLY19ofpLwxqozy3firz"}""")
.blocks {
// You can leverage Kotlin DSL here
section {
blockId("category-block")
markdownText("Select a category of the meeting!")
staticSelect {
actionId("category-selection-action")
placeholder("Select a category")
options {
option {
description("Customer")
value("customer")
}
option {
description("Partner")
value("partner")
}
option {
description("Internal")
value("internal")
}
}
}
}
input {
blockId("agenda-block")
plainTextInput {
actionId("agenda-action")
multiline(true)
}
label("Detailed Agenda", emoji = true)
}
}
}
}
block_actions
requests
block_actions
handle interactive components essentially the same way; the only difference is that a payload coming from a modal has view
and also its private_metadata
.
import com.google.gson.Gson;
import com.slack.api.model.view.View;
import com.slack.api.model.view.ViewState;
import com.slack.api.methods.response.views.ViewsUpdateResponse;
import com.slack.api.util.json.GsonFactory;
import java.util.Map;
View buildViewByCategory(String categoryId, String privateMetadata) {
Gson gson = GsonFactory.createSnakeCase();
Map<String, String> metadata = gson.fromJson(privateMetadata, Map.class);
metadata.put("categoryId", categoryId);
String updatedPrivateMetadata = gson.toJson(metadata);
return view(view -> view
.callbackId("meeting-arrangement")
.type("modal")
.notifyOnClose(true)
.title(viewTitle(title -> title.type("plain_text").text("Meeting Arrangement").emoji(true)))
.submit(viewSubmit(submit -> submit.type("plain_text").text("Submit").emoji(true)))
.close(viewClose(close -> close.type("plain_text").text("Cancel").emoji(true)))
.privateMetadata(updatedPrivateMetadata)
.blocks(asBlocks(
section(section -> section.blockId("category-block").text(markdownText("You've selected \"" + categoryId + "\""))),
input(input -> input
.blockId("agenda-block")
.element(plainTextInput(pti -> pti.actionId("agenda-action").multiline(true)))
.label(plainText(pt -> pt.text("Detailed Agenda").emoji(true)))
)
))
);
}
app.blockAction("category-selection-action", (req, ctx) -> {
String categoryId = req.getPayload().getActions().get(0).getSelectedOption().getValue();
View currentView = req.getPayload().getView();
String privateMetadata = currentView.getPrivateMetadata();
View viewForTheCategory = buildViewByCategory(categoryId, privateMetadata);
ViewsUpdateResponse viewsUpdateResp = ctx.client().viewsUpdate(r -> r
.viewId(currentView.getId())
.hash(currentView.getHash())
.view(viewForTheCategory)
);
return ctx.ack();
});
It looks like below in Kotlin.
app.blockAction("category-selection-action") { req, ctx ->
val categoryId = req.payload.actions[0].selectedOption.value
val currentView = req.payload.view
val privateMetadata = currentView.privateMetadata
val viewForTheCategory = buildViewByCategory(categoryId, privateMetadata)
val viewsUpdateResp = ctx.client().viewsUpdate { it
.viewId(currentView.id)
.hash(currentView.hash)
.view(viewForTheCategory)
}
ctx.ack()
}
view_submission
requests
Bolt does many of the commonly required tasks for you. The steps you need to handle are:
- Specify the
callback_id
to handle (by either of the exact name or regular expression) - Do whatever logic you'd like, such as input validations, storing values in a database, talking to external services, etc.
- Call
ack()
as an acknowledgment with either of the followings:- Sending an empty body means closing only the modal
- Sending a body with
response_action
(possible values areerrors
,update
,push
,clear
)
import com.slack.api.model.view.ViewState;
import java.util.*;
// when a user clicks "Submit"
app.viewSubmission("meeting-arrangement", (req, ctx) -> {
String privateMetadata = req.getPayload().getView().getPrivateMetadata();
Map<String, Map<String, ViewState.Value>> stateValues = req.getPayload().getView().getState().getValues();
String agenda = stateValues.get("agenda-block").get("agenda-action").getValue();
Map<String, String> errors = new HashMap<>();
if (agenda.length() <= 10) {
errors.put("agenda-block", "Agenda needs to be longer than 10 characters.");
}
if (!errors.isEmpty()) {
return ctx.ack(r -> r.responseAction("errors").errors(errors));
} else {
// TODO: may store the stateValues and privateMetadata
// Responding with an empty body means closing the modal now.
// If your app has next steps, respond with other response_action and a modal view.
return ctx.ack();
}
});
It looks like this in Kotlin.
// when a user clicks "Submit"
app.viewSubmission("meeting-arrangement") { req, ctx ->
val privateMetadata = req.payload.view.privateMetadata
val stateValues = req.payload.view.state.values
val agenda = stateValues["agenda-block"]!!["agenda-action"]!!.value
val errors = mutableMapOf<String, String>()
if (agenda.length <= 10) {
errors["agenda-block"] = "Agenda needs to be longer than 10 characters."
}
if (errors.isNotEmpty()) {
ctx.ack { it.responseAction("errors").errors(errors) }
} else {
// TODO: may store the stateValues and privateMetadata
// Responding with an empty body means closing the modal now.
// If your app has next steps, respond with other response_action and a modal view.
ctx.ack()
}
}
If you respond with "response_action": "update"
or push
, then response_action
and view
are required in the response body.
ctx.ack(r -> r.responseAction("update").view(renewedView));
ctx.ack(r -> r.responseAction("push").view(newViewInStack));
It looks like this in Kotlin.
ctx.ack { it.responseAction("update").view(renewedView) }
ctx.ack { it.responseAction("push").view(newViewInStack) }
Publishing messages after modal submissions
view_submission
payloads don't have a response_url
by default. However, if you have an input
block asking users for a channel to post a message, payloads may provide response_urls
(List<ResponseUrl> responseUrls
in Java).
To enable this, set the block element type as either channels_select
or conversations_select
and add "response_url_enabled": true
. Refer to the API documentation for details.
Also, if you want to automatically set the channel that a user is viewing when opening a modal toinitial_conversation(s)
, turn default_to_current_conversation
on in the conversations_select
/ multi_conversations_select
elements.
import static com.slack.api.model.block.Blocks.*;
import static com.slack.api.model.block.composition.BlockCompositions.*;
import static com.slack.api.model.block.element.BlockElements.*;
import static com.slack.api.model.view.Views.*;
View modalView = view(v -> v
.type("modal")
.callbackId("request-modal")
.submit(viewSubmit(vs -> vs.type("plain_text").text("Start")))
.blocks(asBlocks(
section(s -> s
.text(plainText("The channel we'll post the result"))
.accessory(conversationsSelect(conv -> conv
.actionId("notification_conv_id")
.responseUrlEnabled(true)
.defaultToCurrentConversation(true)
))
)
)));
view_closed
requests (only when notify_on_close
is true
)
Bolt does many of the commonly required tasks for you. The steps you need to handle are:
- Specify the
callback_id
to handle (by either of the exact name or regular expression) - Do whatever logic you'd like to do at the timing
- Call
ack()
as an acknowledgment
// when a user clicks "Cancel"
// "notify_on_close": true is required
app.viewClosed("meeting-arrangement", (req, ctx) -> {
// Do some cleanup tasks
return ctx.ack();
});
It looks like this in Kotlin.
// when a user clicks "Cancel"
// "notify_on_close": true is required
app.viewClosed("meeting-arrangement") { req, ctx ->
// Do some cleanup tasks
ctx.ack()
}
Under the hood
If you hope to understand what is happening with the above code, reading the following (a bit pseudo) code may be helpful.
import java.util.Map;
import com.google.gson.Gson;
import com.slack.api.Slack;
import com.slack.api.app_backend.interactive_components.payload.BlockActionPayload;
import com.slack.api.app_backend.interactive_components.payload.BlockSuggestionPayload;
import com.slack.api.app_backend.views.payload.ViewSubmissionPayload;
import com.slack.api.app_backend.views.payload.ViewClosedPayload;
import com.slack.api.app_backend.util.JsonPayloadExtractor;
import com.slack.api.app_backend.util.JsonPayloadTypeDetector;
import com.slack.api.util.json.GsonFactory;
PseudoHttpResponse handle(PseudoHttpRequest request) {
// 1. Verify requests from Slack
// https://docs.slack.dev/authentication/verifying-requests-from-slack
// This needs "X-Slack-Signature" header, "X-Slack-Request-Timestamp" header, and raw request body
if (!PseudoSlackRequestVerifier.isValid(request)) {
return PseudoHttpResponse.builder().status(401).build();
}
// 2. Parse the request body and check the type, callback_id, action_id
// payload={URL-encoded JSON} in the request body
JsonPayloadExtractor payloadExtractor = new JsonPayloadExtractor();
String payloadString = payloadExtractor.extractIfExists(request.getBodyAsString());
// The value looks like: { "type": "block_actions", "team": { "id": "T1234567", ...
JsonPayloadTypeDetector typeDetector = new JsonPayloadTypeDetector();
String payloadType = typeDetector.detectType(payloadString);
Gson gson = GsonFactory.createSnakeCase();
if (payloadType != null && payloadType.equals("view_submission")) {
ViewSubmissionPayload payload = gson.fromJson(payloadString, ViewSubmissionPayload.class);
if (payload.getCallbackId().equals("meeting-arrangement")) {
// 3. Extract the form data from view.state.values
// 4. Do whatever to do such as input validations, storing them in database, talking to external services
// 5. Respond to the Slack API server with 200 OK as an acknowledgment
}
} else if (payloadType != null && payloadType.equals("view_closed")) {
ViewClosedPayload payload = gson.fromJson(payloadString, ViewClosedPayload.class);
if (payload.getCallbackId().equals("meeting-arrangement")) {
// 3. Do whatever to do at the timing
// 4. Respond to the Slack API server with 200 OK as an acknowledgment
}
} else if (payloadType != null && payloadType.equals("block_actions")) {
BlockActionPayload payload = gson.fromJson(payloadString, BlockActionPayload.class);
if (payload.getCallbackId().equals("meeting-arrangement")) {
if (payload.getActionId().equals("category-selection-action")) {
// 3. Modify/push a view via API and/or update the modal to hold the sent data as private_metadata
// 4. Respond to the Slack API server with 200 OK as an acknowledgment
}
}
} else if (payloadType != null && payloadType.equals("block_suggestion")) {
BlockSuggestionPayload payload = gson.fromJson(payloadString, BlockSuggestionPayload.class);
if (payload.getCallbackId().equals("meeting-arrangement")) {
if (payload.getActionId().equals("category-selection-action")) {
List<Option> options = buildOptions(payload.getValue());
// Return a successful response having `options` in its body
return PseudoHttpResponse.builder().body(Map.of("options", options)).status(200).build();
}
}
} else {
// other patterns
return PseudoHttpResponse.builder().status(404).build();
}
}