Skip to content

Diagnostic Slack

token_revoked: the app is gone and retrying will not help

One tenant of your multi-workspace app has received nothing for six weeks. No alert fired. The installation row is still there, still marked active, still being handed to the scheduler every fifteen minutes, and every call it makes returns {"ok": false, "error": "token_revoked"}. Somebody removed the app from Manage apps in January and your store has been retrying ever since.

Read-only token Python and Node.js Tests included
Assorted files
Photo by Viktor Talashuk on Unsplash
The short answer

Iterate the installation store, call auth.test once per stored token, and sort the failures by what should actually be done about them. token_revoked is the one that never recovers: the grant is gone, the string in your database is dead, and no retry schedule will bring it back. Tombstone the row and stop scheduling work for that workspace.

The other failures in the same sweep look identical in a log line and need opposite treatment — token_expired wants a refresh, account_inactive wants a different kind of token, ratelimited wants a wait. The finding worth reporting is not "this token failed"; it is the number of rows your store still believes are healthy, and the number of live installs it has quietly stopped serving.

The problem in plain words

An uninstall is a silent event on the app's side. An admin opens Manage apps, removes your app, and Slack invalidates every token issued for that workspace immediately. Slack does emit app_uninstalled and tokens_revoked, but only to apps that subscribed to them, and only over an event delivery path that the uninstall itself has just severed for that workspace. An app that never subscribed — or that dropped the event during a deploy — is never told.

So the installation store keeps a row that describes an app that is no longer installed. Every scheduled job for that tenant runs, calls the API, gets 200 OK with ok: false, and either logs nothing or logs a line indistinguishable from a transient failure. Retry logic makes it worse: a generic backoff treats a permanently dead credential as a temporary outage and spends months rediscovering that it is still dead.

The mirror image is rarer and more expensive. A row disabled during an incident, or tombstoned by an over-eager cleanup that keyed on the wrong error, describes a workspace that is still installed and still paying. Nobody notices that one either, because a disabled row generates no errors at all. Both directions are the same defect: the store's opinion of an install and the API's are never compared.

Admin removesthe appManage apps, oneclickSlack kills thetokensimmediately, allof themEvent neverhandlednot subscribed, ordroppedRow stillmarked activescheduler keepsqueueingBackoff retriesa corpsefor six weeks
The row survives the thing it describes. Work keeps being scheduled for a workspace that removed the app in January.

Why it happens

token_revoked is terminal. The token is not expired, throttled, or misconfigured; the authorisation behind it no longer exists. Only a fresh OAuth install produces a working credential for that workspace, and that is a customer action, not something a retry can trigger.

Slack's error codes are a disposition table, not a severity scale. token_revoked means delete the row, token_expired means refresh it, account_inactive means the human behind a user token was deactivated, invalid_auth means the string is wrong, and ratelimited means wait. Treating them as one bucket called "auth error" is what produces both a retry storm and a tombstoned paying customer.

Whether the bot or the user token died tells you what happened. If the bot token is revoked and the user token with it, the app was removed from the workspace. If only the user token is revoked while the bot token still authenticates, one person revoked their own authorisation and the app is still installed. Those are different conversations with the customer.

The events exist and are worth subscribing to. tokens_revoked names the revoked bot and user ids; app_uninstalled arrives once per workspace removal. Handling them in the same function this audit's repair describes means the store self-heals and the audit stops finding anything.

Do not delete on a single failed call. A network blip and a revocation both produce an exception in a naive client. The disposition should come from the error code in the body, which is why the sweep reads body.error rather than catching whatever the HTTP layer threw.

The fix, as a flow

One sweep, then two comparisons. The first asks every token whether it still authenticates; the second asks whether your store agrees. The second is where the rows nobody is serving turn up, because a disabled row produces no errors to notice.

auth.test per stored rowerror read from the bodytoken_revokedtombstone, never retryaccount_inactivethe human, not the apptoken_expiredrefresh, then retryratelimitedthe only true retryLive under a disabled rowa customer you droppedAuthenticates and activegenuinely serving
These codes are all auth failures and no two want the same treatment. Folding them into one bucket is how a store gets both a retry storm and a tombstoned paying customer.

How to fix it

