What you'll build.
A Journey Builder custom activity that, every time a contact reaches it, posts a templated Slack message to a configured channel. Marketers configure the channel, the template, and the merge fields directly inside Journey Builder — no engineering ticket per campaign.

Why a Slack custom activity beats middleware.
- No paid hop — the message goes Marketing Cloud → your service → Slack
- Sub-2-second alerts, vs. middleware schedulers that poll on a delay
- Marketers configure each step inline, not in a separate tool
- The activity is reusable across every journey in the BU

Create the Slack Incoming Webhook.
- 01Go to api.slack.com/apps → Create New App → From scratch
- 02Pick a workspace, name the app (e.g. "SFMC Notifier")
- 03Enable Incoming Webhooks under Features
- 04Add a New Webhook to Workspace, select the destination channel
- 05Copy the webhook URL — store it in your secret manager
inArguments directly — anyone with read access to the journey would see it. Use an alias resolved server-side.Build the Node.js backend.
Use the same Express scaffold as any custom activity — four endpoints, JWT verification on /execute. The Slack-specific work happens inside the execute handler.
See the creation walkthrough for the base server setup.
Handle the execute endpoint.
The /execute handler verifies the JWT, resolves the webhook alias to a real URL, renders the message, and posts to Slack.
import jwt from "jsonwebtoken";
import { resolveWebhook } from "./secrets.js";
import { renderBlocks } from "./templates.js";
export async function executeHandler(req, res) {
const body = jwt.verify(req.body, process.env.SFMC_JWT_SECRET);
const { channelAlias, template, ...contact } = body.inArguments[0];
const webhookUrl = await resolveWebhook(channelAlias);
const blocks = renderBlocks(template, contact);
const resp = await fetch(webhookUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ blocks }),
});
if (!resp.ok) return res.status(502).json({ status: "slack_failed" });
return res.json({ status: "executed" });
}Send a Slack message.
The Slack webhook accepts either a plain text field or a structured blocks array (Block Kit). For real product alerts, blocks beat plain text every time.
{
"blocks": [
{ "type": "header", "text": { "type": "plain_text", "text": "New high-value lead" } },
{ "type": "section", "fields": [
{ "type": "mrkdwn", "text": "*Name:*\nJane Doe" },
{ "type": "mrkdwn", "text": "*Score:*\n92" }
] }
]
}Handle failures gracefully.
Slack can rate-limit. Channels can be archived. Webhooks can be revoked. Your handler needs a clear policy for each — and it should never silently drop a contact.
Respect the Retry-After header. Either return non-2xx so JB retries, or queue the message and return 200.
Slack returns channel_not_found. Log the failure, alert ops, and skip the contact rather than retrying forever.
Slack returns 410 Gone or 404. The webhook URL is dead — surface this clearly so marketers reissue it.
Wrap the fetch with a 1.5s timeout. On timeout, queue and return 200 — never block Journey Builder waiting.
/execute and return 200 immediately. The queue worker handles retries and surfaces hard failures into a dead-letter store ops can review — the journey never stalls.Block Kit templating with merge fields.
The marketer-facing template should be safe but expressive. A simple {{firstName}}-style replacement on top of a stored Block Kit JSON gets you most of the way without exposing arbitrary code.
// Replace {{token}} placeholders with values from contact
export function renderBlocks(templateJson, contact) {
const rendered = templateJson.replace(/\{\{\s*(\w+)\s*\}\}/g, (_, key) =>
contact[key] ?? ""
);
return JSON.parse(rendered);
}