Skip to content

Diagnostic Slack

user_not_in_channel: an ephemeral nobody ever sees

A user runs the slash command, the handler answers with chat.postEphemeral, and the API says {"ok": true, "message_ts": "1755000000.000200"}. There is a timestamp. There is no error. The user says nothing happened.

They are both right. An ephemeral message is not a message stored in a channel, it is a rendering drawn into one person's view of one channel. If that person is not in the channel there is no view to draw into, so the drawing goes nowhere. Sometimes Slack tells you, with user_not_in_channel. Sometimes it takes the call, hands back a timestamp, and shows nobody anything.

Read-only token Python and Node.js Tests included
A person sitting in a chair with a laptop and a credit card
Photo by SumUp on Unsplash
The short answer

chat.postEphemeral needs both a channel and a user, and the user has to be able to see that channel. When they cannot, you get user_not_in_channel, or in some situations an ok: true response with a message_ts that renders for nobody. The timestamp is not a receipt; there is no message behind it.

This is a property of the primitive rather than a bug. Ephemeral messages are not persisted anywhere: they do not appear in conversations.history, they cannot be edited after the session, they cannot be deleted, and they vanish when the client reloads. They exist to say got it, working on that and nothing more.

So there are two checks and they are different. The delivery check is a read: conversations.members or users.conversations for membership, and users.info to confirm the recipient is a real, active human rather than a deactivated account or another app. The design check is about the content: an ephemeral that carries a button, a file, or anything the reader might want to come back to is using the wrong primitive, and the tell is that a read-only audit finds no trace of it anywhere.

The problem in plain words

The reason this survives testing is that developers are members of every channel they test in. You install the app, you join #support, you run the command, the ephemeral appears, and the feature ships. The first real user is someone the app is reaching out to rather than someone who came to it: a reviewer named in a ticket, an on-call engineer picked from a rota, the author of a pull request. None of them are necessarily in the channel, and for them the feature simply does not exist.

The silence is what makes it expensive. There is no error in the log for the ok: true case, nothing in conversations.history to inspect afterwards, and nothing the user can screenshot. The bug report is the bot ignored me, which sends everyone to look at the handler, the event subscription, and the token, none of which are involved. The one place the answer lives is a membership list nobody thought to read, because the app is the thing posting and the app is in the channel.

Private channels sharpen it. A user who is not a member of a private channel cannot see it at all, so an ephemeral aimed at them is doubly impossible, and if your token lacks groups:read you cannot even enumerate the membership to find out. Single-channel guests are the same story with a policy on top: they are confined to the channels they were invited to, and there is no move that puts them somewhere else so you can show them a transient string.

Then there are the recipients who are not people. A deactivated account still resolves in users.info, still has a name, and still looks like a valid user argument, so a rota built from a stale list keeps aiming ephemerals at somebody who left in March. Bot users and app users are worse, because they have no client rendering anything at all, and the call can come back clean.

The last failure is the one nobody calls a failure. An app answers a slash command with an ephemeral that contains an approval button and a link to the run. The user is in the channel, everything works, and then they reload Slack and it is gone, with nothing in the channel history and no way to get it back. That is not a delivery problem; it is a durable payload sent down a transient channel, and it is the most common misuse of the primitive.

Reviewer namedpicked from aticketpostEphemeralchannel and userok, and amessage_tslogged asdeliveredNo view to drawintothey are not amemberNothing inhistoryand nothing toshow
Ephemerals are not stored, so there is nothing in history to inspect afterwards and nothing the user can screenshot. The bug report arrives as the bot ignored me.

Why it happens

The recipient's membership is the question, not the app's. This is the distinction that makes this note separate from every other channel note in this section. Whether your bot is in the channel is a different check with a different error, and this script deliberately does not read is_member so the two findings never blur together.

An ok with a message_ts is not evidence of delivery. Ephemerals are the one place in the Slack API where a timestamp comes back for something that was never stored. Code that logs the message_ts and calls it delivered is logging a number that refers to nothing, and no later read will ever find it.

The membership read has to be paginated, and a partial answer is worse than none. conversations.members pages at 100 by default, so a script that reads one page of a 400-person channel decides three hundred people are not members. The script follows the cursor and reports membership-unknown rather than not-member when it could not finish, because a confident wrong answer here reroutes real messages into DMs.

The right fallback is a DM, not an invitation. Adding somebody to a channel so they can see a transient acknowledgement is a permanent change made for a message that lasts seconds, and it is visible to everyone else in the channel. conversations.open followed by chat.postMessage is the fallback, and the script prints that rather than suggesting a join.

Deactivated and bot recipients are skips, not retries. Both will fail forever, so a queue that retries them burns rate limit against your whole app on a message that can never arrive. The script separates them from not-member, which is the only verdict where a fallback is worth building.

Content that outlives the session is the real bug in half of these. An ephemeral carrying a button cannot be updated once the session ends and leaves the click with nothing to replace. The script judges the payload as well as the recipient, because fixing the delivery of a message that should never have been ephemeral only makes the next problem harder to see.

The fix, as a flow