Export the store with its own opinion attached

Each row needs the key, the environment variable holding its token, and the fields your store uses to decide whether to schedule work: a status, and a last_ok timestamp if you keep one. The audit is a comparison between that opinion and the API's, so an export that omits the status has nothing to compare.

Ask every token whether it still authenticates

One auth.test per row. It needs no scopes, so it works for any token in any state, and its error field is the whole diagnosis. A healthy row answers ok: true with the team id, which is also a cheap check that the row is filed under the workspace it thinks it is.

Map each error to a disposition rather than a severity

token_revoked gets tombstoned. token_expired gets a refresh. account_inactive gets migrated to a bot token. invalid_auth gets its credential checked. ratelimited gets retried. Sorting the sweep this way turns a page of identical warnings into a short list of different jobs.

Compare the disposition against what your store believes

A dead token in a row marked active is work going nowhere. A live token in a row marked disabled is a customer you stopped serving. The script counts both, because a cleanup that only looks for the first will eventually create the second.

Read the shape of the revocation per workspace

Where you store both a bot and a user token for the same workspace, the pattern of which one died says what happened: both dead is an app removal, the user token alone is one person revoking their own authorisation, and the bot token alone is odd enough to look at by hand.

Tombstone, and subscribe to the events so it stops happening

The repair is printed: mark the revoked rows dead, stop scheduling them, and handle app_uninstalled and tokens_revoked in the same code path the audit describes. Keep the row as a tombstone rather than deleting it, so a reinstall is an update and your churn numbers survive.

How to check it worked

Re-run after the cleanup. Every remaining active row should authenticate, and no row should be disabled while its token still works.

python3 slack_dead_install_sweep.py --store installs.json
# 24 row(s) swept, 0 dead but active, 0 live but disabled

The full code

One GET per stored token and nothing else — this script is handed every tenant's credential at once, so it reports and never acts, and the cleanup it describes is a migration a human runs deliberately. Four pure functions: disposition maps an error code to a job, is_retryable answers the question retry logic keeps getting wrong, reconcile compares the store's opinion against the API's, and revocation_shape reads the pattern across a workspace's tokens.

Get this script on GitHub Follow @allanninal Python and Node.js, with tests. Read only, it never writes. One of 20 Slack fixes, free and open source.
slack_dead_install_sweep.py
"""Sweep a Slack installation store for dead tokens and sort them by disposition.

Read only. One GET per stored token and nothing else: this script is handed
every tenant's credential at once, so it reports what it found and prints the
cleanup for a human to run.
"""
import argparse
import json
import logging
import os
import sys
from datetime import datetime

import requests

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

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

# What should happen to a row, keyed by the error auth.test returned. This is a
# disposition table, not a severity scale: the codes below are all "auth failed"
# and no two of them want the same treatment.
DISPOSITIONS = {
    "token_revoked": ("tombstone",
                      "the authorisation no longer exists. Only a fresh OAuth "
                      "install produces a working token for this workspace, and "
                      "that is a customer action"),
    "account_inactive": ("migrate",
                         "a user token whose human was deactivated. The app is "
                         "still installed; the person is gone"),
    "token_expired": ("refresh",
                      "a rotated token past its 12 hour life. The refresh token "
                      "you stored alongside it is the repair"),
    "invalid_refresh_token": ("reinstall",
                              "the refresh token was replayed or expired. The "
                              "rotation pair is unrecoverable"),
    "invalid_auth": ("credential",
                     "the string does not authenticate at all. Check what was "
                     "stored before concluding anything about the install"),
    "not_authed": ("credential", "no token was sent on the request"),
    "not_allowed_token_type": ("credential",
                               "this token class cannot call this method. An "
                               "app-level xapp- token in a bot token's variable "
                               "looks exactly like this"),
    "ratelimited": ("wait", "throttled, not broken. Retry after the window"),
}

# The only errors where retrying the same call unchanged is the right move.
# token_expired is deliberately absent: it is retryable, but only after a
# refresh, and a bare retry burns the window without fixing anything.
RETRYABLE = {"ratelimited", "internal_error", "service_unavailable",
             "fatal_error", "request_timeout"}


