Test Slack Webhooks Locally
Your Request URL shows as unverified, or your handler keeps computing a v0= that never matches Slack’s. Get a stable URL Slack can verify once, see the exact X-Slack-Signature and X-Slack-Request-Timestamp it sent, and replay the delivery — including the url_verification challenge — until your signature check agrees.
30-second setup
From a stuck handler to a live Slack delivery you can read and replay.
- 1Start LocalCan and point it at your local server:
localcan http 3000. It opens the tunnel and prints your.localdomains plus a public HTTPS URL. - 2Grab your persistent URL — something like
your-app-12.localcan.dev. It is saved to your project file and comes back identical after every restart, so you wire it up once. - 3In
api.slack.com/apps→ your app → Event Subscriptions, paste your LocalCan URL with your path into Request URL, e.g.https://your-app-12.localcan.dev/slack/events. Slack immediately POSTs atype:"url_verification"payload; echo itschallengeback with HTTP 200 to verify. The same URL also goes in Interactivity & Shortcuts and Slash Commands. The Signing Secret used to verify all of them is under Basic Information → App Credentials. - 4Slack has no “resend last delivery” button. Re-save the Request URL to fire a fresh
url_verification, or trigger a real event in a workspace where the app is installed: @-mention the bot (app_mention), add a reaction (reaction_added), run the slash command, or click a Block Kit button (block_actions). Return a non-2xx or stall past 3s and Slack retries on its own, carryingx-slack-retry-num. - 5Open Inspect Traffic in LocalCan. The delivery lands with full headers and body — expand it, fix your handler, and hit Replay to send the exact same request again. No new test event needed.
Why localhost can't receive Slack webhooks
Slack delivers events from its servers to a public HTTPS Request URL. localhost:3000 only exists on your machine, so Slack’s verification POST never connects and Event Subscriptions stays stuck on “Your URL didn’t respond with the value of the challenge parameter.” Until that handshake passes, no events flow at all — not just the test one.
You need a public URL that forwards to your local handler. And because Slack’s URL has to be re-pasted into three separate places (Event Subscriptions, Interactivity, Slash Commands) and re-verified each time it changes, you want one that does not rotate on restart — or you will be running the url_verification gauntlet every morning.
Just want to see what Slack sends first? Peek at the raw payload on webhook.cool, then point the endpoint at LocalCan to forward it into localhost.
What makes the Slack loop fast
Verify the Request URL once
Your …localcan.dev URL survives restarts and sleeps, so the Request URL you verified yesterday is still verified today. That matters more for Slack than most providers: the URL lives in three places — Event Subscriptions, Interactivity & Shortcuts, and Slash Commands — and every change re-triggers the url_verification challenge in each. A stable URL means you paste and pass the handshake once, not every restart.
See the real signature and the timestamp header
Slack’s signature is computed over v0:{X-Slack-Request-Timestamp}:{rawBody}, and the timestamp lives in a separate header from the v0= digest. The inspector shows both X-Slack-Signature and X-Slack-Request-Timestamp exactly as sent, plus the unparsed body. That is precisely what you need to tell a wrong Signing Secret from a parsed body from a clock-skewed timestamp — instead of staring at two hex strings that don’t match.
Replay the handshake and the retries
Hit Replay to re-send the identical captured request — same headers, same bytes. Replay the url_verification POST to confirm your challenge echo without re-saving the URL in the dashboard, or replay a real app_mention while you fix idempotency. You can also see the x-slack-retry-num Slack stamps on its own redeliveries, so you can reproduce the duplicate-delivery path on demand.
Inspect & replay Slack events
Common events
url_verificationthe setup handshake — echo the challenge backapp_mentionmessage.channelsreaction_addedapp_home_openedmember_joined_channelblock_actionsinteractivity — buttons, menus
These are the payloads most Slack apps wire up first: the url_verification handshake, then app_mention, message.channels (and its message.im / message.groups siblings), reaction_added, app_home_opened, slash-command and interactivity (block_actions / view_submission) posts. Trigger one — @-mention the bot or click a button — watch it land in Inspect Traffic with full headers and body, and expand it to read the event object. When your handler throws, fix it and hit Replay against the exact same request.
Two Slack-specific caveats. First, the url_verification POST expects you to echo its challenge back with a 200 — replaying it is the fastest way to confirm your handshake without re-saving the Request URL. Second, Slack wants a 2xx within three seconds or it retries (up to three times) with x-slack-retry-num; and its signature check rejects an X-Slack-Request-Timestamp more than five minutes old, so a delivery you captured an hour ago will fail the timestamp window if you forward it untouched. Replay while you iterate.
Verify the Slack signature
- Header
X-Slack-Signature- Algorithm
- HMAC-SHA256, lowercase hex, prefixed
v0= - What's signed
- Slack signs the basestring
v0:{X-Slack-Request-Timestamp}:{raw body}— the literalv0, a colon, the timestamp value from theX-Slack-Request-Timestampheader, another colon, then the exact bytes of the request body. HMAC-SHA256 with your Signing Secret, hex-encoded, gives thev0=value inX-Slack-Signature. Sign your own clock instead of the header’s timestamp, or parse the body first, and the digest can never match. - Secret
- The Signing Secret from
api.slack.com/apps→ your app → Basic Information → App Credentials. It is a plain UTF-8 string used as the HMAC key (in practice a lowercase hex string, no prefix). One secret covers Events API, slash commands, and interactivity — they are all signed identically. It is not the deprecated Verification Token and not anxoxb-/xapp-token.
import express from 'express'
import crypto from 'crypto'
const signingSecret = process.env.SLACK_SIGNING_SECRET // from Basic Information
const app = express()
// Slack signs the RAW body — capture the exact bytes BEFORE any JSON/urlencoded parser.
app.post(
'/slack/events',
express.raw({ type: () => true }),
(req, res) => {
const sig = req.headers['x-slack-signature'] // v0=...
const ts = req.headers['x-slack-request-timestamp']
const body = req.body // a Buffer of the raw bytes
// Reject requests older than 5 minutes (replay protection).
if (Math.abs(Math.floor(Date.now() / 1000) - Number(ts)) > 60 * 5) {
return res.status(400).send('stale timestamp')
}
// basestring = "v0:" + timestamp + ":" + rawBody
const basestring = 'v0:' + ts + ':' + body.toString('utf8')
const expected =
'v0=' +
crypto.createHmac('sha256', signingSecret).update(basestring).digest('hex')
// Constant-time compare; bail if lengths differ so timingSafeEqual won't throw.
const a = Buffer.from(expected)
const b = Buffer.from(String(sig))
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return res.status(401).send('bad signature')
}
const payload = JSON.parse(body.toString('utf8'))
// First save of the Request URL: echo the challenge back.
if (payload.type === 'url_verification') {
return res.status(200).send(payload.challenge)
}
// Ack within 3 seconds, then process async.
res.status(200).end()
// handle payload.event ...
},
)LocalCan does not verify the signature for you — your code does — but it hands you every input to that check so a failure is obvious instead of silent. The inspector shows the real X-Slack-Signature, the separate X-Slack-Request-Timestamp, and the untouched body, so you can rebuild the v0:{ts}:{body} basestring by eye and see whether a parsed body, the wrong Signing Secret, or a skewed timestamp broke the match.
Then Replay re-sends the identical request — same v0=, same timestamp header — so you confirm the fix against the exact bytes Slack sent, not a hand-typed fixture.
LocalCan vs Socket Mode
Slack has no first-party HTTP-forwarding tool. Its local story is Socket Mode (via the Slack CLI / Bolt), which connects your app to Slack over a WebSocket — no public URL, no inbound port. The honest split: reach for Socket Mode when your whole loop is Slack and you are behind a firewall; reach for LocalCan when you need to exercise the real signed-HTTP path you ship, inspect and replay the actual requests, or serve one stable URL across Slack and everything else.
| LocalCan | Socket Mode | |
|---|---|---|
| Stable public URL | Persistent …localcan.dev, survives restarts | No public URL — WebSocket refreshes regularly |
| No public URL / behind a firewall | Needs an outbound tunnel (still no inbound port) | WebSocket out only — no inbound port at all |
| Exercises the signed-HTTP path you ship | Real X-Slack-Signature over HTTPS | No X-Slack-Signature — socket auths with xapp- token |
| Works across providers | One URL for Slack, Stripe, GitHub… | Slack only |
| Inspect full payloads | GUI inspector, headers + body, on by default | Terminal logs from slack run |
| Replay a delivery | One-click Replay of the exact signed request | No raw HTTP to replay — re-trigger the event |
| Marketplace-eligible app | HTTP endpoint — Marketplace-eligible | Socket Mode apps cannot be submitted |
| Share with a teammate | Shareable URL + basic auth | Local socket only |
Slack webhook FAQ
Stop guessing why the webhook 401'd. Get a stable Slack endpoint, read every delivery, and replay it until your handler is right.
Free trial. No credit card required.