← All guides
SlackJuly 12, 20266 min read

Test Slack Events API Webhooks on Localhost

Point Slack at Relayers, echo the url_verification challenge, and run your slack events api localhost setup — forward, filter, and transform events locally.

Building a Slack app means wiring the Events API to a public HTTPS Request URL — but your handler runs on localhost:3000, invisible to Slack. Worse, before Slack delivers a single event, it fires a one-time url_verification request and expects your endpoint to echo back a challenge string within seconds. Get that handshake wrong and Slack refuses to save the URL. The usual dance — a tunnel URL that expires, redeploying to staging for every code change — kills your iteration loop.

This guide wires the Slack Events API straight to your local machine with Relayers, including the url_verification handshake, so both the challenge and real event payloads land on your local handler with signatures intact.

What you'll build

Slack talks to a stable public URL. Relayers filters and reshapes the payload, then streams it down a WebSocket tunnel to your laptop. No redeploys, no expiring URLs.

Prerequisites

1Create an endpoint in Relayers

In the Relayers dashboard, open Endpoints and create a new one — call it slack-events. You'll get a public ingest URL that looks like:

https://api.relayers.app/v1/webhooks/wep_a1b2c3d4e5

Copy it. This URL is permanent, so you paste it into Slack once. Before touching Slack, hit Send test from the endpoint view to confirm it's live and shows up in your Events feed.

2Set the Request URL in Slack

Head to api.slack.com/apps, open your app, and go to Event Subscriptions. Toggle Enable Events on, then paste your Relayers ingest URL into Request URL.

The moment you paste it, Slack sends a one-time verification POST to prove you own the endpoint:

{
  "type": "url_verification",
  "token": "Jhj5dZrVaK7ZwHHjRyZWjbDl",
  "challenge": "3eZbrw1aBm2rZgRNFdxV2595E9CY3gmdALWMmHkvFXO7tYXAYM8P"
}

Your endpoint must respond with the challenge value. In your handler, detect type === "url_verification" and reply with the raw challenge string (or { "challenge": "..." }). Because Relayers forwards the request straight through the tunnel to localhost:3000, your local code answers Slack's challenge directly — exactly as it will in production.

Once Slack shows the green Verified check, scroll down to Subscribe to bot events and add the events you care about, for example:

Save your changes. Slack signs every request with X-Slack-Signature and X-Slack-Request-Timestamp headers (an HMAC-SHA256 over v0:{timestamp}:{body} using your app's Signing Secret). Relayers passes these headers through untouched — more on verifying them below.

3Forward events to localhost

Install and authenticate the Relayers CLI, then start listening:

relayers login
relayers listen --forward localhost:3000

That opens a WebSocket tunnel from Relayers to your machine, so anything hitting your wep_… endpoint is delivered to localhost:3000. Trigger an event — @-mention your bot — and watch it hit the Relayers Events feed and your local handler at the same time. Use the payload inspector to see the exact JSON body and headers Slack sent.

4Route and filter with JQ

The Events API is chatty. If you only care about mentions, add a Rule so unrelated traffic never reaches your handler. Rules use JQ filters, destinations point at your tunnel (localhost:3000) or a public URL, and the first matching rule wins — reorder them to control precedence.

Create a rule with this JQ filter to forward only app_mention events:

.event.type == "app_mention"

Anything that doesn't match — channel joins, message edits, other event types — is dropped before it ever reaches your laptop. You can layer more rules for other event types and route each to a different local port or public URL.

5Transform the payload

Slack's event envelope is deeply nested. Open Transformations, pick jq (or JSONata, JavaScript, or Go templates), and flatten it down to just what your handler needs. Use the built-in test playground to preview the output against a real captured event:

{
  user: .event.user,
  text: .event.text,
  channel: .event.channel
}

Now your handler receives a clean { user, text, channel } object instead of the full envelope. Since the transformation runs in Relayers, local and production handlers consume the same simplified shape.

A note on signatures

When your rule forwards to localhost without a transformation, Relayers relays the original body and headers untouched — including X-Slack-Signature and X-Slack-Request-Timestamp. So Slack signature verification works locally just as in production: recompute v0:{timestamp}:{body} with your Signing Secret and compare. This only holds when the raw body is preserved — transforming the payload changes the body, so Slack's original signature won't match. Verify before transforming (or verify in production only).

The same applies to slash commands and interactivity, which POST form-encoded payloads and carry the same signing headers. Point those Request URLs at the same Relayers endpoint (or a second one) and route them with their own rules.

Going to production

When you're ready to ship, change the destination on your rule from the tunnel to your public URL — your production handler. The Slack Request URL never changes, and neither do your filters or transformations. You can Export your endpoint, rules, and transformations as a JSON bundle and Import them into another environment to keep staging and production in sync.

Troubleshooting

Wrap-up

You now have real Slack events flowing to localhost:3000 with a stable Request URL, the url_verification handshake handled by your own code, JQ filtering down to just app_mention, a clean transformed payload, and signatures preserved for local verification. When it's time to deploy, flip one destination.

Ready to test Slack webhooks locally? Get started with Relayers — it's free to try.