Back to Blog

SaaS Slack Integration in 2026: OAuth, Slash Commands, Interactive Messages, and Notifications

Build a production SaaS Slack integration: OAuth app installation, slash commands, interactive message buttons, workspace notifications, and event subscriptions with TypeScript.

Viprasol Tech Team
February 25, 2027
14 min read

SaaS Slack Integration in 2026: OAuth, Slash Commands, Interactive Messages, and Notifications

A Slack integration is one of the highest-ROI features for B2B SaaS. It brings your product into where teams already work, drives daily active usage, and reduces churn. The "Connect to Slack" button on your settings page can be built in a week โ€” but getting the OAuth flow, signature verification, interactive components, and notification delivery all right takes knowing the pitfalls.

This post covers the complete Slack integration: OAuth 2.0 app installation, storing per-workspace tokens, slash command handling, interactive message buttons with block kit, and outgoing notifications when things happen in your product.


Database Schema

CREATE TABLE slack_installations (
  id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  workspace_id    UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
  slack_team_id   TEXT NOT NULL,
  slack_team_name TEXT NOT NULL,
  bot_token       TEXT NOT NULL,         -- Encrypted at rest (AES-256)
  bot_user_id     TEXT NOT NULL,
  app_id          TEXT NOT NULL,
  incoming_webhook_url TEXT,             -- For simple webhook-based notifications
  scope           TEXT NOT NULL,
  installed_by    UUID NOT NULL REFERENCES users(id),
  installed_at    TIMESTAMPTZ NOT NULL DEFAULT now(),
  revoked_at      TIMESTAMPTZ,

  UNIQUE (workspace_id),                 -- One Slack team per workspace
  UNIQUE (slack_team_id)                 -- One installation per Slack team
);

-- Map Slack user IDs to your user IDs (populated on first Slack command)
CREATE TABLE slack_user_mappings (
  id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id         UUID NOT NULL REFERENCES users(id),
  slack_user_id   TEXT NOT NULL,
  slack_team_id   TEXT NOT NULL,
  created_at      TIMESTAMPTZ NOT NULL DEFAULT now(),

  UNIQUE (slack_user_id, slack_team_id)
);

OAuth 2.0 Installation Flow

// app/api/slack/oauth/start/route.ts
import { NextRequest, NextResponse } from "next/server";
import { getWorkspaceContext } from "@/lib/auth/workspace-context";

const SLACK_CLIENT_ID = process.env.SLACK_CLIENT_ID!;
const SCOPES = [
  "commands",          // Slash commands
  "chat:write",        // Post messages
  "chat:write.public", // Post to channels bot isn't member of
  "incoming-webhook",  // Incoming webhooks
  "users:read",        // Read user info
  "users:read.email",  // Map Slack users to your users
].join(",");

export async function GET(req: NextRequest) {
  const ctx = await getWorkspaceContext();
  if (!ctx) return NextResponse.redirect(new URL("/login", req.url));

  // State encodes workspace + CSRF token
  const state = Buffer.from(
    JSON.stringify({ workspaceId: ctx.workspaceId, nonce: crypto.randomUUID() })
  ).toString("base64url");

  // Store state in session/cookie for CSRF validation
  const authUrl = new URL("https://slack.com/oauth/v2/authorize");
  authUrl.searchParams.set("client_id", SLACK_CLIENT_ID);
  authUrl.searchParams.set("scope", SCOPES);
  authUrl.searchParams.set("redirect_uri", `${process.env.APP_URL}/api/slack/oauth/callback`);
  authUrl.searchParams.set("state", state);

  const response = NextResponse.redirect(authUrl.toString());
  response.cookies.set("slack_oauth_state", state, {
    httpOnly: true, secure: true, sameSite: "lax", maxAge: 600,
  });

  return response;
}
// app/api/slack/oauth/callback/route.ts
import { NextRequest, NextResponse } from "next/server";
import { db } from "@/lib/db";
import { encrypt } from "@/lib/crypto";