No red box anywhere in this chain, which is the note. The command runs, the handler answers, the API returns ok and hands back a timestamp, and a person sees nothing. There is no failure to colour because nothing failed; the timestamp refers to a rendering that was attempted in a view that does not exist. The fix branch is the only one in this batch that sorts people rather than payloads, and the actions matter more than the verdicts.

users.info, then the membersthe recipient, not the appNot in the channelopen a DM, do not add themDeactivated in Marchskip it, and fix the rotaA bot or an app usernothing renders, skip itMembership unreadablea missing scope, not a send bugA member, and it is shortan acknowledgement, which is the use
The permanent conditions are checked ahead of the routing one, because a fallback DM to a deactivated account is a retry loop with a nicer name. Every row ends in an action rather than a score.

How to fix it

Check the recipient before you check anything else

users.info answers three questions in one call: does this account exist, is it deleted, and is it a bot or app user. recipient_verdict puts those ahead of membership on purpose, because a deactivated account is not a routing problem and no fallback will rescue it.

Read the membership all the way to the end of the cursor

member_lookup takes the pages of a conversations.members read and folds them into one set, and it refuses to answer if any page failed. A half-read membership list produces false not-member verdicts, which is how a working feature starts sending everybody a DM instead.

Take membership-unknown as a finding rather than a shrug

If your token cannot enumerate the channel, that is usually a missing groups:read on a private conversation. The script says so rather than guessing, and the repair is a scope rather than a change to your send path.

Turn each verdict into the one action it deserves

fallback_plan maps the six verdicts onto four actions: send it, open a DM, skip permanently, or resolve the recipient first. The important pairing is not-member to open-dm and deactivated to skip, because retrying the second one is how a queue eats its own rate limit.

Judge the payload, not only the address

ephemeral_fitness reads the blocks with no token at all. A payload with an actions or input block comes back interactive, one carrying a file or several screens of content comes back durable, and only a short acknowledgement comes back transient-ack. The first two are using the wrong primitive.

Expect the audit to find nothing in history, and read that as the point

Ephemerals never appear in conversations.history. If your team is trying to reconstruct what a user was shown and cannot find it anywhere, that absence is not a gap in your logging. It is the primitive behaving as documented, and it is the argument for moving anything that matters into a real message.

How to check it worked

Run it over the pairs your app actually sends to. Every line should name a person and an action.

python3 slack_ephemeral_recipient.py --pair U7REVIEWR:C024BE91L --pair U0LEFTFEB:C024BE91L
# members    C024BE91L: 214 member(s) read across 3 page(s)
# recipient  not-member     U7REVIEWR in C024BE91L  the ephemeral has no view to render
#                           into; Slack answers user_not_in_channel, or takes the call
#                           and shows nobody anything
#   action:  open-dm        conversations.open then chat.postMessage; do not add someone
#                           to a channel so they can see a message that lasts seconds
# recipient  deactivated    U0LEFTFEB in C024BE91L  users.info says deleted, so this can
#                           never arrive and retrying it spends your rate limit
#   action:  skip           remove the account from the rota rather than the queue
# fitness    interactive    the payload carries an actions block: an ephemeral cannot be
#                           updated after the session and the click has nothing to replace

The full code

Four pure functions and two reads. member_lookup exists because pagination is the difference between a membership list and a wrong answer, and it returns None rather than a short set when a page failed. recipient_verdict orders its checks so that a permanent condition is never reported as a routing one. fallback_plan is the whole point of the note in one table. ephemeral_fitness needs no network and judges the payload rather than the address.

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_ephemeral_recipient.py
"""Say whether each ephemeral recipient could see the message at all.

Read only. Nothing here posts an ephemeral, a DM or anything else: the answer
is in users.info and the channel membership, both of which are reads, and
sending a test ephemeral to find out would show a real person a real message
they did not ask for.

Deliberately does not read is_member on the channel. Whether the app itself is
in the channel is a different failure with a different error, and mixing the two
produces an audit where nobody can tell whose membership is missing.
"""
import argparse
import json
import logging
import os
import sys

import requests

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

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

# Blocks that need the reader to come back to them. An ephemeral cannot be
# updated once the session ends and is not in history, so a click on one of
# these arrives with nothing left to replace.
RETURNING_BLOCKS = {"actions", "input"}
# Blocks whose content a reader is likely to want later, which an ephemeral
# cannot give them.
KEEPING_BLOCKS = {"file", "image", "video"}
ACK_CHARS = 280


def member_lookup(pages):
    """Fold paginated conversations.members answers into one set. Pure.

    Returns None rather than a short set when any page failed or was missing,
    because a half-read membership list turns members into not-member, and the
    fallback that follows sends real people a DM they should not have had.
    """
    found = set()
    for page in pages or []:
        if not isinstance(page, dict) or page.get("ok") is not True:
            return None
        members = page.get("members")
        if not isinstance(members, list):
            return None
        found.update(str(m) for m in members)
    return found if pages else None


