Diagnostic Slack
is_archived: the target channel was archived months ago
Nobody filed a ticket, because nothing looks broken. The channel is still there if you search for it, the ID in the config still resolves, and conversations.info still answers ok: true. What changed is one boolean inside that response, set during a reorganisation in March, and every alert since has been refused on the way in.
Archiving does not delete a channel. The ID keeps resolving, conversations.info keeps succeeding, and the only difference is channel.is_archived: true — after which the channel accepts nothing from anyone, forever, until a human unarchives it. Sends fail with is_archived, and if the sender is an incoming webhook they fail with a real HTTP 410 Gone, which is one of the few places Slack breaks its own 200 habit.
The script below does not check your targets one at a time. It takes one paginated conversations.list sweep with exclude_archived=false, intersects the archived set with the channels your integration points at, dates the silence from the last message in each, and then looks for the live channel the traffic should have moved to. The answer here is not a corrected string; it is a different room.
The problem in plain words
Archiving is a tidying action. Somebody closes out a project, a team reorganises, a quarter ends, and a dozen channels get archived in an afternoon by a person who has never seen your integration's configuration. Slack asks them to confirm; it does not tell them that a build pipeline, a PagerDuty bridge and a nightly digest are all addressed to one of the channels in the list.
From the integration's side there is nothing to notice. The failure is on the sending side, and the sending side is the half nobody watches: a cron job that exits 0, a webhook call whose response is discarded, a library that returns a result object nobody inspects. Alerts do not error, they simply stop arriving, and the absence of a message is the hardest event in the world to alert on.
What makes it worse is that the obvious health check passes. A startup assertion that calls conversations.info and checks for ok: true is satisfied by an archived channel. So is one that checks the ID resolves, and so is one that checks the bot is a member — membership is preserved through archiving. Every reasonable check short of reading the one boolean returns a clean bill of health for a channel that has been refusing messages since March.
Why it happens
Archived is a state, not an absence. The channel object is intact: name, members, history, ID. Detection therefore cannot be an error-handling branch, because there is no error on the read path at all. It has to be a field assertion, which means somebody has to have decided in advance that the field is worth asserting on.
The sweep is cheaper than the per-channel check. conversations.info per target is one Tier 3 call per channel; one paginated conversations.list is a handful of calls for the entire workspace and gives you the inventory you need for the successor search anyway. For anything beyond three or four targets the sweep wins, and it keeps working when the target list grows.
You have to ask for archives explicitly. conversations.list defaults to exclude_archived being false in the reference, but plenty of client wrappers and plenty of existing code set it true because that is what a channel picker wants. A sweep built on that default cannot see the thing it is looking for: an archived target simply vanishes from the inventory and gets misreported as an unknown ID.
The last message dates the outage. Reading one message with conversations.history?limit=1 turns "this is archived" into "this went quiet on 4 January", which is the difference between a finding and an incident timeline. It needs channels:history and it needs the bot to have been a member before the archive, and where it is not available the script says so rather than inventing a date.
Webhooks are the exception that proves the rule. An incoming webhook aimed at an archived channel returns 410 Gone with a plain-text channel_is_archived body. Any HTTP client on earth surfaces that, which is why webhook-based integrations tend to discover this in days and Web API integrations tend to discover it in months.
The fix, as a flow
Nothing in the problem chain is an error, which is the point: the ID resolves, the read succeeds, and the traffic stops anyway. The fix branch sorts targets against one swept inventory, and keeps the suggestion of a replacement room visibly weaker than the findings.
How to fix it
Sweep the workspace once, archives included
conversations.list with types=public_channel,private_channel, exclude_archived=false and limit=1000, following next_cursor to the end. Build a dictionary keyed by ID. Everything after this is a lookup rather than a call.
Intersect the archive with what you actually target
A workspace of any age has hundreds of archived channels and none of them matter except the ones in your configuration. Feed the script the IDs your integration sends to, and the report is short enough that somebody will read it.
Separate archived from invisible
A target that is not in the sweep at all is not archived; it is an ID this token cannot enumerate, which is a private-channel scope question and a different repair. Keeping the two verdicts apart matters, because "archived" sends you to the Slack admin UI and "invisible" sends you to the OAuth screen.
Date the silence from the last message
One conversations.history call with limit=1 per archived target. The ts of the newest message is when the room stopped, which is very close to when your alerts stopped. If the call is refused because the bot was never a member, report it as undatable: an archived channel cannot be joined, so that date is genuinely unrecoverable.
Find where the traffic went instead
Teams rename rather than relocate. An archived #ops-alerts almost always sits beside a live #ops-alerts-v2, and an archived #ops-alerts-old beside a live #ops-alerts. The successor search matches on that stem and offers the shortest live candidate, as a suggestion with an ID attached rather than as an instruction.
Assert on the boolean at boot
Add is_archived to the startup health check that already validates the token. An archive then shows up as a loud boot failure on the next deploy instead of as a silence somebody notices a quarter later. Unarchiving is a Slack UI action; there is no bot-callable unarchive for private channels.
How to check it worked
After the configuration points at live channels, the sweep should report every target as live and exit 0.
python3 slack_archived_target_sweep.py C01ABCDE9 C02XYZ123
# inventory: 1184 channel(s), 407 archived
# live C01ABCDE9 #ops-alerts-v2 accepts messages
# live C02XYZ123 #build-digest accepts messages
# 2 target(s) checked, 0 archived, 0 invisible
The full code
Three pure functions and two GET methods. target_state sorts a target against the swept inventory, silence_since turns one conversations.history response into a date or an honest refusal to give one, and successor is the only function in this section that offers a suggestion rather than a verdict — so it is deliberately conservative and returns nothing at all rather than a plausible wrong room.
"""Find the integration targets that are pointing at archived Slack channels.
Read only. One paginated conversations.list, plus one conversations.history per
archived target to date the silence. Nothing is unarchived and nothing is
posted: the script names the frozen channel, says when it went quiet, and
suggests the live channel the traffic probably belongs in.
"""
import argparse
import logging
import os
import sys
from datetime import datetime, timezone
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("slack_archived_target_sweep")
API = "https://slack.com/api/"
# Suffixes a team adds when it retires a channel rather than renaming the new
# one. Stripping them is how #ops-alerts-old finds its way back to #ops-alerts.
RETIRED_SUFFIXES = ("-old", "-archive", "-archived", "-deprecated", "-legacy",
"-retired", "-v1", "-tmp", "-temp")
def target_state(cid, inventory):
"""Sort one configured target against the swept inventory. Pure.
Returns (state, channel, detail). `invisible` is deliberately not folded into
an error bucket: a target missing from the sweep is a scope question, and it
sends a reader to a different screen than an archived one does.
"""
channel = inventory.get(cid)
if channel is None:
return ("invisible", None,
"not in the sweep at all. Either the ID is wrong, or it is a "
"private channel this token has no scope to enumerate. Slack will "
"not say which.")
name = channel.get("name") or "?"
if channel.get("is_archived"):
return ("archived", channel,
"#%s is archived. The ID still resolves and conversations.info "
"still answers ok; the channel accepts nothing from anyone." % name)
if channel.get("is_general"):
return ("live", channel,
"#%s accepts messages, and it is the workspace default channel, "
"which carries its own posting restrictions." % name)
return ("live", channel, "#%s accepts messages" % name)
def silence_since(body):
"""Date the last message in a channel, or say why it cannot be dated. Pure.
An archived channel cannot be joined, so a bot that was never a member before
the archive can never read its history. That is a real limit and the script
reports it rather than guessing at a date.
"""
if body.get("ok") is not True:
error = body.get("error") or "<no error field>"
if error in ("not_in_channel", "channel_not_found"):
return ("undatable",
"conversations.history says %s. An archived channel cannot be "
"joined, so if the bot was not already a member the date is "
"unrecoverable from here." % error)
if error == "missing_scope":
return ("undatable",
"missing_scope: needed=%s. The archive is readable with the "
"right scope, but not with this token."
% (body.get("needed") or "channels:history"))
return ("undatable", "conversations.history answered ok: false, error=%s" % error)
messages = body.get("messages") or []
if not messages:
return ("empty",
"readable and holding no messages at all, which usually means the "
"channel was archived before anything was ever sent to it")
try:
when = datetime.fromtimestamp(float(messages[0].get("ts")), timezone.utc)
except (TypeError, ValueError):
return ("undatable", "the newest message carries no readable ts")
return ("dated", "last message %s, which is close enough to when the alerts "
"stopped arriving" % when.date().isoformat())
def successor(name, inventory):
"""Guess the live channel that replaced an archived one. Pure.
Conservative on purpose. It matches only on an obvious shared stem and
returns None rather than the nearest plausible room, because this is the one
output here that a reader might act on without checking.
"""
wanted = str(name or "").lower()
if not wanted:
return None
stem = wanted
for suffix in RETIRED_SUFFIXES:
if stem.endswith(suffix) and len(stem) > len(suffix):
stem = stem[: -len(suffix)]
break
candidates = []
for channel in inventory:
if channel.get("is_archived"):
continue
other = str(channel.get("name") or "").lower()
if not other:
continue
if (other == stem or other.startswith(wanted + "-")
or other.startswith(stem + "-")):
candidates.append(channel)
if not candidates:
return None
candidates.sort(key=lambda c: (len(str(c.get("name") or "")), str(c.get("name") or "")))
return candidates[0]
def sweep(session):
"""Every channel the token can enumerate, archives deliberately included."""
out, cursor = [], ""
while True:
params = {"types": "public_channel,private_channel",
"exclude_archived": "false", "limit": 1000}
if cursor:
params["cursor"] = cursor
body = session.get(API + "conversations.list", params=params, timeout=30).json()
if body.get("ok") is not True:
raise SystemExit("conversations.list answered 200 with ok: false, "
"error=%s" % body.get("error"))
out.extend(body.get("channels") or [])
cursor = (body.get("response_metadata") or {}).get("next_cursor") or ""
if not cursor:
return out
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("targets", nargs="+", help="channel IDs the integration sends to")
ap.add_argument("--token-env", default="SLACK_BOT_TOKEN",
help="environment variable holding the bot token")
args = ap.parse_args()
token = os.environ.get(args.token_env)
if not token:
log.error("set %s (channels:read and groups:read are enough for the sweep)",
args.token_env)
return 2
s = requests.Session()
s.headers.update({"Authorization": "Bearer " + token})
channels = sweep(s)
inventory = {c.get("id"): c for c in channels}
log.info("inventory: %d channel(s), %d archived", len(channels),
sum(1 for c in channels if c.get("is_archived")))
archived = invisible = 0
for cid in args.targets:
state, channel, detail = target_state(cid, inventory)
if state == "live":
log.info("%-9s %-12s %s", state, cid, detail)
continue
if state == "invisible":
invisible += 1
log.warning("%-9s %-12s %s", state, cid, detail)
log.warning(" repair: check the ID, then add groups:read and reinstall "
"if the channel is private")
continue
archived += 1
log.warning("%-9s %-12s %s", state, cid, detail)
history = s.get(API + "conversations.history",
params={"channel": cid, "limit": 1}, timeout=30).json()
dated, when = silence_since(history)
log.warning(" %-9s %s", dated, when)
moved = successor(channel.get("name"), channels)
if moved is not None:
log.warning(" repair: the traffic probably belongs in #%s (%s); confirm "
"with the team before you repoint it",
moved.get("name"), moved.get("id"))
log.warning(" repair: unarchive in the Slack UI, or point the integration "
"at a live channel, then assert is_archived at boot")
log.info("%d target(s) checked, %d archived, %d invisible",
len(args.targets), archived, invisible)
return 1 if (archived or invisible) else 0
if __name__ == "__main__":
sys.exit(main())
/**
* Find the integration targets that are pointing at archived Slack channels.
*
* Read only. One paginated conversations.list, plus one conversations.history
* per archived target to date the silence. Nothing is unarchived and nothing is
* posted: the script names the frozen channel, says when it went quiet, and
* suggests the live channel the traffic probably belongs in.
*/
const API = 'https://slack.com/api/';
// Suffixes a team adds when it retires a channel rather than renaming the new
// one. Stripping them is how #ops-alerts-old finds its way back to #ops-alerts.
const RETIRED_SUFFIXES = ['-old', '-archive', '-archived', '-deprecated', '-legacy',
'-retired', '-v1', '-tmp', '-temp'];
/**
* Sort one configured target against the swept inventory. Pure.
* `invisible` is deliberately not folded into an error bucket: it is a scope
* question, and it sends a reader to a different screen than an archive does.
*/
export function targetState(cid, inventory) {
const channel = inventory.get(cid);
if (!channel) {
return ['invisible', null,
'not in the sweep at all. Either the ID is wrong, or it is a private ' +
'channel this token has no scope to enumerate. Slack will not say which.'];
}
const name = channel.name ?? '?';
if (channel.is_archived) {
return ['archived', channel,
`#${name} is archived. The ID still resolves and conversations.info still ` +
'answers ok; the channel accepts nothing from anyone.'];
}
if (channel.is_general) {
return ['live', channel,
`#${name} accepts messages, and it is the workspace default channel, which ` +
'carries its own posting restrictions.'];
}
return ['live', channel, `#${name} accepts messages`];
}
/**
* Date the last message in a channel, or say why it cannot be dated. Pure.
* An archived channel cannot be joined, so a bot that was never a member before
* the archive can never read its history.
*/
export function silenceSince(body) {
if (body?.ok !== true) {
const error = body?.error ?? '<no error field>';
if (error === 'not_in_channel' || error === 'channel_not_found') {
return ['undatable',
`conversations.history says ${error}. An archived channel cannot be ` +
'joined, so if the bot was not already a member the date is unrecoverable ' +
'from here.'];
}
if (error === 'missing_scope') {
return ['undatable',
`missing_scope: needed=${body?.needed ?? 'channels:history'}. The archive ` +
'is readable with the right scope, but not with this token.'];
}
return ['undatable', `conversations.history answered ok: false, error=${error}`];
}
const messages = body.messages ?? [];
if (messages.length === 0) {
return ['empty',
'readable and holding no messages at all, which usually means the channel ' +
'was archived before anything was ever sent to it'];
}
const seconds = Number(messages[0]?.ts);
if (!Number.isFinite(seconds)) {
return ['undatable', 'the newest message carries no readable ts'];
}
const when = new Date(seconds * 1000).toISOString().slice(0, 10);
return ['dated',
`last message ${when}, which is close enough to when the alerts stopped arriving`];
}
/**
* Guess the live channel that replaced an archived one. Pure.
* Conservative on purpose: it returns null rather than the nearest plausible
* room, because this is the one output here a reader might act on unchecked.
*/
export function successor(name, inventory) {
const wanted = String(name ?? '').toLowerCase();
if (!wanted) return null;
let stem = wanted;
for (const suffix of RETIRED_SUFFIXES) {
if (stem.endsWith(suffix) && stem.length > suffix.length) {
stem = stem.slice(0, -suffix.length);
break;
}
}
const candidates = inventory.filter((channel) => {
if (channel.is_archived) return false;
const other = String(channel.name ?? '').toLowerCase();
if (!other) return false;
return other === stem || other.startsWith(`${wanted}-`) || other.startsWith(`${stem}-`);
});
if (candidates.length === 0) return null;
candidates.sort((a, b) => String(a.name).length - String(b.name).length
|| String(a.name).localeCompare(String(b.name)));
return candidates[0];
}
async function get(token, method, params) {
const res = await fetch(`${API}${method}?${new URLSearchParams(params)}`, {
headers: { Authorization: `Bearer ${token}` },
});
return res.json();
}
async function sweep(token) {
const out = [];
let cursor = '';
for (;;) {
const params = {
types: 'public_channel,private_channel',
exclude_archived: 'false',
limit: '1000',
};
if (cursor) params.cursor = cursor;
const body = await get(token, 'conversations.list', params);
if (body.ok !== true) {
throw new Error(`conversations.list answered 200 with ok: false, error=${body.error}`);
}
out.push(...(body.channels ?? []));
cursor = body.response_metadata?.next_cursor ?? '';
if (!cursor) return out;
}
}
function arg(args, name, fallback = null) {
const i = args.indexOf(name);
return i === -1 ? fallback : args[i + 1];
}
async function main() {
const args = process.argv.slice(2);
const tokenEnv = arg(args, '--token-env', 'SLACK_BOT_TOKEN');
const targets = args.filter((a) => !a.startsWith('--') && a !== tokenEnv);
if (targets.length === 0) {
console.error('usage: slack-archived-target-sweep.mjs C01ABCDE9 [C02XYZ123 ...]');
process.exitCode = 2;
return;
}
const token = process.env[tokenEnv];
if (!token) {
console.error(`set ${tokenEnv} (channels:read and groups:read are enough for the sweep)`);
process.exitCode = 2;
return;
}
const channels = await sweep(token);
const inventory = new Map(channels.map((c) => [c.id, c]));
const archivedCount = channels.filter((c) => c.is_archived).length;
console.log(`inventory: ${channels.length} channel(s), ${archivedCount} archived`);
let archived = 0;
let invisible = 0;
for (const cid of targets) {
const [state, channel, detail] = targetState(cid, inventory);
if (state === 'live') {
console.log(`${state.padEnd(9)} ${cid.padEnd(12)} ${detail}`);
continue;
}
if (state === 'invisible') {
invisible += 1;
console.warn(`${state.padEnd(9)} ${cid.padEnd(12)} ${detail}`);
console.warn(' repair: check the ID, then add groups:read and reinstall if ' +
'the channel is private');
continue;
}
archived += 1;
console.warn(`${state.padEnd(9)} ${cid.padEnd(12)} ${detail}`);
const history = await get(token, 'conversations.history', { channel: cid, limit: '1' });
const [dated, when] = silenceSince(history);
console.warn(` ${dated.padEnd(9)} ${when}`);
const moved = successor(channel.name, channels);
if (moved) {
console.warn(` repair: the traffic probably belongs in #${moved.name} ` +
`(${moved.id}); confirm with the team before you repoint it`);
}
console.warn(' repair: unarchive in the Slack UI, or point the integration at a ' +
'live channel, then assert is_archived at boot');
}
console.log(`${targets.length} target(s) checked, ${archived} archived, ` +
`${invisible} invisible`);
process.exitCode = archived || invisible ? 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
successor gets the most attention, because it is the only function here whose output is a suggestion and the only one that could plausibly send somebody to repoint an alert stream at the wrong room. The tests pin both naming conventions teams actually use, and pin the negative case hardest: an archived channel with no obvious replacement has to return nothing rather than the closest thing in the workspace.
from slack_archived_target_sweep import silence_since, successor, target_state
LIVE = {"id": "C01LIVE00", "name": "ops-alerts-v2", "is_archived": False}
DEAD = {"id": "C02DEAD00", "name": "ops-alerts", "is_archived": True}
GENERAL = {"id": "C03GEN000", "name": "general", "is_archived": False, "is_general": True}
def test_an_archived_target_is_a_finding_even_though_the_id_resolves():
state, channel, detail = target_state("C02DEAD00", {"C02DEAD00": DEAD})
assert state == "archived"
assert channel["id"] == "C02DEAD00"
assert "conversations.info still answers ok" in detail
def test_a_live_target_passes():
assert target_state("C01LIVE00", {"C01LIVE00": LIVE})[0] == "live"
def test_a_target_missing_from_the_sweep_is_invisible_not_archived():
state, channel, detail = target_state("C09GHOST0", {"C01LIVE00": LIVE})
assert state == "invisible"
assert channel is None
assert "not say which" in detail
def test_the_default_channel_is_still_live_but_says_so():
state, _, detail = target_state("C03GEN000", {"C03GEN000": GENERAL})
assert state == "live"
assert "default channel" in detail
def test_the_last_message_dates_the_silence():
state, detail = silence_since({"ok": True, "messages": [{"ts": "1735981953.000100"}]})
assert state == "dated"
assert "2025-01-04" in detail
def test_history_refused_to_a_non_member_is_undatable_not_wrong():
state, detail = silence_since({"ok": False, "error": "not_in_channel"})
assert state == "undatable"
assert "cannot be joined" in detail
def test_a_readable_but_empty_archive_is_its_own_answer():
assert silence_since({"ok": True, "messages": []})[0] == "empty"
def test_a_versioned_replacement_is_offered():
moved = successor("ops-alerts", [DEAD, LIVE])
assert moved["name"] == "ops-alerts-v2"
def test_a_retired_suffix_finds_its_way_back_to_the_stem():
old = {"id": "C04OLD000", "name": "ops-alerts-old", "is_archived": True}
live = {"id": "C05NEW000", "name": "ops-alerts", "is_archived": False}
assert successor("ops-alerts-old", [old, live])["name"] == "ops-alerts"
def test_the_shortest_candidate_wins():
inventory = [DEAD, LIVE, {"id": "C06LONG00", "name": "ops-alerts-v2-testing",
"is_archived": False}]
assert successor("ops-alerts", inventory)["name"] == "ops-alerts-v2"
def test_no_obvious_replacement_returns_nothing_at_all():
inventory = [DEAD, {"id": "C07RAND00", "name": "random", "is_archived": False}]
assert successor("ops-alerts", inventory) is None
def test_an_archived_lookalike_is_never_offered_as_a_successor():
twin = {"id": "C08DEAD00", "name": "ops-alerts-v2", "is_archived": True}
assert successor("ops-alerts", [DEAD, twin]) is None
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { silenceSince, successor, targetState }
from './slack-archived-target-sweep.mjs';
const LIVE = { id: 'C01LIVE00', name: 'ops-alerts-v2', is_archived: false };
const DEAD = { id: 'C02DEAD00', name: 'ops-alerts', is_archived: true };
const GENERAL = { id: 'C03GEN000', name: 'general', is_archived: false, is_general: true };
test('an archived target is a finding even though the id resolves', () => {
const [state, channel, detail] = targetState('C02DEAD00', new Map([['C02DEAD00', DEAD]]));
assert.equal(state, 'archived');
assert.equal(channel.id, 'C02DEAD00');
assert.match(detail, /conversations\.info still answers ok/);
});
test('a live target passes', () => {
assert.equal(targetState('C01LIVE00', new Map([['C01LIVE00', LIVE]]))[0], 'live');
});
test('a target missing from the sweep is invisible not archived', () => {
const [state, channel, detail] = targetState('C09GHOST0', new Map([['C01LIVE00', LIVE]]));
assert.equal(state, 'invisible');
assert.equal(channel, null);
assert.match(detail, /not say which/);
});
test('the default channel is still live but says so', () => {
const [state, , detail] = targetState('C03GEN000', new Map([['C03GEN000', GENERAL]]));
assert.equal(state, 'live');
assert.match(detail, /default channel/);
});
test('the last message dates the silence', () => {
const [state, detail] = silenceSince({ ok: true, messages: [{ ts: '1735981953.000100' }] });
assert.equal(state, 'dated');
assert.match(detail, /2025-01-04/);
});
test('history refused to a non member is undatable not wrong', () => {
const [state, detail] = silenceSince({ ok: false, error: 'not_in_channel' });
assert.equal(state, 'undatable');
assert.match(detail, /cannot be joined/);
});
test('a readable but empty archive is its own answer', () => {
assert.equal(silenceSince({ ok: true, messages: [] })[0], 'empty');
});
test('a versioned replacement is offered', () => {
assert.equal(successor('ops-alerts', [DEAD, LIVE]).name, 'ops-alerts-v2');
});
test('a retired suffix finds its way back to the stem', () => {
const old = { id: 'C04OLD000', name: 'ops-alerts-old', is_archived: true };
const live = { id: 'C05NEW000', name: 'ops-alerts', is_archived: false };
assert.equal(successor('ops-alerts-old', [old, live]).name, 'ops-alerts');
});
test('the shortest candidate wins', () => {
const inventory = [DEAD, LIVE,
{ id: 'C06LONG00', name: 'ops-alerts-v2-testing', is_archived: false }];
assert.equal(successor('ops-alerts', inventory).name, 'ops-alerts-v2');
});
test('no obvious replacement returns nothing at all', () => {
const inventory = [DEAD, { id: 'C07RAND00', name: 'random', is_archived: false }];
assert.equal(successor('ops-alerts', inventory), null);
});
test('an archived lookalike is never offered as a successor', () => {
const twin = { id: 'C08DEAD00', name: 'ops-alerts-v2', is_archived: true };
assert.equal(successor('ops-alerts', [DEAD, twin]), null);
});
FAQ
Can a bot unarchive a channel?
Not usefully. conversations.unarchive exists for public channels and needs a write scope this section's scripts deliberately do not hold, and there is no unarchive for private channels at all. In practice a workspace admin unarchives from the Slack UI. That is why the script prints the repair rather than performing it: the useful automation here is the detection, not the action.
Why does my webhook get a 410 when the Web API returns 200?
Incoming webhooks are one of the few Slack surfaces that answer with real HTTP status codes and a plain-text body, so an archived channel comes back as 410 Gone with channel_is_archived. The Web API keeps its usual habit and returns 200 with ok: false and error: is_archived. The same fault, two completely different shapes, which is why webhook integrations tend to find this in days.
Does the bot stay a member of an archived channel?
Yes. Membership, history and the channel ID all survive archiving, which is exactly why membership checks pass while sends fail. If your health check asks is_member and stops there, an archived channel reports as healthy. Both booleans have to be read, and is_archived has to be read first because it outranks membership entirely.
How far back can conversations.history date the silence?
As far as the workspace retention policy allows. On a free plan, history beyond the visible window is not returned at all, so a channel archived a year ago may come back empty and the script reports it as empty rather than dated. Treat the date as corroboration when you get it and do not build the report around always having one.
Should the script pick the successor channel automatically?
No, and the successor function is written to under-reach for exactly that reason. Matching #ops-alerts to #ops-alerts-v2 is a naming convention, not a fact Slack asserts, and repointing an alert stream at the wrong room is a worse outcome than the alerts staying stopped for another hour. It prints a candidate with its ID and asks a human to confirm.
Related field notes
- membership passes while the send still fails
- conversations.history clamped to one call a minute
- every failure arrives as HTTP 200
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.
- conversations.info method reference — Slack Docs
- conversations.list method reference — Slack Docs
- conversations.history method reference — Slack Docs
- Sending messages using incoming webhooks — Slack Docs
If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.