Skip to content

Diagnostic Slack

Slack disabled event delivery and will not turn it back on

There was a two-hour outage on Tuesday. The service came back, the health checks went green, the on-call went to bed. On Thursday somebody asks why the bot has not answered anyone since Tuesday. Slack turned event delivery off during the outage, emailed the app owner about it, and does not turn it back on when you recover — a human has to click a button that nobody knows exists.

Read-only token Python and Node.js Tests included
Stacks of paper documents and file folders
Photo by Wesley Tingey on Unsplash
The short answer

Slack watches delivery success. If your app fails more than 95% of delivery attempts in a 60-minute window it disables the app's event subscriptions and notifies the owner. Slow counts as failed: anything over three seconds, plus SSL errors, redirect loops and every non-2xx.

The Web API does not expose that flag, so the detection is behavioural: read conversations.history in channels the app serves, find the messages that mention it, find the app's own replies, and measure the gap. A run of mentions with no reply after them is the symptom. Then check the app config page, because that is the only place the truth lives.

The problem in plain words

The disable is a protection mechanism working exactly as designed, and it is invisible from every angle a developer normally looks. The app is installed. The token authenticates. The bot is still in the channel. Scopes are unchanged. The Request URL, if you curl it, answers instantly. Nothing in the Web API differs by a single field from a healthy app, because the Web API is your app calling Slack and this failure is Slack not calling your app.

The email went to the app owner — frequently a person who left, or a shared address nobody reads — and the state itself lives on a configuration page that is visited during setup and essentially never again. So the app sits there, permanently deaf, looking perfectly healthy, until a human notices that the mentions are going unanswered.

Recovery not being automatic is the part that costs the days. Teams assume that fixing the endpoint restores delivery, because that is how every other outage they have ever had behaved. It does not. The subscriptions stay off until someone re-enables them.

Endpoint failsfor an hour5xx, or over 3secondsSlack passes 95percentdelivery disabledEmail to theapp ownernobody reads thatinboxServicerecoversdelivery does notMentions gounansweredapp looks healthy
Recovery restores the service and not the subscription. The switch Slack threw during the outage stays thrown until a human finds the page it lives on.

Why it happens

Failure is measured by delivery, not by your definition of an error. A response slower than three seconds is a failed delivery even if the work completed. So is an expired certificate, a redirect chain, and a 502 from a load balancer sitting in front of a healthy app. A deploy that takes an hour can trip the threshold on its own.

The threshold is a rate, so a quiet app trips faster. 95% of attempts within an hour is easy to reach when the hour contains twenty events. A busy app has more headroom than a quiet one, which is why this bites internal tools hardest.

Retries make it worse before it makes it stop. Each failed delivery is retried up to three times, so a struggling endpoint receives multiples of its normal traffic during exactly the window in which its success rate is being judged.

The symptom has three causes and they look identical from outside. Delivery disabled, the handler down, and events never subscribed to in the first place all produce "mentions, no replies". The script reports the shape and refuses to name the cause, because from the workspace side they are genuinely indistinguishable.

This is the boundary of what a token can see, and it is worth being precise about it. No read method reports whether subscriptions are enabled. apps.manifest.export returns the configuration — and needs an app configuration token, a different credential class from your bot token — but it reports what was configured, not whether Slack is currently delivering. Whether your handler verifies X-Slack-Signature or enforces the five-minute timestamp window is entirely inside your process and Slack never reports it at all.

The fix, as a flow

The script measures the distance between the last mention and the last reply, and then mostly declines to conclude. Delivery disabled, a dead handler and events never subscribed to are one shape from inside the workspace, and the script says which shape rather than which cause.

Mentions and repliesfrom one page of historyReplied after the lastdelivery is arrivingAnswered, then stoppedcheck the config pageAddressed, never answeredlikelier never subscribedOne mention pendingnot evidence yetNobody addressed itno evidence either way
The Web API never reports whether Slack is delivering, so the honest output names what was observed and points at the one page where the real state can be read.