def recipient_verdict(user_info, member):
    """Could this person see an ephemeral in this channel? Pure.

    member is True, False, or None for could-not-tell. The permanent
    conditions are checked before membership on purpose: a deactivated account
    is not a routing problem, and reporting it as one invites a fallback that
    will also never arrive.
    """
    info = user_info if isinstance(user_info, dict) else {}
    if not info or not info.get("id"):
        return ("unknown-user", "users.info returned nothing for this id, so there is "
                                "no recipient to reason about. A stale rota or a "
                                "hand-typed id lands here")
    who = str(info.get("id"))
    if info.get("deleted"):
        return ("deactivated", "%s is deactivated. users.info still returns a name and "
                               "a profile, so a stale rota keeps aiming at them; the "
                               "message can never arrive and retrying spends rate limit"
                % who)
    if info.get("is_bot") or info.get("is_app_user"):
        return ("bot-recipient", "%s is a bot or app user, which has no client and "
                                 "renders nothing. An ephemeral to it is a call that "
                                 "cannot fail and cannot be seen" % who)
    if member is None:
        return ("membership-unknown", "the membership of this channel could not be read "
                                      "in full. On a private channel that is usually a "
                                      "missing groups:read, and guessing here would "
                                      "reroute a working message into a DM")
    if not member:
        detail = ("%s is not in this channel, so the ephemeral has no view to render "
                  "into. Slack answers user_not_in_channel, or takes the call and shows "
                  "nobody anything" % who)
        if info.get("is_ultra_restricted"):
            detail += (". They are a single-channel guest, so they cannot be added to "
                       "another channel either")
        return ("not-member", detail)
    return ("member", "%s is in this channel and an ephemeral will render for them"
            % who)


def fallback_plan(verdict):
    """The one action each verdict deserves. Pure.

    The pairing that matters is not-member to open-dm and deactivated to skip.
    Retrying a permanent condition is how a queue spends a whole app's rate
    limit on messages that can never arrive.
    """
    table = {
        "member": ("send-ephemeral", "the recipient can see the channel; an ephemeral "
                                     "is fine for a transient acknowledgement"),
        "not-member": ("open-dm", "conversations.open then chat.postMessage; do not add "
                                  "somebody to a channel so they can see a message that "
                                  "lasts seconds and is visible to nobody else"),
        "deactivated": ("skip", "remove the account from the rota rather than from the "
                                "queue; no fallback reaches a deactivated user"),
        "bot-recipient": ("skip", "nothing renders for an app. If this is an "
                                  "integration talking to another integration, it needs "
                                  "an event or a real message, not an ephemeral"),
        "unknown-user": ("resolve-first", "resolve the id before sending anything. "
                                          "users.lookupByEmail or a fresh users.list is "
                                          "the fix, not a retry"),
        "membership-unknown": ("resolve-first", "add the scope that lets the token read "
                                                "this channel's membership; this is a "
                                                "credential change, not a send-path one"),
    }
    return table.get(verdict, ("resolve-first", "no rule for %s, so decide it by hand "
                                                "rather than defaulting to send"
                               % verdict))


def ephemeral_fitness(text, blocks=None):
    """Is this payload one an ephemeral can carry? Pure.

    Ephemerals are not stored: not in conversations.history, not updatable
    after the session, not deletable, and gone on a client reload. That makes
    them right for an acknowledgement and wrong for anything the reader might
    need twice.
    """
    body = str(text or "").strip()
    rows = blocks if isinstance(blocks, list) else []
    types = {str((b or {}).get("type") or "") for b in rows if isinstance(b, dict)}
    if not body and not rows:
        return ("empty", "no text and no blocks, so there is nothing to render and "
                         "nothing to diagnose")
    returning = sorted(types & RETURNING_BLOCKS)
    if returning:
        return ("interactive", "carries %s. An ephemeral cannot be updated once the "
                               "session ends and is not in history, so the click arrives "
                               "with nothing left to replace and the reader cannot get "
                               "back to it" % ", ".join(returning))
    keeping = sorted(types & KEEPING_BLOCKS)
    if keeping:
        return ("durable", "carries %s, which the reader will want again. An ephemeral "
                           "is gone at the next client reload and leaves no trace in "
                           "history" % ", ".join(keeping))
    if len(body) > ACK_CHARS or len(rows) > 3:
        return ("durable", "%d characters across %d block(s) is more than an "
                           "acknowledgement, and none of it survives a reload"
                % (len(body), len(rows)))
    return ("transient-ack", "short enough to be an acknowledgement, which is what an "
                             "ephemeral is for")


def parse_pair(text):
    """user:channel, in either order of the two id prefixes."""
    parts = str(text or "").split(":", 1)
    if len(parts) != 2:
        return ("", "")
    a, b = parts[0].strip(), parts[1].strip()
    return (b, a) if a.upper().startswith("C") or a.upper().startswith("G") else (a, b)


def read_members(session, channel, page_size=200):
    """Follow the cursor to the end, or give up honestly."""
    pages, cursor = [], ""
    while True:
        params = {"channel": channel, "limit": str(page_size)}
        if cursor:
            params["cursor"] = cursor
        page = session.get(API + "conversations.members", timeout=30,
                           params=params).json()
        pages.append(page)
        if page.get("ok") is not True:
            return pages
        cursor = ((page.get("response_metadata") or {}).get("next_cursor") or "").strip()
        if not cursor:
            return pages


