Gareth Wilson Gareth Wilson

Guide to Slack Webhooks: Features and Best Practices

Published


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

FeatureIncoming WebhooksEvents API
DirectionInbound (push messages to Slack)Outbound (Slack pushes events to you)
ConfigurationSlack App settings UI or OAuth flowSlack App settings UI + Request URL
Hashing algorithmNone (URL secrecy only)HMAC-SHA256 signing secret
TimeoutStandard HTTP response3-second response deadline
Retry logicNone (fire-and-forget from your side)Up to 3 retries with exponential backoff
Delayed retriesN/AOptional hourly retries for 24 hours
Rate limits~1 msg/sec/channel30,000 events/workspace/app/hour
Manual retryN/A (you control sends)Not available
Delivery logsNot availableNot available
Payload formatJSON with Block Kit supportJSON event envelope
Channel targetingFixed per webhook URLEvent-driven (based on subscriptions)
AuthenticationURL contains secret tokenSigning 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.history to find the message by timestamp
  • Use search.messages to 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.postMessage if you need full message lifecycle control.
  • No file uploads — You cannot send files via incoming webhooks. Use the files.upload API method instead.

Error handling

Incoming webhooks return descriptive HTTP error codes and error strings:

ErrorDescription
channel_is_archivedThe target channel has been archived
action_prohibitedAn admin restriction is preventing the post
invalid_payloadMalformed JSON or improperly escaped text
no_textThe text field is missing from the payload
too_many_attachmentsMore than 100 attachments on a single message
channel_not_foundThe webhook's associated channel no longer exists
no_serviceThe incoming webhook is disabled, removed, or invalid
posting_to_general_channel_deniedRestricted 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

FieldDescription
typeCallback type: event_callback for events, url_verification during setup
tokenShared callback token for basic verification (deprecated in favor of signing secrets)
team_idWorkspace where the event occurred
api_app_idYour application's unique identifier
eventThe inner event object with type-specific fields
event_idGlobally unique identifier for this specific event
event_timeEpoch timestamp of when the event was dispatched
authorizationsInstallation 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 signature
  • X-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:

RetryTimingHeader
1stNearly immediatelyx-slack-retry-num: 1
2ndAfter ~1 minutex-slack-retry-num: 2
3rdAfter ~5 minutesx-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

  1. Create a Slack app at api.slack.com/apps (or use an existing app)
  2. Navigate to Incoming Webhooks in the left sidebar
  3. Toggle Activate Incoming Webhooks to on
  4. Click Add New Webhook to Workspace
  5. Select the channel the webhook should post to and click Authorize
  6. 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:

  1. Include the incoming-webhook scope in your OAuth authorization URL
  2. 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

  1. Navigate to Event Subscriptions in your Slack app settings
  2. Toggle Enable Events to on
  3. Enter your Request URL (must be a public HTTPS endpoint)
  4. Slack will send a url_verification challenge — your endpoint must respond with the challenge value
  5. 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:

  1. Create a temporary URL using Hookdeck Console
  2. Set it as your Request URL in Slack
  3. Trigger events in your workspace
  4. 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.postMessage with 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-After header on 429 responses
  • For the Events API, monitor for app_rate_limited events 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: 1 header 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:

  1. Create a temporary inspection URL
  2. Set it as your Request URL in Slack's Event Subscriptions settings
  3. Trigger events in your workspace (post messages, add reactions, upload files)
  4. 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.


Gareth Wilson

Gareth Wilson

Product Marketing

Multi-time founding marketer, Gareth is PMM at Hookdeck and author of the newsletter, Community Inc.