All articles

how-to

How to Test Slack Webhooks Locally

Master Slack webhook testing locally without exposing your machine. Learn signature verification, payload inspection, and replay techniques for reliab

How to Test Slack Webhooks Locally

The Challenge of Testing Slack Webhooks Locally

Testing Slack webhooks locally is deceptively tricky. Slack needs to reach your handler over the internet, but your development machine isn't publicly routable. You can't just point Slack at localhost:3000. Most developers either skip proper local testing (risky), deploy to staging (slow iteration), or spin up temporary tunnels (fragile and tedious). The real pain: when a webhook fails silently, you're left guessing whether it's a network issue, a signature mismatch, or a logic bug in your handler.

This guide walks you through testing Slack webhooks locally—from capturing inbound events to verifying signatures and replaying payloads—so you can iterate fast and debug with confidence.

Prerequisites

  • Node.js (v16+) and npm installed
  • A Slack workspace where you can create apps (free tier works)
  • Basic Express or Fastify knowledge (we'll use Express here)
  • A Slack app already created in your workspace (or ready to create one)
  • curl or Postman (optional, for manual testing)

How to Test Slack Webhooks Locally: Step-by-Step

Step 1: Create a Slack App and Configure Event Subscriptions

  1. Go to api.slack.com/apps and click Create New App.
  2. Choose From scratch, name it (e.g., "LocalTestBot"), and select your workspace.
  3. In the left sidebar, navigate to Event Subscriptions.
  4. Toggle Enable Events to On.
  5. For now, leave Request URL blank—we'll come back to this once we have a public endpoint.
  6. Under Subscribe to bot events, add events you want to capture (e.g., message.channels, app_mention).
  7. Save changes.

Step 2: Set Up a Local Express Handler

Create a basic Node.js project:

mkdir slack-webhook-test
cd slack-webhook-test
npm init -y
npm install express dotenv

Create server.js:

const express = require('express');
const crypto = require('crypto');
require('dotenv').config();

const app = express();
app.use(express.json());

const SLACK_SIGNING_SECRET = process.env.SLACK_SIGNING_SECRET;

// Middleware to verify Slack request signatures
function verifySlackRequest(req, res, next) {
  const timestamp = req.headers['x-slack-request-timestamp'];
  const signature = req.headers['x-slack-signature'];

  // Prevent replay attacks: request must be within 5 minutes
  const now = Math.floor(Date.now() / 1000);
  if (Math.abs(now - parseInt(timestamp)) > 300) {
    return res.status(401).send('Request timestamp too old');
  }

  // Reconstruct the signed content
  const baseString = `v0:${timestamp}:${JSON.stringify(req.body)}`;
  const hmac = crypto
    .createHmac('sha256', SLACK_SIGNING_SECRET)
    .update(baseString)
    .digest('hex');
  const expectedSignature = `v0=${hmac}`;

  if (!crypto.timingSafeEqual(signature, expectedSignature)) {
    return res.status(401).send('Invalid signature');
  }

  next();
}

app.post('/slack/events', verifySlackRequest, (req, res) => {
  const { type, challenge, event } = req.body;

  // Slack sends a challenge request during URL verification
  if (type === 'url_verification') {
    console.log('✓ Slack URL verification received');
    return res.json({ challenge });
  }

  // Handle actual events
  if (type === 'event_callback') {
    console.log('📨 Event received:', event.type);
    console.log('Payload:', JSON.stringify(event, null, 2));
    // Your event handling logic here
  }

  res.json({ ok: true });
});

const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
  console.log(`Server running on port ${PORT}`);
});

Create a .env file:

SLACK_SIGNING_SECRET=your_signing_secret_here
PORT=3000

Get your signing secret from Slack: go to Basic Information on your app page, scroll to App Credentials, and copy the Signing Secret.

Step 3: Expose Your Local Server with a Public Endpoint

Running npx @anonymilyhq/cli listen 3000 creates a stable public URL that forwards Slack webhooks to your local handler:

npx @anonymilyhq/cli listen 3000

This outputs something like:

✓ Listening on http://localhost:3000
✓ Webhook endpoint: https://api.anonymily.com/h/my-endpoint-name
✓ Forwarding to localhost:3000

Copy that endpoint URL.

