Skip to main content

Tutorial: Slack Webhook Notifications with Block Kit

This tutorial walks through how to send rich, formatted notifications to Slack using Incoming Webhooks and Block Kit in a TypeScript application.


Table of Contents

  1. Prerequisites
  2. Setting Up a Slack Webhook
  3. Project Structure
  4. Type Definitions
  5. Core Send Function
  6. Building Blocks
  7. Creating Notification Functions
  8. Custom Error Handling
  9. Environment Configuration
  10. Complete Example
  11. Block Kit Reference
  12. Troubleshooting

Prerequisites

  • A Slack workspace where you have permission to add apps
  • Node.js/Bun installed
  • A TypeScript project
  • Basic understanding of async/await and fetch API

Setting Up a Slack Webhook

Step 1: Create a Slack App

  1. Go to api.slack.com/apps
  2. Click "Create New App"
  3. Choose "From scratch"
  4. Name your app (e.g., "My App Notifications")
  5. Select your workspace

Step 2: Enable Incoming Webhooks

  1. In your app settings, go to "Incoming Webhooks"
  2. Toggle "Activate Incoming Webhooks" to ON
  3. Click "Add New Webhook to Workspace"
  4. Select the channel where notifications should go
  5. Click "Allow"

Step 3: Copy the Webhook URL

You'll get a URL like:

https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX

Security Note: Treat this URL like a password. Anyone with this URL can post to your Slack channel.


Project Structure

Organize your Slack integration into three layers:

app/
├── lib/
│ └── slack.ts # Core send function
├── models/
│ ├── slack.ts # Type definitions
│ └── errors/
│ └── slack.ts # Custom error classes
└── repositories/
└── slack.ts # Notification builders

This separation provides:

  • Types: Reusable across the codebase
  • Library: Low-level send functionality
  • Repository: High-level notification functions for specific events

Type Definitions

Create strongly-typed interfaces for Slack's Block Kit:

// app/models/slack.ts

/**
* Slack Block Kit types for building rich message layouts
* @see https://api.slack.com/block-kit
*/

export interface SlackTextObject {
type: "plain_text" | "mrkdwn";
text: string;
emoji?: boolean;
}

export interface SlackContextElement {
type: "plain_text" | "mrkdwn" | "image";
text?: string;
image_url?: string;
alt_text?: string;
}

export interface SlackBlock {
type: string;
text?: SlackTextObject;
fields?: Array<SlackTextObject>;
elements?: Array<
| SlackContextElement
| {
type: string;
text?: SlackTextObject;
url?: string;
action_id?: string;
}
>;
}

export interface SlackAttachment {
color?: string; // Hex color for sidebar (e.g., "#36a64f")
fallback?: string; // Plain text fallback
text?: string;
fields?: Array<{
title: string;
value: string;
short?: boolean; // Display side-by-side if true
}>;
}

export interface SlackMessage {
text?: string; // Fallback text (shown in notifications)
blocks?: SlackBlock[]; // Rich content blocks
attachments?: SlackAttachment[]; // Legacy attachments (still useful)
}

Core Send Function

Create a simple, reusable function to send messages:

// app/lib/slack.ts

import { SlackError, SlackConfigurationError } from "@/models/errors";
import type { SlackMessage } from "@/models/slack";

// Re-export types for convenience
export type {
SlackMessage,
SlackBlock,
SlackTextObject,
SlackContextElement,
SlackAttachment,
} from "@/models/slack";

/**
* Send a message to a Slack webhook URL
* Note: Notifications are skipped in local development
*/
export async function sendSlackMessage(
webhookUrl: string,
message: SlackMessage
): Promise<void> {
// Skip Slack notifications in local development
if (import.meta.env.DEV) {
console.log("[Slack] Skipping notification in development:", message.text);
return;
}

if (!webhookUrl) {
throw new SlackConfigurationError(
"Slack webhook URL is not configured",
"SLACK_WEBHOOK_URL"
);
}

try {
const response = await fetch(webhookUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(message),
});

if (!response.ok) {
const errorText = await response.text();
throw new SlackError(
`Slack API returned ${response.status}: ${errorText}`,
response.status,
errorText
);
}
} catch (error) {
if (
error instanceof SlackError ||
error instanceof SlackConfigurationError
) {
throw error;
}

throw new SlackError(
error instanceof Error ? error.message : "Unknown Slack error",
500,
error
);
}
}

