n8n Slack Approval Workflow: From Duct-Tape to a Clean Pattern

Iiro Rahkonen

TL;DR: Slack is where most n8n approvals happen today, and it is where most of them quietly break. This post covers three ways to build an n8n Slack approval workflow — the native Send and Wait for Response node, a Block Kit + Wait-for-Webhook pattern (and the "double trigger" trap everyone hits), and a managed human-in-the-loop inbox that keeps Slack as a notification layer while moving routing, escalation, and audit out of the canvas. Node configs and trade-offs for each.


Slack is the default delivery channel for n8n approvals. Your reviewer already lives there, the in-app experience is decent, and n8n ships first-class Slack integrations. So the instinct is right: put the approval there.

The instinct stops being right around your third or fourth workflow. Messages get buried. The "did Alice see this?" question has no answer. A reviewer leaves the company and you discover the workflow is hardcoded to their user ID. Someone hits the approve button on a thread from two weeks ago and an execution silently resumes. And if you tried to wire Block Kit buttons yourself, you almost certainly ran into the double trigger problem — one workflow, two triggers, nothing works, community threads everywhere, no clean answer.

This post walks through three ways to build an n8n Slack approval workflow. The first is the native Send and Wait for Response — right for most teams most of the time. The second is the Block Kit + Wait-for-Webhook pattern, where the power is, but so is the sharpest edge. The third is the direction I am building Humangent toward: Slack stays the notification layer, but the routing, escalation, and audit trail live outside the n8n canvas.


Why Slack approvals keep creaking

Slack is a great messaging app. It is a mediocre system of record for pending decisions. The cracks are predictable:

Messages drift down the channel. An approval that shows up during a standup is gone by lunch. The workflow is still paused. Nobody is actively responsible.

Notifications are per-person, not per-request. Slack can ping a user, but it does not know whether they opened the message, whether they are out of office, or whether a teammate already acted. You find out later, by which time the execution has timed out or a second decision has arrived.

User IDs are hardcoded. Every Send-and-Wait node recipient in Slack is a user ID like U08XXXX or a channel ID. Adding a reviewer means editing the workflow. Swapping a reviewer on leave means editing the workflow. Routing to "the finance lead, whoever that is this quarter" means editing the workflow.

The audit trail is channel search. When someone asks "who approved that refund?" six months from now, the answer is to scroll Slack history and hope the right message is still there. Execution logs record that the workflow continued but not what the reviewer saw or what they changed.

None of this is a reason to avoid Slack for approvals. It is a reason to decide, up front, how much of the approval infrastructure lives inside Slack versus alongside it. The three patterns below draw that line differently.


Pattern 1: Native Send and Wait for Response on Slack

The fastest working approval you can build in n8n. One node, no Block Kit JSON, no parallel workflows, no webhook wiring. If you have not used it yet, this is where to start.

How to set it up

Node: Slack
Resource: Message
Operation: Send and Wait for Response
Send To: User (by ID) → U08XXXXX        # or Channel: #approvals
Message: "Refund request from {{ $json.customer }} for ${{ $json.amount }}. Reason: {{ $json.reason }}"
Response Type: Approval                  # Approval | Free Text | Custom Form
Approval Options: Approve / Reject
Limit Wait Time: On (4h)
On Timeout: Continue (separate output)

Three response types are available, picked by how much you need the reviewer to do:

  • Approval. One-click Approve / Reject buttons (labels configurable). Fastest reviewer experience when the decision is truly binary.
  • Free Text. A single text field. Useful for "approve with rationale" or short commentary.
  • Custom Form. Any fields you define (text, textarea, select, number), pre-filled with your AI output, editable before submit. This is how you do "approve with edits" without building a form yourself.

The reviewer receives a Slack message with buttons (or a link to an n8n-hosted form for Custom Form). Clicking resumes the workflow. The submitted values land on $json on the chosen output branch.

Branching on the response

Send and Wait for Response
  -> Switch
    Mode: Rules
    Property: {{ $json.approved }}    # boolean for Approval; use form field names for Custom Form
    Rules:
      - true  -> [Gmail: Send refund confirmation]
      - false -> [Gmail: Send denial with reason] -> [Slack: notify requester]
  -> (timeout output)
    -> [Slack: Escalate to @finance-backup]