def main():
    ap = argparse.ArgumentParser(description=__doc__)
    ap.add_argument("--pair", action="append", default=[],
                    help="a user:channel pair your app sends ephemerals to; repeatable")
    ap.add_argument("--payload", default="",
                    help="a JSON file holding an ephemeral message, checked with no token")
    ap.add_argument("--token-env", default="SLACK_BOT_TOKEN",
                    help="environment variable holding the bot token")
    args = ap.parse_args()

    findings = 0
    if args.payload:
        raw = json.loads(open(args.payload, encoding="utf-8").read())
        verdict, detail = ephemeral_fitness(raw.get("text"), raw.get("blocks"))
        (log.info if verdict == "transient-ack" else log.warning)(
            "fitness    %-14s %s", verdict, detail)
        if verdict in ("interactive", "durable"):
            findings += 1

    if not args.pair:
        if args.payload:
            return 1 if findings else 0
        log.error("pass at least one --pair USER:CHANNEL, or --payload FILE")
        return 2

    token = os.environ.get(args.token_env)
    if not token:
        log.error("set %s, or pass --payload alone to judge a message with no token",
                  args.token_env)
        return 2

    s = requests.Session()
    s.headers.update({"Authorization": "Bearer " + token})
    who = s.get(API + "auth.test", timeout=30).json()
    if who.get("ok") is not True:
        log.error("auth.test  unavailable    %s", who.get("error"))
        return 2
    log.info("identity   %s in %s", who.get("user_id"), who.get("team"))

    members_by_channel, users = {}, {}
    for pair in args.pair:
        user, channel = parse_pair(pair)
        if not user or not channel:
            log.warning("pair       unusable       %s is not USER:CHANNEL", pair)
            continue
        if channel not in members_by_channel:
            pages = read_members(s, channel)
            members_by_channel[channel] = member_lookup(pages)
            found = members_by_channel[channel]
            if found is None:
                log.warning("members    unavailable    %s: %s", channel,
                            pages[-1].get("error") if pages else "no answer")
            else:
                log.info("members    %s: %d member(s) read across %d page(s)", channel,
                         len(found), len(pages))
        if user not in users:
            body = s.get(API + "users.info", timeout=30, params={"user": user}).json()
            users[user] = (body.get("user") or {}) if body.get("ok") is True else {}

        known = members_by_channel[channel]
        member = None if known is None else (user in known)
        verdict, detail = recipient_verdict(users[user], member)
        action, why = fallback_plan(verdict)
        if verdict == "member":
            log.info("recipient  %-14s %s in %s  %s", verdict, user, channel, detail)
            continue
        findings += 1
        log.warning("recipient  %-14s %s in %s  %s", verdict, user, channel, detail)
        log.warning("  action:  %-14s %s", action, why)

    if findings:
        log.warning("  repair: check membership before the send and fall back to a DM; "
                    "an ok with a message_ts is not evidence anybody saw anything")
        log.warning("  repair: keep ephemerals for transient acknowledgements and put "
                    "anything the reader needs twice in a DM or a real message")
        return 1
    log.info("verdict    clean          every recipient can see the channel they are "
             "being drawn into")
    return 0


if __name__ == "__main__":
    sys.exit(main())
slack-ephemeral-recipient.mjs
/**
 * Say whether each ephemeral recipient could see the message at all.
 *
 * Read only. Nothing here sends an ephemeral, a DM or anything else: the
 * answer is in users.info and the channel membership, both reads, and sending
 * a test ephemeral to find out would show a real person a real message they
 * did not ask for.
 *
 * Deliberately does not read is_member on the channel. Whether the app itself
 * is in the channel is a different failure with a different error.
 */

import { readFile } from 'node:fs/promises';

const API = 'https://slack.com/api/';

// Blocks that need the reader to come back to them. An ephemeral cannot be
// updated once the session ends and is not in history.
const RETURNING_BLOCKS = new Set(['actions', 'input']);
const KEEPING_BLOCKS = new Set(['file', 'image', 'video']);
const ACK_CHARS = 280;

/**
 * Fold paginated conversations.members answers into one set. Pure.
 * Returns null rather than a short set when any page failed.
 */
export function memberLookup(pages) {
  if (!Array.isArray(pages) || !pages.length) return null;
  const found = new Set();
  for (const page of pages) {
    if (!page || typeof page !== 'object' || page.ok !== true) return null;
    if (!Array.isArray(page.members)) return null;
    for (const m of page.members) found.add(String(m));
  }
  return found;
}

/**
 * Could this person see an ephemeral in this channel? Pure.
 * member is true, false, or null for could-not-tell.
 */