def disposition(error):
    """Map an auth.test error to the job it implies. Pure."""
    if not error:
        return ("none", "the token authenticates")
    if error in DISPOSITIONS:
        return DISPOSITIONS[error]
    return ("investigate",
            "error=%s is not in the disposition table. Read it before deciding "
            "whether the row is dead" % error)


def is_retryable(error):
    """Whether retrying this exact call unchanged can ever succeed. Pure."""
    return error in RETRYABLE


def parse_ts(text):
    """Accept the two timestamp shapes an installation store actually holds."""
    if not text:
        return None
    try:
        return datetime.strptime(str(text)[:19], "%Y-%m-%dT%H:%M:%S")
    except ValueError:
        pass
    try:
        return datetime.strptime(str(text)[:10], "%Y-%m-%d")
    except ValueError:
        return None


def reconcile(row, body, now):
    """Compare what the store believes about a row against what Slack says. Pure.

    `row` is the installation record including its own status; `body` is the
    parsed auth.test response for that row's token. The two findings are
    symmetric and a cleanup that only looks for the first eventually creates
    the second.
    """
    active = str(row.get("status", "active")).lower() not in ("disabled", "revoked",
                                                              "dead", "tombstoned")
    ok = body.get("ok") is True
    error = body.get("error")

    if ok and active:
        return ("serving", "team %s authenticates and the row is active"
                % (body.get("team_id") or "?"))
    if ok and not active:
        return ("live-but-disabled",
                "the token still authenticates for team %s and the row is marked "
                "%r. This workspace is installed and you stopped serving it."
                % (body.get("team_id") or "?", row.get("status")))
    if not ok and not active:
        return ("already-tombstoned",
                "error=%s and the row is marked %r. Dead, and your store knows."
                % (error, row.get("status")))

    action, why = disposition(error)
    last = parse_ts(row.get("last_ok"))
    idle = ""
    if last:
        idle = " Nothing has succeeded on this row for %d day(s)." % (now - last).days
    return ("dead-but-active",
            "error=%s -> %s: %s.%s" % (error, action, why, idle))


def revocation_shape(entries):
    """Read the pattern of dead tokens across each workspace. Pure.

    `entries` is a list of {"team", "role", "dead"}. Which token died says what
    happened: both is an app removal, the user token alone is one person
    revoking their own authorisation, and the bot token alone is strange.
    """
    by_team = {}
    for e in entries:
        by_team.setdefault(e.get("team"), []).append(e)
    out = []
    for team in sorted(by_team, key=lambda t: str(t)):
        rows = by_team[team]
        dead = {r.get("role") for r in rows if r.get("dead")}
        alive = {r.get("role") for r in rows if not r.get("dead")}
        if not dead:
            out.append((team, "healthy", "every stored token authenticates"))
        elif not alive:
            out.append((team, "app-removed",
                        "every token for this workspace is dead, which is what an "
                        "uninstall looks like from here"))
        elif dead == {"user"}:
            out.append((team, "user-grant-revoked",
                        "the user token is dead and the bot token is not. One "
                        "person revoked their own authorisation; the app is still "
                        "installed"))
        elif dead == {"bot"}:
            out.append((team, "bot-token-only-dead",
                        "the bot token is dead while a user token still works. "
                        "Unusual enough to look at by hand before deleting"))
        else:
            out.append((team, "mixed", "dead: %s, alive: %s"
                        % (", ".join(sorted(dead)), ", ".join(sorted(alive)))))
    return out


def auth_test(session, token):
    r = session.get(API + "auth.test", headers={"Authorization": "Bearer " + token},
                    timeout=30)
    try:
        return r.json()
    except ValueError:
        return {"ok": False, "error": "unparseable_body"}


def load_rows(path):
    if path:
        return json.loads(open(path, encoding="utf-8").read())
    return [{"key": "<the only row>", "token_env": "SLACK_BOT_TOKEN", "role": "bot"}]


