Automate Slack project updates by receiving external webhooks (GitHub, Jira, custom), formatting data as Block Kit messages, and posting via chat.postMessage to project channels. Use thread_ts to organize follow-ups. chat.scheduleMessage for timed updates, and chat.update to edit sent messages. Rate limit: 1 message per second per channel. Block Kit payload limit: 50 blocks, approximately 40,000 characters — msg_blocks_too_long fires near 13,000 characters in practice.
API Quick Reference
Bot Token (xoxb-)
1 message/second/channel
JSON
Available
Understanding the Slack API
Slack project update automation follows a standard pattern: an external system fires an event (GitHub push, Jira status change, CI/CD deploy), your server receives it, formats it as a Block Kit message, and posts it to the relevant Slack channel. The Web API's chat.postMessage method handles the posting, with optional thread_ts to organize updates under a parent message.
Block Kit is Slack's message layout framework — up to 50 blocks per message, 3,000 characters per section text block, and a total payload of approximately 40,000 characters. In practice, the msg_blocks_too_long error can fire near 13,000 characters due to a known framework issue — keep your messages concise. Use chat.update to modify a sent message (e.g., changing a 'build in progress' message to 'build complete') and chat.scheduleMessage to pre-schedule status updates.
For real-time project updates, the flow is: webhook triggers your endpoint → you format and post → team members see the update without leaving Slack. Official docs: https://docs.slack.dev/messaging/sending-messages
https://slack.com/apiSetting Up Slack API Authentication
Bot tokens (xoxb-) are workspace-scoped and long-lived. For project updates, the bot needs chat:write to post messages, and optionally chat:update if you'll edit messages. Invite the bot to each project channel. Token rotation is opt-in — without it, tokens don't expire.
- 1Go to https://api.slack.com/apps and click Create New App > From scratch.
- 2Click OAuth & Permissions. Add Bot Token Scopes: chat:write, chat:write.public (optional, allows posting to public channels without joining).
- 3Click Install to Workspace and authorize.
- 4Copy the Bot User OAuth Token (xoxb-).
- 5Invite the bot to your project channels: /invite @YourBotName.
- 6Optionally set up Event Subscriptions (incoming webhooks from GitHub/Jira) or a Route Handler endpoint to receive external webhooks.
- 7Store SLACK_BOT_TOKEN in environment variables.
- 8Store channel IDs as environment variables (not names) — channel IDs never change, names can.
1import os2import requests34BOT_TOKEN = os.environ["SLACK_BOT_TOKEN"]5HEADERS = {"Authorization": f"Bearer {BOT_TOKEN}"}67# Verify token8resp = requests.post("https://slack.com/api/auth.test", headers=HEADERS)9data = resp.json()10if data.get("ok"):11 print(f"Authenticated as bot in: {data['team']}")Security notes
- •Store SLACK_BOT_TOKEN in environment variables — never hardcode.
- •Validate incoming webhook signatures from GitHub, Jira, and other sources before processing.
- •Request only chat:write — avoid admin scopes for a project update bot.
- •Use channel IDs (C...), not channel names (#...) — names can change silently.
- •Store webhook secrets from GitHub/Jira in environment variables and verify HMAC signatures on every incoming request.
Key endpoints
/chat.postMessageSends a message to a channel. Supports Block Kit, threading via thread_ts, and optional reply_broadcast to also show thread replies in the main channel. Returns ts for future updates.
| Parameter | Type | Required | Description |
|---|---|---|---|
channel | string | required | Channel ID |
text | string | optional | Fallback plain text — always include even with blocks |
blocks | array | optional | Block Kit payload — max 50 blocks, ~40,000 chars total |
thread_ts | string | optional | Timestamp of parent message — posts as thread reply |
reply_broadcast | boolean | optional | Set to true to also show thread reply in main channel |
Request
1{"channel":"C01234567","text":"Build succeeded: v2.1.4","blocks":[{"type":"section","text":{"type":"mrkdwn","text":"*Build succeeded* :white_check_mark: `main` branch\n>Version: v2.1.4\n>Triggered by: @john"}},{"type":"context","elements":[{"type":"mrkdwn","text":"Duration: 3m 42s | <https://ci.example.com/build/123|View build>"}]}]}Response
1{"ok":true,"channel":"C01234567","ts":"1746612000.000100","message":{"type":"message","text":"Build succeeded: v2.1.4"}}/chat.updateUpdates an existing message. Requires the channel ID and ts from the original chat.postMessage response. Use to change a status message (e.g., 'deploying...' to 'deployed').
| Parameter | Type | Required | Description |
|---|---|---|---|
channel | string | required | Channel ID of the original message |
ts | string | required | Timestamp of the message to update — from chat.postMessage response |
blocks | array | optional | New Block Kit blocks to replace the original |
Request
1{"channel":"C01234567","ts":"1746612000.000100","text":"Deploy complete","blocks":[{"type":"section","text":{"type":"mrkdwn","text":"*Deploy complete* :white_check_mark: v2.1.4 to production"}}]}Response
1{"ok":true,"channel":"C01234567","ts":"1746612000.000100","text":"Deploy complete"}/chat.scheduleMessageSchedules a message to be posted at a future Unix timestamp. Cannot be edited after creation — must delete (chat.deleteScheduledMessage) and recreate to change. Use for scheduled daily standup reminders or end-of-sprint summaries.
| Parameter | Type | Required | Description |
|---|---|---|---|
channel | string | required | Channel ID |
post_at | number | required | Unix timestamp (future) when the message should be sent |
Request
1{"channel":"C01234567","text":"Sprint 12 ends today — retrospective at 4pm!","post_at":1746957600}Response
1{"ok":true,"scheduled_message_id":"Q01234567890","post_at":1746957600,"channel":"C01234567"}Step-by-step automation
Receive and Validate an Incoming Webhook
Why: External tools (GitHub, Jira, CI/CD) send webhooks to trigger project updates — validating their signatures prevents spoofed notifications.
Set up an HTTPS endpoint on your server to receive webhooks. For GitHub, verify the X-Hub-Signature-256 header using your webhook secret. For Jira, verify using the shared secret. For generic integrations, use a shared secret in the URL or header. After validation, parse the JSON payload and extract the fields you'll include in the Slack message.
1# Test your webhook endpoint with a GitHub-style payload2curl -X POST \3 -H "Content-Type: application/json" \4 -H "X-GitHub-Event: push" \5 -H "X-Hub-Signature-256: sha256=$(echo -n '{"ref":"refs/heads/main"}' | openssl dgst -sha256 -hmac 'your_secret' | cut -d' ' -f2)" \6 -d '{"ref":"refs/heads/main","head_commit":{"message":"Fix: login bug","author":{"name":"John"}},"repository":{"name":"myapp"}}' \7 "https://yourapp.com/webhook/github"Pro tip: Always use raw (un-parsed) request body bytes for HMAC verification — parsing JSON first (which re-serializes the payload) changes whitespace and breaks signature validation.
Expected result: The webhook endpoint validates incoming requests, extracts event data, and dispatches to the Slack notification function. Invalid signatures return 403.
Format the Update as a Block Kit Message
Why: Block Kit messages are significantly more readable and actionable than plain text — team members can see the key info at a glance without opening an external tool.
Build a Block Kit array from your webhook data. Use section blocks for main content, a divider, and context blocks for metadata. Keep each section's text under 3,000 characters. Include an action link to the relevant external resource (GitHub commit, Jira ticket, build log). Always include a text fallback for push notifications.
1curl -X POST \2 -H "Authorization: Bearer $SLACK_BOT_TOKEN" \3 -H "Content-Type: application/json" \4 -d '{5 "channel": "C01234567",6 "text": "Push to main: Fix login bug",7 "blocks": [8 {"type":"section","text":{"type":"mrkdwn","text":"*Push to `main`* :arrow_up_small:\n>Fix login bug\n>by @john — <https://github.com/org/repo/commit/abc123|View commit>"}},9 {"type":"context","elements":[{"type":"mrkdwn","text":"myapp | May 7, 2026 10:15 AM"}]}10 ]11 }' \12 "https://slack.com/api/chat.postMessage"Pro tip: Truncate commit messages to 100 characters — long commit messages can push your block payload toward the size limit and make the notification hard to read in Slack's sidebar.
Expected result: A Block Kit array ready to pass to chat.postMessage. The rendered message shows key data (branch, commit, author, link) in a clean two-line format.
Post the Update and Save the Message Timestamp
Why: The ts (timestamp) from chat.postMessage enables threading follow-up updates and editing the original message — save it whenever you want future updates to be organized.
POST to chat.postMessage and save the ts and channel from the response. For multi-step projects (build started → tests running → deploy complete), post the first message and update it with chat.update as statuses change. For update sequences you want to keep organized, post follow-ups as thread replies using thread_ts.
1# Post and capture the ts2curl -s -X POST \3 -H "Authorization: Bearer $SLACK_BOT_TOKEN" \4 -H "Content-Type: application/json" \5 -d '{"channel":"C01234567","text":"Build started","blocks":[{"type":"section","text":{"type":"mrkdwn","text":"*Build started* :hourglass_flowing_sand: v2.1.4"}}]}' \6 "https://slack.com/api/chat.postMessage" \7 | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('ts',''))"Pro tip: Store (channel, ts) in a database keyed by your internal job/build ID. This lets you look up the Slack message to update when the job completes — even if the complete event arrives minutes later via a separate webhook.
Expected result: The message is posted and the ts is captured. The message can later be updated with chat.update or threaded with thread_ts.
Schedule Future Status Updates with chat.scheduleMessage
Why: For predictable milestones (end-of-sprint, weekly standup reminders), scheduling messages ensures they arrive at the right time without keeping a process running.
Call chat.scheduleMessage with a channel, text/blocks, and post_at (Unix timestamp). The message is queued and delivered by Slack at the scheduled time — your server doesn't need to be running at delivery time. Important: scheduled messages cannot be edited after creation. To change a scheduled message, call chat.deleteScheduledMessage and then re-create it.
1# Schedule a message for tomorrow 9am UTC2POST_AT=$(date -d 'tomorrow 09:00:00' +%s)3curl -X POST \4 -H "Authorization: Bearer $SLACK_BOT_TOKEN" \5 -H "Content-Type: application/json" \6 -d "{\"channel\":\"C01234567\",\"text\":\"Sprint 12 ends today — retro at 4pm!\",\"post_at\":$POST_AT}" \7 "https://slack.com/api/chat.scheduleMessage"Pro tip: Store scheduled_message_id in a database with your sprint/project records. If the sprint end date changes, delete the old scheduled message and create a new one with the updated time.
Expected result: A scheduled_message_id is returned. The message will be delivered at the specified time. The ID can be used to delete the scheduled message if plans change.
Complete working code
A complete GitHub webhook handler that receives push and deploy events, formats Block Kit messages, posts to the project Slack channel with live status updates (posting initial message then updating it when the build completes), and includes rate-limit-aware retry logic.
1import os, time, hmac, hashlib, logging, requests2from flask import Flask, request, jsonify34logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")56BOT_TOKEN = os.environ["SLACK_BOT_TOKEN"]7GITHUB_SECRET = os.environ["GITHUB_WEBHOOK_SECRET"]8PROJECT_CHANNEL = os.environ["SLACK_PROJECT_CHANNEL_ID"]9H = {"Authorization": f"Bearer {BOT_TOKEN}"}1011# Store active build messages: {build_id: {channel, ts}}12build_messages = {}1314flask_app = Flask(__name__)1516def post_slack(channel, text, blocks=None, thread_ts=None):17 for _ in range(5):18 payload = {"channel": channel, "text": text}19 if blocks: payload["blocks"] = blocks20 if thread_ts: payload["thread_ts"] = thread_ts21 resp = requests.post("https://slack.com/api/chat.postMessage", headers=H, json=payload)22 if resp.status_code == 429:23 time.sleep(int(resp.headers.get("Retry-After", 2)) + 0.5)24 continue25 data = resp.json()26 if not data.get("ok"): raise ValueError(data.get("error"))27 return data28 raise RuntimeError("Max retries")2930def update_slack(channel, ts, text, blocks=None):31 payload = {"channel": channel, "ts": ts, "text": text}32 if blocks: payload["blocks"] = blocks33 resp = requests.post("https://slack.com/api/chat.update", headers=H, json=payload)34 return resp.json()3536@flask_app.route("/webhook/github", methods=["POST"])37def github_webhook():38 sig = request.headers.get("X-Hub-Signature-256", "")39 expected = "sha256=" + hmac.new(GITHUB_SECRET.encode(),40 request.data, hashlib.sha256).hexdigest()41 if not hmac.compare_digest(expected, sig):42 return jsonify({"error": "bad signature"}), 4034344 event = request.headers.get("X-GitHub-Event", "")45 data = request.json4647 if event == "push" and data.get("ref", "").startswith("refs/heads/main"):48 branch = data["ref"].replace("refs/heads/", "")49 commit = data["head_commit"]50 blocks = [{51 "type": "section",52 "text": {"type": "mrkdwn", "text": (53 f"*Push to `{branch}`* :arrow_up_small:\n"54 f">*{commit['message'][:80]}*\n"55 f">by {commit['author']['name']} | "56 f"<{commit['url']}|View commit>"57 )}58 }]59 result = post_slack(PROJECT_CHANNEL, f"Push to {branch}: {commit['message'][:50]}",60 blocks=blocks)61 logging.info(f"Posted push notification: ts={result['ts']}")6263 return jsonify({"ok": True})6465if __name__ == "__main__":66 flask_app.run(port=3000)67Error handling
HTTP 429, body: {"ok":false,"error":"ratelimited"}More than 1 message per second was sent to the same channel (typical during high-traffic deploy events) or the workspace-level method rate limit was hit.
Read the Retry-After header and sleep for that duration. Add 1.1-second delays between sequential project update messages to the same channel.
Sleep for Retry-After + 0.5 seconds, then retry once.
{"ok":false,"error":"msg_blocks_too_long"}The Block Kit payload exceeds the practical size limit (~13,000 characters) even with fewer than 50 blocks. This is a known Bolt framework issue (#2509).
Truncate long text fields (commit messages, PR descriptions, error logs) to under 200 characters each. If you need full details, link to the external source rather than embedding the text.
Do not retry — reduce payload size.
{"ok":false,"error":"message_not_found"}chat.update was called with a ts that doesn't exist in the specified channel — the message may have been deleted or the ts is from a different channel.
Verify you're using the ts and channel from the original chat.postMessage response. A message's ts is unique per channel — the same ts in a different channel returns not_found.
Do not retry — post a new message if the original is missing.
{"ok":false,"error":"cant_update_message"}Only the bot that originally posted the message can update it. If a different app or user posted the message, chat.update will fail.
Ensure your update call uses the same bot token that posted the original message. Store message ownership in your database.
Do not retry — post a follow-up thread reply instead.
Rate Limits for Slack API
| Scope | Limit | Window |
|---|---|---|
| chat.postMessage (per channel) | 1 message | per second |
| chat.update | Tier 3 (~50+/min) | per workspace per minute |
| chat.scheduleMessage | Tier 3 (~50+/min) | per workspace per minute |
1import time23def post_with_retry(url, headers, payload, max_retries=5):4 import requests5 for i in range(max_retries):6 resp = requests.post(url, headers=headers, json=payload)7 if resp.status_code == 429:8 wait = int(resp.headers.get('Retry-After', 2)) + 0.59 time.sleep(wait)10 continue11 data = resp.json()12 if not data.get('ok'):13 if data.get('error') == 'ratelimited':14 time.sleep(2)15 continue16 raise ValueError(data.get('error'))17 return data18 raise RuntimeError('Max retries exceeded')- Post one message per channel per second maximum — queue rapid-fire events and process them sequentially.
- Use chat.update to edit an existing message instead of posting new messages for status transitions — reduces channel noise and respects rate limits.
- For multi-step build pipelines, post one message per build and update it with the latest status rather than posting a new message for each step.
- Store message (channel, ts) in a database keyed by your build/job ID so you can update it when the job completes via a separate webhook.
- Use thread_ts to post detailed logs as thread replies — keeps the main channel clean while preserving drill-down details.
Security checklist
- Store SLACK_BOT_TOKEN, GITHUB_WEBHOOK_SECRET, and other secrets in environment variables.
- Always verify incoming webhook signatures (HMAC-SHA256 for GitHub, shared secrets for others) before processing events.
- Use express.raw() (Node.js) or request.data (Flask) to get the raw body for signature verification — parsing JSON first breaks HMAC.
- Limit chat.postMessage calls to authenticated and validated webhook payloads only.
- Sanitize external data before including in Slack messages — prevent injection of @channel or @here mentions.
- Store only essential data from webhook payloads — do not log full commit diffs or PR descriptions to your application logs.
Automation use cases
Live Build Status Board
intermediatePost a 'build started' message and update it in place as the pipeline progresses through build, test, and deploy stages.
Jira Status Changes
intermediateReceive Jira webhooks when ticket status changes (In Progress, In Review, Done) and post formatted updates to the relevant project channel.
PR Review Reminders
intermediateSchedule reminder messages for open pull requests that haven't been reviewed after 24 hours, mentioning the assigned reviewers.
Sprint Summary
advancedAt sprint end, aggregate all Jira tickets completed that sprint and post a summary with stories completed, velocity, and blockers.
No-code alternatives
Don't want to write code? These platforms can automate the same workflows visually.
Zapier
Free tier availableZapier has native GitHub and Jira integrations that can post formatted messages to Slack channels when events trigger, without writing webhook handlers.
- + Native GitHub + Jira triggers
- + No webhook handler code needed
- + Easy setup
- - Limited Block Kit customization
- - No message update (chat.update) support
- - Expensive at high event volume
Make (formerly Integromat)
Free tier available (1,000 operations/month)Make's visual builder supports complex webhook flows with conditional logic, data transformation, and Slack posting with Block Kit control.
- + More affordable than Zapier
- + Full Block Kit JSON control
- + Conditional routing
- - Complex scenario setup for multi-step status updates
- - No native chat.update support
- - Execution limits
n8n
Free self-hosted; Cloud from €20/monthn8n's Slack node, GitHub node, and code node combination provides full project update automation including message updates and complex formatting.
- + Self-hostable
- + Built-in GitHub and Slack nodes
- + Code node for complex logic
- - Requires self-hosting for production
- - More initial setup
- - chat.update requires HTTP node
Best practices
- Save the (channel, ts) from every chat.postMessage response in your database keyed by job/build/ticket ID — enables status updates and threading.
- Use chat.update for in-place status changes rather than new messages — a single live-updating message is cleaner than a stream of status messages.
- Keep Block Kit text fields under 200 characters for webhook data — truncate and link to source for full details.
- Always include a plain text fallback alongside blocks — used in push notifications and accessibility.
- For high-frequency events (e.g., every commit), consider batching updates every 30 seconds rather than posting for each event.
- Validate and sanitize all external data before including in Block Kit text — long commit messages, unusual characters, and mrkdwn-like text can break formatting.
- Schedule batch reports (daily, weekly) during off-peak hours — reduces noise and respects rate limits.
Ask AI to help
Copy one of these prompts to get a personalized, working implementation.
I'm building a Slack project update bot that receives GitHub webhooks and posts formatted notifications. Help me: 1) verify the X-Hub-Signature-256 header in Python/Flask using HMAC-SHA256, 2) build a Block Kit message for push events with branch, commit message (truncated to 100 chars), author, and link, 3) post via chat.postMessage and save the ts and channel, 4) when a build completion webhook arrives later, use chat.update with the saved ts to update the message in place, and 5) handle 429 rate limits with Retry-After. Use Python with Flask and requests library.
Build a Slack project update dashboard. Features: webhook URL management (GitHub, Jira, custom) with test-send buttons; Block Kit message template editor for each webhook source with live preview; a message log showing all sent project updates with channel, timestamp, and content preview; edit-in-place UI that calls chat.update on existing messages; a scheduled messages manager showing pending chat.scheduleMessage entries with delete/reschedule options; and analytics showing update frequency by project and day.
Frequently asked questions
Is the Slack API free for project update bots?
Yes. The Slack Web API (chat.postMessage, chat.update, chat.scheduleMessage) is free with no per-message pricing. Rate limits apply regardless of plan.
Why do I get msg_blocks_too_long even with fewer than 50 blocks?
There is a known issue (Bolt #2509) where msg_blocks_too_long fires near 13,000 characters total payload size, even with a small block count. The documented limit is 50 blocks / ~40,000 characters, but in practice the error appears earlier. Truncate long text fields to under 200 characters each and link to external sources for full details.
Can I edit a scheduled message after creating it?
No. chat.scheduleMessage cannot be edited after creation. To change it, call chat.deleteScheduledMessage with the scheduled_message_id, then create a new scheduled message with the updated content or time.
How do I update a message that was posted by my bot?
Call chat.update with the channel ID and ts from the original chat.postMessage response. Only the bot that posted the original message can update it — the bot token must match. Store (channel, ts) in your database when posting to enable future updates.
What happens if my webhook endpoint is down when GitHub fires an event?
GitHub retries webhook deliveries on failure with exponential backoff — three times over 18 hours. Slack's Events API (for Slack-originated events) retries with exponential backoff for up to 24 hours. Implement idempotency in your webhook handler: track event IDs (GitHub delivery IDs) and skip re-processing if already handled.
Can I post to multiple channels from one webhook event?
Yes. In your webhook handler, call chat.postMessage once for each target channel. Add a 1.1-second delay between calls if sending to the same channel repeatedly, but different channels can receive simultaneous posts.
Can RapidDev help build a custom Slack CI/CD integration?
Yes. RapidDev has built 600+ apps including Slack bots with GitHub, Jira, and Linear integrations for live build status updates, PR review workflows, and sprint reporting. Visit rapidevelopers.com for a free consultation.
Need this automated?
Our team has built 600+ apps with API automations. We can build this for you.
Book a free consultation