You can pause a durable workflow until a human approves it in Slack by combining Chat SDK and Workflow SDK. Chat SDK posts an interactive card with Approve and Deny buttons; Workflow SDK's createWebhook generates a URL that those buttons POST to when clicked, suspending the workflow until the click arrives. The workflow resumes with the click payload, decides what to do, and continues, with no onAction handler, no custom approval database, and no polling.
This guide will walk you through suspending a workflow with createWebhook, posting a Chat SDK approval card with callbackUrl buttons, and resuming the workflow based on the user's choice. You'll add a timeout so abandoned approvals can't suspend forever, and see how to extend the pattern to multiple decision points within a single workflow.
Before you begin, make sure you have:
- Node.js 18 or later
- An existing Chat SDK bot (see the Slack agent guide or Slack file bot guide)
- A project configured for Workflow SDK
- A Vercel account if you're deploying to Vercel
Three pieces fit together:
- Workflow SDK runs the long-lived process. A function becomes durable when you mark it with
"use workflow": it can suspend, resume, and survive crashes without losing state.createWebhook()returns an object with aurlthat's a public endpoint. When youawaitit, the workflow is suspended until that endpoint receives an HTTP request. - Chat SDK presents the decision to a human. A
<Button callbackUrl="...">posts the click's action data to the URL you supply, in addition to firing anyonActionhandler. When the URL is the workflow'swebhook.url, the click resumes the workflow directly. - The pairing removes the usual glue. There's no separate approvals table, no
onActioncallback that has to look up which workflow is waiting, and no polling. The workflow suspends, the user clicks, and the same workflow function picks up where it left off with the click payload in hand.
createWebhook() is the right primitive when the workflow itself owns the resume URL. For cases where you'd rather keep the URL private and resume from your own server code, use createHook() with a deterministic business token instead.
Add Workflow SDK to a project that already has a Chat SDK bot set up:
If you're starting from scratch, also install a few Chat SDK packages:
The workflow package is Workflow SDK's core. The chat package is the Chat SDK core, and @chat-adapter/slack and @chat-adapter/state-redis are the Slack platform adapter and Redis state adapter. See the Chat SDK getting started guide and Slack agent guide if you don't have a bot yet.
Create workflows/approval.ts:
The workflow is intentionally thin. It owns the webhook and the resume logic; everything else (posting the card, sending follow-up messages, triggering the deploy) is a step. That separation keeps the workflow body deterministic and pushes side effects into retryable units.
Next, define the helper steps in lib/slack.ts:
bot.thread(threadId) constructs a Thread reference from a serialized ID, which is exactly the entry point Chat SDK provides for posting outside an event handler. Thread IDs follow the format adapter:channel:thread (for example, slack:C123ABC:1234567890.123456) and round-trip cleanly through JSON, so they're safe to pass as workflow inputs.
A few things to notice:
- The
usingdeclaration ensures the webhook is cleaned up automatically when the workflow exits, even if it throws. - Both buttons point at the same
webhook.url. TheactionIdtravels in the callback payload, so a single webhook handles all the buttons on the card. await webhooksuspends the workflow. The function pauses here until the user clicks, which could take seconds, hours, or days. Workflow SDK persists the state and resumes on the click.- The
deployfunction is marked"use step", which makes it a durable step with automatic retries and observability. Workflow SDK only re-runs steps when they fail, not on resume, so approval and deploy stay clean. postApprovalCardreturns the posted message'sid, andfinalizeApprovalCardcallsthread.adapter.editMessageto re-render the card without buttons once the decision is in. That prevents a stale click on the original card from hitting the consumed webhook, and it leaves a clear audit trail in the thread.postReplyposts with{ markdown }rather than a bare string. The bare-string form is passed through as-is, so**bold**would render as literal asterisks on Slack and Teams; the{ markdown }form is converted to the platform's native markup.
If you need to preserve more of the original thread context across the workflow boundary (for example, the triggering message or thread metadata), use thread.toJSON() on the way in and bot.reviver() when restoring on the other side. For the approval flow above, the thread ID is enough.
Trigger the workflow from wherever the approval request originates.
From a Chat SDK handler:
start queues the workflow run and returns immediately with a run handle, allowing the mention handler to respond without blocking. The workflow takes over from there, posting the card, suspending on the webhook, and resuming when the user clicks.
Chat SDK POSTs a JSON body to the callback URL with this shape:
actionId is the button's id prop. value is whatever you passed to value. user is the user who clicked, regardless of who triggered the workflow. Use actionId to branch on which button was pressed, and user.id to record or validate who approved.
For workflows that need more than one approval, call createWebhook() multiple times. Each call generates a fresh URL, so suspensions are independent:
postPrompt is a step function shaped like postApprovalCard from earlier, generalized to accept any prompt text and return the posted messageId. finalizePromptCard is the matching edit helper; it re-renders the card with the same title, replacing the buttons with an outcome line. Each using declaration scopes the webhook to its block, so cleanup happens as soon as the workflow moves past that approval, and the rendered card matches: every stage shows its final state without leaving stale buttons in the thread. The workflow itself can suspend at any point without you tracking which webhook is which, since the runtime handles that.
A workflow that suspends on a webhook indefinitely is fine until someone forgets to click. Race the webhook against a durable sleep to bound the wait:
sleep is itself a durable suspension, so the 24-hour wait costs nothing while it's pending and survives deploys. When the timeout wins the race, the workflow continues without resuming the webhook, and the using cleanup releases the URL. The card is finalized in both branches, so the timeout is visible in the thread rather than silently leaving a stale set of buttons.
The callback URL is authenticated only by its token, which means anyone who can intercept the URL can resume the workflow. For sensitive operations, validate the user in the payload before continuing:
For stronger guarantees, switch to createHook() and resume the workflow from your own authenticated route with resumeHook(). That pattern keeps the resume URL private and gives you full control over the authorization check, at the cost of writing a route handler.
Confirm the button's callbackUrl matches webhook.url exactly. The URL contains a token that's bound to the suspension, so a stale or hand-edited URL won't resolve. Check the Workflow SDK CLI to see whether the workflow is still suspended on the webhook:
If the click reached the URL but the workflow didn't move, look for an error in the workflow logs. The using declaration disposes the webhook on the next throw, so an uncaught error in the workflow body can release the URL before the click arrives.
Discord's custom_id has a 100-character limit; Telegram's callback_data has a 64-byte limit. The encoded button data is the action ID plus the callback token, which can exceed those caps. Shorten the action ID ("a" instead of "approve-deploy-v1-2-3") and move long context into value or out of the card entirely. Slack and Teams don't have this limit.
The main flow above guards against this by calling finalizeApprovalCard immediately after the decision lands. The card is re-rendered without its Actions block, so there's nothing left to click. If you skip that step, Chat SDK won't dedupe clicks for you: once await webhook has resolved, a second click hits a webhook that no longer exists, and the user sees the platform's default error. Either edit the card to remove the buttons (the pattern shown above, via thread.adapter.editMessage), or accept that only the first click matters and ignore the rest.
Workflows suspend until something resumes them. If your callback URL was lost (for example, a card was deleted before anyone could click), the run will stay suspended until its associated webhook is cleaned up. Use a timeout (see above) to bound every suspension, and use the Workflow SDK CLI to cancel runs that should no longer wait: