Diagnostic Slack
not_in_channel: the bot was never invited to the channel
The app is installed. The token authenticates. The channel ID was copied out of the URL and is correct. Every call still comes back {"ok": false, "error": "not_in_channel"}, because installing an app to a workspace does not put it in a single channel — and this is, by view count, the most-asked Slack API question there is.
Call conversations.info?channel=<C...> for every channel the app targets and read channel.is_member. That field reports membership for the calling token directly. For a full sweep, users.conversations?user=<bot_user_id>&types=public_channel,private_channel returns every conversation the bot belongs to in one paginated pass.
The repair depends on one other field. If is_private is false, the bot can join itself with conversations.join and channels:join. If it is true, no API call joins it: a human member has to run /invite @YourApp.
The problem in plain words
Installation and membership are separate things, and only the first one is visible in the app configuration. The OAuth screen lists scopes, the install succeeds, the token works, auth.test is happy — and the bot is in zero channels. Nothing in that flow suggests a step is missing.
It bites automated pipelines hardest. A channel created by Terraform or by a CI job exists, has the right name, appears in conversations.list, and has no bot in it, because channel creation and bot invitation are two separate calls and only the first one got automated. The alerting integration that posts into it looks configured and correct in every place a human would check.
And because the failure is a 200, the send queue records a success. The message that never arrived is not in a dead-letter queue anywhere; it was accepted, discarded, and logged as delivered.
Why it happens
A bot is a member of a channel or it is not, independently of the install. Scopes govern what the app may do; membership governs where it may do it. chat:write on a token that is in no channels posts nowhere.
Private channels cannot be self-joined, ever. conversations.join works on public channels only. For a private channel the sole route in is an invitation from someone already inside it, which means the fix is a message to a human rather than a code change.
channel_not_found is ambiguous on purpose. A token without groups:read cannot see private channels at all, so "this channel does not exist" and "I am not allowed to know whether it exists" come back as the same error. Do not report the first when you cannot rule out the second.
Membership is lost as quietly as it is gained. Someone removes the app from a channel, or a public channel is converted to private and the bot loses access. Nothing notifies your code; the next post simply returns 200 with not_in_channel forever.
chat:write.public is not a general fix. It lets an app post to public channels without joining, which papers over posting. It does not grant history: conversations.history still returns not_in_channel.
The fix, as a flow
The script reads is_archived before is_member, because an archived channel refuses members too and reporting it as a membership gap sends somebody to invite a bot into a room that accepts nothing.
How to fix it
Get the bot's own user ID
auth.test returns user_id for the token in hand — for a bot token that is the bot user, the U/W id you will need in the conversations.invite that repairs this. It also confirms which workspace the token is actually pointed at, which is occasionally the whole answer.
Ask each target channel whether the bot is in it
conversations.info?channel=<C...> and read channel.is_member. This is a per-token answer, so it reports the truth about this credential rather than about the app in general.
Read is_archived before you read is_member
An archived channel refuses posts from members and non-members alike. Reporting "not a member" for a channel that was archived six months ago sends someone to invite a bot into a room that no longer accepts anything.
Split the repair on is_private
Public: conversations.join with channels:join, or an invite. Private: a human member must invite the app, and groups:read is needed before the script can even see the channel. These are different tickets for different people.
Sweep the whole set rather than one channel at a time
users.conversations for the bot user returns every conversation it belongs to, paginated. Diff that set against your configured target channels and you have the complete gap in one pass, which is the version worth putting on a schedule.
How to check it worked
Invite the app, then re-run over the same channel list. Every channel should report member.
python3 slack_channel_membership.py C0123ABCDEF C0456GHIJKL
# 2 channel(s) checked, 0 the bot cannot post to
The full code
Two read methods, both GET: auth.test once for the bot user ID, then conversations.info per channel. The classifier is pure and takes the whole response rather than a boolean, because four of its six answers come from fields other than is_member — and because ok: false has to be handled here as carefully as everywhere else in this section.
"""Report Slack channels the bot cannot post to, and why.
Read only. GET requests and nothing else: give this a bot token with
channels:read and groups:read. The repair is printed, never performed, because
this token can post into your workspace.
"""
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_channel_membership")
API = "https://slack.com/api/"
def verdict(body):
"""Classify one conversations.info response. Pure, so it runs offline.
Order matters: an archived channel refuses everyone, so it outranks
membership, and ok: false outranks both because there is no channel object
to read at all.
"""
if body.get("ok") is not True:
error = body.get("error") or "<no error field>"
if error == "channel_not_found":
return ("not-found",
"channel_not_found. Either the ID is wrong, or it is a private "
"channel this token cannot see. Those are indistinguishable "
"without groups:read.")
if error == "missing_scope":
return ("scope",
"missing_scope: needed=%s. Membership is unknown until the "
"token can read the channel." % (body.get("needed") or "?"))
return ("error", "ok: false, error=%s" % error)
channel = body.get("channel") or {}
if channel.get("is_archived"):
return ("archived",
"archived. Membership is beside the point: an archived channel "
"accepts nothing from anyone until it is unarchived.")
if channel.get("is_member"):
return ("member", "the bot is in this channel")
if channel.get("is_private"):
return ("not-member-private",
"not a member, and private. No API call joins a private channel: "
"a human member has to invite the app.")
return ("not-member-public",
"not a member. Public, so the app can join itself with channels:join, "
"or somebody can invite it.")
def get(session, method, **params):
r = session.get(API + method, params=params, timeout=30)
body = r.json()
return body
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("channels", nargs="+", help="channel IDs the app targets (C..., G...)")
args = ap.parse_args()
token = os.environ.get("SLACK_BOT_TOKEN")
if not token:
log.error("set SLACK_BOT_TOKEN (channels:read and groups:read 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 = me.get("user_id")
log.info("token acts as %s (%s) in %s", me.get("user"), bot, me.get("team"))
bad = 0
for cid in args.channels:
body = get(s, "conversations.info", channel=cid)
state, detail = verdict(body)
name = (body.get("channel") or {}).get("name", "?")
line = "%-19s %-12s #%s %s" % (state, cid, name, detail)
if state == "member":
log.info(line)
continue
bad += 1
log.warning(line)
if state == "not-member-public":
log.warning(" repair: /invite @YourApp in #%s, or call conversations.join "
"with channels:join", name)
log.warning(" in a pipeline: conversations.invite channel=%s users=%s",
cid, bot)
elif state == "not-member-private":
log.warning(" repair: a member of the private channel runs /invite @YourApp; "
"the app cannot let itself in")
elif state == "archived":
log.warning(" repair: unarchive the channel, or point the app at a live one")
elif state == "not-found":
log.warning(" repair: check the ID, then add groups:read and reinstall "
"if the channel is private")
log.info("%d channel(s) checked, %d the bot cannot post to", len(args.channels), bad)
return 1 if bad else 0
if __name__ == "__main__":
sys.exit(main())
/**
* Report Slack channels the bot cannot post to, and why.
*
* Read only. GET requests and nothing else: give this a bot token with
* channels:read and groups:read. The repair is printed, never performed.
*/
const API = 'https://slack.com/api/';
/**
* Classify one conversations.info response. Pure, so it runs offline.
*
* Order matters: an archived channel refuses everyone, so it outranks
* membership, and ok: false outranks both because there is no channel object to
* read at all.
*/
export function verdict(body) {
if (body.ok !== true) {
const error = body.error ?? '<no error field>';
if (error === 'channel_not_found') {
return ['not-found',
'channel_not_found. Either the ID is wrong, or it is a private channel ' +
'this token cannot see. Those are indistinguishable without groups:read.'];
}
if (error === 'missing_scope') {
return ['scope',
`missing_scope: needed=${body.needed ?? '?'}. Membership is unknown until ` +
'the token can read the channel.'];
}
return ['error', `ok: false, error=${error}`];
}
const channel = body.channel ?? {};
if (channel.is_archived) {
return ['archived',
'archived. Membership is beside the point: an archived channel accepts ' +
'nothing from anyone until it is unarchived.'];
}
if (channel.is_member) return ['member', 'the bot is in this channel'];
if (channel.is_private) {
return ['not-member-private',
'not a member, and private. No API call joins a private channel: a human ' +
'member has to invite the app.'];
}
return ['not-member-public',
'not a member. Public, so the app can join itself with channels:join, or ' +
'somebody can invite it.'];
}
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}` } });
return res.json();
}
async function main() {
const token = process.env.SLACK_BOT_TOKEN;
if (!token) {
console.error('set SLACK_BOT_TOKEN (channels:read and groups:read are enough)');
process.exitCode = 2;
return;
}
const channels = process.argv.slice(2).filter((a) => !a.startsWith('-'));
if (channels.length === 0) {
console.error('usage: node slack-channel-membership.mjs C0123ABCDEF [...]');
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 bot = me.user_id;
console.log(`token acts as ${me.user} (${bot}) in ${me.team}`);
let bad = 0;
for (const cid of channels) {
const body = await get(token, 'conversations.info', { channel: cid });
const [state, detail] = verdict(body);
const name = body.channel?.name ?? '?';
const line = `${state.padEnd(19)} ${cid.padEnd(12)} #${name} ${detail}`;
if (state === 'member') { console.log(line); continue; }
bad += 1;
console.warn(line);
if (state === 'not-member-public') {
console.warn(` repair: /invite @YourApp in #${name}, or call conversations.join ` +
'with channels:join');
console.warn(` in a pipeline: conversations.invite channel=${cid} users=${bot}`);
} else if (state === 'not-member-private') {
console.warn(' repair: a member of the private channel runs /invite @YourApp; ' +
'the app cannot let itself in');
} else if (state === 'archived') {
console.warn(' repair: unarchive the channel, or point the app at a live one');
} else if (state === 'not-found') {
console.warn(' repair: check the ID, then add groups:read and reinstall if the ' +
'channel is private');
}
}
console.log(`${channels.length} channel(s) checked, ${bad} the bot cannot post to`);
process.exitCode = bad ? 1 : 0;
}
// Only run when invoked directly, so importing this module from the test file
// does not execute main() and fail the suite 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
Two cases carry this one. An archived channel where the bot is a member still cannot be posted to, so archived has to be checked first; and channel_not_found has to stay ambiguous rather than being reported as “the channel does not exist”, because without groups:read a private channel returns exactly that error.
from slack_channel_membership import verdict
def ok(**channel):
return {"ok": True, "channel": channel}
def test_member_of_a_live_channel_is_fine():
state, _ = verdict(ok(name="alerts", is_member=True))
assert state == "member"
def test_archived_outranks_membership():
# A member of an archived channel still cannot post to it.
state, detail = verdict(ok(name="old-alerts", is_member=True, is_archived=True))
assert state == "archived"
assert "unarchived" in detail
def test_public_channel_can_be_self_joined():
state, detail = verdict(ok(name="general", is_member=False, is_private=False))
assert state == "not-member-public"
assert "channels:join" in detail
def test_private_channel_needs_a_human():
state, detail = verdict(ok(name="secrets", is_member=False, is_private=True))
assert state == "not-member-private"
assert "invite" in detail
def test_channel_not_found_stays_ambiguous():
state, detail = verdict({"ok": False, "error": "channel_not_found"})
assert state == "not-found"
assert "groups:read" in detail
def test_missing_scope_is_not_a_membership_answer():
body = {"ok": False, "error": "missing_scope", "needed": "channels:read"}
state, detail = verdict(body)
assert state == "scope"
assert "channels:read" in detail
def test_other_errors_are_not_reported_as_membership():
assert verdict({"ok": False, "error": "invalid_auth"})[0] == "error"
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { verdict } from './slack-channel-membership.mjs';
const ok = (channel) => ({ ok: true, channel });
test('member of a live channel is fine', () => {
assert.equal(verdict(ok({ name: 'alerts', is_member: true }))[0], 'member');
});
test('archived outranks membership', () => {
const [state, detail] = verdict(ok({ name: 'old', is_member: true, is_archived: true }));
assert.equal(state, 'archived');
assert.match(detail, /unarchived/);
});
test('public channel can be self joined', () => {
const [state, detail] = verdict(ok({ name: 'general', is_member: false, is_private: false }));
assert.equal(state, 'not-member-public');
assert.match(detail, /channels:join/);
});
test('private channel needs a human', () => {
const [state, detail] = verdict(ok({ name: 'secrets', is_member: false, is_private: true }));
assert.equal(state, 'not-member-private');
assert.match(detail, /invite/);
});
test('channel_not_found stays ambiguous', () => {
const [state, detail] = verdict({ ok: false, error: 'channel_not_found' });
assert.equal(state, 'not-found');
assert.match(detail, /groups:read/);
});
test('missing_scope is not a membership answer', () => {
const [state, detail] = verdict({ ok: false, error: 'missing_scope', needed: 'channels:read' });
assert.equal(state, 'scope');
assert.match(detail, /channels:read/);
});
test('other errors are not reported as membership', () => {
assert.equal(verdict({ ok: false, error: 'invalid_auth' })[0], 'error');
});
FAQ
Why is my bot not in the channel when the app is installed?
Because installing an app to a workspace grants it scopes, not memberships. A bot joins a channel only when somebody invites it with /invite @YourApp, or when it calls conversations.join itself, and that second option exists for public channels only.
Can the app join a private channel by itself?
No. conversations.join is public-channel only, and there is no method that lets an app add itself to a private conversation. A human who is already in the channel has to invite it, which makes this the one Slack failure whose repair is a conversation rather than a deploy.
Why do I get channel_not_found for a channel I can see?
Almost always because the token lacks groups:read and the channel is private. Slack does not distinguish 'no such channel' from 'not visible to you', deliberately, so a script cannot either. Add groups:read, reinstall, and re-run before concluding the ID is wrong.
Does chat:write.public solve this?
Only for posting to public channels. It lets an app post without joining, but it grants nothing for reading: conversations.history on a channel the bot is not in still returns not_in_channel, so any integration that reads messages still needs a real membership.
How do I check every channel at once instead of one at a time?
Call users.conversations for the bot user with types=public_channel,private_channel and paginate it fully. That returns every conversation the bot belongs to, so a diff against your configured target list gives you the whole gap in one pass.
Related field notes
- Slack answers 200 and hides the failure in the body
- missing_scope names the scope you need
- next_cursor ignored, so one page is all you see
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.join method reference — Slack Docs
- conversations.members method reference — Slack Docs
- users.conversations method reference — Slack Docs
If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.