Step 4: Register the Endpoint with Slack

  1. Go back to your Slack app's Event Subscriptions page.
  2. Paste your endpoint URL (e.g., https://api.anonymily.com/h/my-endpoint-name/slack/events) into Request URL.
  3. Slack will send a challenge request. If your signature verification is correct, Slack marks it as Verified.
  4. Save changes.

Step 5: Trigger and Capture Events

Now trigger a Slack event. For example, if you subscribed to app_mention:

  1. Go to your Slack channel.
  2. Type @YourBotName hello and send.

Check your terminal—you should see the event logged:

📨 Event received: app_mention
Payload: {
  "type": "app_mention",
  "user": "U12345678",
  "text": "<@U87654321> hello",
  "ts": "1699564800.000100",
  ...
}

Step 6: Test Signature Verification

To verify your HMAC logic is solid, manually test with curl. First, generate a valid signature:

// test-signature.js
const crypto = require('crypto');

const secret = 'your_signing_secret_here';
const timestamp = Math.floor(Date.now() / 1000);
const body = JSON.stringify({ type: 'event_callback', event: { type: 'message' } });

const baseString = `v0:${timestamp}:${body}`;
const hmac = crypto.createHmac('sha256', secret).update(baseString).digest('hex');
const signature = `v0=${hmac}`;

console.log('Timestamp:', timestamp);
console.log('Signature:', signature);
console.log('Body:', body);

Run it:

node test-signature.js

Then test your endpoint:

curl -X POST https://api.anonymily.com/h/my-endpoint-name/slack/events \
  -H "Content-Type: application/json" \
  -H "X-Slack-Request-Timestamp: 1699564800" \
  -H "X-Slack-Signature: v0=your_calculated_signature" \
  -d '{"type":"event_callback","event":{"type":"message"}}'

You should get a 200 OK response.

Common Errors and Fixes

Error 1: "Invalid Signature"

Error message:

401 Unauthorized: Invalid signature

Root cause: The HMAC calculation doesn't match Slack's signature. Common culprits:

  • Wrong signing secret (copy-pasted incorrectly)
  • Request body was modified after receiving it (e.g., Express middleware parsed it twice)
  • Timestamp check failed (your system clock is off)

Fix:

  1. Double-check your signing secret in .env matches Slack's Basic Information page exactly.
  2. Ensure the raw request body is used for HMAC calculation—don't stringify it again.
  3. Verify your system clock is synchronized: date on Unix, Get-Date on Windows.
  4. Log the baseString and signature before comparison to debug:
console.log('Slack signature:', signature);
console.log('Expected:', expectedSignature);
console.log('Base string:', baseString);

Error 2: "Request timestamp too old"

Error message:

401 Unauthorized: Request timestamp too old

Root cause: Slack's request timestamp is older than 5 minutes (the replay-attack window). This usually happens if:

  • Your local machine's clock is significantly behind
  • The request was genuinely delayed (rare with Slack)

Fix:

  1. Sync your system clock.
  2. If testing manually with curl, use the current Unix timestamp:
date +%s  # Unix timestamp
  1. For debugging, temporarily increase the window (but revert before production):
if (Math.abs(now - parseInt(timestamp)) > 3600) { // 1 hour for testing

Frequently Asked Questions

Q: Can I test Slack webhooks without exposing my machine to the internet?

A: Yes. Tools like Anonymily capture webhooks in the cloud and forward them to your local handler over a secure connection. Your machine never accepts inbound connections directly. Test Webhooks Locally Without ngrok covers this in detail.

Q: How do I replay a webhook payload for debugging?

A: Save the JSON payload from your logs, then use curl with the same headers (timestamp and signature). For production-grade replay with signature re-signing, Anonymily's Pro plan includes modify-and-replay. Otherwise, manually reconstruct the HMAC for each test.

Q: What if Slack keeps rejecting my Request URL during verification?

A: Slack sends a url_verification challenge request. Your handler must respond with { "challenge": "<value>" } and pass signature verification. If it fails, check: (1) your signing secret is correct, (2) your endpoint is publicly accessible, (3) your handler returns 200 OK within 3 seconds.

Next Steps

You now have a solid local testing setup for Slack webhooks. The pattern—verify signatures, log payloads, replay for debugging—applies to any webhook provider.

For faster iteration and built-in payload inspection, try npx @anonymilyhq/cli listen 3000 to forward webhooks from a stable public endpoint. Visit anonymily.com to learn more about webhook testing and debugging tools designed for developers.

Try it in 30 seconds

Capture your first webhook — from any provider — with one command. No account required.

npx @anonymilyhq/cli listen 3000Open Dashboard →