Where it works well

  • One to three workflows, known reviewers. The overhead of hardcoding a user ID is trivial at this scale.
  • Channels where reviewers notice pings. A dedicated #approvals channel with a small membership works better than DMing a busy person.
  • Binary decisions that do not need editing. Refund yes/no, permission grants, publish go/no-go.

Where it starts to hurt

DM history drift. Send-and-Wait messages land in the bot's App Home history, not an active DM thread. Reviewers who only check DM pings can miss them entirely. A dedicated channel is a partial fix but trades ping fatigue for mixed accountability.

No cross-workflow inbox. Alice is assigned to approvals across five workflows. There is no single view of everything waiting on her. She is reading five Slack threads.

Single timeout branch. You get one On Timeout output. There is no native "remind at 2h, reassign at 4h, escalate to manager at 8h" — every hop of that is separate wiring.

Audit trail is the execution log. The node records the submitted value. It does not record who else was in the channel, who opened the message, or whether another person had already informally said "looks fine to me" in a side thread.

Teams-style behavior gotchas on Slack.

  • Slack DMs from bots land in App Home, not the message feed, unless the user has explicitly started a DM with the app.
  • Large channels drop the message below the fold quickly. Pin the notification or use a threaded reminder if it is going to sit for a while.

Verdict. Start here. Use it until the pain above is real. Do not over-engineer a future you may not hit.

Always set Limit Wait Time. The default is no timeout, which means a forgotten approval hangs an execution forever. Four hours for routine reviews, 24 hours for weekend-tolerant cases, 72 hours as an outer bound. Handle the timeout branch explicitly — do not silently auto-continue.

If Pattern 1 is creaking on the workflow in front of you, join the early-access list for Humangent — Pattern 3 below is the direction I am building with beta users, and the beta is how the shape gets decided.


Pattern 2: Block Kit buttons + Wait for Webhook (and the double-trigger trap)

This is where the power is and where the sharpest edges live. You post a Slack message with custom Block Kit buttons, your workflow pauses on a Wait for Webhook node, and Slack calls back when someone clicks. More flexible than native — you control the layout, you can include rich context, you can use the buttons for any action — but you are also wiring the pipes yourself.

What the Block Kit message looks like

{
  "channel": "C08XXXXX",
  "text": "Refund approval needed",
  "blocks": [
    {
      "type": "section",
      "text": {
        "type": "mrkdwn",
        "text": "*Refund request*\n*Customer:* {{ $json.customer }}\n*Amount:* ${{ $json.amount }}\n*Reason:* {{ $json.reason }}"
      }
    },
    {
      "type": "actions",
      "block_id": "approval_{{ $json.requestId }}",
      "elements": [
        {
          "type": "button",
          "action_id": "approve",
          "text": { "type": "plain_text", "text": "Approve" },
          "style": "primary",
          "value": "{{ $json.requestId }}"
        },
        {
          "type": "button",
          "action_id": "reject",
          "text": { "type": "plain_text", "text": "Reject" },
          "style": "danger",
          "value": "{{ $json.requestId }}"
        }
      ]
    }
  ]
}

Slack posts this; the reviewer clicks a button; Slack sends an interaction payload to whatever URL you have configured in your Slack app's Interactivity & Shortcuts settings. That URL needs to resolve to the paused Wait for Webhook node.

The double-trigger trap

This is the trap every new builder hits. You try to put both the Send-Slack-message step and the Receive-button-click step in the same workflow, and it does not work. n8n does not like two triggers (or a trigger + a webhook listener) in one linear workflow. Community threads from March and April 2026 keep re-discovering this.

The fix — once you see it — is to split into two workflows.

Workflow A (dispatcher):
  Trigger (AI draft, schedule, whatever)
    -> HTTP Request: POST to Slack chat.postMessage with Block Kit
    -> Save ts (Slack message timestamp) + requestId to DB / cache
    -> Wait for Webhook: /slack-approval/{{ $json.requestId }}
    -> Switch on action -> approve / reject branches