export function recipientVerdict(userInfo, member) {
  const info = (userInfo && typeof userInfo === 'object') ? userInfo : {};
  if (!info.id) {
    return ['unknown-user', 'users.info returned nothing for this id, so there is no '
      + 'recipient to reason about. A stale rota or a hand-typed id lands here'];
  }
  const who = String(info.id);
  if (info.deleted) {
    return ['deactivated', `${who} is deactivated. users.info still returns a name and `
      + 'a profile, so a stale rota keeps aiming at them; the message can never arrive '
      + 'and retrying spends rate limit'];
  }
  if (info.is_bot || info.is_app_user) {
    return ['bot-recipient', `${who} is a bot or app user, which has no client and `
      + 'renders nothing. An ephemeral to it is a call that cannot fail and cannot '
      + 'be seen'];
  }
  if (member === null || member === undefined) {
    return ['membership-unknown', 'the membership of this channel could not be read in '
      + 'full. On a private channel that is usually a missing groups:read, and guessing '
      + 'here would reroute a working message into a DM'];
  }
  if (!member) {
    let detail = `${who} is not in this channel, so the ephemeral has no view to render `
      + 'into. Slack answers user_not_in_channel, or takes the call and shows nobody '
      + 'anything';
    if (info.is_ultra_restricted) {
      detail += '. They are a single-channel guest, so they cannot be added to another '
        + 'channel either';
    }
    return ['not-member', detail];
  }
  return ['member', `${who} is in this channel and an ephemeral will render for them`];
}

/** The one action each verdict deserves. Pure. */
export function fallbackPlan(verdict) {
  const table = {
    member: ['send-ephemeral', 'the recipient can see the channel; an ephemeral is fine '
      + 'for a transient acknowledgement'],
    'not-member': ['open-dm', 'conversations.open then chat.postMessage; do not add '
      + 'somebody to a channel so they can see a message that lasts seconds and is '
      + 'visible to nobody else'],
    deactivated: ['skip', 'remove the account from the rota rather than from the queue; '
      + 'no fallback reaches a deactivated user'],
    'bot-recipient': ['skip', 'nothing renders for an app. If this is an integration '
      + 'talking to another integration, it needs an event or a real message, not an '
      + 'ephemeral'],
    'unknown-user': ['resolve-first', 'resolve the id before sending anything. '
      + 'users.lookupByEmail or a fresh users.list is the fix, not a retry'],
    'membership-unknown': ['resolve-first', 'add the scope that lets the token read this '
      + "channel's membership; this is a credential change, not a send-path one"],
  };
  return table[verdict] ?? ['resolve-first', `no rule for ${verdict}, so decide it by `
    + 'hand rather than defaulting to send'];
}

/** Is this payload one an ephemeral can carry? Pure. */
export function ephemeralFitness(text, blocks = null) {
  const body = String(text ?? '').trim();
  const rows = Array.isArray(blocks) ? blocks : [];
  const types = new Set(rows.filter((b) => b && typeof b === 'object')
    .map((b) => String(b.type ?? '')));
  if (!body && !rows.length) {
    return ['empty', 'no text and no blocks, so there is nothing to render and nothing '
      + 'to diagnose'];
  }
  const returning = [...types].filter((t) => RETURNING_BLOCKS.has(t)).sort();
  if (returning.length) {
    return ['interactive', `carries ${returning.join(', ')}. An ephemeral cannot be `
      + 'updated once the session ends and is not in history, so the click arrives with '
      + 'nothing left to replace and the reader cannot get back to it'];
  }
  const keeping = [...types].filter((t) => KEEPING_BLOCKS.has(t)).sort();
  if (keeping.length) {
    return ['durable', `carries ${keeping.join(', ')}, which the reader will want again. `
      + 'An ephemeral is gone at the next client reload and leaves no trace in history'];
  }
  if (body.length > ACK_CHARS || rows.length > 3) {
    return ['durable', `${body.length} characters across ${rows.length} block(s) is more `
      + 'than an acknowledgement, and none of it survives a reload'];
  }
  return ['transient-ack', 'short enough to be an acknowledgement, which is what an '
    + 'ephemeral is for'];
}

/** user:channel, in either order of the two id prefixes. Pure. */
export function parsePair(text) {
  const parts = String(text ?? '').split(':');
  if (parts.length !== 2) return ['', ''];
  const a = parts[0].trim();
  const b = parts[1].trim();
  return (a.toUpperCase().startsWith('C') || a.toUpperCase().startsWith('G'))
    ? [b, a] : [a, b];
}

function arg(args, name, fallback = null) {
  const i = args.indexOf(name);
  return i === -1 ? fallback : args[i + 1];
}

function argAll(args, name) {
  const out = [];
  for (let i = 0; i < args.length; i += 1) {
    if (args[i] === name && args[i + 1] !== undefined) out.push(args[i + 1]);
  }
  return out;
}

async function readMembers(headers, channel, pageSize = 200) {
  const pages = [];
  let cursor = '';
  for (;;) {
    const params = { channel, limit: String(pageSize) };
    if (cursor) params.cursor = cursor;
    const qs = new URLSearchParams(params).toString();
    const page = await (await fetch(`${API}conversations.members?${qs}`, { headers }))
      .json();
    pages.push(page);
    if (page.ok !== true) return pages;
    cursor = String((page.response_metadata ?? {}).next_cursor ?? '').trim();
    if (!cursor) return pages;
  }
}