How to fix it

Identify the app from the token, not from a config file

auth.test returns bot_id and user_id. The first identifies the app's own messages in history; the second is the id that appears inside a mention as <@U...>. Both come from the token in hand, so the audit describes the app that is actually deployed.

Read history in the channels the app is supposed to serve

conversations.history?channel=<C...>&limit=200 needs channels:history and membership. If it comes back not_in_channel you have a different problem and a different note; membership is a prerequisite for this one, not a finding of it.

Separate triggers from replies

A trigger is a message that mentions the bot and was not written by it. A reply is a message whose bot_id matches auth.test. Everything else in the channel is noise for this purpose, including other apps' messages and threads the bot was never addressed in.

Count the mentions that arrived after the last reply

One unanswered mention is a person typing the bot's name in passing. Three or more, spread over hours, with a reply before them and none after, is the fingerprint. The count matters more than the elapsed time, because a quiet channel can be quiet for ordinary reasons.

Distinguish gone quiet from never spoke

If the app has never posted in a channel where it is repeatedly addressed, the likelier cause is that no events were ever subscribed to, or the Request URL never passed verification. Auto-disable produces a run of replies that stops; never-configured produces silence from the beginning.

Go and look at the app configuration page

This is the step the script cannot do for you. Event Subscriptions is where the disabled state is visible and where it is re-enabled. Fix the endpoint first, then re-enable, then add an uptime check that alerts you long before 95% of an hour's deliveries have failed.

How to check it worked

Re-enable delivery, then mention the app and re-run over the same channels. Every channel should report a reply after its most recent trigger.

python3 slack_event_silence_audit.py C0123ABCDEF C0456GHIJKL
# 2 channel(s) checked, 0 where the app has gone quiet

The full code

Two read methods: auth.test once, then conversations.history per channel. Both pure functions do the part that matters — scan reduces a page of history to four numbers, and verdict decides whether those numbers are evidence of anything. The second one exists mostly to refuse: most channels are quiet for reasons that are nobody's bug.

Get this script on GitHub Follow @allanninal Python and Node.js, with tests. Read only, it never writes. One of 20 Slack fixes, free and open source.
slack_event_silence_audit.py
"""Find channels where a Slack app is addressed and has stopped answering.

Read only. GET requests and nothing else: channels:history and membership are
enough. This detects the symptom of disabled event delivery, not the flag: no
read method reports whether Slack is delivering, so the repair ends at the app
configuration page and is printed, never performed.
"""
import argparse
import logging
import os
import sys

import requests

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("slack_event_silence_audit")

API = "https://slack.com/api/"


def scan(messages, bot_id, bot_user_id):
    """Reduce one page of history to the four numbers that matter. Pure.

    A trigger is a message mentioning the bot that the bot did not write. A
    reply is any message carrying the app's own bot_id. `unanswered` counts the
    triggers that arrived after the app last said anything.
    """
    mention = "<@%s>" % bot_user_id
    replies, triggers = [], []
    for m in messages:
        ts = float(m.get("ts") or 0)
        if m.get("bot_id") == bot_id:
            replies.append(ts)
        elif mention in (m.get("text") or ""):
            triggers.append(ts)
    last_reply = max(replies) if replies else None
    last_trigger = max(triggers) if triggers else None
    unanswered = len([t for t in triggers if last_reply is None or t > last_reply])
    return {"replies": len(replies), "triggers": len(triggers),
            "last_reply": last_reply, "last_trigger": last_trigger,
            "unanswered": unanswered}


