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
- A Slack workspace where you can create or manage an app (api.slack.com/apps).
- A local server listening on some port (this guide uses
3000). - A free Relayers account and the desktop app or CLI (download here).
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:
app_mention— someone @-mentions your botmessage.channels— messages posted in public channels
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
- Challenge not echoed / URL won't verify. Slack marks the Request URL invalid if it doesn't get the
challengeback. Make sure your tunnel is running (relayers listen) and your handler returns the rawchallengevalue forurl_verificationrequests. Check the Relayers Events feed to confirm the verification POST actually arrived. - The 3-second rule. Slack expects a
2xxresponse within 3 seconds, or it considers delivery failed. Acknowledge fast, then do slow work asynchronously — don't block the response on database writes or external API calls. - Duplicate events / retries. When Slack doesn't get a timely
2xx, it retries the same event and adds anX-Slack-Retry-Numheader (withX-Slack-Retry-Reason). Make your handler idempotent, and use the payload inspector to spot retries by that header.
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.