Skip to content

Diagnostic Slack

channel_not_found: private, and the token cannot see it

You are looking at the channel. It is on the screen, the ID is copied from its details panel, other people are talking in it. conversations.info says channel_not_found, and conversations.list returns four hundred channels without it. Nothing is wrong with the ID. The token has not been told that private channels exist.

Read-only token Python and Node.js Tests included
Two baristas smiling behind a counter
Photo by Vincent Leyva on Unsplash
The short answer

Slack splits conversation access by type. channels:read covers public channels, groups:read covers private ones, im:* covers DMs and mpim:* covers group DMs. A token holding only the channels:* pair cannot see private conversations at all — they are absent from conversations.list, and conversations.info on one answers channel_not_found rather than a permission error. That is intentional: Slack will not confirm a private channel exists to a token that is not allowed to know.

So do not read the error, read the grant. The script below takes X-OAuth-Scopes off the response header and uses it to decide which of three situations you are in: no groups:read at all, the scope granted but the bot in no private channel, or the scope granted and this particular ID outside the visible set. The third one is genuinely undecidable from a read-only token, and the script says so instead of guessing.

The problem in plain words

The install screen makes this easy to get wrong. Bot Token Scopes is a long searchable list, channels:read sounds like it means "channels", and nothing on the page suggests that private channels are governed by a scope with a completely different name. The word "groups" is a survival from Slack's pre-2018 API, where private channels were a separate object type called groups, and the scope names never caught up with the vocabulary the product uses today.

Then the error hides the mistake. If Slack answered missing_scope here, this note would not need to exist — that error names what was needed and what was provided, and the repair reads itself. Instead conversations.info on a private channel answers channel_not_found, which is a statement about the ID rather than about the permission. A developer who trusts the error spends the afternoon checking that the ID is right, and the ID is right.

The second half of the trap arrives after the scope is added. groups:read grants the ability to see private channels the bot belongs to, not visibility into every private channel in the workspace. So the reinstall completes, conversations.list?types=private_channel comes back ok: true with an empty array, and it looks like the scope did not take. It took. Nobody has invited the bot yet, and no API call can invite it — a private channel cannot be self-joined.

Channel onscreenID copied fromSlackToken haschannels:readgroups:read neverasked forInfo says notfoundnot a permissionerrorID checkedtwicethe ID was alwaysrightAfternoon gonethe error namedthe wrong thing
The error is a statement about the ID, and the ID is fine. Slack collapses no permission and no such channel into one answer so that the difference cannot be used to enumerate private rooms.

Why it happens

The ambiguity is a security property. If a token without groups:read got missing_scope for one ID and channel_not_found for another, that difference would be an oracle: anyone with a bot token could enumerate which private channel IDs exist. Slack collapses both into channel_not_found on purpose, and no amount of cleverness in your script recovers the distinction.

The header is the honest source. Every Web API response carries X-OAuth-Scopes listing what the token actually holds. That is a direct read of the grant rather than an inference from a failure, it costs nothing on top of a call you are already making, and it answers the question the error refuses to.

Read and history are separate grants. groups:read lets the bot enumerate and inspect a private channel. Reading its messages needs groups:history as well. An app that adds only the first will list the channel happily and then fail on the call that actually matters, which reads as a second, unrelated bug.

Empty is a result, not a failure. ok: true with zero channels is the workspace telling you the bot is a member of no private conversations. Treating that as an error sends people back to the scopes screen they just fixed; treating it as a membership finding sends them to a human who can run /invite.

Conversion moves a channel across the boundary. A public channel converted to private keeps its ID, so a call that worked last week starts answering channel_not_found with no deploy on your side. The scope check is the same, but the story is different, and it is worth naming when the ID used to work.

The fix, as a flow

The detection never asks about the channel, because the channel is the one thing Slack will not discuss. It reads the grant off a response header and interprets that instead, and one branch of the sort ends in a question for a colleague rather than in a verdict.

The grant, off the headerX-OAuth-ScopesNo groups:readabsent, not emptyRead without historylists, never readsScope, no membershipa person must inviteOutside the visible setcannot be answered hereInside the visible setmetadata readable
No scope and no invitation look identical from outside and need opposite repairs, so they get separate rows. The last unresolved case is left unresolved on purpose.

How to fix it

Take the grant off the header of a call you are already making

One auth.test, which needs no scopes, and read X-OAuth-Scopes from the response headers. That string is the authoritative list of what the token holds. Everything after this is interpretation of a fact rather than inference from a symptom.

Split the grant by conversation type

Four pairs: public, private, DM, group DM, each with a read scope and a history scope. Printing the grid makes the shape of the gap obvious, and it catches the app that has groups:read without groups:history before that turns into a second incident.

Ask for the private list explicitly

conversations.list?types=private_channel, paginated. With the scope missing this returns missing_scope and needed: groups:read, which corroborates the header. With the scope present it returns exactly the private channels the bot belongs to.

Distinguish no scope from no membership

These are the two states that both look like "I cannot see it" and they need opposite repairs. Missing scope is an OAuth screen and a reinstall. Empty list with the scope present is a person running /invite @YourApp inside the room. Reporting them as one finding sends half your readers to the wrong place.

Refuse to answer the third question

If the scope is granted, the visible set is non-empty, and your ID is not in it, then it is either a private channel the bot was never invited to or an ID that does not exist — and a read-only token cannot tell those apart, by design. The script prints that plainly. An audit that guesses here is worse than one that admits the limit.

Add the scope, reinstall, then get the bot invited

Both steps, in that order. Add groups:read and groups:history to Bot Token Scopes and reinstall the app, then have a member of each private channel invite it. Neither half works alone, and the reinstall is what people forget: adding a scope in the app configuration does nothing to the token already in your environment.

How to check it worked

Once the scope is granted and the bot has been invited, the probe should place every target in the visible set.

python3 slack_private_visibility_probe.py G01SECRET1
# grant: private_channel read=yes history=yes
# visible   G01SECRET1  the token is in this private channel and can read its metadata
# 1 target(s) checked, 0 that this token cannot account for

The full code

The interesting decision is what the script refuses to conclude. scope_posture parses the granted-scope header into the four conversation families, visibility_verdict combines that with the private listing to reach one of five states — one of which is an explicit “no read-only token can answer this” — and repair_for maps each state to the repair, so the wrong screen is never suggested for the right finding.

Get this script on GitHub Follow @allanninal Python and Node.js, with tests. Read only, it never writes. One of 108 Slack fixes, free and open source.
slack_private_visibility_probe.py
"""Say why a Slack private channel is invisible: no scope, or no invitation.

Read only. One auth.test for the granted-scope header and one paginated
conversations.list for the private set. The script reaches a verdict about the
token's grant, prints the repair, and declines to answer the one question a
read-only token genuinely cannot.
"""
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_private_visibility_probe")

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

# "groups" is a survival from the pre-2018 API where private channels were a
# separate object type. The product stopped using the word; the scopes did not.
CONVERSATION_SCOPES = (
    ("public_channel", "channels:read", "channels:history"),
    ("private_channel", "groups:read", "groups:history"),
    ("im", "im:read", "im:history"),
    ("mpim", "mpim:read", "mpim:history"),
)


def scope_posture(header):
    """Read the granted-scope header into a per-conversation-type grid. Pure.

    Returns (state, rows) with rows as [(kind, can_list, can_read_history), ...].
    The header is a direct statement of the grant, which is the whole point: the
    error a private channel produces is not.
    """
    granted = {s.strip() for s in str(header or "").split(",") if s.strip()}
    if not granted:
        return ("unknown", [])

    rows = [(kind, read in granted, history in granted)
            for kind, read, history in CONVERSATION_SCOPES]
    private = {kind: (can_list, can_read) for kind, can_list, can_read in rows}
    can_list, can_read = private["private_channel"]
    if not can_list:
        return ("no-private-read", rows)
    if not can_read:
        return ("private-list-only", rows)
    return ("private-readable", rows)


