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
- Go to api.slack.com/apps and click Create New App.
- Choose From scratch, name it (e.g., "LocalTestBot"), and select your workspace.
- In the left sidebar, navigate to Event Subscriptions.
- Toggle Enable Events to On.
- For now, leave Request URL blank—we'll come back to this once we have a public endpoint.
- Under Subscribe to bot events, add events you want to capture (e.g.,
message.channels,app_mention). - 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
- Go back to your Slack app's Event Subscriptions page.
- Paste your endpoint URL (e.g.,
https://api.anonymily.com/h/my-endpoint-name/slack/events) into Request URL. - Slack will send a challenge request. If your signature verification is correct, Slack marks it as Verified.
- Save changes.
Step 5: Trigger and Capture Events
Now trigger a Slack event. For example, if you subscribed to app_mention:
- Go to your Slack channel.
- Type
@YourBotName helloand 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:
- Double-check your signing secret in
.envmatches Slack's Basic Information page exactly. - Ensure the raw request body is used for HMAC calculation—don't stringify it again.
- Verify your system clock is synchronized:
dateon Unix,Get-Dateon Windows. - Log the
baseStringandsignaturebefore 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:
- Sync your system clock.
- If testing manually with curl, use the current Unix timestamp:
date +%s # Unix timestamp
- 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.
Top comments (0)