Back to Blog
Guide

Slack message_changed Event: Real Payloads for Edits, Deletes and Threads

We subscribed a Socket Mode app to message.channels, then posted, edited, deleted, replied in a thread, sent a reply to the channel and pasted a link. The raw event for each, three message_changed events nobody typed, and the filter that stopped our bot answering them.

Slack Green Team
October 2, 2026
October 2, 2026
4 min read
Share:
slack api
developers
events api

message_changed is the message event Slack sends with "subtype": "message_changed" when a message in a channel your app can see is modified. The new version sits in event.message, the old one in event.previous_message, and the top-level event has no user and no text. On 2 October 2026 we subscribed a Socket Mode test app to message.channels in our own workspace and logged every event. Edits sent message_changed, but so did three things nobody would call an edit: a link preview loading, a reply sent to the channel, and deleting a message that had replies. A bot that answers every message event answers all of them.

Every event we got, in order

We did each action in one channel and logged the raw event:

What we didsubtypehiddenWhere the text is
Posted a messagenonenoneevent.text
Edited itmessage_changedtrueevent.message.text, old in event.previous_message.text
Replied in its threadnone (has thread_ts)noneevent.text
Replied with "Also send to channel"thread_broadcastnoneevent.text
(same moment, no action)message_changed on that replytruemessage.text and previous_message.text identical
Bot posted with chat.postMessagenone (has bot_id)noneevent.text
Bot edited its message with chat.updatemessage_changedtrueevent.message.text; bot_id only inside message
Posted a link, preview loadednone, then message_changedtruepreview in event.message.attachments
Deleted a messagemessage_deletedtrueonly event.previous_message.text
Deleted a message that had repliesmessage_changed, message.subtype tombstonetruemessage.text "This message was deleted."
Bot joined the channelchannel_joinnoneevent.text

Our bot's own posts came with bot_id and app_id and no subtype. We never got bot_message, the old subtype for bot posts, from a bot token's chat.postMessage.

This is the channel after the test. The parent message was deleted, so Slack kept a placeholder with its 2 replies, and the bot's edited message shows no "(edited)" label:

Slack web channel:

An edit: the raw message_changed payload

This is the event for the edit, with the blocks arrays cut. event.ts is the time of the change; the edited message's own timestamp is event.message.ts, which matches previous_message.ts:

{
  "type": "message",
  "subtype": "message_changed",
  "message": {
    "user": "U0B7L4YK420",
    "type": "message",
    "edited": {"user": "U0B7L4YK420", "ts": "1790925314.000000"},
    "text": "607 edited text",
    "ts": "1790925312.438049"
  },
  "previous_message": {
    "user": "U0B7L4YK420",
    "type": "message",
    "ts": "1790925312.438049",
    "text": "607 original text"
  },
  "channel": "C0C71KAJ87J",
  "hidden": true,
  "ts": "1790925314.000500",
  "event_ts": "1790925314.000500",
  "channel_type": "channel"
}

So to find the message in your database, key on event.message.ts and event.channel, not event.ts.

A delete: the message_deleted payload

{
  "type": "message",
  "subtype": "message_deleted",
  "previous_message": {
    "user": "U0B7L4YK420",
    "type": "message",
    "ts": "1790925326.615359",
    "text": "607 message to delete"
  },
  "channel": "C0C71KAJ87J",
  "hidden": true,
  "deleted_ts": "1790925326.615359",
  "event_ts": "1790925328.001000",
  "ts": "1790925328.001000",
  "channel_type": "channel"
}

There is no message key. deleted_ts is the deleted message's timestamp. A message with thread replies does not send this event when deleted: we got message_changed instead, with "subtype": "tombstone" inside message and the text "This message was deleted.". Code that only listens for message_deleted misses those.

Never appear "away" on Slack again

Cloud-based. No downloads. Works 24/7 even when your laptop is off.

The three edits nobody made

Link previews. We posted a message with a URL. Slack sent the normal message event, then, in the same second, a message_changed with the preview in message.attachments. The text did not change.

Also send to channel. A thread reply with "Also send to channel" came as a thread_broadcast event. Within the same second Slack sent a message_changed for that same reply, with message.text and previous_message.text identical. Only the block_id values differed.

Deleting a thread parent. The tombstone above.

If your bot replies to message_changed, each of these makes it reply again to a message it already answered.

