Skip to content

Diagnostic Slack

slack answers HTTP 200 and puts the failure in the body

The deploy is green. The log line reads POST https://slack.com/api/chat.postMessage 200. Nothing has appeared in the channel for three weeks. When somebody finally logs the response body it reads {"ok": false, "error": "not_in_channel"} — and it has read that, unchanged, every single time.

Read-only token Python and Node.js Tests included
A rack of servers
Photo by Yuriy Vertikov on Unsplash
The short answer

Slack's Web API is RPC over HTTP. It keeps non-2xx status codes for transport-level problems and returns application-level failures — bad auth, a missing scope, a channel the bot is not in, malformed Block Kit — inside a 200 OK body as {"ok": false, "error": "..."}.

The rule for every Slack call you make: response.status == 200 proves nothing; body.ok === true is the only success signal. Record ok, error, needed, provided, warning and response_metadata.warnings, and raise on the first of those that is wrong.

The problem in plain words

Every HTTP client in common use is built on the assumption that the status code carries the verdict. requests only raises inside raise_for_status(), fetch sets res.ok from the status line, axios rejects on 4xx and 5xx, .NET's EnsureSuccessStatusCode does the same. Point any of them at Slack and they will report perfect health while the integration does nothing at all.

What makes it last for weeks rather than minutes is that there is no error anywhere to find. The exception tracker is empty because nothing threw. The dashboard is green because every request returned 200. The retry logic never fires because nothing looked like a failure. The only artefact of the outage is an absence — messages that were never posted — and absences do not page anyone.

Call sentbearer tokenattachedSlack answers200request parsedBody says okfalseerror in the JSONClient readsstatus200 means doneLogged asdeliveredno alert exists
Nothing in this path throws. The client was built to read the status line, and the status line is telling the truth about the wrong thing.

Why it happens

Slack reserves HTTP status for transport, not for logic. A 200 means the request reached Slack, was parsed, and produced an answer. Whether that answer is "done" or "no" is a field in the body. This is a deliberate design decision, documented, and consistent across nearly the whole Web API.

The official SDKs hide it, which is why hand-rolled clients suffer. @slack/web-api and slack_sdk both raise on ok: false, so teams using them meet the error immediately as an exception. Anyone who reached for fetch or requests because "it's just one POST" inherits the raw contract and usually does not know it exists.

The interesting information is in fields nobody reads. missing_scope comes with needed and provided. Deprecations and encoding problems come back as warning on an otherwise successful call. All of it is discarded by code that checks a status code and moves on.

The few exceptions make it worse, not better. Incoming webhooks do return real 4xx with a plain-text body, and rate limiting sometimes surfaces as a genuine 429 with Retry-After. So a developer who once saw Slack return a real error code reasonably concludes that Slack returns real error codes.

The fix, as a flow

The script keeps the status line and the parsed body side by side for every probe, because the whole finding is the gap between them: one says the request arrived, the other says whether anything happened.

Status and parsed bodykept for every probeok is truethe only success signalok true, warning setnot fatal, still newsok false200 carrying an errorno ok fieldsomething else replied
A warning on a successful call and a 200 with no ok field are different findings, and collapsing either into success is how this hides.

How to fix it

Probe a handful of read methods and keep the whole response

Call auth.test, team.info, conversations.list, users.list and emoji.list with Authorization: Bearer <token>. Read methods answer a GET, so nothing here can change your workspace. Keep both the status line and the parsed body for every one of them.

Judge on body.ok, never on the status line

ok is a boolean, and it is the verdict. Treat a missing ok exactly like false: an unparseable or truncated body is not a success, and defaulting it to true is how a proxy error page gets recorded as a delivered message.

Read the diagnostic fields Slack already gave you

On ok: false, error names the failure. On missing_scope, needed and provided tell you exactly which scope to add and what the token holds today. Log all three or you will be back here reading the same response by hand.

Surface warnings on calls that succeeded