async function main() {
  const args = process.argv.slice(2);
  const payload = arg(args, '--payload', '');
  const pairs = argAll(args, '--pair');
  let findings = 0;

  if (payload) {
    const raw = JSON.parse(await readFile(payload, 'utf8'));
    const [verdict, detail] = ephemeralFitness(raw.text, raw.blocks);
    const line = `fitness    ${verdict.padEnd(14)} ${detail}`;
    if (verdict === 'transient-ack') console.log(line); else console.warn(line);
    if (verdict === 'interactive' || verdict === 'durable') findings += 1;
  }

  if (!pairs.length) {
    if (payload) { if (findings) process.exitCode = 1; return; }
    console.error('pass at least one --pair USER:CHANNEL, or --payload FILE');
    process.exitCode = 2;
    return;
  }

  const tokenEnv = arg(args, '--token-env', 'SLACK_BOT_TOKEN');
  const token = process.env[tokenEnv];
  if (!token) {
    console.error(`set ${tokenEnv}, or pass --payload alone to judge a message with `
      + 'no token');
    process.exitCode = 2;
    return;
  }
  const headers = { Authorization: `Bearer ${token}` };
  const who = await (await fetch(`${API}auth.test`, { headers })).json();
  if (who.ok !== true) {
    console.error(`auth.test  unavailable    ${who.error}`);
    process.exitCode = 2;
    return;
  }
  console.log(`identity   ${who.user_id} in ${who.team}`);

  const membersByChannel = new Map();
  const users = new Map();
  for (const pair of pairs) {
    const [user, channel] = parsePair(pair);
    if (!user || !channel) {
      console.warn(`pair       unusable       ${pair} is not USER:CHANNEL`);
      continue;
    }
    if (!membersByChannel.has(channel)) {
      const pages = await readMembers(headers, channel);
      const found = memberLookup(pages);
      membersByChannel.set(channel, found);
      if (found === null) {
        console.warn(`members    unavailable    ${channel}: `
          + `${pages.length ? pages[pages.length - 1].error : 'no answer'}`);
      } else {
        console.log(`members    ${channel}: ${found.size} member(s) read across `
          + `${pages.length} page(s)`);
      }
    }
    if (!users.has(user)) {
      const qs = new URLSearchParams({ user }).toString();
      const body = await (await fetch(`${API}users.info?${qs}`, { headers })).json();
      users.set(user, body.ok === true ? (body.user ?? {}) : {});
    }

    const known = membersByChannel.get(channel);
    const member = known === null ? null : known.has(user);
    const [verdict, detail] = recipientVerdict(users.get(user), member);
    const [action, why] = fallbackPlan(verdict);
    if (verdict === 'member') {
      console.log(`recipient  ${verdict.padEnd(14)} ${user} in ${channel}  ${detail}`);
      continue;
    }
    findings += 1;
    console.warn(`recipient  ${verdict.padEnd(14)} ${user} in ${channel}  ${detail}`);
    console.warn(`  action:  ${action.padEnd(14)} ${why}`);
  }

  if (findings) {
    console.warn('  repair: check membership before the send and fall back to a DM; an '
      + 'ok with a message_ts is not evidence anybody saw anything');
    console.warn('  repair: keep ephemerals for transient acknowledgements and put '
      + 'anything the reader needs twice in a DM or a real message');
    process.exitCode = 1;
  } else {
    console.log('verdict    clean          every recipient can see the channel they are '
      + 'being drawn into');
  }
}

// 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 assertions guard the two mistakes that would make this script actively harmful. A membership read that lost a page must come back membership-unknown and never not-member, because the second one reroutes a working message into somebody's DMs on the strength of an answer the script did not have. And a permanent condition must win over a routing one: a deactivated account that is also not in the channel has to report as deactivated with the action skip, since a fallback DM to a deleted user is a retry loop with a nice name.

test_slack_ephemeral_recipient.py
from slack_ephemeral_recipient import (ephemeral_fitness, fallback_plan, member_lookup,
                                       parse_pair, recipient_verdict)

ACTIVE = {"id": "U7REVIEWR", "name": "dana", "deleted": False, "is_bot": False}


def page(members, cursor=""):
    return {"ok": True, "members": members,
            "response_metadata": {"next_cursor": cursor}}


def test_pages_fold_into_one_membership_set():
    found = member_lookup([page(["U1", "U2"], "c"), page(["U3"])])
    assert found == {"U1", "U2", "U3"}


def test_a_failed_page_gives_up_rather_than_returning_a_short_set():
    assert member_lookup([page(["U1"], "c"), {"ok": False, "error": "ratelimited"}]) \
        is None
    assert member_lookup([{"ok": True}]) is None
    assert member_lookup([]) is None
    assert member_lookup(None) is None


def test_a_member_can_be_shown_an_ephemeral():
    verdict, detail = recipient_verdict(ACTIVE, True)
    assert verdict == "member"
    assert fallback_plan(verdict)[0] == "send-ephemeral"


def test_a_non_member_has_no_view_to_render_into():
    verdict, detail = recipient_verdict(ACTIVE, False)
    assert verdict == "not-member"
    assert "user_not_in_channel" in detail
    assert fallback_plan(verdict)[0] == "open-dm"


def test_the_fallback_is_a_dm_and_never_an_invitation():
    action, why = fallback_plan("not-member")
    assert action == "open-dm"
    assert "conversations.open" in why
    assert "do not add" in why