def main():
    ap = argparse.ArgumentParser(description=__doc__)
    ap.add_argument("--store", help="JSON array of installation rows; each row needs "
                                    "key and token_env, plus status, role and last_ok "
                                    "if you keep them")
    args = ap.parse_args()

    if not args.store and not os.environ.get("SLACK_BOT_TOKEN"):
        log.error("set SLACK_BOT_TOKEN, or pass --store with one token_env per row")
        return 2

    rows = load_rows(args.store)
    s = requests.Session()
    now = datetime.utcnow()

    entries = []
    dead_active = 0
    live_disabled = 0
    for row in rows:
        token = os.environ.get(row.get("token_env") or "SLACK_BOT_TOKEN")
        if not token:
            log.warning("%-19s %-16s row names %s and it is unset", "no-token",
                        row.get("key"), row.get("token_env"))
            continue
        body = auth_test(s, token)
        state, detail = reconcile(row, body, now)
        line = "%-19s %-16s %s" % (state, row.get("key"), detail)
        if state in ("serving", "already-tombstoned"):
            log.info(line)
        else:
            log.warning(line)
            if state == "dead-but-active":
                dead_active += 1
                if not is_retryable(body.get("error")):
                    log.warning("  repair: stop scheduling this row. Retrying this "
                                "error unchanged can never succeed")
            else:
                live_disabled += 1
                log.warning("  repair: this row was disabled by something that did "
                            "not read the error code. Re-enable it")
        entries.append({"team": body.get("team_id") or row.get("key"),
                        "role": row.get("role") or "bot",
                        "dead": body.get("ok") is not True})

    for team, shape, why in revocation_shape(entries):
        log.info("%-19s %-16s %s", shape, team, why)

    if dead_active:
        log.warning("  repair: tombstone the revoked rows rather than deleting them, "
                    "and handle app_uninstalled and tokens_revoked in the same code "
                    "path so the store self-heals")

    log.info("%d row(s) swept, %d dead but active, %d live but disabled",
             len(rows), dead_active, live_disabled)
    return 1 if (dead_active or live_disabled) else 0


if __name__ == "__main__":
    sys.exit(main())
slack-dead-install-sweep.mjs
/**
 * Sweep a Slack installation store for dead tokens and sort them by disposition.
 *
 * Read only. One GET per stored token and nothing else: this script is handed
 * every tenant's credential at once, so it reports what it found and prints the
 * cleanup for a human to run.
 */
import { readFile } from 'node:fs/promises';

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

// What should happen to a row, keyed by the error auth.test returned. A
// disposition table, not a severity scale: these codes are all "auth failed"
// and no two of them want the same treatment.
export const DISPOSITIONS = {
  token_revoked: ['tombstone',
    'the authorisation no longer exists. Only a fresh OAuth install produces a ' +
    'working token for this workspace, and that is a customer action'],
  account_inactive: ['migrate',
    'a user token whose human was deactivated. The app is still installed; the ' +
    'person is gone'],
  token_expired: ['refresh',
    'a rotated token past its 12 hour life. The refresh token you stored ' +
    'alongside it is the repair'],
  invalid_refresh_token: ['reinstall',
    'the refresh token was replayed or expired. The rotation pair is unrecoverable'],
  invalid_auth: ['credential',
    'the string does not authenticate at all. Check what was stored before ' +
    'concluding anything about the install'],
  not_authed: ['credential', 'no token was sent on the request'],
  not_allowed_token_type: ['credential',
    'this token class cannot call this method. An app-level xapp- token in a bot ' +
    "token's variable looks exactly like this"],
  ratelimited: ['wait', 'throttled, not broken. Retry after the window'],
};

// The only errors where retrying the same call unchanged is the right move.
// token_expired is deliberately absent: it is retryable, but only after a
// refresh, and a bare retry burns the window without fixing anything.
export const RETRYABLE = new Set(['ratelimited', 'internal_error',
  'service_unavailable', 'fatal_error', 'request_timeout']);

/** Map an auth.test error to the job it implies. Pure. */
export function disposition(error) {
  if (!error) return ['none', 'the token authenticates'];
  if (Object.prototype.hasOwnProperty.call(DISPOSITIONS, error)) return DISPOSITIONS[error];
  return ['investigate',
    `error=${error} is not in the disposition table. Read it before deciding ` +
    'whether the row is dead'];
}

/** Whether retrying this exact call unchanged can ever succeed. Pure. */
export function isRetryable(error) {
  return RETRYABLE.has(error);
}