body.warning and body.response_metadata.warnings[] carry non-fatal notices — missing_charset, superfluous_charset, deprecation notices — on responses where ok is true. They are the only advance notice you get before a method stops working.

Move the check into the transport, once

One wrapper that raises on ok !== true fixes every call site at the same time. Adopting @slack/web-api or slack_sdk does the same thing and hands you e.data.error as well. What does not work is remembering to check by hand at each call site.

How to check it worked

Re-run the script. Every probed method should report ok, and the summary line should show nothing that returned 200 without ok: true.

python3 slack_ok_false_audit.py
# 5 method(s) probed, 0 answered 200 without ok: true

The full code

Five GET requests and no writes at all — a bot token with read scopes is enough, and is what you should give it. The classifier is a pure function taking the status code and the parsed body, because the rule this whole section rests on is exactly one branch and it deserves to be readable rather than buried in a request loop.

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_ok_false_audit.py
"""Find Slack calls that returned HTTP 200 and failed anyway.

Read only. GET requests and nothing else: give this a bot token with read scopes.
The repair is printed, never performed, because a Slack bot token can post into
your workspace.
"""
import argparse
import logging
import os
import sys

import requests

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

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

# Read methods that are safe to probe and cheap to answer. Every one of them
# returns 200 whether it worked or not, which is the entire point.
PROBES = [
    ("auth.test", {}),
    ("team.info", {}),
    ("conversations.list", {"limit": "1", "types": "public_channel"}),
    ("users.list", {"limit": "1"}),
    ("emoji.list", {}),
]


def verdict(status, body):
    """Classify one Slack response. Pure, so the rule is testable offline.

    `status` is the HTTP status code, `body` the parsed JSON (or the raw text if
    it did not parse). A 200 proves the request reached Slack and nothing more.
    """
    if status != 200:
        return ("transport",
                "HTTP %s. Slack keeps non-2xx for transport level failures, so "
                "this one means what it says: a proxy, a bad host, or a real 429."
                % status)
    if not isinstance(body, dict):
        return ("unreadable",
                "200 with a body that is not JSON. Every Web API method answers "
                "JSON, so something other than Slack replied.")
    if body.get("ok") is not True:
        return ("ok-false",
                "200 OK carrying error=%s. The status line said success and the "
                "body did not." % (body.get("error") or "<no error field>"))
    warnings = [w for w in (body.get("response_metadata") or {}).get("warnings", []) or []]
    if body.get("warning"):
        warnings.insert(0, body["warning"])
    if warnings:
        return ("warned",
                "ok is true, with warning=%s. Not fatal, and invisible to code "
                "that reads only ok." % ",".join(warnings))
    return ("ok", "ok: true, no warnings")


def probe(session, method, params):
    r = session.get(API + method, params=params, timeout=30)
    try:
        body = r.json()
    except ValueError:
        body = r.text
    return r.status_code, body


def main():
    ap = argparse.ArgumentParser(description=__doc__)
    ap.add_argument("--method", action="append", default=[],
                    help="probe this read method instead of the default set; repeatable")
    args = ap.parse_args()

    token = os.environ.get("SLACK_BOT_TOKEN")
    if not token:
        log.error("set SLACK_BOT_TOKEN (a bot token with read scopes is enough)")
        return 2

    probes = [(m, {}) for m in args.method] or PROBES
    s = requests.Session()
    s.headers.update({"Authorization": "Bearer " + token})

    bad = 0
    for method, params in probes:
        status, body = probe(s, method, params)
        state, detail = verdict(status, body)
        line = "%-10s %-20s %s" % (state, method, detail)
        if state == "ok":
            log.info(line)
            continue
        if state == "warned":
            log.warning(line)
            continue
        bad += 1
        log.warning(line)
        if isinstance(body, dict) and body.get("needed"):
            log.warning("  needed=%s provided=%s", body["needed"], body.get("provided"))
        log.warning("  repair: raise when body.ok is not true, at the transport "
                    "layer, for every Slack call")

    log.info("%d method(s) probed, %d answered 200 without ok: true", len(probes), bad)
    return 1 if bad else 0