def test_a_deactivated_recipient_beats_the_membership_question():
    verdict, detail = recipient_verdict({"id": "U0LEFTFEB", "deleted": True}, False)
    assert verdict == "deactivated"
    assert fallback_plan(verdict)[0] == "skip"
    assert "rate limit" in detail


def test_a_bot_recipient_renders_nothing_and_is_skipped():
    verdict, _ = recipient_verdict({"id": "U0BOT", "is_bot": True}, True)
    assert verdict == "bot-recipient"
    assert fallback_plan(verdict)[0] == "skip"
    assert recipient_verdict({"id": "U0APP", "is_app_user": True}, True)[0] \
        == "bot-recipient"


def test_an_unreadable_membership_is_never_reported_as_not_member():
    verdict, detail = recipient_verdict(ACTIVE, None)
    assert verdict == "membership-unknown"
    assert "groups:read" in detail
    assert fallback_plan(verdict)[0] == "resolve-first"


def test_an_unresolvable_user_is_resolved_before_anything_is_sent():
    assert recipient_verdict({}, True)[0] == "unknown-user"
    assert recipient_verdict(None, True)[0] == "unknown-user"
    assert fallback_plan("unknown-user")[0] == "resolve-first"


def test_a_single_channel_guest_is_told_why_the_dm_is_the_only_route():
    guest = dict(ACTIVE, is_ultra_restricted=True)
    verdict, detail = recipient_verdict(guest, False)
    assert verdict == "not-member"
    assert "single-channel guest" in detail


def test_an_unknown_verdict_never_defaults_to_sending():
    assert fallback_plan("something-new")[0] == "resolve-first"


def test_a_short_acknowledgement_is_what_an_ephemeral_is_for():
    verdict, detail = ephemeral_fitness("Got it, working on that now")
    assert verdict == "transient-ack"
    assert "acknowledgement" in detail


def test_a_payload_with_a_button_is_using_the_wrong_primitive():
    blocks = [{"type": "section", "text": {"type": "mrkdwn", "text": "Approve?"}},
              {"type": "actions", "elements": [{"type": "button"}]}]
    verdict, detail = ephemeral_fitness("Approve?", blocks)
    assert verdict == "interactive"
    assert "nothing left to replace" in detail


def test_an_input_block_counts_the_same_way_a_button_does():
    assert ephemeral_fitness("x", [{"type": "input"}])[0] == "interactive"


def test_content_the_reader_will_want_again_is_durable():
    assert ephemeral_fitness("here", [{"type": "file"}])[0] == "durable"
    assert ephemeral_fitness("x" * 400)[0] == "durable"
    assert ephemeral_fitness("x", [{"type": "divider"}] * 4)[0] == "durable"


def test_an_empty_payload_is_named_rather_than_passed():
    assert ephemeral_fitness("", None)[0] == "empty"
    assert ephemeral_fitness(None, [])[0] == "empty"


def test_a_pair_parses_in_either_order():
    assert parse_pair("U7REVIEWR:C024BE91L") == ("U7REVIEWR", "C024BE91L")
    assert parse_pair("C024BE91L:U7REVIEWR") == ("U7REVIEWR", "C024BE91L")
    assert parse_pair("G01PRIVATE:U7REVIEWR") == ("U7REVIEWR", "G01PRIVATE")
    assert parse_pair("nonsense") == ("", "")
slack-ephemeral-recipient.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
  ephemeralFitness, fallbackPlan, memberLookup, parsePair, recipientVerdict,
} from './slack-ephemeral-recipient.mjs';

const ACTIVE = { id: 'U7REVIEWR', name: 'dana', deleted: false, is_bot: false };

const page = (members, cursor = '') => ({ ok: true, members,
  response_metadata: { next_cursor: cursor } });

test('pages fold into one membership set', () => {
  const found = memberLookup([page(['U1', 'U2'], 'c'), page(['U3'])]);
  assert.deepEqual([...found].sort(), ['U1', 'U2', 'U3']);
});

test('a failed page gives up rather than returning a short set', () => {
  assert.equal(memberLookup([page(['U1'], 'c'), { ok: false, error: 'ratelimited' }]),
    null);
  assert.equal(memberLookup([{ ok: true }]), null);
  assert.equal(memberLookup([]), null);
  assert.equal(memberLookup(null), null);
});

test('a member can be shown an ephemeral', () => {
  assert.equal(recipientVerdict(ACTIVE, true)[0], 'member');
  assert.equal(fallbackPlan('member')[0], 'send-ephemeral');
});

test('a non member has no view to render into', () => {
  const [verdict, detail] = recipientVerdict(ACTIVE, false);
  assert.equal(verdict, 'not-member');
  assert.match(detail, /user_not_in_channel/);
  assert.equal(fallbackPlan(verdict)[0], 'open-dm');
});

test('the fallback is a DM and never an invitation', () => {
  const [action, why] = fallbackPlan('not-member');
  assert.equal(action, 'open-dm');
  assert.match(why, /conversations\.open/);
  assert.match(why, /do not add/);
});

