How to Test Slack Events API and Interactivity

See what Slack sends to your Request URL, handle the url_verification challenge, verify X-Slack-Signature and decode form-encoded interactivity payloads.

Updated:

Slack apps receive two kinds of HTTP requests: Events API callbacks (messages, mentions, reactions) as JSON, and interactivity requests (button clicks, modals, shortcuts, slash commands) as form-encoded data. Both are signed and both have to be acknowledged within three seconds. This guide shows how to inspect them, verify signatures, and what to do about the one step a capture URL can't handle: the Events API challenge.

Where Slack sends requests

In your app's settings at api.slack.com:

  • Event Subscriptions: turn on events, enter a Request URL and subscribe to bot or user events such as app_mention or message.channels.
  • Interactivity & Shortcuts: turn on interactivity and enter a Request URL for button clicks, menus, modal submissions and shortcuts.

The url_verification challenge

When you enter an Events API Request URL, Slack immediately sends a verification request:

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

Your endpoint must answer with HTTP 200 and echo the challenge value, as plain text, as challenge=... form data, or as JSON {"challenge":"..."}.

Important: Webhook Inspector can't pass this check. Its custom response is static, so it can't echo a random challenge, and forwarding is one-way: Slack always receives the inspector's response, never your app's. That gives two workflows:

Slack feature Challenge? How to use the inspector
Interactivity & Shortcuts, Slash Commands No Paste the capture URL as the Request URL directly, optionally forward to your local app
Events API Yes Verify the Request URL with your own app, and have the app send a copy of each request to the inspector

Step 1: Create a capture URL

Open Webhook Inspector and create a URL, for example:

https://webhook.voronyuk.com/abcd23efgh/slack/events

Use separate sub-paths, such as .../slack/interactive and .../slack/events. Everything is captured by the same URL and can be filtered and searched separately.

Slack shows the body of a slash-command response to the user, so open the URL's Response settings and switch to a custom body that is empty (status 200). Slack then gets a plain acknowledgement.

Step 2: Verify X-Slack-Signature

Every request carries two headers:

  • X-Slack-Request-Timestamp: Unix time of the request.
  • X-Slack-Signature: v0= followed by the hex HMAC-SHA256 of v0:{timestamp}:{raw body}, keyed with your app's Signing Secret (under Basic Information).

Reject requests whose timestamp is more than five minutes from your clock to block replays. Here's a minimal Node.js app that verifies, answers the challenge and acknowledges events and interactions:

const crypto = require('crypto');
const express = require('express');

const SIGNING_SECRET = process.env.SLACK_SIGNING_SECRET;
const app = express();