def visibility_verdict(posture, listing, target_id=None):
    """Decide what a channel_not_found on a private channel actually means. Pure.

    This function is allowed to answer "I cannot tell", and does. The ambiguity is
    deliberate on Slack's side: a token that may not see private channels is not
    told that one exists, because that difference would be an enumeration oracle.
    """
    ok = listing.get("ok") is True
    error = listing.get("error") or "<no error field>"

    if posture == "unknown":
        return ("unknown-grant",
                "no X-OAuth-Scopes header came back, so the grant could not be "
                "read. Nothing below follows from an empty channel list alone.")

    if posture == "no-private-read":
        answered = "ok: true with an empty list" if ok else "error=" + error
        return ("invisible-by-scope",
                "the grant has no groups:read, so private channels are not empty "
                "for this token, they are absent. conversations.list answered %s. "
                "Every private ID returns channel_not_found, which is Slack "
                "declining to confirm the channel exists." % answered)

    if not ok:
        if error == "missing_scope":
            return ("invisible-by-scope",
                    "missing_scope: needed=%s, provided=%s. The header and the "
                    "error agree." % (listing.get("needed") or "groups:read",
                                      listing.get("provided") or "?"))
        return ("inconclusive",
                "conversations.list answered ok: false, error=%s. Resolve that "
                "before reading anything into visibility." % error)

    channels = listing.get("channels") or []
    if not channels:
        return ("scope-without-membership",
                "groups:read is granted and the token belongs to no private "
                "channel at all. The scope is the ability to see private channels "
                "the bot is in, not blanket visibility into the workspace.")

    if target_id is None:
        return ("visible-set",
                "%d private channel(s) visible to this token" % len(channels))
    if target_id in {c.get("id") for c in channels}:
        return ("visible",
                "the token is in this private channel and can read its metadata")
    return ("outside-the-visible-set",
            "groups:read is granted, %d private channel(s) are visible, and this "
            "ID is not one of them. That is genuinely two possibilities -- a "
            "private channel the bot was never invited to, or an ID that does not "
            "exist -- and no read-only token separates them." % len(channels))


def repair_for(state):
    """The repair each verdict actually calls for. Pure, and printed, never run."""
    if state == "invisible-by-scope":
        return ("add groups:read (and groups:history if the app reads messages) to "
                "Bot Token Scopes",
                "reinstall the app: a scope added in the configuration does nothing "
                "to the token already in your environment")
    if state == "private-list-only":
        return ("add groups:history as well; groups:read lists the channel and does "
                "not read a word of it",)
    if state == "scope-without-membership":
        return ("a member of the private channel runs /invite @YourApp; no API call "
                "joins a private channel",)
    if state == "outside-the-visible-set":
        return ("ask somebody who is in the channel whether the bot is a member and "
                "whether the ID is right; the API will not say",)
    if state == "unknown-grant":
        return ("check that the response really came from slack.com; a proxy that "
                "strips headers takes the grant with it",)
    return ()


def private_listing(session):
    """Every private channel the token can enumerate, as one listing-shaped dict."""
    channels, cursor = [], ""
    while True:
        params = {"types": "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:
            return body
        channels.extend(body.get("channels") or [])
        cursor = (body.get("response_metadata") or {}).get("next_cursor") or ""
        if not cursor:
            return {"ok": True, "channels": channels}


def main():
    ap = argparse.ArgumentParser(description=__doc__)
    ap.add_argument("targets", nargs="*",
                    help="private channel IDs the integration expects to see")
    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 (auth.test needs no scopes; the listing needs groups:read)",
                  args.token_env)
        return 2

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

    probe = s.get(API + "auth.test", timeout=30)
    identity = probe.json()
    if identity.get("ok") is not True:
        log.error("auth.test answered 200 with ok: false, error=%s", identity.get("error"))
        return 2
    log.info("token acts as %s (%s) in %s", identity.get("user"),
             identity.get("user_id"), identity.get("team"))

    posture, rows = scope_posture(probe.headers.get("x-oauth-scopes"))
    for kind, can_list, can_read in rows:
        log.info("grant: %-16s read=%-3s history=%s", kind,
                 "yes" if can_list else "no", "yes" if can_read else "no")

    listing = private_listing(s)
    bad = 0

    if posture == "private-list-only":
        log.warning("%-24s %s", "private-list-only",
                    "groups:read without groups:history: the channel will list and "
                    "its messages will not read")
        for line in repair_for("private-list-only"):
            log.warning("  repair: %s", line)
        bad += 1

    for target in (args.targets or [None]):
        state, detail = visibility_verdict(posture, listing, target)
        label = target or "<no target given>"
        if state in ("visible", "visible-set"):
            log.info("%-24s %-12s %s", state, label, detail)
            continue
        bad += 1
        log.warning("%-24s %-12s %s", state, label, detail)
        for line in repair_for(state):
            log.warning("  repair: %s", line)

    log.info("%d target(s) checked, %d that this token cannot account for",
             len(args.targets), bad)
    return 1 if bad else 0


