The reason most Slack modal bugs feel mysterious is that the slack view_submission payload arrives with almost none of the context you assume it has. There is no session on Slack's side. When a user hits Submit, Slack does not remember who opened the modal, which channel the action came from, or which record the form is about. You get the form values and whatever you deliberately stashed earlier, and that is it. Once you internalize that, the whole lifecycle stops being surprising. This walks through opening a view, carrying context through submission, reading the state map, returning inline validation errors, handling cancellation, and staying inside the three-second clock that quietly breaks careless handlers.
Opening a view and the trigger_id you only get once
You open a modal with views.open, and it requires a trigger_id that Slack mints when the user does something: runs a shortcut, clicks a button, fires a slash command. That trigger_id is single-use and short-lived. You spend it on exactly one views.open or views.push call, and it expires in about three seconds.
const step1 = {
type: 'modal',
callback_id: 'ops_step1',
title: { type: 'plain_text', text: 'Severity' },
submit: { type: 'plain_text', text: 'Continue' },
private_metadata: JSON.stringify({ requester: userId, opened_at: Date.now() }),
blocks: [
{
type: 'input',
block_id: 'severity_block',
label: { type: 'plain_text', text: 'Severity' },
element: {
type: 'static_select',
action_id: 'severity',
options: [
{ text: { type: 'plain_text', text: 'P0 - Down service' }, value: 'P0' },
{ text: { type: 'plain_text', text: 'P1 - Critical' }, value: 'P1' },
{ text: { type: 'plain_text', text: 'P2 - High' }, value: 'P2' },
{ text: { type: 'plain_text', text: 'P3 - Low' }, value: 'P3' },
],
},
},
],
};
await slack.views.open({ trigger_id: payload.trigger_id, view: step1 });
The trap is reuse. If you want to push a second modal on top right after opening the first, you cannot recycle the old trigger_id, because it is already burned. The good news is that the view_submission payload itself carries a fresh trigger_id, so a multi-step flow naturally hands you a new token at each step. Try to push with the original one and Slack returns invalid_trigger_id. An e-commerce operations team I worked with chased that error for an afternoon before realizing their "open then immediately push" sequence was spending the same token twice.
private_metadata: the only memory the modal has
Because there is no session, private_metadata is where you put everything the submit handler will need. It is a single string field on the view, capped at 3000 characters. JSON-encode whatever context you want to survive the round trip.
A reject flow at a retail bank, for an internal campaign-approval app, opens its modal like this:
{
type: 'modal',
callback_id: 'campaign_reject_submit',
title: { type: 'plain_text', text: 'Reject' },
submit: { type: 'plain_text', text: 'Reject' },
private_metadata: JSON.stringify({
campaignId: 123,
channel: 'C0CAMPAIGN_APPROVAL',
messageTs: '1719283456.001200',
responseUrl: 'https://hooks.slack.com/actions/...',
rejecterId: 'U0LEADER',
}),
blocks: [ /* reason input */ ],
}
At submit time you parse it back and you suddenly know which record to update, which message to replace, and who to credit in the audit log. None of that came from Slack. You put it there yourself.
The 3000-character ceiling bites the moment you try to stuff a real payload in there, say a list of a hundred product IDs. The fix is indirection: write the heavy state to Redis under a short key and stash only the key. private_metadata = JSON.stringify({ stateKey: 'modal:state:abc123' }), then look the full state up on submit. It also gives you a natural place to clean up when the modal closes.
Reading view.state.values by block_id and action_id
When the user submits, the form data lives at a two-level path: view.state.values[block_id][action_id]. The block_id comes from the input block, the action_id from the element inside it. Both are yours, both are required, and the path breaks silently if either drifts.
{
"view": {
"callback_id": "ops_step2",
"state": {
"values": {
"title_block": {
"title": { "type": "plain_text_input", "value": "OOM on order service" }
},
"desc_block": {
"desc": { "type": "plain_text_input", "value": "Heap dump shows a memory leak" }
},
"oncall_block": {
"oncall_user": { "type": "users_select", "selected_user": "U0DUNG" }
}
}
},
"private_metadata": "{\"requester\":\"U0HOA\",\"severity\":\"P0\"}"
}
}
Each element type also nests its value differently, which is the part that catches people. A plain_text_input gives you .value, a static_select gives you .selected_option.value, a users_select gives you .selected_user. Wrap the extraction in one function so the shape lives in a single place:
function pullValues(view) {
const v = view.state.values;
return {
title: v.title_block.title.value,
desc: v.desc_block.desc.value,
oncallUser: v.oncall_block.oncall_user.selected_user,
};
}
Returning response_action errors
Server-side validation is what view_submission is really for. Client-side checks in Block Kit are thin, so anything that matters gets enforced here. To reject a submission and keep the modal open with inline messages, return response_action: 'errors' with an errors object keyed by block_id.
async function submitOpsTicket(payload, res) {
const meta = JSON.parse(payload.view.private_metadata);
const data = pullValues(payload.view);
const errors = {};
if (!data.title || data.title.length < 5) {
errors.title_block = 'Title must be at least 5 characters';
}
if (meta.severity === 'P0' && !data.oncallUser) {
errors.oncall_block = 'P0 tickets must name an on-call engineer';
}
if (data.desc && data.desc.length > 5000) {
errors.desc_block = 'Description is capped at 5000 characters';
}
if (Object.keys(errors).length > 0) {
return res.json({ response_action: 'errors', errors });
}
// valid: ACK first, then do the slow work (see the 3-second rule below)
res.json({ response_action: 'clear' });
// ... async DB write and notifications
}
The non-obvious rule: Slack only renders an error under a block of type: 'input'. Set an error key that points at a section or image block and Slack accepts the payload without complaint, but the user sees nothing, the modal just sits there. A developer on that ops team set an error on an image block and burned real time wondering why the modal "swallowed" the submission silently. The key in your errors object must match the block_id of an actual input block.
The other validation people skip is re-checking business rules. A modal opened at 10:00 and submitted at 10:15 is operating on a fifteen-minute-old assumption, and state can move in that window. Re-fetch the entity before you commit:
const c = await db.getCampaign(meta.campaignId);
if (c.status !== 'PENDING') {
return res.json({
response_action: 'errors',
errors: { reason_block: `Campaign is now ${c.status} and can no longer be rejected.` },
});
}
Without that check, two reviewers can race and the second one overwrites the first. For anything truly irreversible, back the re-check with a DB transaction and an optimistic lock rather than trusting the read.
The 3-second timeout
This is the rule that turns a working handler into an intermittently broken one. Slack gives you three seconds to ACK a view_submission. Miss it and the user gets "Something went wrong," the modal hangs, and they often hit Submit again, giving you a duplicate.
So the order of operations is fixed: validate synchronously, respond immediately, then do the slow work after the response.
res.json({ response_action: 'clear' }); // ACK closes the modal stack now
const ticket = await db.createTicket({
severity: meta.severity, title: data.title, desc: data.desc,
requester: meta.requester, oncall: data.oncallUser,
});
await slack.chat.postMessage({
channel: 'C0OPS',
text: `Ticket ${ticket.id} (${meta.severity}) by <@${meta.requester}>: ${data.title}`,
});
The response_action values are worth knowing as a set. clear closes the entire modal stack and is what you want when a flow finishes. update plus a view replaces the current modal, handy for showing a "submitting, please wait" state before you close. push plus a view stacks a new step. Omit response_action entirely and Slack just pops the top modal. The one place teams get burned is when validation must hit the database, for example checking an email is unique. A cold DB lookup can blow the budget, so cache it or push it onto a fast store like Redis and keep the synchronous path well under a second.
view_closed and why it is off by default
When a user hits Cancel or Escape, Slack does not tell you by default. You opt in with notify_on_close: true on the view, and then you get a view_closed event.
const view = {
type: 'modal',
callback_id: 'ops_step2',
notify_on_close: true,
// ... blocks
};
async function handleViewClosed(payload, res) {
res.send('');
const meta = JSON.parse(payload.view.private_metadata);
log.info('user cancelled modal', {
callback_id: payload.view.callback_id,
user: payload.user.id,
duration_ms: Date.now() - meta.opened_at,
});
await redis.del(`modal:state:${meta.stateKey}`); // free the stashed state
}
This is where the Redis indirection from earlier pays off. The close handler is exactly when you release any lock you took and delete the state blob you parked. It is also the only signal you get for an abandonment metric, so if anyone wants to know how often people open a form and walk away, this is the hook that answers it.
One layout note that came out of that same operations team: Slack lets you push three modals deep, but the stack gets unusable on mobile, and they had a five-step pushed flow where users literally complained they could not tell where they were. The fix was to collapse it into a single modal with conditional blocks driven by views.update when the severity changed. As a rule, one modal with dynamic blocks beats three pushed modals.
Takeaway
The view_submission lifecycle is straightforward once you accept its one constraint: the modal remembers nothing, so you carry context in private_metadata, read values by block_id and action_id, validate and ACK within three seconds, and do the heavy lifting after you respond. Get those four moves in the right order and modals stop hanging, stop duplicating, and stop overwriting state out from under each other.
If you are building Slack workflows that need to hold up under real usage, we do this work. See what we build and get in touch.








