Last Updated on February 21, 2026 by Triumphoid Team
Most automation workflows are fire-and-forget. An event happens, a sequence of steps executes, data moves from A to B. No human ever touches it.
That’s fine for 80% of what runs through your systems. But the other 20%? The stuff that involves money, customer-facing communications, data deletions, or anything where a wrong call costs real business value? That needs a human in the loop. Not eventually. Right there, mid-workflow, before the next step runs.
This is where most teams hit a wall. They know they need approval gates. They just don’t know how to pause a workflow, hand control to a person via Slack, wait for their decision, and then pick up exactly where they left off without the whole thing falling apart.

I’ve built this pattern dozens of times. It’s not complicated once you see the full picture. But there are three specific gotchas that will eat you alive if you miss them. We’ll get to those.
The Architecture Before We Write a Single Line of Code
Here’s what’s actually happening when a manager clicks “Approve” in Slack. Most tutorials skip this part and jump straight to code, which is why people build it wrong the first time.
The flow has four distinct phases, and each one has its own failure mode:
Phase 1 — Trigger + Pause: Something kicks off the workflow (a form submission, a webhook, a scheduled job). The workflow runs up to the approval gate, then stops. Not “waits 5 seconds and checks again.” Stops. Completely. The execution state gets persisted to storage, and the workflow engine releases the thread. If you’re not doing this, you’re burning compute while a manager stares at their inbox deciding whether to approve a $3,000 ad spend.
Phase 2 — Notification: A message goes to Slack with Approve/Reject buttons baked in. The message carries metadata — a unique identifier linking it back to the paused workflow execution. This ID is how the system knows which workflow to resume when the button gets clicked. Lose this ID, and you’ve orphaned the workflow permanently.
Phase 3 — Human Decision: The manager sees the message. Reads the context. Clicks a button. This is where Slack’s interactivity layer kicks in. A block_actions payload fires from Slack to your application’s endpoint. You have three seconds to acknowledge it or Slack treats it as a failed delivery.
Phase 4 — Resume: Your handler extracts the decision and the workflow execution ID from the payload. It writes the decision to your persistence layer (database, Redis, wherever your workflow engine stores state), then signals the paused workflow to continue. The workflow reads the decision, branches accordingly, and completes.
Four phases. Four places things can break. Let’s build each one properly.
Part 1: Sending the Approval Message
Slack’s Block Kit is the framework that makes interactive messages work. It’s JSON-based. You stack blocks together to build the message layout, then attach interactive elements — buttons, in this case — inside an actions block.
Here’s the approval message payload. Every field matters:
const approvalMessage = {
channel: MANAGER_CHANNEL_ID,
text: "New campaign approval request", // Fallback for notifications/screen readers
blocks: [
{
type: "header",
text: {
type: "plain_text",
text: "Campaign Approval Required"
}
},
{
type: "section",
fields: [
{ type: "mrkdwn", text: "*Campaign:*\nQ2 Retargeting — Enterprise" },
{ type: "mrkdwn", text: "*Budget:*\n$4,200 / month" },
{ type: "mrkdwn", text: "*Channels:*\nLinkedIn, Google Display" },
{ type: "mrkdwn", text: "*Requested by:*\n<@U84KFMN2>" }
]
},
{
type: "section",
text: {
type: "mrkdwn",
text: "*Expected CPL:*\n$340 based on Q1 benchmarks for this segment"
}
},
{
type: "actions",
block_id: "approval-actions",
elements: [
{
type: "button",
text: { type: "plain_text", text: "✓ Approve" },
style: "primary",
action_id: "campaign_approve",
value: JSON.stringify({
execution_id: "exec_a4f72b9e3c4d",
campaign_id: "camp_q2_retarget_001",
requested_budget: 4200
})
},
{
type: "button",
text: { type: "plain_text", text: "✗ Reject" },
style: "danger",
action_id: "campaign_reject",
value: JSON.stringify({
execution_id: "exec_a4f72b9e3c4d",
campaign_id: "camp_q2_retarget_001",
requested_budget: 4200
})
}
]
}
]
};
const response = await slack.chat.postMessage(approvalMessage);
// Store the message_ts — you'll need it to update the message after the decision
await db.query(
`UPDATE workflow_executions
SET slack_message_ts = $1, slack_channel = $2, status = 'awaiting_approval'
WHERE execution_id = $3`,
[response.ts, MANAGER_CHANNEL_ID, "exec_a4f72b9e3c4d"]
);
Two things to notice here. First, the action_id on each button is what your handler will match against when the payload arrives. campaign_approve and campaign_reject — these are your routing keys. Second, the value field carries the execution context as a JSON string. This is how the decision travels from Slack back to your workflow engine. The execution_id is the critical piece. Without it, the button click is just an event with no connection to anything.
There’s a character limit on value: 75 characters max. If your execution metadata is larger, serialize the minimum required context and fetch the rest from your database using the execution ID. Don’t try to stuff an entire workflow state into a Slack button.
Part 2: The Wait Function — Pausing Without Polling
This is where teams diverge. Some use n8n’s Wait node. Some use Temporal’s signal-based pause. Some build it custom with Redis and a polling loop. The pattern is the same regardless of the tool. The implementation details differ significantly.
n8n: Wait Node with Webhook Resume
n8n’s Wait node is the most accessible entry point for this pattern. It pauses the workflow execution and exposes a unique $execution.resumeUrl — a one-time webhook endpoint that, when called, resumes the workflow from exactly where it stopped.
The workflow structure:
[Form Submission] → [Enrich Data] → [Send Slack Approval] → [Wait Node] → [IF: Approved?] → [Execute / Archive]
The Wait node holds the execution in a suspended state. No compute is consumed while it waits. When your Slack button handler POSTs to the resumeUrl with the decision payload, n8n wakes the workflow, passes the payload as the Wait node’s output, and the next step (your IF branch) reads the decision.
Critical detail: the resumeUrl is single-use. Once called, it’s consumed. If your handler accidentally fires twice (duplicate webhook delivery, retry logic gone wrong), the second call returns a 404. You need idempotency protection on the handler side — more on that shortly.
The Wait node also has a configurable timeout. If nobody clicks Approve or Reject within your window (say, 48 hours), the workflow can auto-route to an escalation path or close with a default decision. Without this timeout, abandoned approvals sit in limbo forever. We’ve seen workflows paused for months because nobody configured a timeout.
Temporal: Signal-Based Pause
If you’re running Temporal, the approach is fundamentally different. Temporal workflows are deterministic code. You don’t “pause” them the same way. Instead, you block on a signal channel:
func ApprovalWorkflow(ctx workflow.Context, campaignID string) error {
// ... earlier steps run here ...
// Send the Slack message (via an Activity)
err := workflow.ExecuteActivity(ctx, SendSlackApproval, campaignID).Get(ctx, nil)
if err != nil {
return err
}
// Block here. The workflow is now paused.
// Temporal persists the state. The goroutine is not consuming resources.
var decision string
signalChan := workflow.GetSignalChannel(ctx, "approval-decision")
signalChan.Receive(ctx, &decision)
// Execution resumes here the moment the signal arrives
if decision == "approved" {
return workflow.ExecuteActivity(ctx, LaunchCampaign, campaignID).Get(ctx, nil)
}
return workflow.ExecuteActivity(ctx, ArchiveCampaign, campaignID).Get(ctx, nil)
}
When the Slack button is clicked, your handler calls Temporal’s Signal API:
err := temporalClient.SignalWorkflow(context.Background(),
workflowID,
"",
"approval-decision",
"approved",
)
Temporal replays the workflow history to reconstruct state, delivers the signal, and execution continues from the signalChan.Receive line. If the Temporal server crashes while the workflow is waiting, nothing is lost. The entire execution history is persisted in an append-only log. Recovery is automatic.
The Custom Redis Approach
If you’re not using n8n or Temporal, you’re probably building this with a job queue (Bull, BullMQ, or similar) backed by Redis. The pattern:
// When the workflow hits the approval gate:
const jobId = await approvalQueue.add('pending', {
execution_id: executionId,
campaign_id: campaignId,
slack_message_ts: messageTs
}, {
removeOnComplete: false,
removeOnFail: false
});
// Store the job ID so the Slack handler can find it later
await redis.set(`approval:${executionId}`, jobId);
// The worker processing this queue does NOT auto-process 'pending' jobs.
// It only processes jobs whose status has been changed to 'decided'.
When the button is clicked, your handler updates the job:
const job = await Job.findById(approvalQueue, jobId);
await job.update({ decision: 'approved', decided_by: userId, decided_at: Date.now() });
await job.changeDelay(0); // Trigger immediate processing
The worker picks it up, reads the decision, and continues the workflow logic. This approach gives you full control but requires you to manage state persistence, timeout handling, and dead-letter logic yourself. n8n and Temporal do most of this for free.
Part 3: Handling the Button Payload
When someone clicks Approve or Reject, Slack sends a POST request to your app’s interactivity endpoint. This is where most implementations have bugs.
Here’s the raw payload structure you’ll receive:
{
"type": "block_actions",
"user": {
"id": "UA8RXUSPL",
"username": "sarah.chen",
"team_id": "T9TK3CUKW"
},
"trigger_id": "12321423423.333649436676.d8c1bb837935619ccad0f624c448ffb3",
"channel": {
"id": "CBR2V3XEX",
"name": "marketing-ops"
},
"message": {
"ts": "1548261231.000200",
"bot_id": "BAH5CA16Z"
},
"actions": [
{
"type": "button",
"action_id": "campaign_approve",
"block_id": "approval-actions",
"value": "{\"execution_id\":\"exec_a4f72b9e3c4d\",\"campaign_id\":\"camp_q2_retarget_001\",\"requested_budget\":4200}",
"action_ts": "1548261231.000200"
}
]
}
Your handler needs to do five things, in this exact order:
// Express.js handler — registered at your interactivity Request URL
app.post('/slack/interactions', async (req, res) => {
// 1. ACKNOWLEDGE IMMEDIATELY. You have 3 seconds.
// Slack will retry if you don't respond. Don't let it.
res.status(200).send();
// 2. PARSE AND VALIDATE
const payload = JSON.parse(req.body.payload);
if (payload.type !== 'block_actions') return;
const action = payload.actions[0];
if (!['campaign_approve', 'campaign_reject'].includes(action.action_id)) return;
// 3. VERIFY THE USER IS AUTHORIZED TO APPROVE
const approverRoles = await db.query(
'SELECT roles FROM users WHERE slack_id = $1', [payload.user.id]
);
if (!approverRoles.rows[0]?.roles?.includes('campaign_approver')) {
await slack.chat.postEphemeral({
channel: payload.channel.id,
user: payload.user.id,
text: "You don't have permission to approve campaigns. Contact your team lead."
});
return;
}
// 4. EXTRACT CONTEXT AND PERSIST THE DECISION
const context = JSON.parse(action.value);
const decision = action.action_id === 'campaign_approve' ? 'approved' : 'rejected';
await db.query(`
INSERT INTO approval_decisions (execution_id, campaign_id, decision, decided_by, decided_at)
VALUES ($1, $2, $3, $4, NOW())
ON CONFLICT (execution_id) DO NOTHING
`, [context.execution_id, context.campaign_id, decision, payload.user.id]);
// 5. RESUME THE WORKFLOW
const execution = await db.query(
'SELECT resume_url FROM workflow_executions WHERE execution_id = $1',
[context.execution_id]
);
if (execution.rows[0]?.resume_url) {
await fetch(execution.rows[0].resume_url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ decision, decided_by: payload.user.id })
});
}
// 6. UPDATE THE SLACK MESSAGE — remove buttons, show the decision
await slack.chat.update({
channel: payload.channel.id,
ts: payload.message.ts,
blocks: [
{
type: "header",
text: { type: "plain_text", text: "Campaign Approval — Decided" }
},
{
type: "section",
fields: [
{ type: "mrkdwn", text: "*Campaign:*\nQ2 Retargeting — Enterprise" },
{ type: "mrkdwn", text: `*Decision:*\n${decision === 'approved' ? '✓ Approved' : '✗ Rejected'}` },
{ type: "mrkdwn", text: `*By:*\n<@${payload.user.id}>` },
{ type: "mrkdwn", text: "*Time:*\nJust now" }
]
}
],
text: `Campaign approval ${decision} by <@${payload.user.id}>`
});
});
The message update at the end is important. If you leave the Approve/Reject buttons visible after a decision, another manager might click them. Slack will send another block_actions payload. Your ON CONFLICT DO NOTHING handles the database side, but the UX is terrible — the second person sees no feedback that a decision was already made.
The 3 Things That Will Break

