Diagnostic Slack
DMs delivered into deactivated accounts, with ok: true
The approval bot has a 96% delivery rate and a 41% response rate, and nobody can explain the gap. Every send returns ok: true. The messages are arriving, in DMs, with people who left the company: the account is deactivated, the conversation still exists, and Slack is faithfully writing into a room that nobody will ever open again.
Deactivation does not delete anything. The user record stays, with deleted: true on it, and the DM conversation your app opened at signup stays with it. Posting into that existing D channel still returns ok: true. There is no error, no bounce, and no signal of any kind that the recipient stopped existing eight months ago.
So the check is a join, not a send. Page users.list, build the directory, and put your recipient table beside it. The script below sorts each recipient into what a send would actually do: silently accepted for a deactivated account, refused loudly for another app with cannot_dm_bot, delivered but possibly useless for a single-channel guest, or genuinely delivered. Loud failures are already visible in your logs. The silent one is the reason the numbers do not add up.
The problem in plain words
The recipient list is built once and never revisited. Someone signs up, the app opens a DM, the D ID goes into a table, and that row is now permanent. People leave, and their leaving is handled thoroughly everywhere else — SSO, the HR system, the badge — and not in your table, because nothing in the offboarding checklist knows your table exists.
What makes this specifically a Slack problem is the direction the failure points. A dead email address bounces. A disconnected number errors. A deactivated Slack account accepts, because the conversation is a real conversation and the write into it is a legitimate write. From the API's point of view nothing has gone wrong, and it is right: the message was stored. It is only wrong from yours.
The result is a slow accumulation with no threshold. Six months in, a percent of the sends go nowhere. Two years in it is a fifth, and the delivery metric still reads 100% because the metric is counting ok: true. The first person to notice is usually not an engineer but someone in operations asking why approvals from one team never come back.
Why it happens
The user record outlives the user. Slack keeps deactivated accounts so that old messages still render with a name and an avatar, so users.info keeps answering and the ID keeps resolving forever. Any check that only asks "is this a real user ID" passes for every person who has ever left.
The silent case is the expensive one. cannot_dm_bot and user_not_found are loud: they turn up in logs, they fail a run, somebody eventually greps for them. A deactivated recipient produces no artefact at all, which is why the script sorts by how a send fails rather than by what the recipient is — the grouping that matters operationally is silent against loud.
Deactivation is the state you can read; timing is approximate. The user object carries an updated timestamp, which for a deactivated account is usually when the deactivation happened, because it is the last thing that changed about them. It is close enough to size the problem and not an audit record, and the script says which of those it is offering.
Bots and guests fail differently and belong in the report anyway. An app cannot DM another app: that attempt fails outright. A single-channel guest can be DM'd, but a message full of links to channels they cannot open is delivered and useless. Neither is the headline, and both are in your recipient list for the same reason the deactivated accounts are — nobody has looked at it since it was built.
The fix is a schedule plus an event. Joining against users.list weekly catches everything eventually; subscribing to user_change, which fires with deleted: true on deactivation, catches it the same day. The two together mean the list is correct in near real time and self-heals if the events are ever missed.
The fix, as a flow
There is no red arrow in the problem chain because there is no failure in it. Every step succeeds, the metric agrees, and the messages go nowhere. The fix branch sorts by how a send fails rather than by what the recipient is, which puts the one invisible outcome at the top and the ones already in your logs below it.
How to fix it
Page the whole directory once
users.list?limit=200, following next_cursor to the end. It is a Tier 2 method and a large workspace is many pages, so do this once per run and index it by ID rather than calling users.info per recipient, which is a call each and rate-limits at scale.
Sort recipients by what the account is
deleted first, because it outranks everything else about a recipient. Then is_bot and is_app_user, then the guest flags. The order is the point: a deactivated bot is a deactivated recipient, and reporting it as a bot sends the reader to the wrong repair.
Translate that into what a send would do
This is the step that makes the report actionable. Deactivated is silent acceptance. A bot recipient is a loud refusal you can already see in your logs. A restricted guest is delivery into an account that may not be able to open half of what the message references. Sorting by loudness puts the invisible failures at the top where they belong.
Date the silence from the user record
The updated field on a deactivated account is usually the deactivation, which turns "this recipient is dead" into "this recipient has been dead since October" and lets you multiply out how many messages went nowhere. Report it as an approximation, because that is what it is.
Report the recipients that are not in the directory at all
An ID in your table with no matching user is not a deactivated account; it is an ID from another workspace, a Grid org this token cannot see, or a corrupted row. It needs a different investigation and it must not be counted as offboarded.
Subscribe to user_change and re-run the join on a schedule
user_change fires with deleted: true when somebody is deactivated, which makes the cleanup event-driven and same-day. Keep the scheduled join anyway as the backstop: events can be missed while a Request URL is unhealthy, and the join does not care why a row went stale.
How to check it worked
After the join has been applied and the dead rows marked inactive, every remaining recipient should be an active human.
python3 slack_dm_recipient_audit.py --recipients recipients.json
# directory: 1842 user record(s), 311 deactivated
# active U01ALICE99 delivered, and somebody is there to read it
# active U02BOBBB88 delivered, and somebody is there to read it
# 2 recipient(s) checked, 0 silent, 0 loud
The full code
recipient_class reads one users.list record in a fixed order, because a deactivated bot has to be reported as deactivated rather than as a bot. delivery_outcome is the function worth stealing: it turns that class into what a send actually does, which sorts the findings into the ones already visible in your logs and the one that never will be. dormant_since dates the silence and is careful to describe itself as an approximation.
"""Find the DM recipients that no longer read anything.
Read only. One paginated users.list, joined against the recipient table your
application keeps. Nothing is sent and no row is rewritten: the script names
the recipients whose sends succeed into a void, dates them where the directory
allows, and prints the repair.
"""
import argparse
import json
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_dm_recipient_audit")
API = "https://slack.com/api/"
# Sorted by how a send fails rather than by what the recipient is. Loud failures
# are already in your logs; the silent one is the reason a delivery metric can
# read 100% while a fifth of the messages go nowhere.
OUTCOMES = {
"deactivated": ("silent",
"the DM conversation still exists, so the send returns ok: "
"true and the message is stored where nobody will read it"),
"bot": ("loud",
"apps cannot DM other apps; the attempt fails with cannot_dm_bot and "
"is already visible in your logs"),
"single-channel-guest": ("partial",
"delivered, but this account can reach one channel, "
"so anything the message links to is probably closed "
"to them"),
"guest": ("partial",
"delivered, though a multi-channel guest may not be able to open "
"every channel the message references"),
"active": ("delivered", "delivered, and somebody is there to read it"),
"unknown": ("loud",
"no user record in this workspace. A send returns user_not_found "
"rather than succeeding, so this is a broken row and not an "
"offboarded person."),
}
def recipient_class(user):
"""Sort one users.list record by what it means for a DM. Pure.
Order matters and deactivation comes first: a deactivated bot is a
deactivated recipient, and reporting it as a bot sends the reader to the
wrong repair.
"""
if user is None:
return ("unknown", "no matching record in the directory")
if user.get("deleted"):
return ("deactivated", "deleted is true; the account was deactivated")
if user.get("is_bot") or user.get("is_app_user"):
return ("bot", "this recipient is an app, not a person")
if user.get("is_ultra_restricted"):
return ("single-channel-guest", "a single-channel guest account")
if user.get("is_restricted"):
return ("guest", "a multi-channel guest account")
return ("active", "an active member of the workspace")
def delivery_outcome(kind):
"""What a send to this recipient actually does. Pure.
Returns (loudness, detail). The split that matters is silent against loud:
one of these is already in your logs and one has never appeared anywhere.
"""
return OUTCOMES.get(kind, OUTCOMES["unknown"])
def dormant_since(user):
"""Date the silence from the user record. Pure.
users.list returns `updated`, the last time the record changed, which for a
deactivated account is usually the deactivation itself. That is close enough
to size the problem and is not an audit record, so it is reported as the
approximation it is.
"""
stamp = (user or {}).get("updated")
if not stamp:
return ("undatable",
"the directory record carries no updated timestamp, so how long "
"this has been true cannot be recovered here")
when = datetime.fromtimestamp(int(stamp), tz=timezone.utc)
return ("approximate",
"the record last changed on %s, which for a deactivated account is "
"usually the deactivation" % when.date().isoformat())
def roster_diff(recipients, directory):
"""Split the recipient list by whether the directory knows the ID. Pure."""
known = sorted(r for r in recipients if r in directory)
unknown = sorted(r for r in recipients if r not in directory)
return {"known": known, "unknown": unknown}
def list_users(session):
"""The whole user directory. GET, paginated, Tier 2, so once per run."""
out, cursor = [], ""
while True:
params = {"limit": 200}
if cursor:
params["cursor"] = cursor
body = session.get(API + "users.list", params=params, timeout=30).json()
if body.get("ok") is not True:
raise SystemExit("users.list answered 200 with ok: false, error=%s"
% body.get("error"))
out.extend(body.get("members") 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("--recipients", required=True,
help="JSON list of the user IDs your application DMs")
ap.add_argument("--token-env", default="SLACK_BOT_TOKEN")
args = ap.parse_args()
token = os.environ.get(args.token_env)
if not token:
log.error("set %s (users:read is enough)", args.token_env)
return 2
recipients = json.loads(open(args.recipients, encoding="utf-8").read())
session = requests.Session()
session.headers.update({"Authorization": "Bearer " + token})
members = list_users(session)
directory = {m.get("id"): m for m in members if m.get("id")}
log.info("directory: %d user record(s), %d deactivated", len(directory),
sum(1 for m in directory.values() if m.get("deleted")))
split = roster_diff(recipients, directory)
silent = loud = 0
for user_id in split["known"] + split["unknown"]:
kind, why = recipient_class(directory.get(user_id))
loudness, outcome = delivery_outcome(kind)
if loudness == "delivered":
log.info("%-22s %-12s %s", kind, user_id, outcome)
continue
if loudness == "silent":
silent += 1
elif loudness == "loud":
loud += 1
log.warning("%-22s %-12s %s", kind, user_id, outcome)
log.warning(" why: %s", why)
if kind == "deactivated":
_, dated = dormant_since(directory.get(user_id))
log.warning(" since: %s", dated)
log.warning(" repair: mark %s inactive in your recipient table", user_id)
elif kind == "bot":
log.warning(" repair: filter is_bot and is_app_user out of the "
"recipient set before it is stored")
elif kind == "unknown":
log.warning(" repair: this ID belongs to another workspace or is a "
"corrupted row; investigate it separately from offboarding")
else:
log.warning(" repair: check that the channels this message links to "
"are reachable by a guest, or route around them")
log.info("%d recipient(s) checked, %d silent, %d loud",
len(recipients), silent, loud)
return 1 if silent or loud else 0
if __name__ == "__main__":
sys.exit(main())
/**
* Find the DM recipients that no longer read anything.
*
* Read only. One paginated users.list, joined against the recipient table your
* application keeps. Nothing is sent and no row is rewritten: the script names
* the recipients whose sends succeed into a void and prints the repair.
*/
import { readFile } from 'node:fs/promises';
const API = 'https://slack.com/api/';
// Sorted by how a send fails rather than by what the recipient is. Loud failures
// are already in your logs; the silent one is the reason a delivery metric can
// read 100% while a fifth of the messages go nowhere.
const OUTCOMES = {
deactivated: ['silent',
'the DM conversation still exists, so the send returns ok: true and the ' +
'message is stored where nobody will read it'],
bot: ['loud',
'apps cannot DM other apps; the attempt fails with cannot_dm_bot and is ' +
'already visible in your logs'],
'single-channel-guest': ['partial',
'delivered, but this account can reach one channel, so anything the message ' +
'links to is probably closed to them'],
guest: ['partial',
'delivered, though a multi-channel guest may not be able to open every ' +
'channel the message references'],
active: ['delivered', 'delivered, and somebody is there to read it'],
unknown: ['loud',
'no user record in this workspace. A send returns user_not_found rather than ' +
'succeeding, so this is a broken row and not an offboarded person.'],
};
/**
* Sort one users.list record by what it means for a DM. Pure.
* Deactivation comes first: a deactivated bot is a deactivated recipient, and
* reporting it as a bot sends the reader to the wrong repair.
*/
export function recipientClass(user) {
if (user === null || user === undefined) {
return ['unknown', 'no matching record in the directory'];
}
if (user.deleted) return ['deactivated', 'deleted is true; the account was deactivated'];
if (user.is_bot || user.is_app_user) return ['bot', 'this recipient is an app, not a person'];
if (user.is_ultra_restricted) return ['single-channel-guest', 'a single-channel guest account'];
if (user.is_restricted) return ['guest', 'a multi-channel guest account'];
return ['active', 'an active member of the workspace'];
}
/**
* What a send to this recipient actually does. Pure.
* The split that matters is silent against loud: one of these is already in
* your logs and one has never appeared anywhere.
*/
export function deliveryOutcome(kind) {
return OUTCOMES[kind] ?? OUTCOMES.unknown;
}
/**
* Date the silence from the user record. Pure.
* `updated` is the last time the record changed, which for a deactivated
* account is usually the deactivation itself. An approximation, reported as one.
*/
export function dormantSince(user) {
const stamp = user?.updated;
if (!stamp) {
return ['undatable',
'the directory record carries no updated timestamp, so how long this has ' +
'been true cannot be recovered here'];
}
const when = new Date(Number(stamp) * 1000).toISOString().slice(0, 10);
return ['approximate',
`the record last changed on ${when}, which for a deactivated account is ` +
'usually the deactivation'];
}
/** Split the recipient list by whether the directory knows the ID. Pure. */
export function rosterDiff(recipients, directory) {
const has = (r) => Object.prototype.hasOwnProperty.call(directory, r);
return {
known: recipients.filter(has).sort(),
unknown: recipients.filter((r) => !has(r)).sort(),
};
}
async function listUsers(token) {
const out = [];
let cursor = '';
for (;;) {
const params = new URLSearchParams({ limit: '200' });
if (cursor) params.set('cursor', cursor);
const res = await fetch(`${API}users.list?${params}`, {
headers: { Authorization: `Bearer ${token}` },
});
const body = await res.json();
if (body.ok !== true) {
throw new Error(`users.list answered 200 with ok: false, error=${body.error}`);
}
out.push(...(body.members ?? []));
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 recipientsPath = arg(args, '--recipients');
if (!recipientsPath) {
console.error('usage: --recipients recipients.json [--token-env SLACK_BOT_TOKEN]');
process.exitCode = 2;
return;
}
const tokenEnv = arg(args, '--token-env', 'SLACK_BOT_TOKEN');
const token = process.env[tokenEnv];
if (!token) {
console.error(`set ${tokenEnv} (users:read is enough)`);
process.exitCode = 2;
return;
}
const recipients = JSON.parse(await readFile(recipientsPath, 'utf8'));
const members = await listUsers(token);
const directory = Object.fromEntries(members.filter((m) => m.id).map((m) => [m.id, m]));
const dead = Object.values(directory).filter((m) => m.deleted).length;
console.log(`directory: ${Object.keys(directory).length} user record(s), ${dead} deactivated`);
const split = rosterDiff(recipients, directory);
let silent = 0;
let loud = 0;
for (const userId of [...split.known, ...split.unknown]) {
const [kind, why] = recipientClass(directory[userId]);
const [loudness, outcome] = deliveryOutcome(kind);
if (loudness === 'delivered') {
console.log(`${kind.padEnd(22)} ${userId.padEnd(12)} ${outcome}`);
continue;
}
if (loudness === 'silent') silent += 1;
else if (loudness === 'loud') loud += 1;
console.warn(`${kind.padEnd(22)} ${userId.padEnd(12)} ${outcome}`);
console.warn(` why: ${why}`);
if (kind === 'deactivated') {
const [, dated] = dormantSince(directory[userId]);
console.warn(` since: ${dated}`);
console.warn(` repair: mark ${userId} inactive in your recipient table`);
} else if (kind === 'bot') {
console.warn(' repair: filter is_bot and is_app_user out of the recipient ' +
'set before it is stored');
} else if (kind === 'unknown') {
console.warn(' repair: this ID belongs to another workspace or is a corrupted ' +
'row; investigate it separately from offboarding');
} else {
console.warn(' repair: check that the channels this message links to are ' +
'reachable by a guest, or route around them');
}
}
console.log(`${recipients.length} recipient(s) checked, ${silent} silent, ${loud} loud`);
process.exitCode = silent || loud ? 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 recipient list.
if (import.meta.url === `file://${process.argv[1]}`) {
main().catch((err) => { console.error(err.message); process.exitCode = 2; });
}
Add a test
The ordering inside recipient_class is load-bearing, so a record that is both a bot and deactivated is asserted to come back deactivated: it is the repair that differs, not the trivia. The outcome tests pin that exactly one class is silent, since that is the finding the whole note exists for, and that an ID missing from the directory is treated as a broken row rather than folded in with the people who left.
from slack_dm_recipient_audit import (delivery_outcome, dormant_since,
recipient_class, roster_diff)
ALICE = {"id": "U01ALICE99", "updated": 1725000000}
GONE = {"id": "U02GONE888", "deleted": True, "updated": 1760000000}
APP = {"id": "U03BOTBOT7", "is_bot": True}
GUEST = {"id": "U04GUEST66", "is_restricted": True}
SINGLE = {"id": "U05ONECH55", "is_restricted": True, "is_ultra_restricted": True}
DIRECTORY = {u["id"]: u for u in (ALICE, GONE, APP, GUEST, SINGLE)}
def test_an_ordinary_member_is_active():
assert recipient_class(ALICE)[0] == "active"
def test_a_deactivated_account_is_the_headline_class():
kind, why = recipient_class(GONE)
assert kind == "deactivated"
assert "deactivated" in why
def test_deactivation_outranks_being_a_bot():
assert recipient_class(dict(APP, deleted=True))[0] == "deactivated"
def test_an_app_user_counts_as_a_bot_recipient():
assert recipient_class({"is_app_user": True})[0] == "bot"
def test_a_single_channel_guest_is_not_folded_in_with_other_guests():
assert recipient_class(SINGLE)[0] == "single-channel-guest"
assert recipient_class(GUEST)[0] == "guest"
def test_a_missing_record_is_unknown_rather_than_deactivated():
assert recipient_class(None)[0] == "unknown"
def test_only_the_deactivated_case_is_silent():
assert delivery_outcome("deactivated")[0] == "silent"
for kind in ("bot", "unknown"):
assert delivery_outcome(kind)[0] == "loud"
assert delivery_outcome("active")[0] == "delivered"
def test_the_silent_outcome_says_the_send_succeeds():
_, detail = delivery_outcome("deactivated")
assert "ok: true" in detail
def test_an_unrecognised_class_falls_back_to_the_loud_row():
assert delivery_outcome("something-new") == delivery_outcome("unknown")
def test_the_updated_timestamp_dates_the_silence_as_an_approximation():
state, detail = dormant_since(GONE)
assert state == "approximate"
assert "2025-10" in detail
def test_a_record_without_a_timestamp_is_not_given_a_date():
assert dormant_since({"deleted": True})[0] == "undatable"
assert dormant_since(None)[0] == "undatable"
def test_recipients_outside_the_directory_are_kept_separate():
split = roster_diff(["U01ALICE99", "U99OTHERWS"], DIRECTORY)
assert split["known"] == ["U01ALICE99"]
assert split["unknown"] == ["U99OTHERWS"]
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { deliveryOutcome, dormantSince, recipientClass, rosterDiff }
from './slack-dm-recipient-audit.mjs';
const ALICE = { id: 'U01ALICE99', updated: 1725000000 };
const GONE = { id: 'U02GONE888', deleted: true, updated: 1760000000 };
const APP = { id: 'U03BOTBOT7', is_bot: true };
const GUEST = { id: 'U04GUEST66', is_restricted: true };
const SINGLE = { id: 'U05ONECH55', is_restricted: true, is_ultra_restricted: true };
const DIRECTORY = Object.fromEntries(
[ALICE, GONE, APP, GUEST, SINGLE].map((u) => [u.id, u]));
test('an ordinary member is active', () => {
assert.equal(recipientClass(ALICE)[0], 'active');
});
test('a deactivated account is the headline class', () => {
const [kind, why] = recipientClass(GONE);
assert.equal(kind, 'deactivated');
assert.match(why, /deactivated/);
});
test('deactivation outranks being a bot', () => {
assert.equal(recipientClass({ ...APP, deleted: true })[0], 'deactivated');
});
test('an app user counts as a bot recipient', () => {
assert.equal(recipientClass({ is_app_user: true })[0], 'bot');
});
test('a single channel guest is not folded in with other guests', () => {
assert.equal(recipientClass(SINGLE)[0], 'single-channel-guest');
assert.equal(recipientClass(GUEST)[0], 'guest');
});
test('a missing record is unknown rather than deactivated', () => {
assert.equal(recipientClass(null)[0], 'unknown');
assert.equal(recipientClass(undefined)[0], 'unknown');
});
test('only the deactivated case is silent', () => {
assert.equal(deliveryOutcome('deactivated')[0], 'silent');
assert.equal(deliveryOutcome('bot')[0], 'loud');
assert.equal(deliveryOutcome('unknown')[0], 'loud');
assert.equal(deliveryOutcome('active')[0], 'delivered');
});
test('the silent outcome says the send succeeds', () => {
assert.match(deliveryOutcome('deactivated')[1], /ok: true/);
});
test('an unrecognised class falls back to the loud row', () => {
assert.deepEqual(deliveryOutcome('something-new'), deliveryOutcome('unknown'));
});
test('the updated timestamp dates the silence as an approximation', () => {
const [state, detail] = dormantSince(GONE);
assert.equal(state, 'approximate');
assert.match(detail, /2025-10/);
});
test('a record without a timestamp is not given a date', () => {
assert.equal(dormantSince({ deleted: true })[0], 'undatable');
assert.equal(dormantSince(null)[0], 'undatable');
});
test('recipients outside the directory are kept separate', () => {
const split = rosterDiff(['U01ALICE99', 'U99OTHERWS'], DIRECTORY);
assert.deepEqual(split.known, ['U01ALICE99']);
assert.deepEqual(split.unknown, ['U99OTHERWS']);
});
FAQ
Why does posting to a deactivated user's DM return ok: true?
Because the conversation is real and the write is legitimate. Deactivation removes the person's ability to sign in; it does not delete their account, their history, or the DM conversation your app opened with them. Slack stores the message exactly as asked. Nothing has failed from the API's point of view, which is why nothing is reported.
Is users.list the only way to find deactivated recipients?
It is the efficient way. users.info per recipient gives the same deleted flag but costs a call each, and at a few thousand recipients that rate-limits long before it finishes. Page the directory once, index it by ID, and join in memory. On a large Enterprise Grid workspace consider the cursor pages carefully: users.list is Tier 2.
What does the updated field actually tell me?
The last time the user record changed. For an account that was deactivated and never touched again, that is effectively the deactivation date, which is enough to estimate how long the messages have been going nowhere. It is not an audit log: a profile edit before deactivation, or an admin change after it, will move the timestamp.
Should deactivated recipients be deleted from our table or just marked?
Marked. Deactivated accounts are sometimes reactivated when someone returns or when an offboarding was reversed, and the DM conversation ID stays valid throughout, so a row you kept is a row you do not have to rebuild. Marking also preserves the history of who was on a notification list, which is often the thing an audit asks for.
How do we stop the list going stale again?
Subscribe to user_change, which fires with deleted true when an account is deactivated, and update the row when it arrives. Keep the scheduled join as a backstop: events can be missed while a Request URL is unhealthy or a subscription is disabled, and the join notices a stale row regardless of why it went stale.
Related field notes
- the other half of the user directory
- when the deactivated user is yours
- the directory pages you never read
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.
- users.list method reference — Slack Docs
- users.info method reference — Slack Docs
- user_change event reference — Slack Docs
- Pagination in the Web API — Slack Docs
If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.