SapotaCorp

Slash Commands in Slack: Setup, Handlers, and Response Types

A Slack slash command looks trivial until the 3-second timeout drops your reply and Ops thinks the bot is broken. Here is the full path we ship in production: registering the command, verifying the payload, and choosing ephemeral, in-channel, or delayed responses without tripping Slack's limits.

Slash Commands in Slack: Setup, Handlers, and Response Types

Key takeaways

  • Slack waits only 3 seconds for an HTTP 200, so acknowledge immediately and push slow work to a background job that posts back through response_url.
  • Always verify the request signature with your signing secret using HMAC-SHA256, and reject anything older than 5 minutes to block replay attacks.
  • Ephemeral responses are private to the caller and in-channel responses are visible to everyone, but you cannot promote one to the other after sending.
  • response_url is valid for 30 minutes and accepts at most 5 calls, so batch your progress updates instead of spawning a worker per step.
  • Each interaction carries its own response_url, and a slash command's URL only updates the message that command produced.

A slack slash command is the cheapest way to give a team a button they already know how to press. Type /order 123456 in any channel and the result shows up inline, no admin tool, no context switch. We built exactly this for an e-commerce operations team that was tired of alt-tabbing into a back-office dashboard every time a customer asked where their package was. The command itself took an afternoon. The parts that actually mattered, signature validation and the 3-second timeout, took the rest of the week to get right, and that is what this post is about.

Registering the command

You define a command in the Slack app config, not in code. Go to api.slack.com/apps, pick your app, open Slash Commands, and create a new one. For the Ops case it looked like this:

  • Command: /team-order
  • Request URL: https://slack-bot.example.com/slack/commands/team-order
  • Short description: Check order status by order ID
  • Usage hint: <order_id>

When you save, Slack adds the commands OAuth scope to your app automatically. That scope change does not take effect until you reinstall the app to the workspace, and forgetting to reinstall is the first reason a freshly created command does nothing.

One naming note that saved us real grief. A workspace can have several apps each registering /order, and Slack will route to whichever it feels like, which produces a maddening intermittent bug. We prefix every command with the team name (/team-order, /team-deploy). It is the cheapest possible defense against colliding with another app you do not control. If you are forced to use a generic name, turn on "Escape channels, users, and links" so Slack hands you IDs instead of display text, which makes the text field easier to parse without ambiguity.

The handler and payload validation

Slack POSTs the command as application/x-www-form-urlencoded, signed with your signing secret. Before you trust a single field, verify that signature. Skipping this means anyone who learns your Request URL can impersonate Slack and trigger your handler.

The signature is an HMAC-SHA256 over v0:{timestamp}:{rawBody}. You need the raw body, so capture it in the body parser before Express mangles it:

const express = require('express');
const crypto = require('crypto');
const { WebClient } = require('@slack/web-api');

const app = express();
const slackSecret = process.env.SLACK_SIGNING_SECRET;
const slack = new WebClient(process.env.SLACK_BOT_TOKEN);