Key Features

  1. Development Skip: Prevents spam during local testing
  2. Configuration Check: Fails fast if webhook URL is missing
  3. Error Wrapping: Converts all errors to typed SlackError
  4. Simple Interface: Just pass URL and message

Building Blocks

Create helper functions for common block patterns:

// app/repositories/slack.ts

import { sendSlackMessage, type SlackBlock } from "@/lib/slack";

/**
* Create a standard header block for notifications
*/
function createHeaderBlock(emoji: string, title: string): SlackBlock {
return {
type: "header",
text: {
type: "plain_text",
text: `${emoji} ${title}`,
emoji: true,
},
};
}

/**
* Create a divider block
*/
function createDivider(): SlackBlock {
return { type: "divider" };
}

/**
* Create a context block with timestamp
*/
function createTimestampContext(timestamp?: Date): SlackBlock {
const dateStr = (timestamp || new Date()).toLocaleString("en-US", {
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});

return {
type: "context",
elements: [
{
type: "mrkdwn",
text: `📅 ${dateStr}`,
},
],
};
}

/**
* Create a section with key-value fields
*/
function createFieldsSection(
fields: Array<{ label: string; value: string }>
): SlackBlock {
return {
type: "section",
fields: fields.map(({ label, value }) => ({
type: "mrkdwn",
text: `*${label}:*\n${value || "Not provided"}`,
})),
};
}

Creating Notification Functions

Build high-level functions for each event type:

Example: User Signup Notification

interface UserSignupInput {
name: string;
email: string;
timestamp?: Date;
}

/**
* Send notification when a new user signs up
*/
export async function notifyUserSignup(
webhookUrl: string,
input: UserSignupInput
): Promise<void> {
const blocks: SlackBlock[] = [
createHeaderBlock("👤", "New User Signup"),
createFieldsSection([
{ label: "Name", value: input.name },
{ label: "Email", value: input.email },
]),
createDivider(),
createTimestampContext(input.timestamp),
];

await sendSlackMessage(webhookUrl, {
text: `New user signup: ${input.email}`, // Fallback for notifications
blocks,
});
}

Example: Form Submission with Multiple Fields

interface WaitlistSubmissionInput {
name: string;
email: string;
company: string;
role: string;
industry?: string;
timestamp?: Date;
}

/**
* Send notification when a user submits waitlist details
*/
export async function notifyWaitlistSubmission(
webhookUrl: string,
input: WaitlistSubmissionInput
): Promise<void> {
const blocks: SlackBlock[] = [
createHeaderBlock("📋", "Waitlist Submission"),
createFieldsSection([
{ label: "Name", value: input.name },
{ label: "Email", value: input.email },
{ label: "Company", value: input.company },
{ label: "Role", value: input.role },
]),
];

// Add optional fields conditionally
if (input.industry) {
blocks.push(
createFieldsSection([
{ label: "Industry", value: input.industry },
])
);
}

blocks.push(
createDivider(),
createTimestampContext(input.timestamp)
);

await sendSlackMessage(webhookUrl, {
text: `Waitlist submission from ${input.email}`,
blocks,
});
}

Example: Action Completed with ID Reference

interface ReportGeneratedInput {
name: string;
email: string;
reportId: string;
reportTitle?: string;
timestamp?: Date;
}