test('a deactivated recipient beats the membership question', () => {
  const [verdict, detail] = recipientVerdict({ id: 'U0LEFTFEB', deleted: true }, false);
  assert.equal(verdict, 'deactivated');
  assert.equal(fallbackPlan(verdict)[0], 'skip');
  assert.match(detail, /rate limit/);
});

test('a bot recipient renders nothing and is skipped', () => {
  assert.equal(recipientVerdict({ id: 'U0BOT', is_bot: true }, true)[0], 'bot-recipient');
  assert.equal(recipientVerdict({ id: 'U0APP', is_app_user: true }, true)[0],
    'bot-recipient');
  assert.equal(fallbackPlan('bot-recipient')[0], 'skip');
});

test('an unreadable membership is never reported as not-member', () => {
  const [verdict, detail] = recipientVerdict(ACTIVE, null);
  assert.equal(verdict, 'membership-unknown');
  assert.match(detail, /groups:read/);
  assert.equal(fallbackPlan(verdict)[0], 'resolve-first');
});

test('an unresolvable user is resolved before anything is sent', () => {
  assert.equal(recipientVerdict({}, true)[0], 'unknown-user');
  assert.equal(recipientVerdict(null, true)[0], 'unknown-user');
  assert.equal(fallbackPlan('unknown-user')[0], 'resolve-first');
});

test('a single channel guest is told why the DM is the only route', () => {
  const guest = { ...ACTIVE, is_ultra_restricted: true };
  const [verdict, detail] = recipientVerdict(guest, false);
  assert.equal(verdict, 'not-member');
  assert.match(detail, /single-channel guest/);
});

test('an unknown verdict never defaults to sending', () => {
  assert.equal(fallbackPlan('something-new')[0], 'resolve-first');
});

test('a short acknowledgement is what an ephemeral is for', () => {
  const [verdict, detail] = ephemeralFitness('Got it, working on that now');
  assert.equal(verdict, 'transient-ack');
  assert.match(detail, /acknowledgement/);
});

test('a payload with a button is using the wrong primitive', () => {
  const blocks = [{ type: 'section', text: { type: 'mrkdwn', text: 'Approve?' } },
    { type: 'actions', elements: [{ type: 'button' }] }];
  const [verdict, detail] = ephemeralFitness('Approve?', blocks);
  assert.equal(verdict, 'interactive');
  assert.match(detail, /nothing left to replace/);
});

test('an input block counts the same way a button does', () => {
  assert.equal(ephemeralFitness('x', [{ type: 'input' }])[0], 'interactive');
});

test('content the reader will want again is durable', () => {
  assert.equal(ephemeralFitness('here', [{ type: 'file' }])[0], 'durable');
  assert.equal(ephemeralFitness('x'.repeat(400))[0], 'durable');
  assert.equal(ephemeralFitness('x', Array.from({ length: 4 },
    () => ({ type: 'divider' })))[0], 'durable');
});

test('an empty payload is named rather than passed', () => {
  assert.equal(ephemeralFitness('', null)[0], 'empty');
  assert.equal(ephemeralFitness(null, [])[0], 'empty');
});

test('a pair parses in either order', () => {
  assert.deepEqual(parsePair('U7REVIEWR:C024BE91L'), ['U7REVIEWR', 'C024BE91L']);
  assert.deepEqual(parsePair('C024BE91L:U7REVIEWR'), ['U7REVIEWR', 'C024BE91L']);
  assert.deepEqual(parsePair('G01PRIVATE:U7REVIEWR'), ['U7REVIEWR', 'G01PRIVATE']);
  assert.deepEqual(parsePair('nonsense'), ['', '']);
});

FAQ

chat.postEphemeral returned ok with a message_ts. How can the user have seen nothing?

Because an ephemeral is not stored, so that timestamp does not refer to a message anywhere. It identifies a rendering that was attempted in one person's view of one channel. If they had no view of that channel, the attempt had nowhere to land. This is the one place in the Slack API where a timestamp comes back for something that will never exist, and code that logs it as a delivery receipt is logging a number about nothing.

Should I just add the user to the channel before sending?

Almost never. Joining a channel is permanent, visible to everyone already in it, and changes what that person sees from then on, all so they can be shown a string that lasts a few seconds. Open a DM instead. The one time an invitation is right is when membership is the actual goal and the ephemeral was only how you noticed it was missing.

Why can I not find the ephemeral in conversations.history afterwards?

Because it was never written there. Ephemerals are absent from history by design, along with search, exports and the mobile notification path. If a team is trying to reconstruct what a user was shown, that absence is not a hole in your logging; it is the strongest argument available for moving the content into a real message.

Can I update or delete an ephemeral after I send it?

Not after the session. There is no chat.update path for one, and once the client reloads it is gone whether you wanted it gone or not. This is why a payload carrying a button is the shape this note flags hardest: the click arrives, your handler wants to replace the message with a result, and the message it would replace no longer exists in any form.

Is this the same as the bot not being in the channel?

No, and keeping them apart is most of the value here. That one is about the app's own membership, produces not_in_channel, and is fixed by having the bot join. This one is about the recipient's membership, produces user_not_in_channel or a silent nothing, and is fixed in the send path by falling back to a DM. Both can be true at once, which is exactly why the script only reports one of them.

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.