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.
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
| Component | Timeline | Cost (USD) |
|---|---|---|
| OAuth installation flow | 1โ2 days | $800โ$1,600 |
| Slash command handler | 1 day | $600โ$1,000 |
| Interactive messages (Block Kit) | 1โ2 days | $800โ$1,600 |
| Outgoing notifications | 0.5โ1 day | $400โ$800 |
| Event subscriptions | 0.5 day | $300โ$500 |
| Full Slack integration | 2โ3 weeks | $10,000โ$18,000 |
See Also
- SaaS Webhook System โ Outgoing webhooks alongside Slack
- SaaS Notification Preferences โ Per-user Slack notification settings
- SaaS Team Invitations โ Invite via Slack slash command
- Next.js Middleware Authentication โ Protecting Slack OAuth callback
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.
About the Author
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.
Building a SaaS Product?
We've helped launch 50+ SaaS platforms. Let's build yours โ fast.
Free consultation โข No commitment โข Response within 24 hours
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.