Receive Slack Events Locally: Test Slack Webhooks on localhost
Test Slack webhooks locally without deploying. Forward Events API requests to localhost, pass the url_verification challenge, and verify the X-Slack-Signature header.
Slack's Events API, slash commands, and interactivity all work the same way: Slack POSTs to a Request URL you configure, and your app responds. Which means the moment you start building a Slack app, you hit the classic wall — Slack needs a public URL, but your handler is on localhost, and you'd rather not deploy to staging on every change just to see one event.
This guide shows how to receive Slack events locally — forwarding live Events API requests, slash commands, and interactions straight to your machine so you can develop and debug entirely on localhost.
Why testing Slack webhooks is tricky
There are two specific hurdles beyond plain connectivity:
- Slack needs a public, reachable Request URL — it can't POST to
localhost:3000. - Slack verifies the URL with a
url_verificationchallenge before it will deliver any events. If your handler doesn't echo the challenge back correctly and quickly, Slack marks the URL as failed and you're stuck on the config screen.
Webhook Relay handles the connectivity: a stable public URL forwarding to your local server over an outbound connection — no firewall ports, no public IP. Your handler still needs to answer the challenge, which we'll cover below.
Step 1: Inspect the request with a Webhook Bin
Start by seeing exactly what Slack sends. Open the free Webhook Bin for an instant URL (no signup), paste it as your Request URL under Event Subscriptions, and watch Slack's first request land in your browser.
The very first request you'll see is the verification handshake — a JSON POST:
{
"token": "Jhj5dZrVaK7ZwHHjRyZWjbDl",
"challenge": "3eZbrw1aBm2rZgRNFdxV2595E9CY3gmdALWMmHkvFXO7tYXAYM8P",
"type": "url_verification"
}
You'll also see the X-Slack-Signature and X-Slack-Request-Timestamp headers on every request. Now you know exactly what to build against.
Step 2: Forward webhooks to localhost with the relay agent
When you're ready to run real code, install the relay agent (CLI or Docker), sign in, and forward to your local port. If your app listens on http://localhost:3000/slack/events:
relay forward --bucket slack http://localhost:3000/slack/events
The agent prints a stable public URL like https://hook.relay.sh/v1/webhooks/.... The connection is outbound, so no ports are opened, and the URL persists across restarts — handy because Slack re-runs the challenge every time you change the Request URL.
Step 3: Configure the Request URL in your Slack app
Go to api.slack.com/apps → your app, then:
- Events API: Event Subscriptions → toggle on → set Request URL to your Webhook Relay URL. Slack immediately fires the
url_verificationchallenge. Below it, subscribe to the bot/workspace events you care about (e.g.message.channels,app_mention). - Slash commands: Slash Commands → create/edit a command → set its Request URL to the same Webhook Relay URL (commands POST
application/x-www-form-urlencoded). - Interactivity: Interactivity & Shortcuts → toggle on → set the Request URL for button clicks, modals, and shortcuts.
Step 4: Answer the url_verification challenge
Slack won't deliver events until your endpoint passes the handshake. When you receive a body with "type": "url_verification", respond within a few seconds, echoing the exact challenge value back. Plain text or JSON both work:
const express = require("express");
const app = express();
app.use(express.json());
app.post("/slack/events", (req, res) => {
// 1. Handle the verification handshake
if (req.body.type === "url_verification") {
return res.type("text/plain").send(req.body.challenge);
}
// 2. Normal events
const event = req.body.event;
if (event && event.type === "app_mention") {
console.log(`Mentioned in ${event.channel}: ${event.text}`);
}
// Slack expects a fast 200 — ack now, do work async
res.sendStatus(200);
});
app.listen(3000, () => console.log("listening on :3000"));
Two practical notes: Slack expects a 200 quickly (do heavy work asynchronously, after acking), and for the challenge you can also return {"challenge": "..."} as JSON if you prefer.
Step 5: Verify the X-Slack-Signature
Once events flow, confirm they actually came from Slack. Slack signs every request with HMAC-SHA256 using your app's Signing Secret. The base string is built by joining three parts with colons:
v0:{X-Slack-Request-Timestamp}:{raw_request_body}
HMAC-SHA256 that string with your signing secret, hex-encode it, prefix v0=, and compare to the X-Slack-Signature header. Two things matter a lot:
- Compute over the raw, unparsed body bytes. If middleware reparses or reserializes the body first, the signature won't match. Capture the raw body before JSON parsing.
- Check the timestamp. Reject requests where
X-Slack-Request-Timestampis more than ~5 minutes from now — this is Slack's built-in replay protection.
For a walkthrough of HMAC signature checking in general, see our webhook signature verification guide, and use the free HMAC verifier to validate a signature by hand while you debug.
Step 6: Replay and iterate
You don't want to spam a channel to trigger events while fixing your handler. Every request through Webhook Relay is captured, so you can resend the exact same payload — challenge or real event — to your local server as many times as you need. Change code, replay, repeat, all on localhost. See how to test webhooks for the full workflow.
Wrapping up
Receiving Slack events on localhost comes down to: a stable public Request URL that forwards to your machine, passing the url_verification challenge by echoing it back fast, and validating X-Slack-Signature (HMAC-SHA256 over v0:timestamp:body) against the raw body. Webhook Relay gives you the public URL and live forwarding without deploying or opening a port.
Sign up for free to forward Slack events to localhost, or grab an instant Webhook Bin URL to inspect Slack's challenge request right now.