function verifySlack(req) {
  const ts = req.get('X-Slack-Request-Timestamp');
  const sig = req.get('X-Slack-Signature');
  if (!ts || !sig) return false;
  if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
  const expected =
    'v0=' +
    crypto
      .createHmac('sha256', SIGNING_SECRET)
      .update(`v0:${ts}:`)
      .update(req.body) // raw Buffer
      .digest('hex');
  const a = Buffer.from(expected);
  const b = Buffer.from(sig);
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

app.use(express.raw({ type: '*/*' }));

app.post('/events', (req, res) => {
  if (!verifySlack(req)) return res.sendStatus(401);
  const body = JSON.parse(req.body.toString('utf8'));
  if (body.type === 'url_verification') {
    return res.type('text/plain').send(body.challenge);
  }
  res.sendStatus(200); // ack within 3 seconds
  console.log(body.event && body.event.type, body.event_id);
});

app.post('/interactive', (req, res) => {
  if (!verifySlack(req)) return res.sendStatus(401);
  const params = new URLSearchParams(req.body.toString('utf8'));
  const payload = JSON.parse(params.get('payload'));
  res.sendStatus(200);
  console.log(payload.type, payload.user && payload.user.id);
});

app.listen(3000);

The same signature check in Python:

import hashlib
import hmac
import os
import time

SIGNING_SECRET = os.environ["SLACK_SIGNING_SECRET"].encode()


def verify_slack(raw_body: bytes, timestamp: str, signature: str) -> bool:
    if not timestamp or abs(time.time() - int(timestamp)) > 300:
        return False
    base = b"v0:" + timestamp.encode() + b":" + raw_body
    expected = "v0=" + hmac.new(SIGNING_SECRET, base, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, signature or "")

Step 3: Interactivity — capture and forward

Enter https://webhook.voronyuk.com/abcd23efgh/slack/interactive as the Interactivity Request URL (and as the URL of your slash commands). No challenge is involved, so Slack accepts it right away.

To run your handler code as well, start the app above on port 3000 and forward requests to it. The sub-path is appended, so .../abcd23efgh/slack/interactive reaches http://localhost:3000/interactive. Run the CLI agent:

WI_TOKEN='<token>' inspector listen https://webhook.voronyuk.com/abcd23efgh/slack --to localhost:3000

Copy the exact command with your token from the Forwarding panel; the browser relay also works if your server allows CORS. Remember that Slack only sees the inspector's response, so your app should reply to users through the response_url from the payload rather than through the HTTP response. See how to test webhooks locally for how each forwarding mode works.

Step 4: Events API — mirror requests to the inspector

Expose your app with a tunnel or deploy it, and enter its own URL (for example https://your-tunnel.example.com/events) as the Events API Request URL, so the app answers the challenge. Then add a few lines that send a copy of every request to your capture URL:

const INSPECTOR_URL = 'https://webhook.voronyuk.com/abcd23efgh/slack/events';

app.use((req, res, next) => {
  // Fire-and-forget copy of the raw request; never delays the reply to Slack.
  fetch(INSPECTOR_URL, {
    method: 'POST',
    headers: {
      'Content-Type': req.get('Content-Type') || 'application/json',
      'X-Slack-Signature': req.get('X-Slack-Signature') || '',
      'X-Slack-Request-Timestamp': req.get('X-Slack-Request-Timestamp') || '',
    },
    body: req.body,
  }).catch(() => {});
  next();
});

Put this middleware after express.raw() so req.body is the raw Buffer (Node.js 18+ has a global fetch). Every event now shows up in the inspector exactly as Slack sent it, including the signature headers.

Step 5: Inspect events and interactions

Mention your bot in a channel. The captured request shows an event_callback body:

{
  "type": "event_callback",
  "team_id": "T0001",
  "api_app_id": "A0001",
  "event_id": "Ev0001",
  "event_time": 1760950000,
  "event": {
    "type": "app_mention",
    "user": "U0001",
    "text": "<@U0BOT> deploy status?",
    "channel": "C0001",
    "ts": "1760950000.000100"
  }
}

Click a button in a message, and the interactivity request arrives as application/x-www-form-urlencoded with a single payload field holding JSON. The inspector decodes form data, so you can read payload directly instead of URL-decoding it by hand.

The 3-second limit and retries

Slack expects a 2xx within three seconds. If you don't respond in time, or return an error, Slack retries the event up to three times: almost immediately, after one minute and after five minutes. Retries carry two headers:

  • X-Slack-Retry-Num: the attempt number (1, 2 or 3).
  • X-Slack-Retry-Reason: why, e.g. http_timeout, http_error, connection_failed or ssl_error.

Acknowledge first, process in a queue, and deduplicate on event_id. Use search in the Slack request inspector to find X-Slack-Retry-Num and spot handlers that respond too slowly.

Related: GitHub webhooks and Telegram bot webhooks.

FAQ

Why does Slack say my Request URL didn't respond with the challenge?

Your endpoint must echo the challenge from the url_verification request with HTTP 200. A capture URL can't do that — its response is static and forwarding doesn't return your app's reply — so verify the Events API URL with your own app and mirror requests to the inspector, as shown above.

How do I verify a Slack request signature?

Compute HMAC-SHA256 of v0:{X-Slack-Request-Timestamp}:{raw body} with your Signing Secret, prefix the hex digest with v0=, and compare it to X-Slack-Signature in constant time. Reject timestamps older than five minutes.

Why does Slack send the same event several times?

Your app didn't return a 2xx within three seconds, so Slack retried. Check X-Slack-Retry-Num and X-Slack-Retry-Reason, respond faster and deduplicate by event_id.

What format are Slack interactivity payloads?

Form-encoded. The body has one payload field containing JSON, which you parse after verifying the signature over the raw body.

Try it now

Get a free webhook URL and watch the requests arrive live — no signup.

Get a webhook URL