background-shape
Slack-Driven Approval Flows for Dev Backlogs With n8n and Block Kit
May 23, 2023 · 7 min read · by Muhammad Amal programming

TL;DR — Build approvals in Slack with Block Kit and interactive components — never with reaction emojis if you care about an audit trail. / The interactivity URL has to be hardened: verify Slack signatures, respond within 3 seconds, do the actual work async. / Two-step confirmation (button -> modal) is worth the extra code for anything that’s irreversible.

A pattern I keep coming back to: automation gets you 80% of the way there, then the last 20% is a judgment call that needs a human. The naive approach is to send a “please review this” email or Slack message and have someone go click around in Jira or GitHub. Most of the time, that human is already in Slack. Why make them leave?

This post is about doing approvals well inside Slack — specifically for backlog operations like “this ticket looks like a duplicate, should we close it?” or “this issue is going stale, should we drop it from the sprint?” The kinds of decisions that don’t need a meeting but do need a person.

It builds on the LLM-assisted triage post — in fact, the most common producer of these approval requests is the triage workflow itself flagging low-confidence decisions.

Why Block Kit, not reactions

The lazy way to do Slack approvals is to post a message and ask people to react with thumbs up or down. Don’t. Three reasons:

  1. No audit trail. Reactions can be added and removed. You can poll the reaction list, but you’ll miss intermediate states.
  2. No structured input. What if the approval needs a reason? What if it needs to choose between three options?
  3. Authorization is implicit. Anyone in the channel can react. You probably want approvals only from specific people.

Block Kit’s interactive components — buttons, select menus, modals — solve all three. They land an authenticated POST at your interactivity URL when a user clicks, with the click’s user, timestamp, and chosen value.

A minimal approval message:

const block = {
  channel: 'C0123456789',
  text: 'Approval needed',  // fallback for notifications
  blocks: [
    {
      type: 'section',
      text: {
        type: 'mrkdwn',
        text: `*Possible duplicate detected*\n<https://example.atlassian.net/browse/ENG-4231|ENG-4231> looks similar to <https://example.atlassian.net/browse/ENG-4019|ENG-4019>.\n\nClose ENG-4231 as duplicate?`,
      },
    },
    {
      type: 'actions',
      block_id: 'duplicate_approval',
      elements: [
        {
          type: 'button',
          action_id: 'approve_close',
          style: 'primary',
          text: { type: 'plain_text', text: 'Close as duplicate' },
          value: JSON.stringify({ ticket: 'ENG-4231', duplicateOf: 'ENG-4019' }),
        },
        {
          type: 'button',
          action_id: 'reject_close',
          text: { type: 'plain_text', text: 'Keep open' },
          value: JSON.stringify({ ticket: 'ENG-4231' }),
        },
        {
          type: 'button',
          action_id: 'open_modal',
          text: { type: 'plain_text', text: 'More options' },
          value: 'modal',
        },
      ],
    },
    {
      type: 'context',
      elements: [
        { type: 'mrkdwn', text: 'Requested by automation. Approvers: @amal, @rina' },
      ],
    },
  ],
};

await slack.chat.postMessage(block);

block_id and action_id are your handles when the click comes back. Make them stable, descriptive, and don’t put dynamic data in them — that’s what value is for.

The interactivity endpoint

When a user clicks a button, Slack POSTs a payload form field to your configured interactivity URL. The contents are URL-encoded JSON. Two non-negotiable parts of the handler:

Verify the signature. Slack signs every request with HMAC-SHA256 using your signing secret. The signature is in x-slack-signature and the timestamp in x-slack-request-timestamp. The Slack docs on verifying requests lay out the algorithm.

const crypto = require('crypto');

function verifySlackSignature(rawBody, timestamp, signature, secret) {
  // Reject requests older than 5 minutes (replay protection)
  const age = Math.abs(Date.now() / 1000 - parseInt(timestamp, 10));
  if (age > 300) return false;

  const base = `v0:${timestamp}:${rawBody}`;
  const expected = 'v0=' + crypto
    .createHmac('sha256', secret)
    .update(base)
    .digest('hex');

  return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
}

Respond within 3 seconds. Slack will timeout and retry otherwise. The work you actually do — updating Jira, posting a confirmation — happens async. Acknowledge immediately.

app.post('/slack/interactivity',
  express.urlencoded({ extended: true, verify: (req, res, buf) => { req.rawBody = buf.toString(); } }),
  async (req, res) => {
    const sig = req.headers['x-slack-signature'];
    const ts = req.headers['x-slack-request-timestamp'];

    if (!verifySlackSignature(req.rawBody, ts, sig, process.env.SLACK_SIGNING_SECRET)) {
      return res.status(401).end();
    }

    res.status(200).end();  // ACK immediately

    const payload = JSON.parse(req.body.payload);
    enqueueForProcessing(payload);
  }
);