if __name__ == "__main__":
    sys.exit(main())
slack-private-visibility-probe.mjs
/**
 * Say why a Slack private channel is invisible: no scope, or no invitation.
 *
 * Read only. One auth.test for the granted-scope header and one paginated
 * conversations.list for the private set. The script reaches a verdict about the
 * token's grant, prints the repair, and declines to answer the one question a
 * read-only token genuinely cannot.
 */
const API = 'https://slack.com/api/';

// "groups" is a survival from the pre-2018 API where private channels were a
// separate object type. The product stopped using the word; the scopes did not.
const CONVERSATION_SCOPES = [
  ['public_channel', 'channels:read', 'channels:history'],
  ['private_channel', 'groups:read', 'groups:history'],
  ['im', 'im:read', 'im:history'],
  ['mpim', 'mpim:read', 'mpim:history'],
];

/**
 * Read the granted-scope header into a per-conversation-type grid. Pure.
 * The header is a direct statement of the grant, which is the whole point: the
 * error a private channel produces is not.
 */
export function scopePosture(header) {
  const granted = new Set(String(header ?? '').split(',').map((s) => s.trim()).filter(Boolean));
  if (granted.size === 0) return ['unknown', []];

  const rows = CONVERSATION_SCOPES.map(([kind, read, history]) =>
    [kind, granted.has(read), granted.has(history)]);
  const [, canList, canRead] = rows.find(([kind]) => kind === 'private_channel');
  if (!canList) return ['no-private-read', rows];
  if (!canRead) return ['private-list-only', rows];
  return ['private-readable', rows];
}

/**
 * Decide what a channel_not_found on a private channel actually means. Pure.
 * This function is allowed to answer "I cannot tell", and does: the ambiguity is
 * deliberate on Slack's side, because the difference would be an enumeration oracle.
 */
export function visibilityVerdict(posture, listing, targetId = null) {
  const ok = listing?.ok === true;
  const error = listing?.error ?? '<no error field>';

  if (posture === 'unknown') {
    return ['unknown-grant',
      'no X-OAuth-Scopes header came back, so the grant could not be read. ' +
      'Nothing below follows from an empty channel list alone.'];
  }

  if (posture === 'no-private-read') {
    const answered = ok ? 'ok: true with an empty list' : `error=${error}`;
    return ['invisible-by-scope',
      'the grant has no groups:read, so private channels are not empty for this ' +
      `token, they are absent. conversations.list answered ${answered}. Every ` +
      'private ID returns channel_not_found, which is Slack declining to confirm ' +
      'the channel exists.'];
  }

  if (!ok) {
    if (error === 'missing_scope') {
      return ['invisible-by-scope',
        `missing_scope: needed=${listing?.needed ?? 'groups:read'}, ` +
        `provided=${listing?.provided ?? '?'}. The header and the error agree.`];
    }
    return ['inconclusive',
      `conversations.list answered ok: false, error=${error}. Resolve that before ` +
      'reading anything into visibility.'];
  }

  const channels = listing.channels ?? [];
  if (channels.length === 0) {
    return ['scope-without-membership',
      'groups:read is granted and the token belongs to no private channel at all. ' +
      'The scope is the ability to see private channels the bot is in, not blanket ' +
      'visibility into the workspace.'];
  }

  if (targetId === null) {
    return ['visible-set', `${channels.length} private channel(s) visible to this token`];
  }
  if (channels.some((c) => c.id === targetId)) {
    return ['visible', 'the token is in this private channel and can read its metadata'];
  }
  return ['outside-the-visible-set',
    `groups:read is granted, ${channels.length} private channel(s) are visible, and ` +
    'this ID is not one of them. That is genuinely two possibilities -- a private ' +
    'channel the bot was never invited to, or an ID that does not exist -- and no ' +
    'read-only token separates them.'];
}