def verdict(stats, min_triggers=3):
    """Decide whether the silence is evidence. Pure, and mostly a refusal.

    Three different causes produce this shape - delivery disabled by Slack, the
    handler down, and events never subscribed to - and none of them can be told
    apart from inside the workspace. The states name the shape, not the cause.
    """
    if not stats["triggers"]:
        return ("no-triggers",
                "nothing addressed the app in this window, so there is no "
                "evidence either way. Silence is not a finding on its own.")
    if not stats["unanswered"]:
        return ("answering",
                "%d mention(s), and the app replied after the most recent one"
                % stats["triggers"])
    if not stats["replies"]:
        return ("never-answered",
                "%d mention(s) and the app has never posted here. That points at "
                "subscriptions never configured or a Request URL that never "
                "verified, rather than at delivery being switched off."
                % stats["triggers"])
    if stats["unanswered"] >= min_triggers:
        hours = (stats["last_trigger"] - stats["last_reply"]) / 3600.0
        return ("silent",
                "%d mention(s) since the app last replied, spanning %.1f hour(s). "
                "It was answering and then stopped: check whether Slack disabled "
                "event delivery." % (stats["unanswered"], hours))
    return ("too-little-evidence",
            "%d unanswered mention(s), below the %d needed to call it. People "
            "type a bot's name without expecting an answer."
            % (stats["unanswered"], min_triggers))


def get(session, method, **params):
    r = session.get(API + method, params=params, timeout=30)
    try:
        return r.json()
    except ValueError:
        return {"ok": False, "error": "unparseable_body"}


def main():
    ap = argparse.ArgumentParser(description=__doc__)
    ap.add_argument("channels", nargs="+", help="channel IDs the app serves (C...)")
    ap.add_argument("--limit", type=int, default=200,
                    help="messages of history per channel (default 200)")
    ap.add_argument("--min-triggers", type=int, default=3,
                    help="unanswered mentions before it counts (default 3)")
    args = ap.parse_args()

    token = os.environ.get("SLACK_BOT_TOKEN")
    if not token:
        log.error("set SLACK_BOT_TOKEN (channels:history and membership are enough)")
        return 2

    s = requests.Session()
    s.headers.update({"Authorization": "Bearer " + token})

    me = get(s, "auth.test")
    if me.get("ok") is not True:
        log.error("auth.test answered 200 with ok: false, error=%s", me.get("error"))
        return 2
    bot_id, bot_user = me.get("bot_id"), me.get("user_id")
    log.info("app is %s (bot_id=%s, mentioned as <@%s>)", me.get("user"), bot_id, bot_user)

    bad = 0
    for cid in args.channels:
        body = get(s, "conversations.history", channel=cid, limit=str(args.limit))
        if body.get("ok") is not True:
            bad += 1
            log.warning("%-20s %-12s history refused: error=%s. Membership and "
                        "channels:history come first; this audit assumes both",
                        "unreadable", cid, body.get("error"))
            continue
        stats = scan(body.get("messages") or [], bot_id, bot_user)
        state, detail = verdict(stats, args.min_triggers)
        line = "%-20s %-12s %s" % (state, cid, detail)
        if state in ("silent", "never-answered"):
            bad += 1
            log.warning(line)
            log.warning("  the Web API cannot tell you whether Slack disabled "
                        "delivery: open Event Subscriptions in the app config")
            log.warning("  repair: fix the endpoint, re-enable delivery by hand, then "
                        "alert on the Request URL before 95%% of an hour fails")
        else:
            log.info(line)

    log.info("%d channel(s) checked, %d where the app has gone quiet",
             len(args.channels), bad)
    return 1 if bad else 0


if __name__ == "__main__":
    sys.exit(main())
slack-event-silence-audit.mjs
/**
 * Find channels where a Slack app is addressed and has stopped answering.
 *
 * Read only. GET requests and nothing else: channels:history and membership are
 * enough. This detects the symptom of disabled event delivery, not the flag: no
 * read method reports whether Slack is delivering, so the repair ends at the app
 * configuration page and is printed, never performed.
 */
const API = 'https://slack.com/api/';

