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: "This message was deleted." with "2 replies", then sieun "replied to a thread: 607 reply also sent to channel", the sglabw2 app message "607 bot message, edited", and a message with a link to slack.green and its preview card

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 "pong" replies plus its own "ping from the bot itself (naive)" message; after the filtered run it posted one "pong" below the link message plus its own ping message

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

How to See All Your DMs in Slack, Including the Hidden Ones

The DMs tab lists your direct messages with people. App DMs, Slackbot and DMs with deleted accounts sit elsewhere. We compared the tab with the full list the Slack API returned.

Slack Green Team
Guide

Slack Images Not Showing? 5 Causes We Reproduced

We uploaded images to a Slack test channel and then switched each image setting off, ran /collapse, and posted from a bot that was not in the channel. Five causes, each reproduced.

Slack Green Team
Guide

How to Message Yourself on Slack (and What It Notifies)

Your own name under Direct messages opens a private DM with yourself. We sent it a note, a file, a scheduled message and a reminder, and checked which ones raised a badge.

Slack Green Team