/** The repair each verdict actually calls for. Pure, and printed, never run. */
export function repairFor(state) {
  if (state === 'invisible-by-scope') {
    return ['add groups:read (and groups:history if the app reads messages) to Bot ' +
      'Token Scopes',
    'reinstall the app: a scope added in the configuration does nothing to the ' +
      'token already in your environment'];
  }
  if (state === 'private-list-only') {
    return ['add groups:history as well; groups:read lists the channel and does not ' +
      'read a word of it'];
  }
  if (state === 'scope-without-membership') {
    return ['a member of the private channel runs /invite @YourApp; no API call joins ' +
      'a private channel'];
  }
  if (state === 'outside-the-visible-set') {
    return ['ask somebody who is in the channel whether the bot is a member and ' +
      'whether the ID is right; the API will not say'];
  }
  if (state === 'unknown-grant') {
    return ['check that the response really came from slack.com; a proxy that strips ' +
      'headers takes the grant with it'];
  }
  return [];
}

async function privateListing(token) {
  const channels = [];
  let cursor = '';
  for (;;) {
    const params = new URLSearchParams({
      types: 'private_channel', exclude_archived: 'false', limit: '1000',
    });
    if (cursor) params.set('cursor', cursor);
    const res = await fetch(`${API}conversations.list?${params}`, {
      headers: { Authorization: `Bearer ${token}` },
    });
    const body = await res.json();
    if (body.ok !== true) return body;
    channels.push(...(body.channels ?? []));
    cursor = body.response_metadata?.next_cursor ?? '';
    if (!cursor) return { ok: true, channels };
  }
}

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);
  const token = process.env[tokenEnv];
  if (!token) {
    console.error(`set ${tokenEnv} (auth.test needs no scopes; the listing needs groups:read)`);
    process.exitCode = 2;
    return;
  }

  const probe = await fetch(`${API}auth.test`, {
    headers: { Authorization: `Bearer ${token}` },
  });
  const identity = await probe.json();
  if (identity.ok !== true) {
    console.error(`auth.test answered 200 with ok: false, error=${identity.error}`);
    process.exitCode = 2;
    return;
  }
  console.log(`token acts as ${identity.user} (${identity.user_id}) in ${identity.team}`);

  const [posture, rows] = scopePosture(probe.headers.get('x-oauth-scopes'));
  for (const [kind, canList, canRead] of rows) {
    console.log(`grant: ${kind.padEnd(16)} read=${(canList ? 'yes' : 'no').padEnd(3)} ` +
                `history=${canRead ? 'yes' : 'no'}`);
  }

  const listing = await privateListing(token);
  let bad = 0;

  if (posture === 'private-list-only') {
    console.warn(`${'private-list-only'.padEnd(24)} groups:read without ` +
                 'groups:history: the channel will list and its messages will not read');
    for (const line of repairFor('private-list-only')) console.warn(`  repair: ${line}`);
    bad += 1;
  }

  for (const target of targets.length ? targets : [null]) {
    const [state, detail] = visibilityVerdict(posture, listing, target);
    const label = target ?? '<no target given>';
    if (state === 'visible' || state === 'visible-set') {
      console.log(`${state.padEnd(24)} ${label.padEnd(12)} ${detail}`);
      continue;
    }
    bad += 1;
    console.warn(`${state.padEnd(24)} ${label.padEnd(12)} ${detail}`);
    for (const line of repairFor(state)) console.warn(`  repair: ${line}`);
  }

  console.log(`${targets.length} target(s) checked, ${bad} that this token cannot account for`);
  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 assertion that matters most is a negative one: given a granted scope, a non-empty visible set and an ID outside it, the verdict has to be outside-the-visible-set and the repair has to be “ask a person”, not a confident claim that the channel does not exist. The rest of the suite pins the two states that look identical from the outside — no scope, and no invitation — onto their two different repairs.