/**
 * Reduce one page of history to the four numbers that matter. Pure.
 * A trigger is a message mentioning the bot that the bot did not write; a reply
 * is any message carrying the app's own bot_id.
 */
export function scan(messages, botId, botUserId) {
  const mention = `<@${botUserId}>`;
  const replies = [];
  const triggers = [];
  for (const m of messages) {
    const ts = Number(m.ts ?? 0);
    if (m.bot_id === botId) replies.push(ts);
    else if ((m.text ?? '').includes(mention)) triggers.push(ts);
  }
  const lastReply = replies.length ? Math.max(...replies) : null;
  const lastTrigger = triggers.length ? Math.max(...triggers) : null;
  const unanswered = triggers.filter((t) => lastReply === null || t > lastReply).length;
  return {
    replies: replies.length, triggers: triggers.length,
    lastReply, lastTrigger, unanswered,
  };
}

/**
 * Decide whether the silence is evidence. Pure, and mostly a refusal.
 * Delivery disabled, the handler down and events never subscribed to all produce
 * this shape, so the states name the shape and not the cause.
 */
export function verdict(stats, minTriggers = 3) {
  if (!stats.triggers) {
    return ['no-triggers',
      'nothing addressed the app in this window, so there is no evidence either ' +
      'way. Silence is not a finding on its own.'];
  }
  if (!stats.unanswered) {
    return ['answering',
      `${stats.triggers} mention(s), and the app replied after the most recent one`];
  }
  if (!stats.replies) {
    return ['never-answered',
      `${stats.triggers} mention(s) and the app has never posted here. That points ` +
      'at subscriptions never configured or a Request URL that never verified, ' +
      'rather than at delivery being switched off.'];
  }
  if (stats.unanswered >= minTriggers) {
    const hours = (stats.lastTrigger - stats.lastReply) / 3600;
    return ['silent',
      `${stats.unanswered} mention(s) since the app last replied, spanning ` +
      `${hours.toFixed(1)} hour(s). It was answering and then stopped: check ` +
      'whether Slack disabled event delivery.'];
  }
  return ['too-little-evidence',
    `${stats.unanswered} unanswered mention(s), below the ${minTriggers} needed to ` +
    "call it. People type a bot's name without expecting an answer."];
}

async function get(token, method, params = {}) {
  const url = new URL(API + method);
  for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
  const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
  try {
    return await res.json();
  } catch {
    return { ok: false, error: 'unparseable_body' };
  }
}

async function main() {
  const token = process.env.SLACK_BOT_TOKEN;
  if (!token) {
    console.error('set SLACK_BOT_TOKEN (channels:history and membership are enough)');
    process.exitCode = 2;
    return;
  }

  const argv = process.argv.slice(2);
  const li = argv.indexOf('--limit');
  const mi = argv.indexOf('--min-triggers');
  const limit = li === -1 ? '200' : argv[li + 1];
  const minTriggers = mi === -1 ? 3 : Number(argv[mi + 1]);
  const channels = argv.filter((a, n) => !a.startsWith('--')
    && argv[n - 1] !== '--limit' && argv[n - 1] !== '--min-triggers');

  if (!channels.length) {
    console.error('usage: node slack-event-silence-audit.mjs C0123ABCDEF [C...]');
    process.exitCode = 2;
    return;
  }

  const me = await get(token, 'auth.test');
  if (me.ok !== true) {
    console.error(`auth.test answered 200 with ok: false, error=${me.error}`);
    process.exitCode = 2;
    return;
  }
  const botId = me.bot_id;
  const botUser = me.user_id;
  console.log(`app is ${me.user} (bot_id=${botId}, mentioned as <@${botUser}>)`);

  let bad = 0;
  for (const cid of channels) {
    const body = await get(token, 'conversations.history', { channel: cid, limit });
    if (body.ok !== true) {
      bad += 1;
      console.warn(`${'unreadable'.padEnd(20)} ${cid.padEnd(12)} history refused: ` +
                   `error=${body.error}. Membership and channels:history come ` +
                   'first; this audit assumes both');
      continue;
    }
    const stats = scan(body.messages ?? [], botId, botUser);
    const [state, detail] = verdict(stats, minTriggers);
    const line = `${state.padEnd(20)} ${cid.padEnd(12)} ${detail}`;
    if (state === 'silent' || state === 'never-answered') {
      bad += 1;
      console.warn(line);
      console.warn('  the Web API cannot tell you whether Slack disabled delivery: ' +
                   'open Event Subscriptions in the app config');
      console.warn('  repair: fix the endpoint, re-enable delivery by hand, then ' +
                   'alert on the Request URL before 95% of an hour fails');
    } else {
      console.log(line);
    }
  }

  console.log(`${channels.length} channel(s) checked, ${bad} where the app has gone quiet`);
  process.exitCode = bad ? 1 : 0;
}

