SapotaCorp

Building a Slack App with Bolt for JavaScript

Wiring up a Slack app from scratch means signature verification, raw-body parsing, and the dreaded 3-second ack rule. Bolt for JavaScript handles all of it so you can focus on listeners and business logic. Here is the setup we actually ship, ExpressReceiver and all.

Building a Slack App with Bolt for JavaScript

Key takeaways

  • Bolt for JavaScript removes the security-sensitive boilerplate of HMAC signature checks, raw-body parsing, and event routing so you write listeners instead of plumbing.
  • ExpressReceiver exposes the underlying Express router, which lets you mount custom endpoints like health checks and inbound webhooks next to your Slack routes.
  • Every command, action, and view handler must call await ack() within three seconds, then offload slow work to a background job so Slack never times out.
  • Socket Mode lets you develop locally without ngrok or a public URL, and the same listener code runs unchanged when you switch to an HTTP receiver in production.
  • Never apply express.json() globally, because it destroys the raw body Bolt needs to verify Slack signatures; scope it to your custom routes only.

If you have ever tried to stand up a Slack app the hard way, you already know where the pain lives. You verify an HMAC signature on every inbound request, you parse a raw body that arrives as JSON for events but form-encoded for interactivity, you route by callback_id and action_id and command and event type, and you respond inside Slack's three-second window or the user sees a red error. That is a lot of security-sensitive plumbing before you write a single line of real logic. Bolt for JavaScript is Slack's official framework that handles all of it, and once you know how to wire it correctly, building a slack bolt javascript app stops being plumbing work and becomes feature work.

This walkthrough is the setup we actually use on client projects: the App class, the ExpressReceiver for HTTP, Socket Mode for local dev, the listeners for commands, actions, events, and views, and the env config that ties it together. The running example is a small ops bot that creates an incident channel when an order has a problem, the kind of internal tool we have built for an e-commerce operations team.

Why Bolt and not raw Express

You can absolutely skip Bolt and write Express plus a manual signature check. We have seen teams do it, and we have seen the bugs that come with it. The signature verification reads the raw body before any JSON middleware touches it, and the moment someone adds express.json() in the wrong place, every Slack request starts failing with invalid_signature. Bolt also gives you the automatic ack inside the three-second deadline, a single flag to swap between HTTP and Socket Mode, and an Express-style middleware chain for auth, logging, and metrics. For anything headed to production, reach for Bolt.

Project setup

Start with a clean Node project and pull in the three dependencies that matter.

mkdir ops-bot && cd ops-bot
npm init -y
npm install @slack/bolt dotenv
npm install --save-dev nodemon

@slack/bolt is the framework, dotenv loads your secrets from a .env file, and nodemon gives you hot reload while you iterate. We keep listeners in their own directory so the same files work whether the entrypoint is HTTP or Socket Mode:

ops-bot/
  src/
    app.js
    listeners/
      events.js
      commands.js
      actions.js
      views.js
  .env
  .env.example
  .gitignore
  package.json

Three tokens drive everything, so put them in .env and add .env to .gitignore before your first commit.

SLACK_BOT_TOKEN=xoxb-...
SLACK_SIGNING_SECRET=...
SLACK_APP_TOKEN=xapp-...
PORT=3000
NODE_ENV=development

The bot token (xoxb-) comes from OAuth & Permissions after you install the app. The signing secret lives under Basic Information and is what Bolt uses to verify requests. The app-level token (xapp-) comes from the Socket Mode tab and is only needed when you run Socket Mode.

The App class and ExpressReceiver

The App wraps a receiver, your bot token, and your listeners. For HTTP, you build an ExpressReceiver first so you can control the route paths and reach the underlying Express router later.

// src/app.js
require('dotenv').config();
const { App, ExpressReceiver } = require('@slack/bolt');

const receiver = new ExpressReceiver({
  signingSecret: process.env.SLACK_SIGNING_SECRET,
  endpoints: {
    events: '/slack/events',
    commands: '/slack/cmd',
    actions: '/slack/interactivity',
  },
});

const app = new App({
  token: process.env.SLACK_BOT_TOKEN,
  receiver,
});

require('./listeners/events')(app);
require('./listeners/commands')(app);
require('./listeners/actions')(app);
require('./listeners/views')(app);

// custom route, not a Slack endpoint
receiver.router.get('/health', (req, res) => {
  res.status(200).send('ok');
});

(async () => {
  const port = process.env.PORT || 3000;
  await app.start(port);
  console.log(`Ops bot running on port ${port}`);
})();

The reason we prefer ExpressReceiver over the default is that it exposes the Express layer directly. receiver.app is the Express application, so you can add normal Express middleware, and receiver.router is the router, so you can mount your own endpoints. That is how you put a health check and an inbound webhook right next to your Slack routes.

Here is a real one. An external order management system POSTs to the bot when an order hits a problem, and the bot spins up a private channel, invites the ops team, and drops in context:

const express = require('express');

receiver.router.post('/oms-webhook', express.json(), async (req, res) => {
  const { order_id, issue_type } = req.body;
  const result = await app.client.conversations.create({
    name: `order-issue-${order_id}`,
    is_private: true,
  });
  await app.client.conversations.invite({
    channel: result.channel.id,
    users: 'U0OPS001,U0OPS002',
  });
  await app.client.chat.postMessage({
    channel: result.channel.id,
    text: `New issue: ${issue_type} for order ${order_id}`,
  });
  res.status(200).json({ ok: true });
});

