Building Slack Block Kit Messages That Don't Fail Silently
Slack's chat.postMessage returns HTTP 200 even when your message fails. Here's how to compose Block Kit payloads that respect block limits, include real fallback text, and surface API errors instead of swallowing them.
20 Oct 2025, 08:23 UTC

Your Slack integration worked fine for weeks, then a deploy notification started vanishing. No exception in your logs, no alert — just a missing message. When you finally reproduce it, the API response is {"ok": false, "error": "invalid_blocks"}. The payload grew past a limit you didn't know existed, and because your code only checked the HTTP status (200, always 200), nobody noticed.
That's the core trap with Slack's chat.postMessage endpoint: transport success and message success are different things. This post covers the three places Block Kit payloads actually break — block limits, missing fallback text, and schema drift — and how to build payloads that degrade gracefully instead of disappearing.
The limits that bite in production
Slack enforces hard structural limits on Block Kit payloads. The ones that matter most:
- 30 top-level blocks per message. Exceed this and the call fails, no matter how valid each individual block is.
- 15 elements per actions block, 100 interactive elements per message. Easy to hit when you render one button row per item in a list.
- 3,000 characters per section text field. Long stack traces or changelogs blow past this quickly.
The failure mode is almost always data-driven. A message that renders five deploys works; a message rendering forty fails. So the fix isn't "be careful" — it's to enforce limits in code at composition time, not discover them at send time.
Fallback text is not optional decoration
Every block-based message should include top-level text (the fallback). Slack uses it in notifications, screen readers, mobile push previews, and any client that can't render blocks. If you omit it, notifications show something unhelpful like "This content can't be displayed." Keep it under roughly 2,000 characters — longer strings risk truncation — and make it a real summary of the message, not a placeholder like "block message."
A worked example: a resilient message composer
Here's a small Python helper that composes a deploy summary, truncates section text, caps the block count, and checks the API response body rather than the HTTP status. Run it anywhere with the requests library and a bot token with the chat:write scope (set as the SLACK_BOT_TOKEN environment variable):
import os
import requests
MAX_BLOCKS = 30
MAX_SECTION_CHARS = 3000
def build_deploy_message(deploys):
blocks = [{"type": "header",
"text": {"type": "plain_text", "text": "Deploy summary"}}]
for d in deploys:
if len(blocks) >= MAX_BLOCKS - 1:
remaining = len(deploys) - len(blocks) + 1
blocks.append({"type": "context", "elements": [{
"type": "mrkdwn",
"text": f"_…and {remaining} more deploys omitted_"}]})
break
line = f"*{d['service']}* `{d['version']}` — {d['status']}"
blocks.append({"type": "section",
"text": {"type": "mrkdwn", "text": line[:MAX_SECTION_CHARS]}})
fallback = f"Deploy summary: {len(deploys)} deploys. " + \
"; ".join(f"{d['service']} {d['version']} {d['status']}" for d in deploys[:5])
return {"channel": CHANNEL_ID, "text": fallback[:1900], "blocks": blocks}
def send(payload):
resp = requests.post("https://slack.com/api/chat.postMessage",
headers={"Authorization": f"Bearer {os.environ['SLACK_BOT_TOKEN']}"},
json=payload, timeout=10)
resp.raise_for_status() # transport errors only
body = resp.json()
if not body.get("ok"):
raise RuntimeError(f"Slack API error: {body.get('error')}")
return body["ts"]Three deliberate choices here: the composer reserves one block slot for an "omitted" notice instead of silently dropping data, the fallback summarizes rather than duplicates everything, and send() treats ok: false as a failure worth raising. Verify it works by sending to a test channel and confirming the response contains "ok": true and a message timestamp.
Validate before you send
Catching invalid_blocks in production means your schema drifted — a field renamed, a block type misused, an element count exceeded. Two cheap defenses:
- Prototype in Slack's Block Kit Builder. Paste your JSON there during development; it flags schema errors immediately and shows the rendered result.
- Snapshot-test your composer. Assert on block count, per-section text length, and presence of
textin unit tests with realistic worst-case data (many items, long strings). This catches data-driven limit violations before the API does.
The trade-off: truncation vs. threading
Capping at 30 blocks means choosing what to do with overflow. Truncation with an "omitted" notice (as above) is simple but loses information. Alternatives: post overflow as a threaded reply, split into multiple messages, or link out to a dashboard. Threading keeps channels clean but complicates rate limiting and error handling; splitting multiplies your API calls. For most notification use cases, truncation plus a link to the full source is the right default — the Slack message is a pointer, not the system of record.
Closing: treat Slack errors as first-class failures
The single highest-value change is the smallest: always inspect the ok field and log the error value (message_too_big, invalid_blocks, channel_not_found) with enough context to reproduce. Add limit enforcement at composition time, write a real fallback string, and snapshot-test your payload shape. Do those three things and the "message silently vanished" class of bug disappears entirely.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.