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:
- Rejects requests whose timestamp differs from your clock by more than five minutes. Slack's example uses the same limit.
- Computes the signature from the raw body and compares it to
X-Slack-Signature. - Answers the
url_verificationchallenge by echoing thechallengevalue.
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.
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 devSlack 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-appHorizon prints HORIZON: Tunnel connected. Your public URL is https://my-slack-app.hrzn.run.
Keeping a subdomain across restarts needs a reserved subdomain, which is a paid feature. See Pricing.
Add the Request URL in Slack
- Open your Slack app's settings and go to Event Subscriptions.
- Turn the feature on. A Request URL field appears.
- 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/eventsPost a message in a channel where your app receives message.channels events. The terminal prints another line.
POST | [200] | /api/slack/eventsYour 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
POSTwith a status. If you see[401], the signature check failed: see the next item. - The handler returns the
challengevalue in the response body, with a200status.
Signature mismatch ([401])
Check these in order:
SLACK_SIGNING_SECRETin 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
- Getting started
- Pricing, for reserved subdomains