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
- Prerequisites
- Setting Up a Slack Webhook
- Project Structure
- Type Definitions
- Core Send Function
- Building Blocks
- Creating Notification Functions
- Custom Error Handling
- Environment Configuration
- Complete Example
- Block Kit Reference
- 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
- Go to api.slack.com/apps
- Click "Create New App"
- Choose "From scratch"
- Name your app (e.g., "My App Notifications")
- Select your workspace
Step 2: Enable Incoming Webhooks
- In your app settings, go to "Incoming Webhooks"
- Toggle "Activate Incoming Webhooks" to ON
- Click "Add New Webhook to Workspace"
- Select the channel where notifications should go
- 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
- Development Skip: Prevents spam during local testing
- Configuration Check: Fails fast if webhook URL is missing
- Error Wrapping: Converts all errors to typed SlackError
- 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 Type | Purpose | Example |
|---|---|---|
header | Large bold title | { type: "header", text: { type: "plain_text", text: "Title" } } |
section | Text with optional fields | { type: "section", fields: [...] } |
divider | Horizontal line | { type: "divider" } |
context | Small muted text/images | { type: "context", elements: [...] } |
actions | Buttons and menus | { type: "actions", elements: [...] } |
image | Full-width image | { type: "image", image_url: "...", alt_text: "..." } |
Text Formatting (mrkdwn)
| Format | Syntax | Example |
|---|---|---|
| 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
- Always include fallback text: The
textfield shows in notifications and search - Use semantic emojis: Help users scan notifications quickly
- Include timestamps: Makes debugging and auditing easier
- Skip in development: Prevent spam during local testing
- Handle errors gracefully: Don't fail critical operations due to Slack
- Use monospace for IDs: Makes them easier to copy (
\ID123``) - Keep messages focused: One notification = one event
Next Steps
- Add buttons: Use
actionsblocks 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
- Slack Block Kit Builder - Visual editor for testing layouts
- Block Kit Reference - Official documentation
- Incoming Webhooks Guide - Setup guide
- Message Formatting - Text formatting reference