export async function GET(req: NextRequest) {
  const { searchParams } = req.nextUrl;
  const code  = searchParams.get("code");
  const state = searchParams.get("state");
  const error = searchParams.get("error");

  if (error) {
    return NextResponse.redirect(`${process.env.APP_URL}/settings/integrations?error=slack_denied`);
  }

  // Validate CSRF state
  const storedState = req.cookies.get("slack_oauth_state")?.value;
  if (!state || state !== storedState) {
    return NextResponse.redirect(`${process.env.APP_URL}/settings/integrations?error=invalid_state`);
  }

  const { workspaceId } = JSON.parse(Buffer.from(state, "base64url").toString());

  // Exchange code for tokens
  const tokenRes = await fetch("https://slack.com/api/oauth.v2.access", {
    method: "POST",
    headers: { "Content-Type": "application/x-www-form-urlencoded" },
    body: new URLSearchParams({
      client_id:     process.env.SLACK_CLIENT_ID!,
      client_secret: process.env.SLACK_CLIENT_SECRET!,
      code:          code!,
      redirect_uri:  `${process.env.APP_URL}/api/slack/oauth/callback`,
    }),
  });

  const token = await tokenRes.json();
  if (!token.ok) {
    console.error("Slack OAuth error:", token.error);
    return NextResponse.redirect(`${process.env.APP_URL}/settings/integrations?error=oauth_failed`);
  }

  // Persist installation โ€” encrypt bot token at rest
  await db.slackInstallation.upsert({
    where: { workspaceId },
    create: {
      workspaceId,
      slackTeamId:         token.team.id,
      slackTeamName:       token.team.name,
      botToken:            encrypt(token.access_token),
      botUserId:           token.bot_user_id,
      appId:               token.app_id,
      incomingWebhookUrl:  token.incoming_webhook?.url,
      scope:               token.scope,
      installedBy:         (await getInstallingUser(workspaceId)).id,
    },
    update: {
      botToken:           encrypt(token.access_token),
      slackTeamName:      token.team.name,
      incomingWebhookUrl: token.incoming_webhook?.url,
      scope:              token.scope,
      revokedAt:          null,
    },
  });

  const response = NextResponse.redirect(
    `${process.env.APP_URL}/settings/integrations?success=slack_connected`
  );
  response.cookies.delete("slack_oauth_state");
  return response;
}

async function getInstallingUser(workspaceId: string) {
  // Get the workspace owner to attribute installation
  return db.workspaceMember.findFirstOrThrow({
    where: { workspaceId, role: "owner" },
    select: { userId: true },
  }).then((m) => ({ id: m.userId }));
}

๐Ÿš€ SaaS MVP in 8 Weeks โ€” Seriously

We have launched 50+ SaaS platforms. Multi-tenant architecture, Stripe billing, auth, role-based access, and cloud deployment โ€” all handled by one senior team.

  • Week 1โ€“2: Architecture design + wireframes
  • Week 3โ€“6: Core features built + tested
  • Week 7โ€“8: Launch-ready on AWS/Vercel with CI/CD
  • Post-launch: Maintenance plans from month 3

Request Signature Verification (Security Critical)

Every Slack request to your endpoints must be verified:

// lib/slack/verify.ts
import { createHmac, timingSafeEqual } from "crypto";

export async function verifySlackRequest(req: Request): Promise<boolean> {
  const signingSecret = process.env.SLACK_SIGNING_SECRET!;
  const timestamp     = req.headers.get("x-slack-request-timestamp");
  const signature     = req.headers.get("x-slack-signature");
  const body          = await req.text();

  if (!timestamp || !signature) return false;

  // Prevent replay attacks: reject requests older than 5 minutes
  const age = Math.abs(Date.now() / 1000 - parseInt(timestamp, 10));
  if (age > 300) return false;

  const sigBasestring = `v0:${timestamp}:${body}`;
  const mySignature = `v0=${createHmac("sha256", signingSecret)
    .update(sigBasestring, "utf8")
    .digest("hex")}`;

  // Timing-safe comparison to prevent timing attacks
  try {
    return timingSafeEqual(
      Buffer.from(mySignature, "utf8"),
      Buffer.from(signature, "utf8")
    );
  } catch {
    return false;
  }
}

Slash Command Handler

// app/api/slack/commands/route.ts
import { NextRequest, NextResponse } from "next/server";
import { verifySlackRequest } from "@/lib/slack/verify";
import { db } from "@/lib/db";
import { decrypt } from "@/lib/crypto";

