Updating in Place
chat.update's absolute read-only guarantee, the pinned-dashboard pattern, and Table block limits
Only the Authoring App Can Update
chat.update edits an existing message's text and blocks. Per Slack's own docs, "only messages posted by the authenticated user can be updated" (chat.update) — "the authenticated user" is the token's identity, not necessarily the app. For a bot token, that's the bot itself: only the bot that originally posted a message can edit it, and there is no ACL to configure, no share dialog to get wrong, and no admin override that grants a different app or a human editing rights on the message body. A user token, though, authenticates as a human — chat.update called with one can edit any message that human authored, including one posted on their behalf by a different app (e.g. a Slack Connect or workflow integration acting as them). The absolute, zero-configuration read-only guarantee below holds for the bot-token dashboard pattern this page describes; a user-token integration doesn't get it for free.
No Notification, No "Edited" Flag — When Blocks Are Used
Editing a message in place produces no channel notification, which is the correct behavior for a status board that shouldn't spam the channel on every refresh. When the message content is Block Kit blocks (rather than plain text), Slack also shows no "(edited)" flag — visually, a chat.update-refreshed Block Kit message reads exactly like it did on its first post, every time it's refreshed.
The Pinned-Dashboard Pattern
The combination above — bot-exclusive write access, silent refresh, no visual "this changed" marker — makes chat.update the default building block for an in-Slack status board:
chat.postMessageonce, with the initial Block Kit body.pins.addthe resulting message so it's discoverable from the channel's pinned-items panel.On a cron (or any trigger),
chat.updatethe samechannel+tswith a freshly renderedblockspayload.
This costs exactly one API call per refresh, needs no state beyond the channel/ts pair, and inherits the read-only guarantee above with zero additional configuration. Pinning does not keep a message fixed at the top of the channel timeline — it only adds the message to the pinned items list, so the dashboard still scrolls out of the visible history as new messages arrive; the pin is what lets a reader jump back to it regardless of scroll position. Any channel member can unpin it — unpinning does not delete the message, so the dashboard survives, it just drops out of that pinned-items list.
Default choice among Slack's bot-writable display surfaces
Against the other surfaces a bot can write a dashboard to — a Slack List, a Canvas, an App Home tab, a channel bookmark to an external page — a pinned chat.update message is the lowest-cost option with the strongest read-only guarantee. It trades away real layout modes (no Kanban board, no per-row threads); reach for a heavier surface only after the team has actually asked for those.
Reconciling a Fleet of Messages
The pattern above assumes a single dashboard message living at one channel/ts. A bot that maintains many previously-posted messages instead — one per record, one per team, one per open item — needs a reconciliation pass rather than a single fixed target.
Store a version-salted content hash alongside each message's channel/ts (a SHA-256 digest over [renderVersion, parentPayload, replyPayload] works well). Each reconciliation pass rebuilds the payload from current data, computes the hash, and compares it against the stored value; chat.update is called only when the hashes differ, so an unchanged record costs zero API calls.
Write the new hash to storage only after every Slack call for that row succeeds — the hash is the commit marker for a completed write, not a record of "we attempted an update." If the parent update succeeds but a dependent reply update throws, the stored hash stays at its old value, so the row still reads as dirty and is retried on the next pass instead of being silently marked done against a Slack state that doesn't actually match it.
Bumping the renderVersion salt is how a template redesign forces every previously-posted message to rewrite itself: the hash changes for every row even though the underlying data didn't, so the next pass treats the whole fleet as dirty and rewrites each message exactly once.
Cap how many updates a single pass performs, and order candidates deliberately — rows that have never been hashed first (new records with no Slack presence yet), then the newest rows — so a large backlog drains gradually across many cron ticks instead of bursting through the per-channel posting ceiling in one pass (see Rate Limits).
Sharing a Payload Builder Between Post and Update
Reusing one function to build the Block Kit payload for both chat.postMessage and chat.update keeps the two call sites from drifting apart, but a few fields are post-only and must be stripped before the payload is sent to chat.update: thread_ts, unfurl_links, and unfurl_media. Threading is fixed at post time — chat.update has no thread_ts parameter to begin with, and passing unfurl flags on an update call is a no-op at best; treat the builder's output as needing a strip step for the update path, not as directly reusable.
If a bot's thread replies might ever need to be re-discovered — the reply's ts was never persisted, or the row that tracked it was lost — give those replies a stable, machine-recognizable text prefix (e.g. a fixed marker string at the start of the reply text). A later pass can then re-find its own reply by calling conversations.replies on the parent ts and scanning results for the bot's own bot_id plus that prefix, recovering the ts needed to resume idempotent chat.update calls without ever having stored it.
Table Block Limits
Block Kit's Table block is the highest-fidelity structured layout available inside a chat.update-refreshed message. As of 2026-08-06, per the primary reference (Table block):
| Limit | Value |
|---|---|
| Tables per message | 1 — a second Table block in the same message fails with only_one_table_allowed |
| Rows | Maximum 100 |
| Columns per row | Maximum 20 table cells per row array |
| Characters per table | 10,000, across all cells in that table |
| Characters per message | 10,000, aggregated across all table cells in the message |
| Cell types | rich_text, raw_text, raw_number |
block_id | Maximum 255 characters |
A dashboard that needs more than 100 rows or 10,000 characters of table content has outgrown this surface — page the data, summarize it, or move to a surface built for larger structured data (a Slack List; see the Lists section).