The filter that stopped repeat replies

We ran this bot twice, once answering every event and once with should_answer. It replies "pong" to any message that contains "ping":

import json, os, time
from slack_sdk import WebClient
from slack_sdk.socket_mode import SocketModeClient
from slack_sdk.socket_mode.response import SocketModeResponse

web = WebClient(token=os.environ["SLACK_BOT_TOKEN"])
FILTER = os.environ.get("MODE") == "filtered"

def should_answer(event):
    if event.get("bot_id"):                                       # our own posts and other bots
        return False
    return event.get("subtype") in (None, "thread_broadcast")     # skip edits, deletes, joins

def handle(client, req):
    client.send_socket_mode_response(SocketModeResponse(envelope_id=req.envelope_id))
    event = req.payload.get("event", {})
    if event.get("type") != "message":
        return
    text = event.get("text") or event.get("message", {}).get("text", "")
    if "ping" not in text:
        return
    answer = should_answer(event) if FILTER else True
    print(f"subtype={event.get('subtype')!s:16} bot_id={event.get('bot_id')!s:12} answered={answer}", flush=True)
    if answer:
        web.chat_postMessage(channel=event["channel"], text="pong")

sm = SocketModeClient(app_token=os.environ["SLACK_APP_TOKEN"])
sm.socket_mode_request_listeners.append(handle)
sm.connect()
time.sleep(float(os.environ.get("RUN_SECONDS", "60")))

In each run we posted "ping one", edited it, posted a "ping" message with a link, had the bot post a message containing "ping" itself, then deleted "ping one". Answering everything:

subtype=None             bot_id=None         answered=True
subtype=message_changed  bot_id=None         answered=True
subtype=None             bot_id=None         answered=True
subtype=message_changed  bot_id=None         answered=True
subtype=None             bot_id=B0C673PQSRY  answered=True

With the filter:

subtype=None             bot_id=None         answered=True
subtype=message_changed  bot_id=None         answered=False
subtype=None             bot_id=None         answered=True
subtype=message_changed  bot_id=None         answered=False
subtype=None             bot_id=B0C673PQSRY  answered=False

The unfiltered bot replied 5 times to 2 human messages: once for the edit, once for the link preview, and once to its own post. The filtered one replied twice. In Slack the difference was easy to see:

Slack web: after the unfiltered run, the sglabw2 app posted a run of

The bot_id check alone was not enough: on the bot's own edit, the top-level event had no bot_id; it was inside event.message. The subtype check caught it. If your bot should react to real edits, handle message_changed in its own branch and compare message.text with previous_message.text first, which drops link previews and the broadcast echo.

For a bot that gets each event twice instead, the cause is a slow ack and Slack's retries, covered in Slack Events API retries. Our Socket Mode guide shows the listener setup. To read the same messages later instead of as events, see getting messages from a channel.

FAQ

Which scope do I need for message_changed? The same one as for new messages. Our app subscribed to the message.channels bot event with the channels:history scope and got every row in the table above. Private channels use message.groups and DMs message.im.

Does a thread reply change the parent message? Not as an event. Our first plain thread reply sent only its own message event, with thread_ts set to the parent. The parent's reply_count showed up later inside the broadcast's message.root.

Always Active

Stop Jiggling Your Mouse.

Join hundreds of remote workers who never worry about their Slack status. Set it up once, stay green forever.

Related Articles

Guide

assistant.threads.setStatus vs agents.sessions.setStatus: Both Tested in Slack

We called assistant.threads.setStatus and agents.sessions.setStatus on a test app before and after turning on the agent feature, clicked the stop button, and timed how long the status line stays. Every response, the stop event, and what Slack showed.

Slack Green Team
Guide

Slack Slash Command Payload: Every Field, Responses, and response_url Limits

We caught the payload a slash command sends, answered it as ephemeral, in_channel, plain text and empty, posted to its response_url until it failed, and typed the command inside a thread. Every result is from a test app on 2 October 2026.

Slack Green Team
Guide

Slackbot MCP Client: We Connected a 17-Line MCP Server and Slackbot Called It

We wrote a one-tool MCP server, added it to a Slack app with the mcp_servers manifest field, switched it on in Slackbot and asked for the time. Every request Slackbot sent to the server, the permission prompt, and what Slackbot said when the server was down.

Slack Green Team