export async function POST(req: NextRequest) {
  // Clone request so we can read body twice (verify + parse)
  const reqClone = req.clone();
  const valid = await verifySlackRequest(reqClone);
  if (!valid) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });

  // Parse URL-encoded form body
  const body = await req.text();
  const params = Object.fromEntries(new URLSearchParams(body));

  const { command, text, team_id, user_id, response_url } = params;

  // Find the workspace installation for this Slack team
  const installation = await db.slackInstallation.findUnique({
    where: { slackTeamId: team_id },
    include: { workspace: true },
  });

  if (!installation) {
    return NextResponse.json({
      response_type: "ephemeral",
      text: "This Slack workspace isn't connected to Viprasol. Visit your settings to connect.",
    });
  }

  // Route to command handler
  switch (command) {
    case "/viprasol":
      return handleViprasolCommand(text.trim(), params, installation);
    default:
      return NextResponse.json({
        response_type: "ephemeral",
        text: `Unknown command: ${command}`,
      });
  }
}

async function handleViprasolCommand(
  subcommand: string,
  params: Record<string, string>,
  installation: any
) {
  const [cmd, ...args] = subcommand.split(" ");

  switch (cmd) {
    case "status":
      return getStatusResponse(installation.workspaceId);
    case "create":
      return createTaskResponse(args.join(" "), params, installation);
    case "help":
    default:
      return NextResponse.json({
        response_type: "ephemeral",
        blocks: [
          {
            type: "section",
            text: {
              type: "mrkdwn",
              text: "*Viprasol Slack Commands*\n`/viprasol status` โ€” Show workspace usage\n`/viprasol create <task>` โ€” Create a task\n`/viprasol help` โ€” Show this message",
            },
          },
        ],
      });
  }
}

๐Ÿ’ก The Difference Between a SaaS Demo and a SaaS Business

Anyone can build a demo. We build SaaS products that handle real load, real users, and real payments โ€” with architecture that does not need to be rewritten at 1,000 users.

  • Multi-tenant PostgreSQL with row-level security
  • Stripe subscriptions, usage billing, annual plans
  • SOC2-ready infrastructure from day one
  • We own zero equity โ€” you own everything

Interactive Messages (Block Kit)

// lib/slack/blocks.ts โ€” Block Kit helpers

export function taskCreatedBlock(task: {
  id: string;
  title: string;
  createdBy: string;
  projectName: string;
}) {
  return {
    blocks: [
      {
        type: "header",
        text: { type: "plain_text", text: "โœ… Task Created", emoji: true },
      },
      {
        type: "section",
        fields: [
          { type: "mrkdwn", text: `*Task:*\n${task.title}` },
          { type: "mrkdwn", text: `*Project:*\n${task.projectName}` },
        ],
      },
      {
        type: "actions",
        elements: [
          {
            type: "button",
            text: { type: "plain_text", text: "View Task", emoji: true },
            url: `${process.env.APP_URL}/tasks/${task.id}`,
            action_id: "view_task",
            style: "primary",
          },
          {
            type: "button",
            text: { type: "plain_text", text: "Mark Complete", emoji: true },
            action_id: "complete_task",
            value: task.id,                // Passed back in interaction payload
            confirm: {
              title: { type: "plain_text", text: "Mark as complete?" },
              text: { type: "mrkdwn", text: `Mark _${task.title}_ as complete?` },
              confirm: { type: "plain_text", text: "Yes, complete it" },
              deny: { type: "plain_text", text: "Cancel" },
            },
          },
        ],
      },
    ],
  };
}
// app/api/slack/interactions/route.ts
import { NextRequest, NextResponse } from "next/server";
import { verifySlackRequest } from "@/lib/slack/verify";
import { db } from "@/lib/db";
import { decrypt } from "@/lib/crypto";

export async function POST(req: NextRequest) {
  const reqClone = req.clone();
  const valid = await verifySlackRequest(reqClone);
  if (!valid) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });

  const body = await req.text();
  const payload = JSON.parse(new URLSearchParams(body).get("payload")!);

  const { type, actions, team, user, response_url } = payload;

  if (type === "block_actions") {
    const action = actions[0];

    switch (action.action_id) {
      case "complete_task": {
        const taskId = action.value;

        await db.task.update({
          where: { id: taskId },
          data: { status: "completed", completedAt: new Date() },
        });

        // Update the Slack message to show completion
        await fetch(response_url, {
          method: "POST",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify({
            replace_original: true,
            blocks: [
              {
                type: "section",
                text: { type: "mrkdwn", text: "โœ… *Task marked as complete*" },
              },
            ],
          }),
        });

        return NextResponse.json({ ok: true });
      }
    }
  }

  return NextResponse.json({ ok: true });
}

