Slack Notification Bots: Webhooks vs. chat.postMessage and the 429 Retry-After Contract
A practical guide to choosing between Slack Incoming Webhooks and chat.postMessage for notification bots, covering rate-limit tiers, the HTTP 429 Retry-After contract, and a batched retry example.
21 May 2026, 00:44 UTC

The burst that breaks your pipeline
Your CI system finishes five deploys in ninety seconds. Each one posts to Slack. By the fourth message, the API starts returning HTTP 429 Too Many Requests. Do you reach for an Incoming Webhook or the Web API's chat.postMessage method? The answer is rarely about raw speed—both are rate-limited. It is about how much flexibility your bot needs and how gracefully it handles throttling.
Webhooks and chat.postMessage solve different problems
An Incoming Webhook is a pre-registered URL that posts a JSON payload to a channel chosen when the webhook is created. There is no token rotation, no method-call plumbing, and no scope negotiation. For a deploy bot that always announces to #engineering-deploys, a webhook is the lowest-ceremony path.
The Web API method chat.postMessage is different. Your app authenticates with a bot token carrying the chat:write scope (and chat:write.public if it must post to public channels without being invited). The channel is a parameter in the request body, so the same code can target #team-a, #team-b, or a dynamically discovered channel. It also returns a message timestamp (ts) that you can reuse for updates, reactions, or threaded replies, and it accepts Block Kit payloads for structured layouts.
| Capability | Incoming Webhook | chat.postMessage |
|---|---|---|
| Authentication | Secret URL only | Bot token + OAuth scopes |
| Target channel | Fixed at creation | Dynamic per request |
| Threading / updates | Not supported | Supported via thread_ts and returned ts |
| Rich formatting | Basic attachments / blocks | Full Block Kit |
| Rate-limit tier | Per-webhook limits apply | Per-app, per-method tier |
Rate limits, 429s, and the Retry-After contract
Both paths are rate-limited, so the choice between them is about flexibility, not throughput. Slack organizes Web API methods into tiers—broadly, Tier 1 allows at least one request per minute, Tier 2 at least twenty, Tier 3 at least fifty, and Tier 4 at least one hundred. These are documented minimums, and Slack can adjust them, so treat the numbers as planning baselines rather than guarantees. chat.postMessage carries an additional operational guideline of roughly one message per channel per second, tolerating short bursts.
When you exceed a limit, Slack returns HTTP 429 with a Retry-After header telling you how many seconds to wait. The reliable recovery pattern is to sleep for exactly that interval and retry. Fixed or exponential backoff applied blindly can make throttling worse by ignoring the server's explicit instruction.
Worked example: a deploy notifier that batches and retries
Consider a composite deploy notifier that collapses a burst of builds into a single summary message. The snippet below uses chat.postMessage because the target channel varies by project. Run it from your CI runner or an application server with the SLACK_BOT_TOKEN environment variable set; the app must be installed in the workspace with the chat:write scope.
import os
import time
import requests
API_URL = "https://slack.com/api/chat.postMessage"
TOKEN = os.environ["SLACK_BOT_TOKEN"]
def post_deploy_summary(channel, events):
text = f"Deploy summary: {len(events)} builds completed."
payload = {"channel": channel, "text": text}
headers = {"Authorization": f"Bearer {TOKEN}"}
for attempt in range(3):
resp = requests.post(API_URL, json=payload, headers=headers)
if resp.status_code == 429:
retry_after = int(resp.headers.get("Retry-After", "1"))
time.sleep(retry_after)
continue
resp.raise_for_status()
data = resp.json()
if not data.get("ok"):
raise RuntimeError(f"Slack API error: {data.get('error')}")
return data["ts"]
raise RuntimeError("Exceeded retry attempts after 429 responses")
Expected checks: a successful call returns HTTP 200 with a JSON body containing "ok": true and a ts field. The main risk is leaking the bot token into CI logs; mask the environment variable in your pipeline settings. Official SDKs such as Python's slack-sdk and Node's @slack/web-api include built-in retry handling for 429 responses, so hand-rolled backoff is mainly educational unless you need custom batching logic.
The batching trade-off
Batching reduces API calls and lowers your exposure to 429s, but it sacrifices per-event immediacy. A stakeholder who wants to know the instant a single deploy finishes will not be satisfied by a five-minute digest. Threading keeps channels tidy, yet every threaded reply still counts against the same per-method limit, so a fifty-message thread is fifty calls regardless of how visually compact it appears.
A practical decision path
- Fixed channel, one-way, no replies needed: use an Incoming Webhook. Keep the payload simple and stay within the channel configured at creation time.
- Dynamic channels, threads, updates, or Block Kit: use
chat.postMessagewith a bot token and thechat:writescope. - High volume: batch where possible, but verify the current tier assignment for your method in Slack's rate-limit documentation before you size your pipeline.
- Always: parse
Retry-Afteron 429 and retry after that interval rather than guessing.
Before you ship, post a test message from a scratch workspace and confirm the response shape. Rate-limit tiers and scope names evolve; a quick check against current Slack API documentation turns a guess into a plan.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.