// Only run when invoked directly, so importing this module in the tests does not
// execute main() and fail the file on a missing token.
if (import.meta.url === `file://${process.argv[1]}`) {
  main().catch((err) => { console.error(err.message); process.exitCode = 2; });
}

Add a test

The state that keeps this audit honest is never-answered. An app that has been addressed forty times and has never once replied is almost certainly not an app whose delivery was disabled — it is one that was never subscribed to anything. Collapsing the two sends a team to re-enable a switch that was never off.

test_slack_event_silence_audit.py
from slack_event_silence_audit import scan, verdict

BOT = "B123"
BOT_USER = "U999"


def msg(ts, text="hello", bot=False):
    m = {"ts": "%d.000100" % ts, "text": text}
    if bot:
        m["bot_id"] = BOT
    return m


def mention(ts):
    return msg(ts, "<@%s> please deploy" % BOT_USER)


def test_scan_separates_triggers_from_replies():
    messages = [mention(100), msg(110, "unrelated chatter"), msg(120, "done", bot=True)]
    stats = scan(messages, BOT, BOT_USER)
    assert stats["triggers"] == 1
    assert stats["replies"] == 1
    assert stats["unanswered"] == 0


def test_the_bots_own_mention_of_itself_is_not_a_trigger():
    messages = [msg(100, "<@%s> was asked" % BOT_USER, bot=True)]
    assert scan(messages, BOT, BOT_USER)["triggers"] == 0


def test_a_run_of_mentions_after_the_last_reply_is_the_finding():
    messages = [msg(1000, "on it", bot=True), mention(5000), mention(9000), mention(13000)]
    state, detail = verdict(scan(messages, BOT, BOT_USER))
    assert state == "silent"
    assert "3 mention(s)" in detail


def test_an_app_that_never_replied_is_a_different_diagnosis():
    messages = [mention(1000), mention(2000), mention(3000), mention(4000)]
    state, detail = verdict(scan(messages, BOT, BOT_USER))
    assert state == "never-answered"
    assert "never configured" in detail


def test_a_reply_after_the_last_mention_is_healthy():
    messages = [mention(1000), msg(1100, "done", bot=True)]
    assert verdict(scan(messages, BOT, BOT_USER))[0] == "answering"


def test_one_unanswered_mention_is_not_enough():
    messages = [msg(1000, "done", bot=True), mention(2000)]
    assert verdict(scan(messages, BOT, BOT_USER))[0] == "too-little-evidence"


def test_a_quiet_channel_is_not_evidence():
    messages = [msg(1000, "morning"), msg(2000, "morning")]
    assert verdict(scan(messages, BOT, BOT_USER))[0] == "no-triggers"


def test_the_threshold_is_adjustable():
    messages = [msg(1000, "done", bot=True), mention(2000), mention(3000)]
    assert verdict(scan(messages, BOT, BOT_USER), min_triggers=2)[0] == "silent"
slack-event-silence-audit.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { scan, verdict } from './slack-event-silence-audit.mjs';