if __name__ == "__main__":
    sys.exit(main())
slack-ok-false-audit.mjs
/**
 * Find Slack calls that returned HTTP 200 and failed anyway.
 *
 * Read only. GET requests and nothing else: give this a bot token with read
 * scopes. The repair is printed, never performed.
 */
const API = 'https://slack.com/api/';

// Read methods that are safe to probe and cheap to answer. Every one of them
// returns 200 whether it worked or not, which is the entire point.
const PROBES = [
  ['auth.test', {}],
  ['team.info', {}],
  ['conversations.list', { limit: '1', types: 'public_channel' }],
  ['users.list', { limit: '1' }],
  ['emoji.list', {}],
];

/**
 * Classify one Slack response. Pure, so the rule is testable offline.
 * A 200 proves the request reached Slack and nothing more.
 */
export function verdict(status, body) {
  if (status !== 200) {
    return ['transport',
      `HTTP ${status}. Slack keeps non-2xx for transport level failures, so this ` +
      'one means what it says: a proxy, a bad host, or a real 429.'];
  }
  if (typeof body !== 'object' || body === null || Array.isArray(body)) {
    return ['unreadable',
      '200 with a body that is not JSON. Every Web API method answers JSON, so ' +
      'something other than Slack replied.'];
  }
  if (body.ok !== true) {
    return ['ok-false',
      `200 OK carrying error=${body.error ?? '<no error field>'}. The status line ` +
      'said success and the body did not.'];
  }
  const warnings = [...(body.response_metadata?.warnings ?? [])];
  if (body.warning) warnings.unshift(body.warning);
  if (warnings.length) {
    return ['warned',
      `ok is true, with warning=${warnings.join(',')}. Not fatal, and invisible ` +
      'to code that reads only ok.'];
  }
  return ['ok', 'ok: true, no warnings'];
}

async function probe(token, method, params) {
  const url = new URL(API + method);
  for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
  const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
  let body;
  try {
    body = await res.json();
  } catch {
    body = null;
  }
  return { status: res.status, body };
}

async function main() {
  const token = process.env.SLACK_BOT_TOKEN;
  if (!token) {
    console.error('set SLACK_BOT_TOKEN (a bot token with read scopes is enough)');
    process.exitCode = 2;
    return;
  }

  const args = process.argv.slice(2);
  const only = args.filter((a) => !a.startsWith('-')).map((m) => [m, {}]);
  const probes = only.length ? only : PROBES;

  let bad = 0;
  for (const [method, params] of probes) {
    const { status, body } = await probe(token, method, params);
    const [state, detail] = verdict(status, body);
    const line = `${state.padEnd(10)} ${method.padEnd(20)} ${detail}`;
    if (state === 'ok') { console.log(line); continue; }
    if (state === 'warned') { console.warn(line); continue; }
    bad += 1;
    console.warn(line);
    if (body?.needed) console.warn(`  needed=${body.needed} provided=${body.provided}`);
    console.warn('  repair: raise when body.ok is not true, at the transport layer, ' +
                 'for every Slack call');
  }

  console.log(`${probes.length} method(s) probed, ${bad} answered 200 without ok: true`);
  process.exitCode = bad ? 1 : 0;
}

// Only run when invoked directly. The test file imports this module, and without
// the guard main() would run there too, fail on the missing token, and set a
// non-zero exit code that fails the whole test file even as every test passes.
if (import.meta.url === `file://${process.argv[1]}`) {
  main().catch((err) => { console.error(err.message); process.exitCode = 2; });
}

Add a test

The case worth pinning is a 200 with no ok field at all. It is not a success and it is not a Slack error either — it is usually a proxy or an error page that got as far as your JSON parser, and any classifier that treats a missing ok as true will record it as a delivered message.

test_slack_ok_false_audit.py
from slack_ok_false_audit import verdict


