A custom Journey Builder activity for Salesforce Marketing Cloud (SFMC). Drop it on a journey path, pick a Slack channel and write a message. Each contact who reaches that step triggers a Slack post, such as:
New VIP Ana (0031x00000AbCdE) reached VIP alert
It runs as one Cloudflare Worker. The Worker serves the configuration UI and handles Journey Builder's calls.
- Channel picker. Lists the Slack channels the bot has been invited to. If listing isn't set up, you can enter a channel ID instead.
- Message templates. Write the message with
{{Field}}tokens and insert Entry Event fields with one click. The built-in tokens are{{JourneyName}},{{ActivityName}}and{{ContactKey}}. A preview shows the result as you type. Slack formatting (*bold*, links) works in the template. - Safe values. Contact data is escaped before posting, so a field containing
<!channel>or a link can't ping a channel or disguise a URL. - Flood control. Messages are spaced about one second apart, which is Slack's per-channel limit. Each activity sends up to a set number of individual messages per hour (60 by default). After that, contacts are counted and posted as a single summary every 5 minutes, for example "…and 1,240 more contacts reached VIP alert in Welcome Journey".
- Never blocks a journey. Once a request is verified as coming from Journey Builder, the activity always answers
200. A Slack outage or a wrong channel is logged, and the contact carries on. - Signed requests only. Every Journey Builder call is verified against the installed package's JWT signing secret. The bot token stays in the Worker and never appears in the journey.
Journey Builder ──(config modal)──▶ Worker: index.html (React + Postmonger)
│ └─ GET /api/channels (checks your SFMC session)
└──(per contact, signed JWT)──▶ Worker: POST /activity/execute
└─▶ ChannelGate Durable Object (one per channel)
└─▶ Slack chat.postMessage
- Saving the activity. The UI writes everything the runtime needs into flat
inArguments: the channel ID, the template (base64-encoded, so Journey Builder doesn't try to resolve its{{...}}tokens), the hourly limit, the journey and activity names, and one{{Event.<key>.<field>}}binding per field the template uses. Journey Builder resolves those bindings for each contact. A copy of the settings is also kept inmetaData.slackNotifyso the form reopens as you left it. - Execute. The Worker verifies the JWT, fills in the template and passes the message to the channel's
ChannelGateDurable Object. The gate reserves a send slot, enforces the hourly limit, and either posts the message or adds the contact to the summary count. The summary is posted by a Durable Object alarm. If Slack returns429, the gate honoursRetry-Afterand counts the message into the summary. - Channel list. The UI asks Journey Builder for your session token (
requestTokens) and sends it with the list request. The Worker checks the token with your tenant's/v2/userinfoendpoint, and optionally checks the enterprise ID, before returning channel names.
You need a Cloudflare account, a Slack workspace where you can create apps, and SFMC admin access to create installed packages.
- At https://api.slack.com/apps, create an app from scratch.
- Under OAuth & Permissions, add these bot token scopes:
chat:write, to post messages;channels:readandgroups:read, for the channel picker (public and private channels).
- Install the app to the workspace and copy the Bot User OAuth Token (
xoxb-…). - In each channel you want to post to, type
/invite @your-bot.
npm ci
npx wrangler login
npx wrangler secret put SLACK_BOT_TOKEN # the xoxb- token
npm run deployNote the Worker URL, for example https://sfmc-slack-notify.<you>.workers.dev.
- In Setup → Apps → Installed Packages, create a package.
- Add a Journey Builder Activity component:
- Category: Messages.
- Endpoint URL: the Worker URL.
- Copy the package's JWT Signing Secret and the activity's Unique Key (the application extension key).
npx wrangler secret put JWT_SIGNING_SECRET # from the installed packageIn the Cloudflare dashboard (Worker → Settings → Variables), add these text variables. npm run deploy keeps them.
| Variable | Required | What it is |
|---|---|---|
APPLICATION_EXTENSION_KEY |
Yes | The Journey Builder activity's unique key from the installed package |
SFMC_SUBDOMAIN |
For the channel picker | Your tenant subdomain, the part before .auth.marketingcloudapis.com |
SFMC_ENTERPRISE_ID |
Optional | Only allow channel listing for this enterprise (EID) |
Without SFMC_SUBDOMAIN, the channel list stays off and people enter a channel ID instead. To find a channel's ID in Slack: click the channel name, then About, and it's at the bottom.
Check https://<worker>/health. All four flags should be true, or channelListing false if you skipped it.
Open a journey. Slack Notify is under Messages. Drag it onto a path, then pick the channel, write the message, set the hourly limit and click Done. Save the journey, then activate it.
npm ci
npm test # unit tests (Node)
npm run dev # UI at http://127.0.0.1:5173/?demo=1The demo uses made-up journey fields and channels, and Done saves to the browser's localStorage, so you can reload to test reopening. The demo is switched off inside an iframe.
To run the Worker locally, copy .dev.vars.example to .dev.vars, fill it in, and run npm run preview. For offline testing, set SLACK_API_BASE to point Slack calls at a mock server.
public/config.json Journey Builder activity definition (URLs and key are filled in by the Worker)
src/shared/ Template tokens, escaping, Entry Event field discovery, save/load of the activity
src/web/ Configuration UI: React + SLDS, Postmonger session, local demo
src/worker/app.ts Routes: /config.json, /health, /api/channels, /activity/*
src/worker/gate.ts ChannelGate Durable Object: pacing, hourly limit, summaries
src/worker/jwt.ts HS256 verification of Journey Builder requests
src/worker/slack.ts chat.postMessage and conversations.list
src/worker/sfmc.ts Checks the SFMC session token before listing channels
tests/ Node test runner tests for all of the above
- Not yet tested inside a real SFMC org. Two things are unconfirmed:
- that
requestTokensreturns afuel2tokenthat/v2/userinfoaccepts. If it doesn't, the picker falls back to manual channel IDs. - the exact shape of the Entry Event schema. Field discovery reads the places a schema has been seen. When none is supplied, the built-in tokens still work.
- that
- Names are copied at save time. The journey and activity names in messages are the ones from when the activity was last saved.
- Channel access isn't checked on publish. Journey Builder's publish and validate calls don't include the activity's settings, so the Worker can't check that the bot is in the channel before activation. A missing invite shows up as
channel_not_foundin the Worker logs. - Hourly counts can reset. They live in the Durable Object and reset if the object is evicted. That's rare, and the effect is at most one extra batch of individual messages.
Versions before 2.0 were a Postmonger learning experiment that posted to a hardcoded incoming webhook. That code is still in the git history.