test_slack_private_visibility_probe.py
from slack_private_visibility_probe import (repair_for, scope_posture,
                                            visibility_verdict)

FULL = "channels:read,channels:history,groups:read,groups:history,chat:write"
PUBLIC_ONLY = "channels:read,channels:history,chat:write"
LIST_ONLY = "channels:read,groups:read,chat:write"
PRIVATE = {"id": "G01SECRET1", "name": "leadership"}


def test_the_header_is_read_into_a_per_type_grid():
    state, rows = scope_posture(FULL)
    assert state == "private-readable"
    assert dict((kind, (a, b)) for kind, a, b in rows)["private_channel"] == (True, True)
    assert dict((kind, (a, b)) for kind, a, b in rows)["im"] == (False, False)


def test_a_public_only_grant_cannot_see_private_channels():
    assert scope_posture(PUBLIC_ONLY)[0] == "no-private-read"


def test_read_without_history_is_its_own_posture():
    assert scope_posture(LIST_ONLY)[0] == "private-list-only"


def test_a_missing_header_is_not_read_as_a_missing_scope():
    assert scope_posture("")[0] == "unknown"
    assert visibility_verdict("unknown", {"ok": True, "channels": []})[0] == "unknown-grant"


def test_no_scope_makes_the_channel_absent_rather_than_empty():
    state, detail = visibility_verdict(
        "no-private-read", {"ok": False, "error": "missing_scope"}, "G01SECRET1")
    assert state == "invisible-by-scope"
    assert "declining to confirm" in detail


def test_the_scope_granted_with_no_membership_is_a_different_finding():
    state, detail = visibility_verdict(
        "private-readable", {"ok": True, "channels": []}, "G01SECRET1")
    assert state == "scope-without-membership"
    assert "not blanket visibility" in detail


def test_a_visible_private_channel_passes():
    state, _ = visibility_verdict(
        "private-readable", {"ok": True, "channels": [PRIVATE]}, "G01SECRET1")
    assert state == "visible"


def test_an_id_outside_the_visible_set_is_never_declared_nonexistent():
    state, detail = visibility_verdict(
        "private-readable", {"ok": True, "channels": [PRIVATE]}, "G09OTHER99")
    assert state == "outside-the-visible-set"
    assert "two possibilities" in detail
    assert "no read-only token separates them" in detail


def test_an_unrelated_error_is_inconclusive_not_a_visibility_answer():
    state, _ = visibility_verdict(
        "private-readable", {"ok": False, "error": "ratelimited"}, "G01SECRET1")
    assert state == "inconclusive"


def test_the_two_lookalike_states_get_different_repairs():
    scope_fix = " ".join(repair_for("invisible-by-scope"))
    invite_fix = " ".join(repair_for("scope-without-membership"))
    assert "reinstall" in scope_fix
    assert "/invite" in invite_fix
    assert "reinstall" not in invite_fix


def test_the_undecidable_state_asks_a_human_rather_than_guessing():
    assert "will not say" in " ".join(repair_for("outside-the-visible-set"))
slack-private-visibility-probe.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { repairFor, scopePosture, visibilityVerdict }
  from './slack-private-visibility-probe.mjs';

const FULL = 'channels:read,channels:history,groups:read,groups:history,chat:write';
const PUBLIC_ONLY = 'channels:read,channels:history,chat:write';
const LIST_ONLY = 'channels:read,groups:read,chat:write';
const PRIVATE = { id: 'G01SECRET1', name: 'leadership' };

test('the header is read into a per type grid', () => {
  const [state, rows] = scopePosture(FULL);
  assert.equal(state, 'private-readable');
  const grid = Object.fromEntries(rows.map(([kind, a, b]) => [kind, [a, b]]));
  assert.deepEqual(grid.private_channel, [true, true]);
  assert.deepEqual(grid.im, [false, false]);
});