Outgoing Notifications

// lib/slack/notify.ts
import { WebClient } from "@slack/web-api";
import { db } from "@/lib/db";
import { decrypt } from "@/lib/crypto";

export async function notifyWorkspace(
  workspaceId: string,
  message: { channel: string; blocks: object[] }
) {
  const installation = await db.slackInstallation.findUnique({
    where: { workspaceId, revokedAt: null },
  });

  if (!installation) return; // Slack not connected

  const client = new WebClient(decrypt(installation.botToken));

  try {
    await client.chat.postMessage({
      channel: message.channel,
      blocks: message.blocks,
      text: "Viprasol notification", // Fallback for screen readers
    });
  } catch (err: any) {
    if (err.data?.error === "token_revoked" || err.data?.error === "not_in_channel") {
      // Mark installation as revoked; user needs to reinstall
      await db.slackInstallation.update({
        where: { workspaceId },
        data: { revokedAt: new Date() },
      });
    }
    throw err;
  }
}

// Usage: notify when a new member joins
export async function notifyMemberJoined(
  workspaceId: string,
  member: { name: string; email: string; role: string },
  notificationChannel: string
) {
  await notifyWorkspace(workspaceId, {
    channel: notificationChannel,
    blocks: [
      {
        type: "section",
        text: {
          type: "mrkdwn",
          text: `๐Ÿ‘‹ *${member.name}* joined the workspace as *${member.role}*`,
        },
      },
    ],
  });
}

Slack Event Subscriptions

// app/api/slack/events/route.ts
import { NextRequest, NextResponse } from "next/server";
import { verifySlackRequest } from "@/lib/slack/verify";

export async function POST(req: NextRequest) {
  const reqClone = req.clone();
  const body = await req.json();

  // URL verification challenge (one-time during app setup)
  if (body.type === "url_verification") {
    return NextResponse.json({ challenge: body.challenge });
  }

  const valid = await verifySlackRequest(reqClone);
  if (!valid) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });

  // Acknowledge immediately (Slack requires < 3s response)
  // Process event asynchronously
  processSlackEvent(body.event).catch(console.error);

  return NextResponse.json({ ok: true });
}

async function processSlackEvent(event: any) {
  switch (event.type) {
    case "app_uninstalled":
      // Mark installation as revoked
      await db.slackInstallation.updateMany({
        where: { slackTeamId: event.team_id },
        data: { revokedAt: new Date() },
      });
      break;

    case "tokens_revoked":
      await db.slackInstallation.updateMany({
        where: { slackTeamId: event.team_id },
        data: { revokedAt: new Date() },
      });
      break;
  }
}

Cost and Timeline

ComponentTimelineCost (USD)
OAuth installation flow1โ€“2 days$800โ€“$1,600
Slash command handler1 day$600โ€“$1,000
Interactive messages (Block Kit)1โ€“2 days$800โ€“$1,600
Outgoing notifications0.5โ€“1 day$400โ€“$800
Event subscriptions0.5 day$300โ€“$500
Full Slack integration2โ€“3 weeks$10,000โ€“$18,000

See Also


Working With Viprasol

We build Slack integrations for SaaS products โ€” from simple incoming webhook notifications through full slash command suites with interactive block kit messages. Our team has shipped Slack apps with thousands of workspace installations.

What we deliver:

  • Slack app setup and OAuth 2.0 installation flow
  • Request signature verification (timing-safe)
  • Slash command routing with subcommand parsing
  • Interactive Block Kit messages with action callbacks
  • Outgoing notifications for key product events
  • Graceful token revocation handling

Explore our SaaS development services or contact us to build your Slack integration.

Share this article:

About the Author

V

Viprasol Tech Team

Custom Software Development Specialists

The Viprasol Tech team specialises in algorithmic trading software, AI agent systems, and SaaS development. With 100+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement. Based in India, serving clients globally.

MT4/MT5 EA DevelopmentAI Agent SystemsSaaS DevelopmentAlgorithmic Trading

Building a SaaS Product?

We've helped launch 50+ SaaS platforms. Let's build yours โ€” fast.

Free consultation โ€ข No commitment โ€ข Response within 24 hours

Viprasol ยท AI Agent Systems

Add AI automation to your SaaS product?

Viprasol builds custom AI agent crews that plug into any SaaS workflow โ€” automating repetitive tasks, qualifying leads, and responding across every channel your customers use.