/**
* Send notification when a report is generated
*/
export async function notifyReportGenerated(
webhookUrl: string,
input: ReportGeneratedInput
): Promise<void> {
const blocks: SlackBlock[] = [
createHeaderBlock("📄", "Report Generated"),
createFieldsSection([
{ label: "Name", value: input.name },
{ label: "Email", value: input.email },
]),
{
type: "section",
fields: [
{
type: "mrkdwn",
text: `*Report:*\n${input.reportTitle || "Untitled"}`,
},
{
type: "mrkdwn",
text: `*Report ID:*\n\`${input.reportId}\``, // Monospace for IDs
},
],
},
createDivider(),
createTimestampContext(input.timestamp),
];

await sendSlackMessage(webhookUrl, {
text: `Report generated for ${input.email}: ${input.reportTitle || "Untitled"}`,
blocks,
});
}

Custom Error Handling

Create specific error classes for better error handling:

// app/models/errors/slack.ts

import { RepositoryError } from "./repository";

/**
* Error thrown when Slack message sending fails
*/
export class SlackError extends RepositoryError {
constructor(
message: string,
public readonly statusCode: number = 500,
public readonly originalError?: unknown
) {
super(
message || "Failed to send Slack message",
"SLACK_SEND_FAILED",
statusCode,
{ originalError }
);
this.name = "SlackError";
Object.setPrototypeOf(this, SlackError.prototype);
}
}

/**
* Error thrown when Slack configuration is invalid or missing
*/
export class SlackConfigurationError extends RepositoryError {
constructor(
message: string,
public readonly field?: string
) {
super(
message || "Invalid Slack configuration",
"SLACK_CONFIGURATION_ERROR",
500,
{ field }
);
this.name = "SlackConfigurationError";
Object.setPrototypeOf(this, SlackConfigurationError.prototype);
}
}

Using Errors in Calling Code

import { notifyUserSignup } from "@/repositories/slack";
import { SlackError, SlackConfigurationError } from "@/models/errors";

async function handleUserSignup(user: User) {
try {
await notifyUserSignup(process.env.SLACK_WEBHOOK_URL!, {
name: user.name,
email: user.email,
});
} catch (error) {
if (error instanceof SlackConfigurationError) {
// Log but don't fail the operation
console.warn("Slack not configured:", error.message);
return;
}

if (error instanceof SlackError) {
// Log the error but don't fail user signup
console.error("Failed to send Slack notification:", error.message);
return;
}

throw error; // Re-throw unexpected errors
}
}

Environment Configuration

Setting Up Environment Variables

# .env
SLACK_WEBHOOK_URL=https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXX

TypeScript Environment Types (for Cloudflare Workers)

// workers/app.ts or env.d.ts

interface Env {
SLACK_WEBHOOK_URL: string;
// ... other env vars
}

Accessing in Different Contexts

In tRPC routes (Cloudflare Workers):

export const myRouter = router({
createUser: protectedProcedure
.input(z.object({ name: z.string(), email: z.string() }))
.mutation(async ({ ctx, input }) => {
// Create user...

// Send notification
if (ctx.cfContext.SLACK_WEBHOOK_URL) {
await notifyUserSignup(ctx.cfContext.SLACK_WEBHOOK_URL, {
name: input.name,
email: input.email,
});
}
}),
});

In Cloudflare Workflows:

export class MyWorkflow extends WorkflowEntrypoint<Env, Payload> {
async run(event: WorkflowEvent<Payload>, step: WorkflowStep) {
await step.do("notify-slack", async () => {
if (this.env.SLACK_WEBHOOK_URL) {
await notifyReportGenerated(this.env.SLACK_WEBHOOK_URL, {
name: event.payload.userName,
email: event.payload.userEmail,
reportId: event.payload.reportId,
});
}
});
}
}

Complete Example

Here's a complete, copy-paste ready implementation:

Types (app/models/slack.ts)

export interface SlackTextObject {
type: "plain_text" | "mrkdwn";
text: string;
emoji?: boolean;
}

export interface SlackContextElement {
type: "plain_text" | "mrkdwn" | "image";
text?: string;
image_url?: string;
alt_text?: string;
}

export interface SlackBlock {
type: string;
text?: SlackTextObject;
fields?: Array<SlackTextObject>;
elements?: Array<
| SlackContextElement
| {
type: string;
text?: SlackTextObject;
url?: string;
action_id?: string;
}
>;
}

