---
title: "Receive Slack Events Locally: Test Slack Webhooks on localhost | WebhookRelay"
meta:
  "og:description": "Test Slack webhooks locally without deploying. Forward Events API requests to localhost, pass the url_verification challenge, and verify the X-Slack-Signature header."
  "og:title": "Receive Slack Events Locally: Test Slack Webhooks on localhost"
  description: "Test Slack webhooks locally without deploying. Forward Events API requests to localhost, pass the url_verification challenge, and verify the X-Slack-Signature header."
---

![Stripes](https://webhookrelay.com/blog/receive-slack-events-locally/images/stripes.svg)

# **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.

![Receive Slack Events Locally: Test Slack Webhooks on localhost](https://webhookrelay.com/blog/receive-slack-events-locally/images/blog/heroes/localhost.jpg)

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](#why-testing-slack-webhooks-is-tricky)

There are two specific hurdles beyond plain connectivity:

1. **Slack needs a public, reachable Request URL** — it can't POST to `localhost:3000`.
2. **Slack verifies the URL with a `url_verification` challenge** 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](#step-1-inspect-the-request-with-a-webhook-bin)

Start by seeing exactly what Slack sends. Open the free [Webhook Bin](https://webhookrelay.com/blog/receive-slack-events-locally/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](#step-2-forward-webhooks-to-localhost-with-the-relay-agent)

When you're ready to run real code, install the [relay agent](https://webhookrelay.com/blog/receive-slack-events-locally/docs/webhooks/internal/localhost/) (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](#step-3-configure-the-request-url-in-your-slack-app)

Go to [api.slack.com/apps](https://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_verification` challenge. 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](#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](#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-Timestamp` is 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](https://webhookrelay.com/blog/receive-slack-events-locally/blog/verify-webhook-signature/), and use the free [HMAC verifier](https://webhookrelay.com/blog/receive-slack-events-locally/hmac-verification/) to validate a signature by hand while you debug.

## [Step 6: Replay and iterate](#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](https://webhookrelay.com/blog/receive-slack-events-locally/blog/how-to-test-webhooks/) for the full workflow.

## [Wrapping up](#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](https://my.webhookrelay.com/register) to forward Slack events to localhost, or grab an instant [Webhook Bin](https://webhookrelay.com/blog/receive-slack-events-locally/webhook-bin/) URL to inspect Slack's challenge request right now.