def test_two_hundred_with_ok_false_is_a_failure():
    state, detail = verdict(200, {"ok": False, "error": "not_in_channel"})
    assert state == "ok-false"
    assert "not_in_channel" in detail


def test_two_hundred_with_ok_true_is_the_only_success():
    state, _ = verdict(200, {"ok": True})
    assert state == "ok"


def test_missing_ok_field_is_not_silently_a_success():
    # A proxy error page that happens to parse as JSON lands here.
    state, detail = verdict(200, {"channels": []})
    assert state == "ok-false"
    assert "no error field" in detail


def test_warning_on_a_successful_call_is_its_own_state():
    state, detail = verdict(200, {"ok": True, "warning": "missing_charset"})
    assert state == "warned"
    assert "missing_charset" in detail


def test_response_metadata_warnings_are_read_too():
    body = {"ok": True, "response_metadata": {"warnings": ["superfluous_charset"]}}
    assert verdict(200, body)[0] == "warned"


def test_non_json_body_is_not_a_slack_answer():
    assert verdict(200, "<html>proxy error</html>")[0] == "unreadable"


def test_real_status_codes_are_still_real():
    state, detail = verdict(429, {"ok": False, "error": "ratelimited"})
    assert state == "transport"
    assert "429" in detail
slack-ok-false-audit.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { verdict } from './slack-ok-false-audit.mjs';

test('two hundred with ok false is a failure', () => {
  const [state, detail] = verdict(200, { ok: false, error: 'not_in_channel' });
  assert.equal(state, 'ok-false');
  assert.match(detail, /not_in_channel/);
});

test('two hundred with ok true is the only success', () => {
  assert.equal(verdict(200, { ok: true })[0], 'ok');
});

test('missing ok field is not silently a success', () => {
  const [state, detail] = verdict(200, { channels: [] });
  assert.equal(state, 'ok-false');
  assert.match(detail, /no error field/);
});

test('warning on a successful call is its own state', () => {
  const [state, detail] = verdict(200, { ok: true, warning: 'missing_charset' });
  assert.equal(state, 'warned');
  assert.match(detail, /missing_charset/);
});

test('response_metadata warnings are read too', () => {
  const body = { ok: true, response_metadata: { warnings: ['superfluous_charset'] } };
  assert.equal(verdict(200, body)[0], 'warned');
});

test('non json body is not a slack answer', () => {
  assert.equal(verdict(200, '<html>proxy error</html>')[0], 'unreadable');
});

test('real status codes are still real', () => {
  const [state, detail] = verdict(429, { ok: false, error: 'ratelimited' });
  assert.equal(state, 'transport');
  assert.match(detail, /429/);
});

FAQ

Why does Slack return 200 for an error?

Because the Web API is RPC over HTTP: the status code reports whether the request reached Slack and was parsed, and the body reports whether the operation succeeded. Application-level failures like a missing scope or a channel the bot is not in are answers, not transport faults, so they come back as 200 with ok: false.

Does this apply to every Slack surface?

Almost. Web API methods behave this way consistently. Incoming webhooks are the exception: they return real 4xx and 5xx with a plain-text body such as invalid_payload or no_service. Rate limiting can also surface as a genuine 429 with a Retry-After header, so handle both shapes.

Will the official SDKs fix this for me?

Yes, for the raising part. Both @slack/web-api and slack_sdk throw when ok is false and expose the error on the exception, which is why teams on the SDKs rarely hit this and teams with a hand-rolled fetch call hit it immediately. They will not read body.warning for you, though.

What should I log on a failed call?

error, plus needed and provided when they are present, plus warning and response_metadata.warnings. Those five fields turn nearly every Slack failure into a self-describing one, and all five are discarded by code that only reads the status code.

Is a missing ok field the same as ok: false?

Treat it as worse. A well-formed Slack response always has ok. A body without it usually came from a proxy, a captive portal or an error page that happened to parse, so it means the request may not have reached Slack at all.

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.