In n8n, you achieve the same shape with a Webhook node set to “Respond Immediately,” followed by the signature verification, followed by the actual processing. Don’t put long-running API calls before the response node.

Authorization

Slack tells you who clicked. Your handler decides whether they’re allowed to do what they clicked.

The simplest model is an allowlist of approver Slack user IDs per workflow. For higher-stakes operations, I check the user’s Slack group membership at click time:

async function isApprover(userId) {
  const { usergroups } = await slack.usergroups.users.list({
    usergroup: process.env.APPROVER_GROUP_ID,
  });
  return usergroups.users.includes(userId);
}

If they’re not an approver, update the message ephemerally to tell them so and don’t proceed. Don’t silently swallow — that confuses the user and produces ghost “I clicked but nothing happened” reports.

Two-step confirmation for irreversible actions

For anything destructive — closing a ticket, dropping a sprint, archiving a project — make it two clicks. The first click opens a modal. The modal asks for a reason and a confirmation.

async function openConfirmModal(triggerId, ticket) {
  await slack.views.open({
    trigger_id: triggerId,
    view: {
      type: 'modal',
      callback_id: 'confirm_close_duplicate',
      title: { type: 'plain_text', text: 'Confirm close' },
      submit: { type: 'plain_text', text: 'Confirm' },
      close: { type: 'plain_text', text: 'Cancel' },
      private_metadata: JSON.stringify({ ticket }),
      blocks: [
        {
          type: 'section',
          text: { type: 'mrkdwn', text: `Closing *${ticket}* as duplicate. This is reversible but generates noise. Type a brief reason:` },
        },
        {
          type: 'input',
          block_id: 'reason_block',
          element: {
            type: 'plain_text_input',
            action_id: 'reason',
            min_length: 10,
          },
          label: { type: 'plain_text', text: 'Reason' },
        },
      ],
    },
  });
}

trigger_id is short-lived — about 3 seconds — so the open-modal call must happen immediately after the click ACK. Don’t put async work between them.

Modal submission arrives back at the same interactivity URL with type: 'view_submission'. The handler validates the inputs, returns a response_action of clear or errors, and proceeds with the work.

Updating the original message

After an approval lands, the original message should reflect the outcome. Nothing’s worse than a stale “approval needed” message sitting in a channel after the decision was made.

Use chat.update with the original channel and ts:

await slack.chat.update({
  channel: payload.channel.id,
  ts: payload.message.ts,
  text: 'Approved',
  blocks: [
    {
      type: 'section',
      text: {
        type: 'mrkdwn',
        text: `*Closed as duplicate by <@${payload.user.id}> at ${new Date().toISOString()}*\n${payload.actions[0].value}`,
      },
    },
  ],
});

The result is that the message becomes its own audit log. Everyone in the channel can see what was decided, by whom, and when. No separate logging system needed for human-visible audit — though you should still log machine-readable events somewhere for compliance.

Audit logging that actually helps

Slack messages alone aren’t enough. For real auditability, every approval should write to a structured log:

CREATE TABLE approval_events (
  id BIGSERIAL PRIMARY KEY,
  workflow_id TEXT NOT NULL,
  action TEXT NOT NULL,
  target_type TEXT NOT NULL,    -- jira_issue, github_issue, etc.
  target_id TEXT NOT NULL,
  requested_at TIMESTAMPTZ NOT NULL,
  decided_at TIMESTAMPTZ,
  decided_by_slack_user TEXT,
  decision TEXT,                -- approve, reject, expired
  reason TEXT,
  slack_channel TEXT NOT NULL,
  slack_ts TEXT NOT NULL,
  payload JSONB NOT NULL
);

A periodic job marks unanswered approval requests as expired after a configurable window — typically 24 hours for non-urgent decisions, 1 hour for incidents. Expired requests update the Slack message and fall back to a default action (usually “do nothing”).

Common Pitfalls

  • Block Kit field limits. A button’s value is capped at around 2,000 characters. Stuffing a full payload in there will fail silently. Use a short ID and look up the rest from your state store.
  • Message blocks are limited to 50. Long approval messages need pagination or a modal to display detail.
  • Slack’s 3-second timeout is real. If your handler is slow, Slack retries, your handler runs twice, and you’ve now closed the ticket twice. Idempotency keys keyed on payload.trigger_id save you here.
  • Bot user vs user token. Buttons fired from messages posted by your bot user need a bot token to update. Some workspace-level operations require a user token. Have both, scoped correctly.
  • Channel privacy. A button posted in a public channel is clickable by anyone in the workspace who can see the channel. If the approval is sensitive, post in a private channel or DM.

Wrapping Up

Slack-driven approvals are one of those workflows where the engineering effort is real but the user experience win is enormous. Done well, the on-call triage person never has to leave their chat tool to make a decision, and every decision lands in a queryable audit log.

Next post in the series steps back to look at the broader landscape: when does n8n make sense vs Zapier vs Power Automate vs writing it yourself? I’ve been running all three in parallel and have opinions.