Guide to Slack Webhooks: Features and Best Practices
Slack is the dominant workplace messaging platform, used by millions of teams to coordinate work, manage incidents, and stay connected. Beyond real-time messaging, Slack's webhook and event delivery capabilities enable developers to build powerful integrations that push data into and out of Slack workspaces programmatically.
This guide covers everything you need to know about Slack webhooks: their features, how to configure them, best practices for production deployments, and the common pain points developers face along with solutions to address them.
What are Slack webhooks?
Slack webhooks are HTTP-based integrations that enable automated communication between Slack and external systems. They come in two primary forms: incoming webhooks that push messages into Slack channels, and the Events API that delivers real-time workspace events to your endpoints via HTTP callbacks.
Webhooks in Slack exist in several contexts:
- Incoming Webhooks — Send messages from external systems into Slack channels via a unique URL
- Events API (HTTP mode) — Receive real-time events from Slack workspaces at your HTTP endpoint
- Workflow Webhooks — Trigger Slack Workflow Builder automations from external services
- Legacy Outgoing Webhooks — Deprecated method for receiving messages from Slack channels (replaced by Events API)
This guide covers incoming webhooks and the Events API in depth, as they represent the two sides of modern Slack webhook integration: sending data to Slack and receiving data from Slack.
Slack webhook features
| Feature | Incoming Webhooks | Events API |
|---|---|---|
| Direction | Inbound (push messages to Slack) | Outbound (Slack pushes events to you) |
| Configuration | Slack App settings UI or OAuth flow | Slack App settings UI + Request URL |
| Hashing algorithm | None (URL secrecy only) | HMAC-SHA256 signing secret |
| Timeout | Standard HTTP response | 3-second response deadline |
| Retry logic | None (fire-and-forget from your side) | Up to 3 retries with exponential backoff |
| Delayed retries | N/A | Optional hourly retries for 24 hours |
| Rate limits | ~1 msg/sec/channel | 30,000 events/workspace/app/hour |
| Manual retry | N/A (you control sends) | Not available |
| Delivery logs | Not available | Not available |
| Payload format | JSON with Block Kit support | JSON event envelope |
| Channel targeting | Fixed per webhook URL | Event-driven (based on subscriptions) |
| Authentication | URL contains secret token | Signing secret + timestamp verification |
Incoming webhooks
How they work
Incoming webhooks provide a simple way to post messages from external applications into Slack. When you create an incoming webhook for a Slack app, you receive a unique URL in the format:
https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX
Sending an HTTP POST request with a JSON payload to this URL publishes a message to the associated Slack channel. The webhook URL acts as both the endpoint and the authentication credential — anyone with the URL can post messages.
Payload structure
A basic incoming webhook payload contains a text field:
{
"text": "Deployment to production completed successfully."
}
For richer messages, you can use Block Kit — Slack's UI framework for composing layouts:
{
"text": "New order received",
"blocks": [
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": "*New Order #1234*\nCustomer: Acme Corp\nTotal: $1,250.00"
}
},
{
"type": "section",
"fields": [
{
"type": "mrkdwn",
"text": "*Status:*\nProcessing"
},
{
"type": "mrkdwn",
"text": "*Priority:*\nHigh"
}
]
},
{
"type": "actions",
"elements": [
{
"type": "button",
"text": {
"type": "plain_text",
"text": "View Order"
},
"url": "https://dashboard.example.com/orders/1234"
}
]
}
]
}
The text field serves as a fallback for notifications and accessibility when blocks are present.
Threading support
Incoming webhooks support posting replies to existing threads using the thread_ts field. However, since incoming webhooks don't return a message timestamp in the response, you'll need to retrieve the ts value through other means:
- Subscribe to Events API callbacks for new messages
- Use
conversations.historyto find the message by timestamp - Use
search.messagesto locate the message
Key limitations of incoming webhooks
Incoming webhooks attached to Slack apps have important constraints compared to legacy custom integration webhooks:
- Channel is locked — Each webhook URL is permanently bound to the channel selected at creation time. You cannot override the destination channel in the payload.
- No identity override — You cannot change the username or icon on a per-message basis. These values are inherited from the Slack app configuration.
- No message deletion — Incoming webhooks cannot delete messages after posting. Use
chat.postMessageif you need full message lifecycle control. - No file uploads — You cannot send files via incoming webhooks. Use the
files.uploadAPI method instead.
Error handling
Incoming webhooks return descriptive HTTP error codes and error strings:
| Error | Description |
|---|---|
channel_is_archived | The target channel has been archived |
action_prohibited | An admin restriction is preventing the post |
invalid_payload | Malformed JSON or improperly escaped text |
no_text | The text field is missing from the payload |
too_many_attachments | More than 100 attachments on a single message |
channel_not_found | The webhook's associated channel no longer exists |
no_service | The incoming webhook is disabled, removed, or invalid |
posting_to_general_channel_denied | Restricted posting to #general and the webhook creator isn't authorized |
Events API
How it works
The Events API is Slack's system for delivering real-time workspace events to your application via HTTP callbacks. When subscribed events occur — such as messages being posted, reactions being added, or files being shared — Slack sends a JSON payload to your configured Request URL.
The Events API supports two transport modes:
- HTTP mode — Slack sends events to a public HTTPS endpoint you configure
- Socket Mode — Events are delivered over a WebSocket connection, eliminating the need for a public endpoint
This guide focuses on HTTP mode, which functions as a traditional webhook delivery system.
Event payload structure
Events are wrapped in an outer envelope containing metadata, with the specific event data nested in the event field:
{
"type": "event_callback",
"token": "XXYYZZ",
"team_id": "T123ABC456",
"api_app_id": "A123ABC456",
"event": {
"type": "message",
"channel": "C123ABC456",
"user": "U123ABC456",
"text": "Hello from the channel",
"ts": "1234567890.123456",
"event_ts": "1234567890.123456"
},
"event_id": "Ev123ABC456",
"event_time": 1234567890,
"authorizations": [
{
"enterprise_id": "E123ABC456",
"team_id": "T123ABC456",
"user_id": "U123ABC456",
"is_bot": false,
"is_enterprise_install": false
}
]
}
Key envelope fields
| Field | Description |
|---|---|
type | Callback type: event_callback for events, url_verification during setup |
token | Shared callback token for basic verification (deprecated in favor of signing secrets) |
team_id | Workspace where the event occurred |
api_app_id | Your application's unique identifier |
event | The inner event object with type-specific fields |
event_id | Globally unique identifier for this specific event |
event_time | Epoch timestamp of when the event was dispatched |
authorizations | Installation context showing which app installation can see the event |
URL verification challenge
Before Slack will deliver events to your endpoint, it must verify ownership via a challenge-response handshake. Slack sends a POST request with a url_verification type:
{
"type": "url_verification",
"token": "SHARED_VERIFICATION_TOKEN",
"challenge": "3eZbrw1aBm2rZgRNFdxV2595E9CY3gmdALWMmHkvFXO7tYXAYM8P"
}
Your endpoint must respond with the challenge value:
{
"challenge": "3eZbrw1aBm2rZgRNFdxV2595E9CY3gmdALWMmHkvFXO7tYXAYM8P"
}
Security with signing secrets
Slack uses HMAC-SHA256 signatures to verify that events genuinely originated from Slack. Every request includes two headers:
X-Slack-Signature— The computed HMAC signatureX-Slack-Request-Timestamp— Unix timestamp of when the request was sent
The signature is computed over a base string in the format v0:{timestamp}:{request_body} using your app's signing secret as the HMAC key.
Retry behavior
When your endpoint fails to respond with an HTTP 2xx within 3 seconds, Slack considers the delivery failed and retries:
| Retry | Timing | Header |
|---|---|---|
| 1st | Nearly immediately | x-slack-retry-num: 1 |
| 2nd | After ~1 minute | x-slack-retry-num: 2 |
| 3rd | After ~5 minutes | x-slack-retry-num: 3 |
The x-slack-retry-reason header indicates why the retry occurred: http_timeout, connection_failed, ssl_error, http_error, or too_many_redirects.
With the Delayed Events feature enabled, Slack follows the initial 3 retries with hourly retries for up to 24 hours.
Rate limits
Events API deliveries are capped at 30,000 events per workspace per app per 60 minutes. When this limit is exceeded, your endpoint receives app_rate_limited events instead of the actual events.
Failure limits and automatic disabling
If your endpoint responds with errors for more than 95% of delivery attempts within 60 minutes, Slack will temporarily disable your app's event subscriptions. You must maintain a success rate of at least 5% to avoid automatic disabling. Apps receiving fewer than 1,000 events per hour are exempt from automatic disabling.
Setting up Slack webhooks
Setting up incoming webhooks
Via the Slack App settings UI
- Create a Slack app at api.slack.com/apps (or use an existing app)
- Navigate to Incoming Webhooks in the left sidebar
- Toggle Activate Incoming Webhooks to on
- Click Add New Webhook to Workspace
- Select the channel the webhook should post to and click Authorize
- Copy the generated webhook URL from the Webhook URLs for Your Workspace section
Via OAuth (for distributed apps)
When distributing your app to other workspaces, generate webhook URLs programmatically during the OAuth flow:
- Include the
incoming-webhookscope in your OAuth authorization URL - After the user completes authorization, the OAuth response includes the webhook URL:
{
"ok": true,
"access_token": "xoxp-XXXXXXXX-XXXXXXXX-XXXXX",
"scope": "identify,bot,commands,incoming-webhook",
"incoming_webhook": {
"channel": "#alerts",
"channel_id": "C05002EAE",
"configuration_url": "https://workspace.slack.com/services/BXXXXX",
"url": "https://hooks.slack.com/TXXXXX/BXXXXX/XXXXXXXXXX"
}
}
Setting up the Events API
Configure your Request URL
- Navigate to Event Subscriptions in your Slack app settings
- Toggle Enable Events to on
- Enter your Request URL (must be a public HTTPS endpoint)
- Slack will send a
url_verificationchallenge — your endpoint must respond with the challenge value - Once verified, select your event subscriptions
Choose event subscriptions
Event subscriptions are split into two categories:
- Bot Events — Events your bot user can see (based on bot token scopes)
- Workspace Events — Events visible to users who install your app (requires corresponding OAuth scopes)
Common event subscriptions include message.channels, message.groups, message.im, reaction_added, file_shared, member_joined_channel, and app_mention.
Test with a request inspector
Before building your handler, use a request inspection tool like Hookdeck Console to capture and inspect real event payloads:
- Create a temporary URL using Hookdeck Console
- Set it as your Request URL in Slack
- Trigger events in your workspace
- Inspect the payload structure and headers
Best practices when working with Slack webhooks
Verifying signing secrets (Events API)
Always verify the HMAC-SHA256 signature on incoming events to ensure they genuinely originated from Slack.
Node.js
const crypto = require('crypto');
const express = require('express');
const app = express();
// Capture raw body for signature verification
app.use(express.json({
verify: (req, res, buf) => {
req.rawBody = buf.toString('utf8');
}
}));
function verifySlackSignature(req) {
const timestamp = req.headers['x-slack-request-timestamp'];
const slackSignature = req.headers['x-slack-signature'];
// Reject requests older than 5 minutes (prevent replay attacks)
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - parseInt(timestamp, 10)) > 300) {
return false;
}
const sigBaseString = `v0:${timestamp}:${req.rawBody}`;
const mySignature = 'v0=' + crypto
.createHmac('sha256', process.env.SLACK_SIGNING_SECRET)
.update(sigBaseString, 'utf8')
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(mySignature),
Buffer.from(slackSignature)
);
}
app.post('/webhooks/slack', (req, res) => {
if (!verifySlackSignature(req)) {
return res.status(401).send('Invalid signature');
}
// Handle URL verification challenge
if (req.body.type === 'url_verification') {
return res.json({ challenge: req.body.challenge });
}
// Acknowledge immediately
res.status(200).send('OK');
// Process event asynchronously
processEvent(req.body).catch(console.error);
});
Python
import hmac
import hashlib
import time
import os
from flask import Flask, request, abort, jsonify
app = Flask(__name__)
def verify_slack_signature(req):
timestamp = req.headers.get('X-Slack-Request-Timestamp', '')
slack_signature = req.headers.get('X-Slack-Signature', '')
# Reject requests older than 5 minutes
if abs(time.time() - int(timestamp)) > 300:
return False
sig_basestring = f"v0:{timestamp}:{req.get_data(as_text=True)}"
my_signature = 'v0=' + hmac.new(
os.environ['SLACK_SIGNING_SECRET'].encode('utf-8'),
sig_basestring.encode('utf-8'),
hashlib.sha256
).hexdigest()
return hmac.compare_digest(my_signature, slack_signature)
@app.route('/webhooks/slack', methods=['POST'])
def handle_slack_event():
if not verify_slack_signature(request):
abort(401)
payload = request.get_json()
# Handle URL verification challenge
if payload.get('type') == 'url_verification':
return jsonify({'challenge': payload['challenge']})
# Acknowledge immediately
# Process asynchronously via task queue
process_event_async.delay(payload)
return 'OK', 200
Respond within 3 seconds to avoid retries
Slack's 3-second timeout is the single most common source of problems with the Events API. Your endpoint must acknowledge receipt immediately and defer all processing to an asynchronous worker.
Use event_id for idempotent processing
Slack may deliver the same event multiple times due to retries. Use the globally unique event_id field to deduplicate:
async function processSlackEvent(payload) {
const { event_id, event } = payload;
// Check if already processed
const exists = await redis.get(`processed:${event_id}`);
if (exists) {
console.log(`Event ${event_id} already processed, skipping`);
return;
}
// Process the event
await handleEvent(event);
// Mark as processed with a 24-hour TTL
await redis.setex(`processed:${event_id}`, 86400, '1');
}
Manage incoming webhook URLs securely
Incoming webhook URLs contain embedded secrets. Treat them with the same care as API keys:
- Store webhook URLs in environment variables or a secrets manager — never commit them to source control
- Slack actively scans for and revokes leaked webhook URLs found in public repositories
- Rotate webhooks periodically by creating new ones and deactivating old ones through the app settings
- Use separate webhook URLs for development, staging, and production environments
Use chat.postMessage for dynamic channel routing
Since incoming webhooks are locked to a single channel, use chat.postMessage with a bot token when you need to route messages dynamically:
const { WebClient } = require('@slack/web-api');
const slack = new WebClient(process.env.SLACK_BOT_TOKEN);
async function postToChannel(channel, message) {
await slack.chat.postMessage({
channel: channel, // Can be any channel the bot is in
text: message.text,
blocks: message.blocks
});
}
This approach gives you full control over channel targeting, message updates, deletions, and threading — features that incoming webhooks don't support.
Slack webhook limitations and pain points
Strict 3-second response deadline (Events API)
The Problem: Slack requires your endpoint to respond with an HTTP 2xx within 3 seconds. If your server takes longer — whether due to processing logic, downstream API calls, or database queries — Slack marks the delivery as failed and retries, potentially causing duplicate event processing.
Why It Happens: Slack enforces this aggressive timeout to maintain system-wide delivery performance across millions of workspaces. The timeout is not configurable and applies to all Events API deliveries.
Workarounds:
- Immediately acknowledge the event with a 200 response and process asynchronously using a message queue (Hookdeck, SQS, RabbitMQ, Redis, etc.)
- Use Socket Mode instead of HTTP to avoid the timeout constraint entirely (though this requires a persistent WebSocket connection)
- If running serverless functions (e.g., AWS Lambda), be aware of cold start latency eating into your 3-second budget
How Hookdeck Can Help: Hookdeck receives events from Slack on your behalf and delivers them to your endpoint with configurable timeouts and automatic retries, giving your handler more time to process events without triggering Slack's retry behavior.
Incoming webhooks locked to a single channel
The Problem: Each incoming webhook URL is permanently bound to the channel selected at creation time. You cannot override the destination channel, username, or icon in the payload. If you need to post to 10 channels, you need 10 separate webhook URLs.
Why It Happens: When Slack moved from legacy custom integrations to app-based webhooks, they removed the ability to override channel and identity fields at runtime. The channel binding is set during the OAuth authorization flow or manual setup.
Workarounds:
- Use
chat.postMessagewith a bot token instead of incoming webhooks for dynamic channel routing - Generate webhook URLs programmatically via the OAuth flow for each channel you need
- Maintain a mapping of channel names to webhook URLs in your configuration
How Hookdeck Can Help: Hookdeck's transformation and routing capabilities can route a single inbound request to multiple Slack webhook URLs based on payload content, reducing the complexity of managing many webhook endpoints.
No delivery logs or monitoring
The Problem: Slack provides no dashboard or API for inspecting webhook delivery history. For incoming webhooks, you have zero visibility into whether your messages were delivered. For the Events API, you can see whether your app's subscriptions are active, but you can't browse a log of individual event deliveries, failures, or response codes.
Why It Happens: Slack's webhook infrastructure is designed for simplicity. Incoming webhooks are fire-and-forget by design, and the Events API prioritizes delivery speed over observability.
Workarounds:
- Implement comprehensive logging on your own endpoint, capturing all incoming events with timestamps, event IDs, and processing status
- Build health-check alerts that monitor your webhook endpoint's availability
- Use structured logging to track the full lifecycle of each event from receipt through processing
How Hookdeck Can Help: Hookdeck's dashboard provides complete visibility into every webhook delivery, including request/response payloads, HTTP status codes, latency metrics, and delivery history. You can search, filter, and inspect events without building custom logging infrastructure.
No built-in dead letter queue
The Problem: When Events API deliveries fail after all retries are exhausted, the events are simply lost. There's no built-in mechanism to inspect, recover, or replay failed event deliveries. For incoming webhooks, if a POST fails, your application must handle its own retry logic.
Why It Happens: Slack's Events API retry system (3 retries, or 24 hours with Delayed Events enabled) is the only safety net. Once retries are exhausted, events are discarded.
Workarounds:
- Enable Delayed Events in your app settings to extend the retry window to 24 hours with hourly retries
- Implement your own dead letter queue by persisting every received event to a durable store before processing
- Build reconciliation logic that compares expected events against received events to identify gaps
How Hookdeck Can Help: Hookdeck automatically preserves all failed deliveries in a dead letter queue, allowing you to inspect, debug, and replay them once your endpoint issues are resolved. This ensures no events are lost during outages.
Rate limits can silently drop events
The Problem: The Events API caps delivery at 30,000 events per workspace per app per 60 minutes. When exceeded, Slack stops delivering actual events and instead sends app_rate_limited notifications. For incoming webhooks, the limit of approximately 1 message per second per channel can cause HTTP 429 errors during bursts, and continued violations risk your app being permanently disabled.
Why It Happens: Slack enforces rate limits to protect platform stability. The Events API limit is per-workspace, so high-activity workspaces with many users can exceed the threshold during peak periods. Incoming webhook rate limits are per-channel to prevent message flooding.
Workarounds:
- For incoming webhooks, implement client-side rate limiting with exponential backoff and respect the
Retry-Afterheader on 429 responses - For the Events API, monitor for
app_rate_limitedevents and alert on them immediately - Batch or aggregate messages before sending to reduce the volume of incoming webhook calls
- Consider whether all events in your subscription are necessary — unsubscribe from event types you don't need
How Hookdeck Can Help: Hookdeck can queue and throttle webhook deliveries to your endpoint, smoothing out traffic spikes and ensuring events are delivered at a rate your infrastructure can handle. Rate-limited events are queued and retried automatically rather than being dropped.
No HMAC verification for incoming webhooks
The Problem: Unlike Events API webhooks, Incoming webhooks have no request signing mechanism. Security depends entirely on the secrecy of the webhook URL. If the URL is leaked — through logs, source control, or error messages — anyone can post messages to your Slack channel, potentially for phishing or social engineering attacks.
Why It Happens: Incoming webhooks are designed for simplicity. The URL itself serves as both the endpoint and the authentication credential. Slack actively scans for leaked URLs in public repositories and revokes them, but this is reactive rather than preventive.
Workarounds:
- Treat webhook URLs as secrets — store them in environment variables or a secrets manager
- Never log full webhook URLs; mask the token portion in logs and error messages
- Use IP allowlisting on your network if posting from known infrastructure
- Rotate webhook URLs periodically and after any suspected exposure
- Monitor your Slack channels for unexpected messages that could indicate a compromised URL
How Hookdeck Can Help: Hookdeck can act as a secure proxy for your incoming webhook workflow, providing an additional authentication layer and request validation before forwarding messages to Slack.
Automatic disabling with limited alerting
The Problem: If your Events API endpoint fails to respond successfully to more than 95% of deliveries within a 60-minute window, Slack automatically disables your event subscriptions. The only notification is an email to the app owner — there's no webhook, API callback, or dashboard alert. Re-enabling requires manual intervention through the app settings.
Why It Happens: Slack protects its delivery infrastructure by stopping deliveries to consistently failing endpoints. The 5% success threshold is low, meaning even brief outages during high-traffic periods can trigger disabling.
Workarounds:
- Ensure your endpoint has high availability with redundancy and health monitoring
- Implement circuit breakers that return 200 OK immediately even when downstream processing fails, queuing events for later processing
- Set up external monitoring that pings your webhook endpoint independently
- If the endpoint must return non-200 responses for specific events, use the
x-slack-no-retry: 1header to prevent those from counting against your failure rate
How Hookdeck Can Help: Hookdeck absorbs event deliveries from Slack with high availability, then forwards them to your endpoint with configurable retry policies. Since Hookdeck always acknowledges Slack's deliveries promptly, your app's subscriptions won't be disabled due to your endpoint's temporary issues.
Testing Slack webhooks
Testing incoming webhooks
Use cURL or any HTTP client to send a test message:
curl -X POST https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX \
-H "Content-Type: application/json" \
-d '{"text": "Test message from webhook"}'
A successful delivery returns an HTTP 200 with the body ok.
Testing the Events API
Use a request inspector:
Before writing your handler, capture and inspect real event payloads using a tool like Hookdeck Console:
- Create a temporary inspection URL
- Set it as your Request URL in Slack's Event Subscriptions settings
- Trigger events in your workspace (post messages, add reactions, upload files)
- Inspect the full payload structure, headers, and timing
Validate in staging:
Test your webhook integration with realistic scenarios:
- Single events (a message posted, a reaction added)
- High-frequency events (many users posting in a busy channel)
- Edge cases (messages with special characters, large file uploads, events from shared channels)
- Failure scenarios (what happens when your handler throws an error or times out?)
- Verify signature validation works correctly by testing with both valid and invalid signatures
Use Socket Mode for local development
During development, Socket Mode lets you receive events over a WebSocket connection without exposing a public URL. This eliminates the need for tunneling tools like ngrok or Hookdeck CLI during early development, though you should test against HTTP mode before deploying to production.
Conclusion
Slack's webhook ecosystem provides two distinct integration paths: incoming webhooks for pushing messages into channels with minimal setup, and the Events API for receiving real-time workspace events with security and reliability features.
Incoming webhooks are simple to configure but come with significant constraints — fixed channel targeting, no delivery verification, and no message lifecycle control. The Events API is more capable but demands careful engineering: you must respond within 3 seconds, verify HMAC signatures, handle retries idempotently, and build your own observability layer.
For lightweight integrations — posting alerts, notifications, or status updates to a known channel — incoming webhooks combined with proper URL management work well. For production event processing at scale — where delivery guarantees, observability, and flexible routing matter — webhook infrastructure like Hookdeck can address Slack's limitations, providing configurable timeouts, automatic dead letter queuing, payload transformation, and comprehensive delivery monitoring without modifying your Slack app configuration.