test('a public only grant cannot see private channels', () => {
  assert.equal(scopePosture(PUBLIC_ONLY)[0], 'no-private-read');
});

test('read without history is its own posture', () => {
  assert.equal(scopePosture(LIST_ONLY)[0], 'private-list-only');
});

test('a missing header is not read as a missing scope', () => {
  assert.equal(scopePosture('')[0], 'unknown');
  assert.equal(visibilityVerdict('unknown', { ok: true, channels: [] })[0], 'unknown-grant');
});

test('no scope makes the channel absent rather than empty', () => {
  const [state, detail] = visibilityVerdict(
    'no-private-read', { ok: false, error: 'missing_scope' }, 'G01SECRET1');
  assert.equal(state, 'invisible-by-scope');
  assert.match(detail, /declining to confirm/);
});

test('the scope granted with no membership is a different finding', () => {
  const [state, detail] = visibilityVerdict(
    'private-readable', { ok: true, channels: [] }, 'G01SECRET1');
  assert.equal(state, 'scope-without-membership');
  assert.match(detail, /not blanket visibility/);
});

test('a visible private channel passes', () => {
  const [state] = visibilityVerdict(
    'private-readable', { ok: true, channels: [PRIVATE] }, 'G01SECRET1');
  assert.equal(state, 'visible');
});

test('an id outside the visible set is never declared nonexistent', () => {
  const [state, detail] = visibilityVerdict(
    'private-readable', { ok: true, channels: [PRIVATE] }, 'G09OTHER99');
  assert.equal(state, 'outside-the-visible-set');
  assert.match(detail, /two possibilities/);
  assert.match(detail, /no read-only token separates them/);
});

test('an unrelated error is inconclusive not a visibility answer', () => {
  const [state] = visibilityVerdict(
    'private-readable', { ok: false, error: 'ratelimited' }, 'G01SECRET1');
  assert.equal(state, 'inconclusive');
});

test('the two lookalike states get different repairs', () => {
  const scopeFix = repairFor('invisible-by-scope').join(' ');
  const inviteFix = repairFor('scope-without-membership').join(' ');
  assert.match(scopeFix, /reinstall/);
  assert.match(inviteFix, /\/invite/);
  assert.doesNotMatch(inviteFix, /reinstall/);
});

test('the undecidable state asks a human rather than guessing', () => {
  assert.match(repairFor('outside-the-visible-set').join(' '), /will not say/);
});

FAQ

Why is the scope called groups:read and not private_channels:read?

Because private channels used to be a separate object type called a group, before the conversations.* family unified them in 2018. The methods were renamed and the scopes were not, so the API you read today talks about private channels while the permission you have to grant talks about groups. Searching the scope list for the word private finds nothing useful, which is most of why this is missed.

Can I tell whether a private channel ID exists at all?

Not from a read-only token, and not from any token that is not in the channel. Slack returns channel_not_found for a private channel you are not permitted to see, which is the same answer it returns for an ID that never existed. The collapse is deliberate, since a difference between the two would let anyone enumerate private channel IDs. Ask a member instead.

I added groups:read and conversations.list still returns nothing.

Two things to check, in order. First, did you reinstall? A scope added on the app configuration page does not change the token sitting in your environment until the app is reinstalled and you take the new value. Second, has anybody invited the bot? The scope grants the ability to see private channels the bot belongs to, so with no invitations the correct answer really is an empty list.

Can the bot invite itself to a private channel?

No. conversations.join works for public channels only, and there is no self-invite path for a private one. A human member has to run /invite @YourApp, or a user token belonging to a member has to call conversations.invite. This is the reason the empty-list finding is worth separating from the missing-scope finding: only one of them is fixed by an engineer.

A channel that used to work now returns channel_not_found. Same ID.

That is the usual signature of a public channel being converted to private. The ID survives conversion, but the scope that governs it changes from channels:read to groups:read, so a token holding only the public pair loses it from one day to the next with no deploy on your side. The grant check reads the same in both cases; what changed is which side of the boundary the channel is on.

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.