All code helpers
AutomationTypeScript

Forward webhooks to Slack

Turn events from any tool that can send a webhook (monitoring, payments, forms, CI) into readable Slack messages, protected by a shared secret.

#slack#webhooks#notifications#nextjs
app/api/notify/route.ts
import { timingSafeEqual } from "node:crypto";

type IncomingEvent = {
  title: string;
  message?: string;
  severity?: "info" | "warning" | "critical";
  url?: string;
  source?: string;
};

const ICON = { info: ":information_source:", warning: ":warning:", critical: ":rotating_light:" };

// Slack mrkdwn treats &, < and > as control characters
const escape = (text: string) =>
  text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");

function hasValidToken(req: Request): boolean {
  const expected = Buffer.from(process.env.NOTIFY_TOKEN ?? "");
  const received = Buffer.from(req.headers.get("x-notify-token") ?? "");
  return expected.length > 0 && received.length === expected.length && timingSafeEqual(received, expected);
}

export async function POST(req: Request) {
  if (!hasValidToken(req)) return new Response("Unauthorized", { status: 401 });

  const event = (await req.json().catch(() => null)) as IncomingEvent | null;
  if (!event?.title) return new Response("Missing title", { status: 400 });

  const severity = event.severity ?? "info";
  const heading = `${ICON[severity]} *${escape(event.title)}*`;
  const link = event.url ? `\n<${event.url}|Open details>` : "";

  const res = await fetch(process.env.SLACK_WEBHOOK_URL!, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    signal: AbortSignal.timeout(5_000),
    body: JSON.stringify({
      text: `${severity.toUpperCase()}: ${event.title}`, // shown in push notifications
      blocks: [
        {
          type: "section",
          text: { type: "mrkdwn", text: `${heading}\n${escape(event.message ?? "")}${link}` },
        },
        {
          type: "context",
          elements: [{ type: "mrkdwn", text: `Source: ${escape(event.source ?? "unknown")}` }],
        },
      ],
    }),
  });

  if (!res.ok) return new Response(`Slack error: ${await res.text()}`, { status: 502 });
  return Response.json({ ok: true });
}

Usage

Bash
curl -X POST https://your-app.com/api/notify \
  -H "Content-Type: application/json" \
  -H "x-notify-token: $NOTIFY_TOKEN" \
  -d '{"title":"Payment failed","message":"Card declined for order 1042","severity":"warning","source":"billing"}'

How it works & gotchas

  • 01SLACK_WEBHOOK_URL comes from a Slack app with Incoming Webhooks enabled. Each URL posts to one channel, so use one per channel.
  • 02The top-level text is the fallback shown in notifications and screen readers. Slack still recommends it when you send blocks.
  • 03User-supplied text is escaped. Without it, a payload containing <!channel> would ping the whole channel.
  • 04If the source tool signs its webhooks, verify that signature instead of a shared token. A static token in a header can leak through logs.
  • 05For high-volume sources, queue events and batch them. Slack rate-limits incoming webhooks to roughly one message per second per channel.

Need this wired into a real system?

We build and harden production software end to end.

Talk to an engineer