const BOT = 'B123';
const BOT_USER = 'U999';

function msg(ts, text = 'hello', bot = false) {
  const m = { ts: `${ts}.000100`, text };
  if (bot) m.bot_id = BOT;
  return m;
}

const mention = (ts) => msg(ts, `<@${BOT_USER}> please deploy`);

test('scan separates triggers from replies', () => {
  const stats = scan([mention(100), msg(110, 'unrelated chatter'), msg(120, 'done', true)],
    BOT, BOT_USER);
  assert.equal(stats.triggers, 1);
  assert.equal(stats.replies, 1);
  assert.equal(stats.unanswered, 0);
});

test('the bots own mention of itself is not a trigger', () => {
  const messages = [msg(100, `<@${BOT_USER}> was asked`, true)];
  assert.equal(scan(messages, BOT, BOT_USER).triggers, 0);
});

test('a run of mentions after the last reply is the finding', () => {
  const messages = [msg(1000, 'on it', true), mention(5000), mention(9000), mention(13000)];
  const [state, detail] = verdict(scan(messages, BOT, BOT_USER));
  assert.equal(state, 'silent');
  assert.match(detail, /3 mention\(s\)/);
});

test('an app that never replied is a different diagnosis', () => {
  const messages = [mention(1000), mention(2000), mention(3000), mention(4000)];
  const [state, detail] = verdict(scan(messages, BOT, BOT_USER));
  assert.equal(state, 'never-answered');
  assert.match(detail, /never configured/);
});

test('a reply after the last mention is healthy', () => {
  const messages = [mention(1000), msg(1100, 'done', true)];
  assert.equal(verdict(scan(messages, BOT, BOT_USER))[0], 'answering');
});

test('one unanswered mention is not enough', () => {
  const messages = [msg(1000, 'done', true), mention(2000)];
  assert.equal(verdict(scan(messages, BOT, BOT_USER))[0], 'too-little-evidence');
});

test('a quiet channel is not evidence', () => {
  const messages = [msg(1000, 'morning'), msg(2000, 'morning')];
  assert.equal(verdict(scan(messages, BOT, BOT_USER))[0], 'no-triggers');
});

test('the threshold is adjustable', () => {
  const messages = [msg(1000, 'done', true), mention(2000), mention(3000)];
  assert.equal(verdict(scan(messages, BOT, BOT_USER), 2)[0], 'silent');
});

FAQ

What exactly trips the disable?

Failing more than 95% of delivery attempts inside a 60-minute window. Counted failures include any non-2xx, responses slower than three seconds, SSL validation errors and too many redirects. A quiet app trips more easily than a busy one, because the percentage is over attempts rather than over time.

Does delivery resume when my service recovers?

No, and that is the part that costs days. Slack disables the subscriptions and notifies the app owner by email; re-enabling is a manual action on the Event Subscriptions page in the app configuration. Fixing the endpoint alone changes nothing.

Can a script read whether delivery is disabled?

Not with a bot token. No Web API read method exposes the flag. apps.manifest.export can show what is configured, but it needs an app configuration token, which is a different credential class, and it still reports configuration rather than live delivery state. That is why this note detects the symptom in the workspace instead.

Could the silence mean something else?

Yes, and the script says so rather than guessing. Delivery disabled, a handler that is down, and events that were never subscribed to all look identical from inside the workspace. The one distinction it can draw is between an app that was answering and stopped, and one that never answered at all.

How do I stop this happening again?

Ack within milliseconds and do the work asynchronously, so a slow dependency cannot turn into a failed delivery. Then put an external uptime check on the Request URL that alerts on the first sustained failures, long before 95% of an hour has failed, and make sure the app owner address on the configuration is a mailbox somebody reads.

Related field notes

Sources

Every figure in this note is traced to one of these. Prices are list rates and change — check them for your own region before acting.

Stuck on a tricky one?

If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.