export interface SlackAttachment {
color?: string;
fallback?: string;
text?: string;
fields?: Array<{
title: string;
value: string;
short?: boolean;
}>;
}

export interface SlackMessage {
text?: string;
blocks?: SlackBlock[];
attachments?: SlackAttachment[];
}

Library (app/lib/slack.ts)

import type { SlackMessage } from "@/models/slack";

export type { SlackMessage, SlackBlock, SlackTextObject } from "@/models/slack";

export class SlackError extends Error {
constructor(
message: string,
public readonly statusCode: number = 500,
public readonly originalError?: unknown
) {
super(message);
this.name = "SlackError";
}
}

export class SlackConfigurationError extends Error {
constructor(message: string, public readonly field?: string) {
super(message);
this.name = "SlackConfigurationError";
}
}

export async function sendSlackMessage(
webhookUrl: string,
message: SlackMessage
): Promise<void> {
// Skip in development
if (typeof process !== "undefined" && process.env.NODE_ENV === "development") {
console.log("[Slack] Dev mode - would send:", message.text);
return;
}

if (!webhookUrl) {
throw new SlackConfigurationError(
"Slack webhook URL is not configured",
"SLACK_WEBHOOK_URL"
);
}

try {
const response = await fetch(webhookUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(message),
});

if (!response.ok) {
const errorText = await response.text();
throw new SlackError(
`Slack API error ${response.status}: ${errorText}`,
response.status,
errorText
);
}
} catch (error) {
if (error instanceof SlackError || error instanceof SlackConfigurationError) {
throw error;
}
throw new SlackError(
error instanceof Error ? error.message : "Unknown Slack error",
500,
error
);
}
}

Repository (app/repositories/slack.ts)

import { sendSlackMessage, type SlackBlock } from "@/lib/slack";

// === Helper Functions ===

function createHeader(emoji: string, title: string): SlackBlock {
return {
type: "header",
text: { type: "plain_text", text: `${emoji} ${title}`, emoji: true },
};
}

function createDivider(): SlackBlock {
return { type: "divider" };
}

function createFields(fields: Array<{ label: string; value: string }>): SlackBlock {
return {
type: "section",
fields: fields.map(({ label, value }) => ({
type: "mrkdwn",
text: `*${label}:*\n${value || "Not provided"}`,
})),
};
}

