Horizon
GuidesWebhooks

Test Slack events locally

Receive Slack Events API requests on localhost with a Horizon tunnel, answer the URL verification challenge, and verify request signatures.

Receive Slack events on your laptop while you build, with a URL Slack can reach.

Before you begin

  • Node.js 18 or later
  • A Horizon account and the CLI (see Getting started)
  • A Slack app, with its signing secret at hand

Start your app

Create a Next.js App Router route handler. It does three things:

  1. Rejects requests whose timestamp differs from your clock by more than five minutes. Slack's example uses the same limit.
  2. Computes the signature from the raw body and compares it to X-Slack-Signature.
  3. Answers the url_verification challenge by echoing the challenge value.

Slack builds the signature from the string v0:<timestamp>:<raw body>. It hashes that string with HMAC SHA256, using your signing secret as the key. The header holds v0= followed by the hex digest. Read the body as text, before you parse it as JSON.

app/api/slack/events/route.ts
import { createHmac, timingSafeEqual } from "node:crypto";

const MAX_TIMESTAMP_AGE_SECONDS = 60 * 5;

const isValidSignature = (
  rawBody: string,
  timestamp: string,
  signature: string,
): boolean => {
  const ageInSeconds = Math.abs(Date.now() / 1000 - Number(timestamp));
  if (!(ageInSeconds <= MAX_TIMESTAMP_AGE_SECONDS)) return false;

  const expected = `v0=${createHmac("sha256", process.env.SLACK_SIGNING_SECRET ?? "")
    .update(`v0:${timestamp}:${rawBody}`)
    .digest("hex")}`;

  const expectedBuffer = Buffer.from(expected);
  const receivedBuffer = Buffer.from(signature);
  if (expectedBuffer.length !== receivedBuffer.length) return false;

  return timingSafeEqual(expectedBuffer, receivedBuffer);
};

export async function POST(request: Request) {
  const rawBody = await request.text();
  const timestamp = request.headers.get("x-slack-request-timestamp") ?? "";
  const signature = request.headers.get("x-slack-signature") ?? "";

  if (!isValidSignature(rawBody, timestamp, signature)) {
    return new Response("Invalid signature", { status: 401 });
  }

  const payload = JSON.parse(rawBody);

  if (payload.type === "url_verification") {
    return new Response(payload.challenge, {
      headers: { "Content-Type": "text/plain" },
    });
  }

  console.log("Slack event:", payload.event?.type);
  return new Response("ok");
}

Start the app with your signing secret.

SLACK_SIGNING_SECRET=your-signing-secret npm run dev

Slack expects a 2xx response within three seconds, so keep slow work out of the handler.

Start a tunnel

Pick a subdomain with -s. Without it, the subdomain is random and changes every run, so you would paste a new URL into Slack after each restart.

hrzn tunnel http://localhost:3000 -s my-slack-app

Horizon prints HORIZON: Tunnel connected. Your public URL is https://my-slack-app.hrzn.run.

Add the Request URL in Slack

  1. Open your Slack app's settings and go to Event Subscriptions.
  2. Turn the feature on. A Request URL field appears.
  3. Enter https://my-slack-app.hrzn.run/api/slack/events. Request URLs are case-sensitive.

Slack sends a POST with "type": "url_verification" and a challenge string. Your handler echoes the challenge, and Slack shows a green check mark. If it fails, use the Retry button after you fix the cause.

Subscribe to an event

Under Event Subscriptions, add an event in the bot events section. message.channels is a good first one. It needs the channels:history scope.

If you add an event your app has no scope for, Slack sends you through the OAuth flow again to request it. Events start arriving after that.

Check it works

Slack's verification request shows up in the terminal where hrzn tunnel runs.

HORIZON: Tunnel connected
  POST | [200] | /api/slack/events

Post a message in a channel where your app receives message.channels events. The terminal prints another line.

  POST | [200] | /api/slack/events

Your npm run dev terminal logs Slack event: message.

Troubleshooting

Slack can't verify the URL

Check three things:

  • The tunnel is running and the URL in Slack matches it exactly, including the path. Request URLs are case-sensitive.
  • The terminal shows POST with a status. If you see [401], the signature check failed: see the next item.
  • The handler returns the challenge value in the response body, with a 200 status.

Signature mismatch ([401])

Check these in order:

  • SLACK_SIGNING_SECRET in the shell that runs your app is the signing secret of the same Slack app.
  • You hash the raw body from request.text(). Slack's docs say to use the body before it is deserialized. Parsing it as JSON and stringifying it again changes the bytes.
  • Your computer's clock is correct. The handler rejects timestamps more than five minutes from local time.

The URL changed after a restart

You started the tunnel without -s. Horizon gave you a new random subdomain, and Slack still points at the old one. Restart with -s my-slack-app and update the Request URL in Event Subscriptions once.

Slack retries events

Slack expects a 2xx response within three seconds. If it doesn't get one, it retries. Each retry carries an x-slack-retry-num header. Return fast, then do the slow work.

Next steps

On this page