Logo

Private DMs from a Slack Workflow Without the Webhook Step

6 min read
PythonSlackGCP

Table of Contents

Intro

This is the first of a few automations I built around Slack Workflow Builder forms. The team fills out a short form, and a bot turns that submission into something useful: a private message, a spreadsheet row, an owner mention, or a pull request.

This first one is the simplest in scope but had the most annoying constraint. The goal:

A user submits an intake form. The bot calculates a deadline (the date of the next weekly review meeting) and DMs it back to that user privately, so the channel does not get spammed with one message per submission.

The catch is how a Slack Workflow talks to your backend at all.

The Constraint: No More Outbound Webhook

Older Slack workflows had a "send a web request" step, so a form could POST its fields straight to your service. Slack removed that step from the standard Workflow Builder. A workflow can now post a message into a channel, but it cannot call an arbitrary HTTP endpoint.

So the bot needs to read the submission somewhere. The two realistic options:

  1. Have the workflow post into a channel, and let the bot listen for messages in that channel.
  2. Use a paid connector (a third-party automation platform) to bridge the form to an HTTP call.

Option 2 means another vendor and a recurring bill for what is essentially "move this text from A to B." Not worth it. So option 1 it is, with one wrinkle: I do not want a public channel filling up with internal form dumps.

The fix is boring and effective: the workflow posts into a dedicated private channel that only the bot and a couple of admins are in. The bot reads messages there, does its work, and DMs the actual requester. The noisy intermediate message lives somewhere nobody watches.

What We're Building

1┌──────────────────────────────────────────────┐
2│ Slack Workflow form (deadline request) │
3│ Requester: @someone │
4│ Attendees: @a, @b │
5└───────────────────────┬────────────────────────┘
6 │ posts into
7
8┌──────────────────────────────────────────────┐
9│ #private-intake (bot + admins only) │
10└───────────────────────┬────────────────────────┘
11 │ message event
12
13┌──────────────────────────────────────────────┐
14│ Flask + slack-bolt bot (Cloud Run) │
15│ • compute next meeting date (skip holidays) │
16│ • DM the requester the deadline │
17└──────────────────────────────────────────────┘

The Shared Routing Pattern

All of these automations share one detail worth establishing up front, because the other articles reuse it. A Slack Workflow message arrives as a bot message, and modern Workflow Builder does not include a stable workflow id in the event metadata. So I identify which workflow sent a message with three cheap checks:

1@app.event("message")
2def _message(event, say, client, logger):
3 bot_id = event.get("bot_id")
4 channel = event.get("channel")
5 app_id = event.get("app_id")
6 text = event.get("text", "") or ""
7
8 if (
9 bot_id
10 and channel == INTAKE_CHANNEL_ID
11 and DEADLINE_MARKER in text # a label unique to this form
12 and app_id == DEADLINE_WORKFLOW_APP_ID
13 ):
14 event_ts = event.get("event_ts") or event.get("ts")
15 if _is_duplicate(f"deadline:{event_ts}"):
16 return
17 handle_deadline_request(event, say, client, logger)
18 return

Computing the Deadline

The business rule: the deadline is the date of the next weekly review meeting (say, the next Tuesday), but never a public holiday. A holiday library handles the calendar so I do not maintain a list by hand.

1import datetime
2import jpholiday # any holiday calendar lib works
3
4def next_meeting_date(today: datetime.date, weekday: int = 1) -> datetime.date:
5 # weekday: Monday=0 ... so Tuesday=1
6 days_ahead = (weekday - today.weekday()) % 7
7 days_ahead = days_ahead or 7 # always strictly in the future
8 candidate = today + datetime.timedelta(days=days_ahead)
9 while jpholiday.is_holiday(candidate):
10 candidate += datetime.timedelta(days=7)
11 return candidate

Keeping this as a pure function (date in, date out) makes it trivial to unit test, which matters more than it sounds: off-by-one weekday math is exactly the kind of thing that silently ships wrong.

Sending the DM

To DM a user you open a conversation with their user id and post to it. The requester's id comes from the form text, where Slack has already converted a typed @name into a <@U…> token:

1import re
2
3def extract_user_id(text: str) -> str | None:
4 m = re.search(r"<@([A-Z0-9]+)>", text)
5 return m.group(1) if m else None
6
7def dm_user(client, user_id: str, message: str) -> None:
8 # chat.postMessage to a user id opens/uses the bot DM channel
9 client.chat_postMessage(channel=user_id, text=message)

The handler ties it together: parse the requester, compute the date, format a friendly message, send it.

1def handle_deadline_request(event, say, client, logger):
2 text = event.get("text", "") or ""
3 user_id = extract_user_id(text)
4 if not user_id:
5 logger.warning("deadline request without a requester mention")
6 return
7
8 deadline = next_meeting_date(datetime.date.today())
9 client.chat_postMessage(
10 channel=user_id,
11 text=DEADLINE_DM_MESSAGE.format(date=deadline.isoformat()),
12 )

Gotchas

1. The bot must be a member of the private channel

A bot only receives message events from a private channel if it is invited to it, and the app needs the groups:history scope plus a message.groups event subscription (public channels use channels:history and message.channels). Forget any one of those and the bot simply goes quiet, with no error to tell you why.

2. DMs need the message to be openable

Posting to a user id with chat.postMessage works when the bot has chat:write and the user has not blocked it. There is no separate "open DM" call needed in practice; posting to the user id is enough.

3. Keep the intermediate channel boring

Because the workflow posts a full form dump into the private channel on every submission, that channel is intentionally a junk drawer. The value is in the DM. I keep the channel private and unwatched so nobody mistakes it for a feed they need to read.

Wrapping Up

The whole feature exists to dodge one missing Slack feature. The lesson that carried into the rest of the project: when a managed tool removes the integration point you wanted, a private channel plus a bot that listens is often a cheaper and more flexible bridge than paying a connector to do the same thing.

Next: turning a richer form into a spreadsheet row and a calendar event, with an approval button in the middle.

Project Navigation

  1. 1.Private DMs from a Slack Workflow Without the Webhook Step
  2. 2.From a Slack Form to a Spreadsheet Row and a Calendar Event
  3. 3.Auto-Routing a Report to the Right Owner with a Spreadsheet Lookup
  4. 4.Letting an AI Agent Open the Pull Request