app.use('/slack/commands', express.urlencoded({
  extended: true,
  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 (!ts || !sig) return false;
  // reject anything older than 5 minutes
  if (Math.abs(Date.now() / 1000 - Number(ts)) > 60 * 5) return false;
  const base = `v0:${ts}:${req.rawBody}`;
  const mac = 'v0=' + crypto.createHmac('sha256', slackSecret).update(base).digest('hex');
  return crypto.timingSafeEqual(Buffer.from(mac), Buffer.from(sig));
}

Two things in there are not optional. The timestamp check rejects requests older than 5 minutes, which blunts replay attacks where someone captures one valid request and fires it repeatedly. And crypto.timingSafeEqual instead of === keeps the comparison constant-time so an attacker cannot probe the signature byte by byte. If you want belt and suspenders, store a nonce of timestamp + signature in Redis with a 5-minute TTL and reject duplicates outright:

const replayKey = `slack:nonce:${ts}:${sig}`;
const fresh = await redis.set(replayKey, '1', 'EX', 300, 'NX');
if (!fresh) return res.status(401).send('replay');

With the request trusted, validate the user input before you do any work. Empty input gets a usage hint, garbage input gets a clear error, and both go back privately so you are not scolding someone in front of the channel:

app.post('/slack/commands/team-order', async (req, res) => {
  if (!verifySlack(req)) return res.status(401).send('bad signature');

  const { command, text, user_id, channel_id, response_url } = req.body;
  const orderId = (text || '').trim();

  if (!orderId) {
    return res.json({
      response_type: 'ephemeral',
      text: `I need an order ID. Example: ${command} 123456789`,
    });
  }
  if (!/^[0-9]{6,12}$/.test(orderId)) {
    return res.json({
      response_type: 'ephemeral',
      text: 'That order ID is not valid. It must be 6 to 12 digits.',
    });
  }

  // ack within 3 seconds, then do the heavy lookup elsewhere
  res.json({ response_type: 'ephemeral', text: `Looking up order ${orderId}...` });
  enqueueLookup({ orderId, userId: user_id, channelId: channel_id, responseUrl: response_url });
});

The 3-second ack and response_url

This is the rule that breaks more slash commands than anything else: Slack waits 3 seconds for an HTTP 200. Miss it and the user sees operation_timeout, even if your job finishes correctly a second later. The order lookup hit a MySQL primary and routinely took 2 to 4 seconds, so awaiting it before replying produced sporadic failures that were almost impossible to reproduce on a fast dev box.

The fix is to acknowledge first and finish later. The handler above already returns a quick "Looking up..." message and hands the rest to a background job. That job posts the real result to response_url, a one-time webhook Slack includes in the payload that stays valid for 30 minutes and accepts up to 5 calls:

const axios = require('axios');
const { lookupOrder } = require('./services/order');

async function enqueueLookup({ orderId, userId, responseUrl }) {
  const order = await lookupOrder(orderId);

  const blocks = [
    { type: 'header', text: { type: 'plain_text', text: `Order ${order.id}` } },
    {
      type: 'section',
      fields: [
        { type: 'mrkdwn', text: `*Status:*\n${order.status}` },
        { type: 'mrkdwn', text: `*Total:*\n${order.total.toLocaleString()}` },
        { type: 'mrkdwn', text: `*Customer:*\n${order.customer.name}` },
      ],
    },
    { type: 'context', elements: [{ type: 'mrkdwn', text: `Looked up by <@${userId}>` }] },
  ];

  await axios.post(responseUrl, {
    response_type: 'ephemeral',
    replace_original: true,
    blocks,
    text: `Order ${order.id} is ${order.status}`,
  });
}

replace_original: true swaps the "Looking up..." placeholder for the final card, so the user ends up with one clean message instead of two stacked ones. One more limit worth knowing: the payload you send back to Slack caps at 30KB. A retail bank we worked with hit this trying to dump 200 rows of transactions into a single Block Kit message. Paginate to roughly 20 rows and add a "Show more" button that loads the next page through an action.

Ephemeral vs in-channel

Every response carries a response_type. ephemeral is the default and is visible only to the person who ran the command, which is what you want for routine lookups so you do not spam the channel every time someone checks an order. in_channel posts publicly for the whole team. We let the caller decide with a flag:

const args = (text || '').split(/\s+/);
const orderId = args[0];
const share = args.includes('--share');

res.json({
  response_type: share ? 'in_channel' : 'ephemeral',
  blocks: buildOrderBlocks(order),
});

So /team-order 123456 stays private and /team-order 123456 --share broadcasts it. The trap here, and one that cost us an afternoon of confusion, is that you cannot promote an ephemeral message to in-channel after it is sent. Updating response_type through response_url is silently ignored. When a user runs the private command and then clicks a "Share to channel" button, you have to chat.postMessage a fresh public message and send delete_original: true to clean up the ephemeral one.

For jobs that take longer than 3 seconds you can show progress, but spend the 5 calls wisely:

async function progressLookup({ orderId, responseUrl }) {
  await axios.post(responseUrl, {
    response_type: 'ephemeral', replace_original: true,
    text: 'Step 1/3: fetching the order from the database...',
  });
  const order = await db.findOrder(orderId);

  await axios.post(responseUrl, {
    response_type: 'ephemeral', replace_original: true,
    text: 'Step 2/3: fetching tracking from the carrier...',
  });
  const tracking = await carrier.getTracking(order.trackingCode);

  await axios.post(responseUrl, {
    response_type: 'ephemeral', replace_original: true,
    blocks: buildOrderBlocks(order, tracking),
    text: `Order ${order.id}`,
  });
}

Three calls out of five is fine. Six steps is not, because the sixth call fails on quota. If your work genuinely takes that many updates, collapse several steps into one multi-block message, and if it runs past 30 minutes drop response_url entirely and post fresh messages with chat.postMessage.

The last bug worth flagging: do not cross your response_url wires. A slash command's URL only updates the message that command produced, and a block action's URL only updates the message containing that block. We shipped a button handler that reused the original command's response_url instead of the action's, so clicks updated nothing and users sat there clicking into the void. Always read response_url from the payload in front of you, never from a stored session.

Takeaway

A solid slash command comes down to four habits: verify the signature before trusting anything, acknowledge inside 3 seconds and defer slow work to response_url, pick ephemeral or in-channel deliberately because you cannot switch after the fact, and respect the 30-minute, 5-call, 30KB ceilings so your nicest feature does not fail under load. Get those right and the command feels instant and bulletproof, which is the whole point of putting it in Slack in the first place.

If you want a Slack integration that holds up when the whole Ops team leans on it at once, we have shipped these end to end. Tell us what your team keeps alt-tabbing into at /contact, or see how we work at /service.

Engineering certifications

Sapota engineers hold credentials on Slack. Each badge links to the individual engineer's credly profile.

Browse Slack certs

Need this on your team?

Sapota engineers ship the patterns you read here. Two-week paid trial, direct pricing from $1,800/ engineer/month, no agency markup.

Get a quote
Contact Us Now

Share Your Story

We build trust by delivering what we promise – the first time and every time!

We'd love to hear your vision. Our IT experts will reach out to you during business hours to discuss making it happen.

WHY CHOOSE US

"Collaborate, Elevate, Celebrate where Associates - Create Project Excellence"

SapotaCorp beyond the IT industry standard, we are

  • Certificated
  • Assured quality
  • Extra maintenance

Tell us about your project