function createTimestamp(date?: Date): SlackBlock {
const formatted = (date || new Date()).toLocaleString("en-US", {
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
return {
type: "context",
elements: [{ type: "mrkdwn", text: `📅 ${formatted}` }],
};
}

// === Notification Functions ===

interface NotifyUserSignupInput {
name: string;
email: string;
timestamp?: Date;
}

export async function notifyUserSignup(
webhookUrl: string,
input: NotifyUserSignupInput
): Promise<void> {
await sendSlackMessage(webhookUrl, {
text: `New user signup: ${input.email}`,
blocks: [
createHeader("👤", "New User Signup"),
createFields([
{ label: "Name", value: input.name },
{ label: "Email", value: input.email },
]),
createDivider(),
createTimestamp(input.timestamp),
],
});
}

interface NotifyOrderInput {
orderId: string;
customerName: string;
customerEmail: string;
amount: number;
timestamp?: Date;
}

export async function notifyNewOrder(
webhookUrl: string,
input: NotifyOrderInput
): Promise<void> {
await sendSlackMessage(webhookUrl, {
text: `New order #${input.orderId} from ${input.customerName}`,
blocks: [
createHeader("🛒", "New Order Received"),
createFields([
{ label: "Order ID", value: `\`${input.orderId}\`` },
{ label: "Customer", value: input.customerName },
{ label: "Email", value: input.customerEmail },
{ label: "Amount", value: `$${(input.amount / 100).toFixed(2)}` },
]),
createDivider(),
createTimestamp(input.timestamp),
],
});
}

interface NotifyErrorInput {
errorMessage: string;
errorCode?: string;
userId?: string;
context?: string;
timestamp?: Date;
}

export async function notifyError(
webhookUrl: string,
input: NotifyErrorInput
): Promise<void> {
const blocks: SlackBlock[] = [
createHeader("🚨", "Error Alert"),
{
type: "section",
text: {
type: "mrkdwn",
text: `\`\`\`${input.errorMessage}\`\`\``,
},
},
];

const fields: Array<{ label: string; value: string }> = [];
if (input.errorCode) fields.push({ label: "Error Code", value: input.errorCode });
if (input.userId) fields.push({ label: "User ID", value: input.userId });
if (input.context) fields.push({ label: "Context", value: input.context });

if (fields.length > 0) {
blocks.push(createFields(fields));
}

blocks.push(createDivider(), createTimestamp(input.timestamp));

await sendSlackMessage(webhookUrl, {
text: `Error: ${input.errorMessage}`,
blocks,
});
}

Block Kit Reference

Common Block Types

Block TypePurposeExample
headerLarge bold title{ type: "header", text: { type: "plain_text", text: "Title" } }
sectionText with optional fields{ type: "section", fields: [...] }
dividerHorizontal line{ type: "divider" }
contextSmall muted text/images{ type: "context", elements: [...] }
actionsButtons and menus{ type: "actions", elements: [...] }
imageFull-width image{ type: "image", image_url: "...", alt_text: "..." }

Text Formatting (mrkdwn)

FormatSyntaxExample
Bold*text**Important*
Italic_text__Note_
Strikethrough~text~~deleted~
Code`text``code`
Code block```text``````error log```
Link<url|text><https://example.com|Click here>
User mention<@USER_ID><@U123456>
Channel<#CHANNEL_ID><#C123456>

Emoji Support

Use standard emoji shortcodes or Unicode:

  • :rocket: → 🚀
  • :white_check_mark: → ✅
  • :warning: → ⚠️
  • Or just use Unicode directly: 🎉, 📄, 👤

Troubleshooting

Common Issues

1. "invalid_payload" error

Cause: Malformed JSON or invalid block structure.

Solution: Validate your payload at Block Kit Builder:

// Debug by logging the payload
console.log(JSON.stringify(message, null, 2));

2. "channel_not_found" error

Cause: Webhook was deleted or channel archived.

Solution: Create a new webhook in Slack app settings.

3. Messages not appearing

Cause: Development mode skip or missing webhook URL.

Solution: Check your environment:

console.log("Webhook URL exists:", !!process.env.SLACK_WEBHOOK_URL);
console.log("NODE_ENV:", process.env.NODE_ENV);

4. Rate limiting (HTTP 429)

Cause: Sending too many messages too quickly.

Solution: Implement exponential backoff:

async function sendWithRetry(
webhookUrl: string,
message: SlackMessage,
maxRetries = 3
): Promise<void> {
for (let i = 0; i < maxRetries; i++) {
try {
await sendSlackMessage(webhookUrl, message);
return;
} catch (error) {
if (error instanceof SlackError && error.statusCode === 429) {
await new Promise(resolve => setTimeout(resolve, Math.pow(2, i) * 1000));
continue;
}
throw error;
}
}
}

5. Long messages getting truncated

Cause: Slack has limits on text length.

Solution: Keep text blocks under 3000 characters:

const truncatedText = longText.length > 2900 
? longText.substring(0, 2900) + "..."
: longText;

Best Practices

  1. Always include fallback text: The text field shows in notifications and search
  2. Use semantic emojis: Help users scan notifications quickly
  3. Include timestamps: Makes debugging and auditing easier
  4. Skip in development: Prevent spam during local testing
  5. Handle errors gracefully: Don't fail critical operations due to Slack
  6. Use monospace for IDs: Makes them easier to copy (\ID123``)
  7. Keep messages focused: One notification = one event

Next Steps

  • Add buttons: Use actions blocks for interactive messages
  • Multiple channels: Create different webhooks for different notification types
  • Scheduled digests: Batch notifications into periodic summaries
  • Slack Bot API: For bidirectional communication (requires OAuth)

Resources

Written by

Sean Stuart Urgel
Senior Software Engineer @ Casper Studios