Notice the express.json() is attached to this one route, not applied globally. ExpressReceiver deliberately keeps the raw body for the Slack routes so it can verify signatures. If you call app.use(express.json()) at the top level, you break that raw body and every Slack request fails verification. Scope the JSON parser to your custom routes only.

Wiring the listeners

Each listener type lives in its own file and takes the app instance. Events are the simplest. An app_mention handler can reply in the same channel with the say shortcut:

// src/listeners/events.js
module.exports = (app) => {
  app.event('app_mention', async ({ event, say }) => {
    await say(`Hi <@${event.user}>, I'm the ops bot`);
  });
};

Commands and actions both have to ack first. This is the rule that trips up everyone at least once. Call await ack() at the very top of the handler, then do the work. If the handler does something slow before the ack lands, Slack times out at three seconds and the user sees a failure.

// src/listeners/commands.js
module.exports = (app) => {
  app.command('/ops-issue', async ({ ack, command, respond }) => {
    await ack(); // satisfy the 3s rule first
    const orderId = command.text.trim();
    if (!orderId) {
      await respond({
        text: 'Usage: /ops-issue ORDER-ID',
        response_type: 'ephemeral',
      });
      return;
    }
    const order = await fetchOrder(orderId);
    await respond({
      blocks: buildOrderBlock(order),
      response_type: 'ephemeral',
    });
  });
};

Actions fire from buttons and selects. A resolve button can update the original message in place and strip the button out so it cannot be clicked twice:

// src/listeners/actions.js
module.exports = (app) => {
  app.action('resolve_issue', async ({ ack, body, client }) => {
    await ack();
    const issueId = body.actions[0].value;
    await markResolved(issueId);
    await client.chat.update({
      channel: body.channel.id,
      ts: body.message.ts,
      text: `Issue ${issueId} resolved by <@${body.user.id}>`,
      blocks: [], // clear the button
    });
  });
};

View submissions are where validation gets interesting, because ack can carry errors back into the modal. Pass response_action: 'errors' and Slack keeps the modal open with the message attached to the right input block:

// src/listeners/views.js
module.exports = (app) => {
  app.view('escalate_modal', async ({ ack, body, view, client }) => {
    const note = view.state.values.note_block.note_input.value;
    if (!note || note.length < 10) {
      await ack({
        response_action: 'errors',
        errors: { note_block: 'Note must be at least 10 characters' },
      });
      return;
    }
    await ack();
    await client.chat.postMessage({
      channel: view.private_metadata, // channel id stashed in metadata
      text: `Escalated by <@${body.user.id}>: ${note}`,
    });
  });
};

Running it locally without ngrok

For day-to-day development, Socket Mode is the fastest path. Set socketMode: true, supply the app-level token, and you no longer need ExpressReceiver, an open port, or a public URL. Slack pushes events down a socket and your listener code runs unchanged.

// src/app-socket.js (dev only)
require('dotenv').config();
const { App } = require('@slack/bolt');

const app = new App({
  token: process.env.SLACK_BOT_TOKEN,
  appToken: process.env.SLACK_APP_TOKEN,
  socketMode: true,
});

require('./listeners/events')(app);
require('./listeners/commands')(app);
require('./listeners/actions')(app);
require('./listeners/views')(app);

(async () => {
  await app.start();
  console.log('Socket Mode dev running');
})();

We keep one entrypoint for HTTP production and one for Socket Mode dev, both importing the same listeners. The npm scripts make the choice explicit:

{
  "scripts": {
    "dev": "nodemon src/app-socket.js",
    "dev:http": "nodemon src/app.js",
    "start": "node src/app.js"
  }
}

When you do need HTTP locally, for example to test signature verification or your custom webhook, run ngrok http 3000, paste the public URL plus /slack/events into Event Subscriptions, and do the same for Interactivity and Slash Commands. The catch on the free ngrok tier is that the URL changes on every restart, so you re-enter it in the Slack config each time. A paid static domain removes that friction.

Pitfalls worth internalizing

A few failure modes show up on almost every project. Forgetting await ack() produces the three-second timeout and an "operation failed" message to the user. Rotating the signing secret in the Slack UI without updating .env and restarting gives you invalid_signature on every request. Doing slow synchronous work inside a handler, like a heavy database query, blocks the ack, so ack immediately and push the heavy work to a queue or background job. An uncaught exception in a handler crashes the process, Slack retries the delivery, and you end up processing the same event twice, so wrap every handler in try/catch, log the error, and still call ack so Slack does not retry. And keep @slack/bolt reasonably current, because when Slack adds new event fields, an old SDK may fail to parse them.

Takeaway

Bolt for JavaScript turns the riskiest parts of a Slack integration, signature verification, raw-body handling, and the ack deadline, into framework defaults, leaving you to write listeners and business logic. Use ExpressReceiver when you need custom routes alongside Slack, lean on Socket Mode for local dev, ack first and offload slow work, and never let express.json() near your Slack routes. Get those right and the rest is just features.

We design and ship internal Slack tooling like this for engineering and operations teams. If you want a production-ready bot built right the first time, reach out to us or take a look at what we offer on our services page.

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