The 3-Second Acknowledgment Window
Slack sends the block_actions payload to your endpoint and expects an HTTP 200 within 3 seconds. If you don’t respond in time, Slack retries. This means your handler could fire multiple times for a single button click.
The wrong approach: do all your database writes, workflow resumption, and message updates inside the acknowledgment window. If any of those take longer than 3 seconds (database is slow, n8n’s resume endpoint is laggy), you’ll timeout, Slack retries, and you get duplicate processing.
The right approach: acknowledge immediately with an empty 200. Do everything else asynchronously. Your handler returns the response, then a background worker (or even just an async function that you don’t await) handles the actual logic. The ON CONFLICT DO NOTHING on your database insert ensures that if Slack does retry and your handler fires again, the second execution is a no-op.
The response_url Is Not What You Think
Slack gives you a response_url in the payload. It looks like a convenient way to update the original message. It is — but only for 30 minutes, and only 5 times.
If your approval workflow takes longer than 30 minutes to process (realistic for complex campaigns that need multiple data checks before the message gets updated), the response_url will be dead by the time you try to use it.
Don’t rely on response_url. Use chat.update with the message.ts from the payload instead. chat.update has no time constraint. You can update that message hours or days later. We store the message.ts in the database when we first post the approval message, and retrieve it when we need to update.
Who Is Allowed to Click the Button?
Slack buttons don’t have access control built in. Anyone who can see the message can click Approve. If you post the approval request to a shared channel, any team member can approve a $50,000 ad spend.
You must validate the approver’s identity and authorization in your handler. The user.id in the payload tells you who clicked. Cross-reference that against your permissions system before persisting the decision or resuming the workflow. If they’re not authorized, post an ephemeral message (visible only to them) explaining why their action was rejected.
We also restrict approval messages to DMs with the specific manager, or to private channels with limited membership. Belt and suspenders.
Putting It All Together: The Full Execution Timeline
Let me walk through exactly what happens when a marketing analyst submits a campaign for approval, using the architecture we’ve built:
T+0ms: Analyst submits a campaign creation form. A webhook fires to the workflow engine. The workflow starts executing: it validates the campaign data, pulls historical CPL benchmarks from the data warehouse, calculates expected ROI.
T+2.3s: Workflow reaches the approval gate. It posts the Slack message with all the enriched context (budget, benchmarks, expected performance). It records the message.ts and pauses execution. The workflow engine persists state to its backing store.
T+2.3s to T+14hrs: Nothing happens. The workflow is paused. No compute is consumed. The manager sees the Slack message at their convenience.
T+14hrs+7min: Manager clicks “Approve.” Slack sends the block_actions payload to our endpoint.
T+14hrs+7min+12ms: Handler acknowledges with HTTP 200. Background processing begins.
T+14hrs+7min+45ms: Handler validates the approver. Writes the decision to the database (with idempotency guard). Calls n8n’s resumeUrl with the decision payload.
T+14hrs+7min+890ms: n8n wakes the workflow. The IF branch reads decision === 'approved'. The workflow continues: it calls the LinkedIn Ads API to create the campaign, sets up tracking in the analytics platform, sends a confirmation DM to the analyst.
T+14hrs+8min+200ms: Handler updates the Slack message, removing the buttons and showing the decision with approver info. The message now reads as a record of what happened and when.
Total time from approval click to campaign live: under 2 seconds of actual processing. The 14 hours were just waiting for a human.
When to Use This Pattern (And When Not To)
This pattern is right for decisions that are genuinely binary (approve or reject), where the decision-maker has enough context in the Slack message to act without leaving the platform, and where the workflow can cleanly branch based on the outcome.
It’s the wrong pattern when you need the approver to provide additional input — a revised budget, a comment, a modified targeting list. Buttons give you binary decisions. If you need richer input, you need a modal (Slack’s pop-up form layer) triggered by the button click, which collects structured data before resuming the workflow. That’s a different conversation.
It’s also the wrong pattern when approval chains are involved — when Manager A approves, then Director B must approve, then VP C signs off. Each level adds a new pause-notify-resume cycle. The architecture scales, but the UX doesn’t. Three sequential approval messages in Slack, each requiring a different person to act, creates friction that kills adoption. For multi-level approvals, consider a single message that shows the approval chain status and updates in place as each level signs off.
The Audit Trail You Can’t Skip
Every approval decision needs to be logged. Not for compliance (though that too). For debugging.
When something goes wrong — and it will — you need to know exactly who approved what, when, and what the workflow did afterward. Your approval_decisions table should capture: the execution ID, the campaign or request ID, the decision, the Slack user ID of the decision-maker, the timestamp, and ideally a hash of the decision payload for tamper detection.
We also log every workflow resumption event separately. If a workflow resumes but then fails at the next step (the LinkedIn Ads API returns an error, say), you need the audit trail to determine whether the failure happened before or after the approval was persisted. Without this, you can’t tell if the approval was “consumed” or if you need to retry.
Every human decision in a HITL workflow is valuable data. Log it into a persistent store with enough context to reconstruct what happened months later. This kind of logging unlocks insight into both system accuracy and future automation opportunities — and eventually reduces the number of decisions that need human review as patterns emerge.
The Numbers
We run this pattern across 12 client workflows processing roughly 800 approval requests per month combined. Median time from message posted to decision made: 2 hours 14 minutes. Longest single approval wait: 6 days (holiday weekend, single approver, no escalation configured — we fixed that). Zero orphaned workflows in the last 90 days after implementing timeout + escalation logic.
The pattern itself adds 340ms of latency to workflow execution (acknowledge + persist + resume). Negligible. The real latency is always the human.
Build the system so the human part is as frictionless as possible. Put all the relevant context in the Slack message so they never have to leave the platform. Make the buttons obvious. Log everything. And for the love of engineering, configure a timeout.