NewLocalCan 3.0 Beta with CLI, Multi-region and more ⟶

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.

Start Free Trial →DownloadFree trial. No credit card required.

30-second setup

From a stuck handler to a live Slack delivery you can read and replay.

  1. 1Start LocalCan and point it at your local server: localcan http 3000. It opens the tunnel and prints your .local domains plus a public HTTPS URL.
  2. 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.
  3. 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 a type:"url_verification" payload; echo its challenge back 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.
  4. 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, carrying x-slack-retry-num.
  5. 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 back
  • app_mention
  • message.channels
  • reaction_added
  • app_home_opened
  • member_joined_channel
  • block_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 literal v0, a colon, the timestamp value from the X-Slack-Request-Timestamp header, another colon, then the exact bytes of the request body. HMAC-SHA256 with your Signing Secret, hex-encoded, gives the v0= value in X-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 an xoxb- / xapp- token.
Node.js
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.

LocalCanSocket Mode
Stable public URLPersistent …localcan.dev, survives restartsNo public URL — WebSocket refreshes regularly
No public URL / behind a firewallNeeds an outbound tunnel (still no inbound port)WebSocket out only — no inbound port at all
Exercises the signed-HTTP path you shipReal X-Slack-Signature over HTTPSNo X-Slack-Signature — socket auths with xapp- token
Works across providersOne URL for Slack, Stripe, GitHub…Slack only
Inspect full payloadsGUI inspector, headers + body, on by defaultTerminal logs from slack run
Replay a deliveryOne-click Replay of the exact signed requestNo raw HTTP to replay — re-trigger the event
Marketplace-eligible appHTTP endpoint — Marketplace-eligibleSocket Mode apps cannot be submitted
Share with a teammateShareable URL + basic authLocal 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.