/** Accept the two timestamp shapes an installation store actually holds. */
export function parseTs(text) {
  if (!text) return null;
  const d = new Date(text);
  return Number.isNaN(d.getTime()) ? null : d;
}

/**
 * Compare what the store believes about a row against what Slack says. Pure.
 * The two findings are symmetric, and a cleanup that only looks for the first
 * eventually creates the second.
 */
export function reconcile(row, body, now) {
  const status = String(row.status ?? 'active').toLowerCase();
  const active = !['disabled', 'revoked', 'dead', 'tombstoned'].includes(status);
  const ok = body?.ok === true;
  const error = body?.error;

  if (ok && active) {
    return ['serving', `team ${body.team_id ?? '?'} authenticates and the row is active`];
  }
  if (ok && !active) {
    return ['live-but-disabled',
      `the token still authenticates for team ${body.team_id ?? '?'} and the row ` +
      `is marked ${JSON.stringify(row.status)}. This workspace is installed and ` +
      'you stopped serving it.'];
  }
  if (!ok && !active) {
    return ['already-tombstoned',
      `error=${error} and the row is marked ${JSON.stringify(row.status)}. Dead, ` +
      'and your store knows.'];
  }

  const [action, why] = disposition(error);
  const last = parseTs(row.last_ok);
  let idle = '';
  if (last) {
    const days = Math.floor((now.getTime() - last.getTime()) / 86400000);
    idle = ` Nothing has succeeded on this row for ${days} day(s).`;
  }
  return ['dead-but-active', `error=${error} -> ${action}: ${why}.${idle}`];
}

/**
 * Read the pattern of dead tokens across each workspace. Pure.
 * `entries` is a list of { team, role, dead }.
 */
export function revocationShape(entries) {
  const byTeam = new Map();
  for (const e of entries) {
    const key = e.team;
    if (!byTeam.has(key)) byTeam.set(key, []);
    byTeam.get(key).push(e);
  }
  const out = [];
  for (const team of [...byTeam.keys()].sort((a, b) => String(a).localeCompare(String(b)))) {
    const rows = byTeam.get(team);
    const dead = [...new Set(rows.filter((r) => r.dead).map((r) => r.role))].sort();
    const alive = [...new Set(rows.filter((r) => !r.dead).map((r) => r.role))].sort();
    if (dead.length === 0) {
      out.push([team, 'healthy', 'every stored token authenticates']);
    } else if (alive.length === 0) {
      out.push([team, 'app-removed',
        'every token for this workspace is dead, which is what an uninstall looks ' +
        'like from here']);
    } else if (dead.length === 1 && dead[0] === 'user') {
      out.push([team, 'user-grant-revoked',
        'the user token is dead and the bot token is not. One person revoked their ' +
        'own authorisation; the app is still installed']);
    } else if (dead.length === 1 && dead[0] === 'bot') {
      out.push([team, 'bot-token-only-dead',
        'the bot token is dead while a user token still works. Unusual enough to ' +
        'look at by hand before deleting']);
    } else {
      out.push([team, 'mixed', `dead: ${dead.join(', ')}, alive: ${alive.join(', ')}`]);
    }
  }
  return out;
}

async function authTest(token) {
  const res = await fetch(API + 'auth.test', {
    headers: { Authorization: `Bearer ${token}` },
  });
  try {
    return await res.json();
  } catch {
    return { ok: false, error: 'unparseable_body' };
  }
}

async function loadRows(path) {
  if (path) return JSON.parse(await readFile(path, 'utf8'));
  return [{ key: '<the only row>', token_env: 'SLACK_BOT_TOKEN', role: 'bot' }];
}