Workflow B (listener):
  Webhook trigger: POST /slack-interactivity
    -> Extract action_id + request_id from Slack payload
    -> HTTP Request: POST to Workflow A's waiting Wait URL
         URL: https://your-n8n.com/webhook/slack-approval/{{ $json.request_id }}
         Body: { "action": "{{ $json.action_id }}", "user": "{{ $json.user_id }}" }
    -> Respond to Slack (200 OK, optionally with message update)

Workflow A is paused on Wait for Webhook. Workflow B receives the Slack interaction, then calls Workflow A's unique resume URL to unblock it. The two workflows are connected only by the Wait URL that Workflow A generated and stored.

Slack interaction URLs have a 3-second response window. If Workflow B takes longer than that to respond, Slack retries the interaction — which means you get duplicate resume calls. Respond to Slack immediately (200 OK), then do the real work after. Or use Slack's response_url for async follow-up messages instead of blocking the initial interaction.

Updating the Slack message after the decision

A small touch that makes Block Kit-based approvals feel professional: after someone approves or rejects, update the original message so it is no longer clickable.

HTTP Request to Slack chat.update
URL: https://slack.com/api/chat.update
Body:
  channel: {{ $json.channel }}
  ts:      {{ $json.message_ts }}
  blocks:  [ section with "Approved by @{{ user }} at {{ timestamp }}" ]

Without this, two things break. Another reviewer clicks the button ten minutes later and triggers a second Wait for Webhook callback — which either double-fires your downstream flow or errors because the execution already resumed. And the message in the channel still looks pending, so nobody has visibility that the decision was made.

What you gain over native Send-and-Wait

Rich context in the message. Product images, structured fields, links to the source record, formatted diff of proposed changes. Native Send-and-Wait gives you text; Block Kit gives you layout.

Custom actions, not just approve / reject. Add buttons for "request revision", "escalate to finance", "flag for legal". Each action routes to a different branch in Workflow A via the Switch node.

Thread-based context. Post the main message to the channel, and reply in a thread with the full context or upstream data. The reviewer decides how deep to go.

What you still have to build

User / channel lookup infrastructure. To notify "whoever is on finance rotation today", you need a table somewhere mapping roles to Slack IDs. Workflow A reads it before posting. Slack user IDs change when people change profile emails; keep it fresh.

Timeout and reminder logic. The Wait for Webhook timeout output tells you nobody clicked. What you do about it is your problem:

Wait for Webhook -> (timeout branch)
  -> IF: has been pinged once already?
    -> YES: escalate to backup reviewer, start new Wait node
    -> NO: send reminder in thread, start new Wait node

Each timeout path is its own branch. These branches only fire in edge cases — the exact cases that cause the most damage when unhandled.

An audit log. Slack keeps message history, but nothing structured about who decided what. You need a database write after each decision (request_id, action, user, timestamp, edited_fields) or the compliance question six months later has no answer.

No centralized view across workflows. Each Workflow A is its own island. A reviewer responsible for approvals across ten workflows is still reading ten Slack threads.

Verdict. The right tool when you need rich context, custom actions, or Block Kit layouts that the native node cannot deliver. Budget the initial 2–4 hours to wire it plus ongoing maintenance of the Slack app, the interactivity endpoint, and the role-to-user mapping.


Pattern 3: A human-in-the-loop inbox with Slack as the notification layer

Patterns 1 and 2 both keep the approval state inside Slack — as messages, threads, and buttons. That is exactly where it drifts, gets buried, and becomes impossible to audit. The third pattern moves the state out of Slack entirely. Slack stays the notification layer, because that is what it is good at. The request itself — the pending decision, the context, the routing, the escalation, the audit trail — lives in a purpose-built inbox.

This is the direction I am building Humangent toward. Humangent is in private beta. The shape below is a design sketch, not a list of shipped features.

What the n8n side looks like

