The Slack Events API is the part of the platform that lets your app react to what people do in a workspace without polling for it. Where a slash command fires because a user typed something and a shortcut fires because a user clicked something, the Events API works the other way around: Slack pushes an HTTP POST to a URL you own every time an activity you subscribed to happens. Someone mentions your bot, a message lands in a channel it watches, a new teammate joins, and Slack delivers each of those as an event. We built a DevOps bot for a large engineering org on exactly this model, and most of the work was not the handlers themselves but the plumbing around acknowledgment, retries, and deduplication. That plumbing is where teams get burned, so this post walks through it end to end.
Turning on Event Subscriptions
You enable this in your app config at api.slack.com/apps. Pick the app, open Event Subscriptions, flip the toggle, and set a Request URL that Slack will deliver to. Ours was https://devops-bot.example.tech/slack/events.
The moment you paste that URL, Slack tries to validate it. It sends a single POST with a url_verification body before it will let you save anything:
{
"token": "Jhj5dZrVaK7ZwHHjRyZWjbDl",
"challenge": "3eZbrw1aBm2rZgRNFdxV2595E9CY3gmdALWMmHkvFXO7tYXAYM8P",
"type": "url_verification"
}
Your endpoint has to reply within 3 seconds, echoing the challenge back:
{ "challenge": "3eZbrw1aBm2rZgRNFdxV2595E9CY3gmdALWMmHkvFXO7tYXAYM8P" }
A text/plain body containing the same string also works. Only after Slack sees its challenge come back does it accept the URL. This handshake happens once at setup, not on every event, so treat it as a special case at the top of your handler.
The endpoint: verification, signing, and dispatch
Here is the core of the route. Two things matter: parsing the raw body so you can verify Slack's signature, and answering the verification challenge before you get clever with security checks.
const express = require('express');
const crypto = require('crypto');
const app = express();
app.use('/slack/events', express.json({
verify: (req, _res, buf) => { req.rawBody = buf.toString('utf8'); },
}));
function verifySlack(req) {
const ts = req.headers['x-slack-request-timestamp'];
const sig = req.headers['x-slack-signature'];
if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
const base = `v0:${ts}:${req.rawBody}`;
const mac = 'v0=' + crypto.createHmac('sha256', process.env.SLACK_SIGNING_SECRET)
.update(base).digest('hex');
return crypto.timingSafeEqual(Buffer.from(mac), Buffer.from(sig));
}
app.post('/slack/events', async (req, res) => {
// Answer the verification handshake first.
if (req.body.type === 'url_verification') {
return res.json({ challenge: req.body.challenge });
}
if (!verifySlack(req)) return res.status(401).send('bad sig');
if (req.body.type === 'event_callback') {
res.status(200).send(''); // ack immediately
enqueueEvent(req.body); // hand off to async worker
return;
}
res.status(400).send('unknown type');
});
The signature verification is standard Slack HMAC over v0:timestamp:body with your signing secret, plus a 5-minute timestamp window to block replays. Use timingSafeEqual, not ===. One subtlety: the url_verification POST also carries a valid X-Slack-Signature, so you can verify it if you want, but handling it before the signature check keeps the handshake bulletproof while you are still wiring things up.
A pitfall we hit early: if your JSON middleware is not scoped to the events route, req.body is undefined and verification "fails" for reasons that have nothing to do with signing. Make sure the body actually parses before you blame your secret.
Subscribing to bot events
Scroll down to Subscribe to bot events and add the ones you care about. For the DevOps bot we used four:
app_mentionfires when someone tags the bot, like@DevOpsBot deploy stg.message.channelsfires on messages in public channels the bot is a member of.team_joinfires when someone new joins the workspace.member_joined_channelfires when someone joins a channel the bot is in.
Each event pulls in a required scope: app_mentions:read, channels:history, users:read, and so on. After you add them, hit Save Changes and reinstall the app, because new scopes do not take effect until the workspace reauthorizes. Forgetting the reinstall is the single most common reason "my subscription is set but no events arrive."
There are two families here. Bot events ride your xoxb- bot token and require the bot to actually be in the channel to see activity there. User events like message.im ride an xoxp- user token tied to whoever installed the app. We kept the DevOps bot on bot events only, because a user token dies the day that person leaves, and you do not want your deploy bot going dark because someone changed teams. The tradeoff is reach: with hundreds of channels in this workspace, the bot has to be invited into each one. We scripted that with conversations.list and a bulk conversations.invite rather than typing /invite a hundred times.
Why 3 seconds matters
Slack waits 3 seconds for your 200. Miss it and Slack retries up to 3 times, which means the same event can be delivered four times total. If your handler does real work, talks to a deploy API, writes to a database, posts a message, before it returns the 200, you will blow past the budget under load, Slack will retry while your first handler is still running, and you get duplicate actions. We saw this exact failure: a deploy triggered twice because the handler was slow.
The fix is the shape already in the code above. Acknowledge with res.status(200).send('') the instant the signature checks out, then push the work onto a queue:
function enqueueEvent(envelope) {
const ev = envelope.event;
jobQueue.add({
eventId: envelope.event_id,
teamId: envelope.team_id,
type: ev.type,
payload: ev,
});
}
queueWorker.process(async (job) => {
const { type, payload, teamId, eventId } = job.data;
if (await alreadyProcessed(eventId)) return;
await markProcessed(eventId);
switch (type) {
case 'app_mention': return onAppMention(payload, teamId);
case 'message': return onMessage(payload, teamId);
case 'team_join': return onTeamJoin(payload, teamId);
case 'member_joined_channel': return onMemberJoinedChannel(payload, teamId);
}
});
Delivery is at-least-once, so dedup is not optional. The retry reuses the same event_id, which gives you a natural idempotency key. Redis with SET NX is plenty:
async function alreadyProcessed(eventId) {
const set = await redis.set(`slack:event:${eventId}`, '1', 'NX', 'EX', 86400);
return set === null; // null means the key already existed
}
A 24-hour TTL is comfortable since retries only span a few minutes. The headers X-Slack-Retry-Num and X-Slack-Retry-Reason are also there if you want to detect and short-circuit retries explicitly.
Writing the handlers
With dispatch sorted, the handlers are the easy part. For app_mention, strip the leading mention token out of the text and parse what is left:
async function onAppMention(ev, teamId) {
const cleanText = ev.text.replace(/<@[A-Z0-9]+>/g, '').trim();
const [cmd, env, service] = cleanText.split(/\s+/);
if (cmd === 'deploy') {
if (!['stg', 'prod'].includes(env)) {
return reply(ev, 'I can only deploy stg or prod.');
}
if (env === 'prod') {
return reply(ev, 'Prod deploys need approval. I raised a request to the leads channel.');
}
await triggerDeploy({ env, service, requester: ev.user });
return reply(ev, `Deploying ${service} to ${env}, will report back when done.`);
}
return reply(ev, "I didn't catch that. Mention me with 'help' to see options.");
}
async function reply(ev, text) {
await slack.chat.postMessage({
channel: ev.channel,
thread_ts: ev.thread_ts || ev.ts, // keep it in-thread, not in the main channel
text,
});
}
The message.channels handler is where anti-loop discipline earns its keep. When your bot posts a message, that post is itself a message event, which can trigger your bot to reply, which posts another message, and now you are rate-limited into oblivion. Guard the top of the handler:
async function onMessage(ev, teamId) {
if (ev.bot_id || ev.subtype === 'bot_message') return; // skip all bots, including self
if (ev.subtype) return; // skip edits, deletes, joins
const text = ev.text || '';
if (!SPAM_PATTERNS.some((re) => re.test(text))) return;
try {
await slack.chat.delete({ channel: ev.channel, ts: ev.ts });
} catch (e) {
log.warn('cannot delete', { channel: ev.channel, error: e.data?.error });
}
const im = await slack.conversations.open({ users: ev.user });
await slack.chat.postMessage({ channel: im.channel.id, text: 'That post broke channel rules.' });
}
Checking ev.bot_id is safer than checking against your own bot user ID. If you run dev, staging, and prod copies of the bot in one workspace, each has a different bot user, and ev.bot_id skips all of them in one line.
The team_join and member_joined_channel handlers follow the same pattern: open a DM with conversations.open, then post a welcome with Block Kit or a plain text fallback, auto-inviting new hires into default channels and pointing people who join an alerts channel at the on-call guide before they mute it.
The ordering trap
One more thing the docs underplay: Slack does not guarantee event ordering. Once retries and your own queue get involved, events can arrive out of sequence. We learned this when the bot miscounted reactions because it processed a reaction_removed before the matching reaction_added. If your logic depends on order, do not incrementally fold the event stream into state. Either sort by event_ts or, better, treat the event as a signal to refetch the source of truth, in that case calling reactions.get to read the current state rather than trusting your running tally. The event tells you something changed; the API tells you what it is now.
Takeaway
The Slack Events API is straightforward to turn on and easy to get subtly wrong. Answer the URL verification challenge before anything else, verify signatures on real events, acknowledge inside 3 seconds and do the work in a queue, dedup on event_id, and guard every message handler against bot loops. Get those five right and the handlers themselves are a few lines each. Get them wrong and you ship a bot that double-deploys, talks to itself, and miscounts state, all of which we have watched happen before fixing them.
If you are building Slack automation, internal bots, or event-driven integrations and want them to be production-safe from day one, we do this work. Tell us what you are building at /contact and see how we can help at /service.