async function main() {
  const args = process.argv.slice(2);
  const i = args.indexOf('--store');
  const store = i === -1 ? null : args[i + 1];

  if (!store && !process.env.SLACK_BOT_TOKEN) {
    console.error('set SLACK_BOT_TOKEN, or pass --store with one token_env per row');
    process.exitCode = 2;
    return;
  }

  const rows = await loadRows(store);
  const now = new Date();
  const entries = [];
  let deadActive = 0;
  let liveDisabled = 0;

  for (const row of rows) {
    const token = process.env[row.token_env ?? 'SLACK_BOT_TOKEN'];
    if (!token) {
      console.warn(`${'no-token'.padEnd(19)} ${String(row.key).padEnd(16)} row names ` +
        `${row.token_env} and it is unset`);
      continue;
    }
    const body = await authTest(token);
    const [state, detail] = reconcile(row, body, now);
    const line = `${state.padEnd(19)} ${String(row.key).padEnd(16)} ${detail}`;
    if (state === 'serving' || state === 'already-tombstoned') {
      console.log(line);
    } else {
      console.warn(line);
      if (state === 'dead-but-active') {
        deadActive += 1;
        if (!isRetryable(body?.error)) {
          console.warn('  repair: stop scheduling this row. Retrying this error ' +
            'unchanged can never succeed');
        }
      } else {
        liveDisabled += 1;
        console.warn('  repair: this row was disabled by something that did not read ' +
          'the error code. Re-enable it');
      }
    }
    entries.push({
      team: body?.team_id ?? row.key,
      role: row.role ?? 'bot',
      dead: body?.ok !== true,
    });
  }

  for (const [team, shape, why] of revocationShape(entries)) {
    console.log(`${shape.padEnd(19)} ${String(team).padEnd(16)} ${why}`);
  }

  if (deadActive) {
    console.warn('  repair: tombstone the revoked rows rather than deleting them, and ' +
      'handle app_uninstalled and tokens_revoked in the same code path so the store ' +
      'self-heals');
  }

  console.log(`${rows.length} row(s) swept, ${deadActive} dead but active, ` +
    `${liveDisabled} live but disabled`);
  process.exitCode = deadActive || liveDisabled ? 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

Two cases carry the note. A revoked token must never be classed as retryable, or the scheduler spends a quarter rediscovering that an uninstalled app is still uninstalled. And a live token in a row somebody disabled must be reported as loudly as a dead one, because that finding is a paying customer nobody is serving and it produces no errors at all.

test_slack_dead_install_sweep.py
from datetime import datetime

from slack_dead_install_sweep import (disposition, is_retryable, reconcile,
                                      revocation_shape)

NOW = datetime(2026, 8, 30)


def test_revoked_is_a_tombstone_and_never_retryable():
    action, why = disposition("token_revoked")
    assert action == "tombstone"
    assert "fresh OAuth install" in why
    assert is_retryable("token_revoked") is False


def test_expired_wants_a_refresh_not_a_bare_retry():
    assert disposition("token_expired")[0] == "refresh"
    assert is_retryable("token_expired") is False


def test_ratelimited_is_the_one_that_should_be_retried():
    assert disposition("ratelimited")[0] == "wait"
    assert is_retryable("ratelimited") is True


def test_unknown_error_is_investigated_rather_than_deleted():
    action, why = disposition("something_new")
    assert action == "investigate"
    assert "something_new" in why


def test_dead_token_in_an_active_row_is_the_finding():
    row = {"key": "T1", "status": "active", "last_ok": "2026-06-01T00:00:00Z"}
    state, detail = reconcile(row, {"ok": False, "error": "token_revoked"}, NOW)
    assert state == "dead-but-active"
    assert "90 day(s)" in detail


def test_live_token_in_a_disabled_row_is_the_mirror_finding():
    row = {"key": "T1", "status": "disabled"}
    state, detail = reconcile(row, {"ok": True, "team_id": "T1"}, NOW)
    assert state == "live-but-disabled"
    assert "stopped serving it" in detail


def test_dead_token_already_tombstoned_is_not_a_finding():
    row = {"key": "T1", "status": "tombstoned"}
    assert reconcile(row, {"ok": False, "error": "token_revoked"}, NOW)[0] == "already-tombstoned"


def test_healthy_row_is_quiet():
    assert reconcile({"key": "T1"}, {"ok": True, "team_id": "T1"}, NOW)[0] == "serving"


def test_both_tokens_dead_reads_as_an_app_removal():
    shapes = revocation_shape([{"team": "T1", "role": "bot", "dead": True},
                               {"team": "T1", "role": "user", "dead": True}])
    assert shapes[0][1] == "app-removed"


def test_only_the_user_token_dead_is_one_person_revoking():
    shapes = revocation_shape([{"team": "T1", "role": "bot", "dead": False},
                               {"team": "T1", "role": "user", "dead": True}])
    assert shapes[0][1] == "user-grant-revoked"
    assert "still installed" in shapes[0][2]
slack-dead-install-sweep.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { disposition, isRetryable, reconcile, revocationShape } from './slack-dead-install-sweep.mjs';

const NOW = new Date('2026-08-30T00:00:00Z');

test('revoked is a tombstone and never retryable', () => {
  const [action, why] = disposition('token_revoked');
  assert.equal(action, 'tombstone');
  assert.match(why, /fresh OAuth install/);
  assert.equal(isRetryable('token_revoked'), false);
});

test('expired wants a refresh not a bare retry', () => {
  assert.equal(disposition('token_expired')[0], 'refresh');
  assert.equal(isRetryable('token_expired'), false);
});

test('ratelimited is the one that should be retried', () => {
  assert.equal(disposition('ratelimited')[0], 'wait');
  assert.equal(isRetryable('ratelimited'), true);
});

test('unknown error is investigated rather than deleted', () => {
  const [action, why] = disposition('something_new');
  assert.equal(action, 'investigate');
  assert.match(why, /something_new/);
});

test('dead token in an active row is the finding', () => {
  const [state, detail] = reconcile(
    { key: 'T1', status: 'active', last_ok: '2026-06-01T00:00:00Z' },
    { ok: false, error: 'token_revoked' }, NOW,
  );
  assert.equal(state, 'dead-but-active');
  assert.match(detail, /90 day\(s\)/);
});

test('live token in a disabled row is the mirror finding', () => {
  const [state, detail] = reconcile(
    { key: 'T1', status: 'disabled' }, { ok: true, team_id: 'T1' }, NOW,
  );
  assert.equal(state, 'live-but-disabled');
  assert.match(detail, /stopped serving it/);
});

test('dead token already tombstoned is not a finding', () => {
  assert.equal(reconcile({ key: 'T1', status: 'tombstoned' },
    { ok: false, error: 'token_revoked' }, NOW)[0], 'already-tombstoned');
});

test('healthy row is quiet', () => {
  assert.equal(reconcile({ key: 'T1' }, { ok: true, team_id: 'T1' }, NOW)[0], 'serving');
});

test('both tokens dead reads as an app removal', () => {
  const shapes = revocationShape([
    { team: 'T1', role: 'bot', dead: true },
    { team: 'T1', role: 'user', dead: true },
  ]);
  assert.equal(shapes[0][1], 'app-removed');
});

test('only the user token dead is one person revoking', () => {
  const shapes = revocationShape([
    { team: 'T1', role: 'bot', dead: false },
    { team: 'T1', role: 'user', dead: true },
  ]);
  assert.equal(shapes[0][1], 'user-grant-revoked');
  assert.match(shapes[0][2], /still installed/);
});

FAQ

Can a revoked token ever start working again?

No. Revocation destroys the authorisation, not just the string, so there is nothing for a retry to recover. A workspace comes back only by installing the app again, which mints an entirely new token. Any backoff schedule pointed at a token_revoked row is spending requests to re-learn a permanent fact.

Should I delete the installation row or keep it?

Keep it as a tombstone. A deleted row loses the history that says this workspace was once a customer, and a reinstall then arrives as a brand new install with no continuity. Mark it dead, stop scheduling work against it, and let a fresh OAuth callback flip it back to active.

Why did I not receive app_uninstalled?

Either the app never subscribed to it, or the event was delivered while the handler was down and Slack's retries were exhausted. It is also worth checking whether event delivery for the app was disabled entirely, which is a separate failure that silences every event rather than one.

How do I tell token_revoked apart from account_inactive?

By the error string, and they mean different things. token_revoked means the grant is gone, usually because the app was removed. account_inactive means the app is still installed but the human that a user token belongs to was deactivated. The first needs a tombstone, the second needs a bot token.

Is one failed auth.test enough to tombstone a row?

Only when the body names a terminal error. A transport failure, a timeout, or a 429 says nothing about the grant, which is why the sweep reads body.error rather than catching whatever the HTTP client threw. Anything not in the disposition table is reported for a human rather than acted 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.