Trigger
  -> [AI Agent / Content generator]
  -> HTTP Request: POST to Humangent
     URL: https://api.humangent.io/v1/reviews
     Headers:
       Authorization: Bearer {{ $env.HUMANGENT_API_KEY }}
     Body (JSON):
     {
       "callback_url": "{{ $node['Wait for Webhook'].webhookUrl }}",
       "title": "Refund approval: {{ $json.customer }} — ${{ $json.amount }}",
       "body": "...structured content for the reviewer...",
       "editable_fields": ["amount", "reason"],
       "actions": ["approve", "reject", "request_revision"],
       "assignee": "team-finance",
       "timeout": "4h",
       "escalate_to": "finance-lead"
     }
  -> Wait for Webhook  (the platform calls back when the decision is made)
  -> Switch on action -> approve / reject / revision branches

Five nodes. The Slack message is one of several notification channels — the reviewer picks whichever they prefer (Slack, Teams, Telegram, email) on their own account. Your workflow never hardcodes a Slack user ID.

Design goals that map directly to the Slack pain points above

  • Reviewers as people, not U08XXXX. Workflows route to a person or a team (alice, team-finance). Each reviewer links their own Slack / Telegram / Teams / email and sets a backup when they go on leave. No workflow edit required.
  • One inbox across workflows. A reviewer handling approvals from ten workflows sees one pane. The Slack ping is the call to action; the inbox is the source of truth.
  • Multi-step escalation as config. Assignment → remind → backup → escalate → final action, declared per workflow, not wired as 15 extra nodes.
  • Audit trail by default. Who was assigned, who opened the request, when, what they edited, what action they took — logged automatically rather than reconstructed from Slack search.
  • Message updates happen automatically. When the decision is made, the original Slack message updates to "approved/rejected by whom, at when" — no chat.update wiring on your side.
  • No plugins. The n8n side stays on standard HTTP Request and Wait for Webhook nodes. If Humangent disappears tomorrow, you swap two nodes and move on.

Honest status

Humangent is in private beta. Not all of the above is built yet. If you are weighing it against a DIY build today, assume the DIY cost (Pattern 2) is real and concrete, and Pattern 3 is a direction I am committed to but still shaping. If that mismatch matters for your timeline, stick with Pattern 1 or 2. If it does not, early access is the most direct way to influence what ships — the beta is literally how the routing, escalation, and audit shapes get decided.

Nothing in Pattern 3 is a finished product you can deploy next week. What is real is the design direction, the problem model, and the beta process. If you need a live system right now, the honest answer is Pattern 1 for single workflows or Pattern 2 for richer ones.


Practical patterns that apply to all three approaches

A handful of node-level habits are worth building regardless of which pattern you pick. These are the same patterns I reach for every time.

Include enough context in the message. The reviewer should not have to flip between tabs to decide. Source record ID, the prompt that produced the draft, a link back to the full record, and a short summary. If the full context is too large, include a summary plus a link.

Use editable fields, not approve-or-reject-only. If the reviewer can only say yes or no, every small AI mistake becomes a round-trip: reject, regenerate, re-review. If they can edit the email subject or tweak a paragraph before approving, the small mistakes never become workflow loops. Custom Form in Pattern 1, a separate "edit and resubmit" webhook in Pattern 2, built-in in Pattern 3.

Always timeout. Always handle the timeout. No timeout is a bug. An unhandled timeout output is a bigger bug. Pick a realistic number and wire the timeout path explicitly.

Post to a dedicated approvals channel, not a busy general channel. Dedicated channels create focus and shared accountability. General channels create ping fatigue and buried messages.

Update the message after the decision. Whether via chat.update manually (Pattern 2) or automatically (Pattern 3), a stale approval message in a channel is a bug. It invites double-clicks from late-reading teammates and obscures whether the decision was made.

Log every decision to your own store. Even when the platform provides an audit trail, write a row to a database or Google Sheet you own. When compliance asks in six months, you want the answer to live somewhere you control.

Test the callback path before trusting it. Before handing an approval workflow to a real reviewer, POST to the Wait for Webhook URL yourself with a synthetic payload (cURL or Postman) and watch the execution resume. It is the fastest way to catch a broken Switch condition or a missing field.


Comparison at a glance

Native Send and Wait (Slack) Block Kit + Wait for Webhook Managed inbox (target state)
Setup time ~15 min 2–4 h + Slack app config + listener workflow Designed for ~30 min
Reviewer layout Text + buttons or n8n form Full Block Kit Dedicated review page + Slack ping
Recipient Slack user or channel ID per node Role → Slack ID (you maintain the map) Person or team (platform resolves)
Double-trigger risk None (single node) Real (need two workflows) None (platform handles callback)
Custom actions Three response types Any Block Kit buttons you design Any actions per request
Timeout Single branch Whatever you wire Multi-step escalation as config
Cross-workflow inbox No No Design goal (yes)
Audit trail Execution log + manual Whatever you log Built-in (design goal)
Maintenance Low at small scale Medium–high Intended to be low

"Target state" describes the Pattern 3 design, not shipped features.


Starter template: AI refund approval in Slack

A complete workflow structure for the common case — an AI classifies an incoming refund request, and a human approves before the money moves.

[Webhook: refund-request]
    |
[AI Agent: classify request (auto-approve under $50, route others)]
    |
[IF: amount >= $50]
    |-- No:
    |     [Stripe: issue refund]
    |     [Slack: notify #refunds with summary]
    |-- Yes:
    |     [Slack: Send and Wait for Response — Custom Form]
    |       Fields: amount (number, editable), reason (textarea, editable),
    |               decision (select: approve / reject / escalate)
    |     [Switch on decision]
    |       approve   -> [Stripe: issue refund using edited amount]
    |                  -> [Slack: post confirmation in thread]
    |       reject    -> [Gmail: send denial with reason]
    |       escalate  -> [Slack: Send and Wait — to @finance-lead, 24h timeout]
    |     [Timeout branch]
    |       -> [Slack: reminder @backup-reviewer, 2h Wait for Webhook]
    |       -> [Timeout again -> auto-reject, notify requester]

Editable fields in action. If the reviewer changes amount from $250 to $200 before approving, the Stripe refund uses $json.amount — the edited value, not the original. One of the most common approval-flow mistakes is passing the pre-review value to the downstream action.

Two-step timeout. First a reminder to the same person. Then a reassignment to a backup. A silent timeout with no reminder is almost always a bug; a timeout that only reassigns without warning creates surprises.


Common questions

Do I need a custom Slack app to use Send-and-Wait? No. The native Slack node in n8n uses your n8n Slack credentials — the app is already configured for messaging. You only need a custom Slack app if you want Block Kit interactivity (Pattern 2), because that requires an interactivity endpoint Slack will POST to.

How do I route approvals by role instead of by user? In Pattern 1, maintain a small lookup table (Airtable, Postgres, n8n Variables) that maps roles to Slack user IDs. A Set node reads the role and inserts the user ID into the Send-and-Wait recipient field. In Pattern 2, same thing, but the lookup happens before your chat.postMessage call. In Pattern 3, assign by role directly ("assignee": "team-finance") and let the platform resolve it.

What happens when a Slack user is out of office? Slack's OOO status is not exposed to bots. For Pattern 1 you build your own out-of-office table and check before routing. Pattern 2 is the same. Pattern 3 treats "backup when unavailable" as a setting the reviewer manages on their own account, not something each workflow encodes.

Can I combine Slack buttons with an editable form? Yes. The native Custom Form response type does this in Pattern 1 — Slack shows a button, clicking opens an n8n form with pre-filled editable fields. In Pattern 2, you post a Block Kit message with an "Open form" button linking to an internal form endpoint you build. Either way the reviewer's edits come back in the callback payload.

Why does my Slack approval message sometimes not deliver? Three usual culprits: the Slack bot has not been invited to the target channel, the user has not started a DM with the app (DMs from apps land in App Home by default), or the workspace has app restrictions blocking the bot from messaging certain users. Test delivery with a known-good channel before chasing workflow logic bugs.

Is there a way to test Slack approvals without spamming reviewers? Use a private test channel and set the Send-and-Wait recipient to your own user ID. For Pattern 2, use Slack's interactivity testing tool or POST a synthetic interaction payload to your webhook with cURL. Never test against real reviewers.



If Slack has stopped being enough for your approvals and the thought of maintaining another listener workflow is exhausting, join the Humangent waitlist at humangent.io. Humangent is a human-in-the-loop inbox for n8n workflows, in private beta — early access members shape the roadmap, including the Slack integration. Free during private beta, no credit card.