Skip to content

Diagnostic Slack

invalid_limit: asking for more than a page can hold

Somebody read the pagination documentation, decided that following cursors was a lot of code for a workspace with three hundred channels, and wrote limit=5000. It is an entirely rational thing to try, and Slack answers it with HTTP 200 carrying ok: false and error: invalid_limit.

The interesting version is the one that does not error. Ask for a thousand and you may get fifteen, with ok: true, no warning, and a cursor waiting that nobody reads.

Read-only token Python and Node.js Tests included
A product photo shoot
Photo by Randy Fath on Unsplash
The short answer

limit is capped. Cursor-paginated Slack methods take at most 1000 items per page and reject anything larger outright, so limit=5000 is a hard failure rather than a clamp. That much is easy to find, because it is an error.

The failure worth auditing is the other direction. Several methods return fewer items than you asked for while still answering ok: true: a non-Marketplace app gets fifteen messages from conversations.history however many it requested, and users.list is documented up to 1000 but is unreliable and slow near the top of that range on a large workspace. A page smaller than the one you asked for, with a non-empty cursor, is Slack telling you your constant is fiction. The script below sends each method's configured page size once, compares what came back against what was asked, and sorts the result into rejected, cut down, honoured, or genuinely the end of the data.

The problem in plain words

The page size is a constant somewhere in the code, and it was chosen once. Usually it was chosen to avoid pagination: if the workspace has three hundred channels and the limit can be a thousand, then one call is the whole answer and the cursor loop never has to be written. That reasoning is correct on the day it is written and it decays in two different ways.

The loud decay is invalid_limit. Somebody raises the constant past 1000, either to cover a workspace that grew or because a different API they used last week allowed it, and every call to that method starts failing. The error arrives as HTTP 200 with ok: false like everything else in Slack, so a client that checks status codes reports success and the application sees an empty list rather than an error. An empty channel list is much harder to attribute than a 400 would have been.

The quiet decay is worse. Slack does not always reject an over-large limit; sometimes it simply returns less. The most common case in 2026 is conversations.history for an app that is not on the Slack Marketplace, which returns a maximum of fifteen objects no matter what you ask for. Code that requested a thousand, received fifteen, and stops because it assumed a short page meant the end of the data has now silently truncated the history to fifteen messages with a perfectly healthy ok: true. The constant is not the problem there. The belief that the constant was honoured is.

limit chosenonceto avoid a loopAsked for 200fifteen came backok: trueno error anywhereShort page readas endthe cursor isignoredHistory is 15longand nobody askswhy
Asking for more than the ceiling is rejected outright. Asking for less than the ceiling can still come back smaller, with ok: true, and code that reads a short page as the end of the data truncates there.

Why it happens

The ceiling is per method, and it is not documented in one place. Each method's reference page states its own maximum. There is no global constant to import and no header that reports it, so the only reliable way to know what a method will give you is to ask for a page and measure what arrives.

Rejection and clamping are different bugs with different repairs. invalid_limit is loud, immediate and fixed by lowering a number. A clamp is silent, is not fixed by lowering the number, and is fixed by not assuming a short page is the last page. A detector that only looks for the error string finds the easy half.

A short page with a cursor is the signature. Fewer items than requested plus a non-empty next_cursor means Slack decided your page size for you. Fewer items than requested with no cursor means the data ran out, which is fine. Those two look identical to code that only counts the array.

Bigger pages are not faster past a point. Slack's own guidance recommends 200 for users.list on large workspaces, because a request for a thousand users spends longer being assembled and is more likely to time out than five requests for two hundred. The page size that avoids pagination is frequently the page size that makes the call fail.

This is one call per method, deliberately. The audit does not walk the pages. Walking is a different question, answered by a different script, and a page size audit that paginates would be spending the tier budget of the method it is trying to describe.

The fix, as a flow

The chain breaks at the response rather than at the request, which is the half of this note people miss: the loud version returns an error and the quiet one returns a smaller page and says ok. The fix branch keeps four outcomes apart that a single length check collapses into two, and the two plain rows are the ones that are not bugs.

Asked against returnedone call per methodAbove the ceilinginvalid_limit, nothing backSmaller page, cursor setSlack chose the sizeLegal and over the guidance1000 on users.listSmaller page, no cursorthe data ran outThe page you asked forthe constant is honest
A page smaller than the one you asked for means one of two opposite things, and the cursor is what tells them apart. With a cursor Slack chose the size; without one the data simply ran out.

How to fix it

Write down the page size the application actually sends

Not the one in the documentation and not the one in the constants file if they have drifted. The script takes it as an argument, per method if you need to, because the whole point is to compare a real request against a real response.

Check it offline against the documented ceiling first

limit_verdict needs no token. It knows each method's maximum and its recommended value, and it will tell you that 5000 is going to be rejected and that 1000 on users.list is legal and unwise before a single request is sent.

Send exactly one request per method

One call, with your limit, no cursor. That is enough to learn everything: the error if there is one, the number of items, and whether a cursor came back. Anything more is spending the method's own rate budget to measure the method.

Compare what arrived against what was asked

Four outcomes, and they are not variations on each other. rejected is an error string. cut-down is a smaller page with a cursor. short-final is a smaller page with no cursor, which is correct behaviour. honoured is the full page you asked for.

Treat a cut-down page as a pagination bug, not a limit bug

Lowering the constant does not fix a clamp; it only makes the request honest. What fixes it is code that never infers "no more data" from "fewer items than I asked for", and reads the cursor instead. The script prints that repair separately, because it is a different line of code in a different function.

Set the recommended value and stop tuning it

200 for users.list, 200 for the rest unless you have a measured reason. The printed repair names the value per method. A correct cursor loop over small pages beats a large page every time, and it stops being a thing anybody has to think about again.

How to check it worked

Re-run with the repaired constants. Every method should come back honoured or short-final, and nothing should be reported as cut down.

python3 slack_page_size_audit.py --limit 200
# conversations.list     configured  within-limits   200 is at the recommended page size
# conversations.list     response    honoured        asked 200, got 200, cursor set
# users.list             configured  within-limits   200 is at the recommended page size
# users.list             response    short-final     asked 200, got 141, no cursor: the data ran out
# conversations.history  response    cut-down        asked 200, got 15, cursor set
# 1 method(s) not answering at the size they were asked

The full code

Three pure functions and one GET per method. limit_verdict is the offline half: a table of per-method ceilings and recommendations, no token required, which will tell you that a constant is doomed before you spend a request finding out. page_verdict is the live half, and it exists to keep four genuinely different outcomes apart. repair_for prints the value to set, per method, so the finding ends in a number rather than in advice.

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_page_size_audit.py
"""Compare the page size your code asks Slack for against what Slack returns.

Read only, and one GET per method: no cursor is ever followed, because
following cursors is a different question and would spend the rate budget of
the method being measured. Reports whether the configured limit is rejected,
quietly cut down, honoured, or simply larger than the data, and prints the
value to set instead.
"""
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_page_size_audit")

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

# Per method: the documented hard ceiling, the value worth sending, the key the
# items arrive under, and any extra parameters the call needs. The ceiling is
# what produces invalid_limit. The recommendation is what avoids the timeouts
# that a legal but very large page produces on a big workspace.
METHODS = {
    "conversations.list": {
        "ceiling": 1000, "recommended": 200, "items": "channels",
        "params": {"types": "public_channel,private_channel"}},
    "users.list": {
        "ceiling": 1000, "recommended": 200, "items": "members", "params": {},
        "note": "documented to 1000 and unreliable near it on a large workspace; "
                "Slack's own guidance is 200"},
    "users.conversations": {
        "ceiling": 1000, "recommended": 200, "items": "channels",
        "params": {"types": "public_channel,private_channel"}},
    "conversations.members": {
        "ceiling": 1000, "recommended": 200, "items": "members", "params": {},
        "needs_channel": True},
    "conversations.history": {
        "ceiling": 1000, "recommended": 200, "items": "messages", "params": {},
        "needs_channel": True,
        "note": "an app that is not on the Slack Marketplace is held to 15 objects "
                "per call whatever it asks for, which arrives as a cut down page "
                "rather than as an error"},
    "files.list": {
        "ceiling": 1000, "recommended": 200, "items": "files", "params": {}},
}


def limit_verdict(method, limit, table=None):
    """Judge a configured page size offline. Pure, and needs no token.

    Returns (verdict, detail). Knowing that 5000 will be refused before
    spending a request to find out is the cheapest finding in this script.
    """
    spec = (table or METHODS).get(str(method or ""))
    if spec is None:
        return ("unknown-method",
                "%s is not in this table, so nothing can be said about its page "
                "size ceiling. Its own reference page states it." % method)

    if limit is None or str(limit).strip() == "":
        return ("defaulted",
                "no limit is being sent, so Slack uses its default of 100 items. "
                "That is a legitimate choice and it is rarely a deliberate one.")

    try:
        n = int(limit)
    except (TypeError, ValueError):
        return ("not-a-number",
                "%r is not an integer. Whatever the client does with it, Slack "
                "will not read the number you meant." % limit)

    if n <= 0:
        return ("not-a-number",
                "a limit of %d is not a page size. Anything at or below zero is a "
                "bug upstream of this call." % n)
    if n > spec["ceiling"]:
        return ("over-ceiling",
                "%d is above the %d maximum for this method, so every call "
                "returns HTTP 200 with ok: false and error invalid_limit. Nothing "
                "downstream sees an exception; it sees an empty list."
                % (n, spec["ceiling"]))
    if n > spec["recommended"]:
        return ("over-recommended",
                "%d is legal, at or below the %d ceiling, and above the %d worth "
                "sending. %s" % (n, spec["ceiling"], spec["recommended"],
                                 spec.get("note", "Large pages take longer to "
                                          "assemble and time out sooner than the "
                                          "extra round trips they save.")))
    return ("within-limits", "%d is at or under the recommended page size of %d"
                             % (n, spec["recommended"]))


def page_verdict(asked, returned, cursor, error=None):
    """Classify one response against the request that produced it. Pure.

    Four outcomes that are not variations on each other: an error, a page Slack
    made smaller, a page the data made smaller, and the page you asked for.
    """
    err = str(error or "").strip()
    if err == "invalid_limit":
        return ("rejected",
                "invalid_limit: %s is above this method's ceiling and the call "
                "returns nothing at all. Lower the constant." % asked)
    if err:
        return ("unreadable",
                "the call answered ok: false with error %s, so the page size was "
                "never exercised. Fix that first." % err)

    got = int(returned or 0)
    want = int(asked or 0)
    has_cursor = bool(str(cursor or "").strip())

    if got >= want:
        return ("honoured",
                "asked %d, got %d, cursor %s" % (want, got,
                                                 "set" if has_cursor else "empty"))
    if has_cursor:
        return ("cut-down",
                "asked %d, got %d, and a cursor is waiting. Slack chose the page "
                "size, not your constant. Code that reads a short page as the end "
                "of the data is truncating here with ok: true." % (want, got))
    return ("short-final",
            "asked %d, got %d, no cursor: the data ran out. This is the method "
            "behaving correctly." % (want, got))


def repair_for(method, verdict, table=None):
    """The value to set, per method, so a finding ends in a number. Pure."""
    spec = (table or METHODS).get(str(method or ""))
    rec = spec["recommended"] if spec else 200
    if verdict in ("over-ceiling", "rejected"):
        return ("set limit=%d for %s; the ceiling is %d and asking past it "
                "returns nothing" % (rec, method, spec["ceiling"] if spec else 1000))
    if verdict == "over-recommended":
        return ("lower limit to %d for %s; the extra round trips cost less than "
                "the timeouts" % (rec, method))
    if verdict == "cut-down":
        return ("a smaller limit will not change this: the repair is in the "
                "pagination, which must follow response_metadata.next_cursor "
                "rather than stopping on a short page. Send limit=%d and loop"
                % rec)
    if verdict == "defaulted":
        return "send limit=%d explicitly for %s rather than inheriting 100" % (rec, method)
    return "nothing to change for %s" % method


def probe(session, method, spec, limit, channel):
    """One GET, with the configured limit and no cursor. Returns the body."""
    params = dict(spec["params"], limit=str(limit))
    if spec.get("needs_channel"):
        params["channel"] = channel
    return session.get(API + method, params=params, timeout=30).json()


def main():
    ap = argparse.ArgumentParser(description=__doc__)
    ap.add_argument("--token-env", default="SLACK_BOT_TOKEN",
                    help="environment variable holding the bot token")
    ap.add_argument("--limit", default="1000",
                    help="the page size your application sends (default 1000)")
    ap.add_argument("--channel", default="",
                    help="a channel ID for the methods that need one")
    ap.add_argument("--offline", action="store_true",
                    help="check the configured limit against the table and stop")
    args = ap.parse_args()

    bad = 0
    for method, spec in METHODS.items():
        verdict, detail = limit_verdict(method, args.limit)
        line = "%-22s %-11s %-15s %s" % (method, "configured", verdict, detail)
        if verdict in ("within-limits",):
            log.info(line)
        else:
            log.warning(line)
            log.warning("  repair: %s", repair_for(method, verdict))
            bad += 1

    if args.offline:
        log.info("%d configured page size(s) worth changing", bad)
        return 1 if bad else 0

    token = os.environ.get(args.token_env)
    if not token:
        log.error("set %s, or pass --offline to check the constants only",
                  args.token_env)
        return 2

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

    live = 0
    for method, spec in METHODS.items():
        if spec.get("needs_channel") and not args.channel:
            log.info("%-22s %-11s %-15s pass --channel to exercise this one",
                     method, "response", "skipped")
            continue
        body = probe(s, method, spec, args.limit, args.channel)
        cursor = (body.get("response_metadata") or {}).get("next_cursor") or ""
        verdict, detail = page_verdict(
            args.limit, len(body.get(spec["items"]) or []), cursor,
            None if body.get("ok") is True else body.get("error"))
        line = "%-22s %-11s %-15s %s" % (method, "response", verdict, detail)
        if verdict in ("honoured", "short-final"):
            log.info(line)
            continue
        log.warning(line)
        log.warning("  repair: %s", repair_for(method, verdict))
        if spec.get("note"):
            log.warning("  context: %s", spec["note"])
        live += 1

    log.info("%d method(s) not answering at the size they were asked", live)
    return 1 if (live or bad) else 0


if __name__ == "__main__":
    sys.exit(main())
slack-page-size-audit.mjs
/**
 * Compare the page size your code asks Slack for against what Slack returns.
 *
 * Read only, and one GET per method: no cursor is ever followed, because
 * following cursors is a different question and would spend the rate budget of
 * the method being measured. Reports whether the configured limit is rejected,
 * quietly cut down, honoured, or simply larger than the data.
 */

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

// Per method: the documented hard ceiling, the value worth sending, the key the
// items arrive under, and any extra parameters the call needs.
const METHODS = new Map([
  ['conversations.list', {
    ceiling: 1000, recommended: 200, items: 'channels',
    params: { types: 'public_channel,private_channel' },
  }],
  ['users.list', {
    ceiling: 1000, recommended: 200, items: 'members', params: {},
    note: 'documented to 1000 and unreliable near it on a large workspace; ' +
      "Slack's own guidance is 200",
  }],
  ['users.conversations', {
    ceiling: 1000, recommended: 200, items: 'channels',
    params: { types: 'public_channel,private_channel' },
  }],
  ['conversations.members', {
    ceiling: 1000, recommended: 200, items: 'members', params: {},
    needsChannel: true,
  }],
  ['conversations.history', {
    ceiling: 1000, recommended: 200, items: 'messages', params: {},
    needsChannel: true,
    note: 'an app that is not on the Slack Marketplace is held to 15 objects per ' +
      'call whatever it asks for, which arrives as a cut down page rather than ' +
      'as an error',
  }],
  ['files.list', { ceiling: 1000, recommended: 200, items: 'files', params: {} }],
]);

/**
 * Judge a configured page size offline. Pure, and needs no token.
 * Knowing that 5000 will be refused before spending a request to find out is
 * the cheapest finding in this script.
 */
export function limitVerdict(method, limit, table = METHODS) {
  const spec = table.get(String(method ?? ''));
  if (spec === undefined) {
    return ['unknown-method',
      `${method} is not in this table, so nothing can be said about its page ` +
      'size ceiling. Its own reference page states it.'];
  }

  if (limit === null || limit === undefined || String(limit).trim() === '') {
    return ['defaulted',
      'no limit is being sent, so Slack uses its default of 100 items. That is a ' +
      'legitimate choice and it is rarely a deliberate one.'];
  }

  const n = Number(limit);
  if (!Number.isInteger(n)) {
    return ['not-a-number',
      `${limit} is not an integer. Whatever the client does with it, Slack will ` +
      'not read the number you meant.'];
  }
  if (n <= 0) {
    return ['not-a-number',
      `a limit of ${n} is not a page size. Anything at or below zero is a bug ` +
      'upstream of this call.'];
  }
  if (n > spec.ceiling) {
    return ['over-ceiling',
      `${n} is above the ${spec.ceiling} maximum for this method, so every call ` +
      'returns HTTP 200 with ok: false and error invalid_limit. Nothing ' +
      'downstream sees an exception; it sees an empty list.'];
  }
  if (n > spec.recommended) {
    return ['over-recommended',
      `${n} is legal, at or below the ${spec.ceiling} ceiling, and above the ` +
      `${spec.recommended} worth sending. ` + (spec.note ?? 'Large pages take ' +
      'longer to assemble and time out sooner than the extra round trips they save.')];
  }
  return ['within-limits',
    `${n} is at or under the recommended page size of ${spec.recommended}`];
}

/**
 * Classify one response against the request that produced it. Pure.
 * Four outcomes that are not variations on each other.
 */
export function pageVerdict(asked, returned, cursor, error = null) {
  const err = String(error ?? '').trim();
  if (err === 'invalid_limit') {
    return ['rejected',
      `invalid_limit: ${asked} is above this method's ceiling and the call ` +
      'returns nothing at all. Lower the constant.'];
  }
  if (err) {
    return ['unreadable',
      `the call answered ok: false with error ${err}, so the page size was never ` +
      'exercised. Fix that first.'];
  }

  const got = Number(returned) || 0;
  const want = Number(asked) || 0;
  const hasCursor = Boolean(String(cursor ?? '').trim());

  if (got >= want) {
    return ['honoured',
      `asked ${want}, got ${got}, cursor ${hasCursor ? 'set' : 'empty'}`];
  }
  if (hasCursor) {
    return ['cut-down',
      `asked ${want}, got ${got}, and a cursor is waiting. Slack chose the page ` +
      'size, not your constant. Code that reads a short page as the end of the ' +
      'data is truncating here with ok: true.'];
  }
  return ['short-final',
    `asked ${want}, got ${got}, no cursor: the data ran out. This is the method ` +
    'behaving correctly.'];
}

/** The value to set, per method, so a finding ends in a number. Pure. */
export function repairFor(method, verdict, table = METHODS) {
  const spec = table.get(String(method ?? ''));
  const rec = spec ? spec.recommended : 200;
  if (verdict === 'over-ceiling' || verdict === 'rejected') {
    return `set limit=${rec} for ${method}; the ceiling is ${spec ? spec.ceiling : 1000} ` +
      'and asking past it returns nothing';
  }
  if (verdict === 'over-recommended') {
    return `lower limit to ${rec} for ${method}; the extra round trips cost less ` +
      'than the timeouts';
  }
  if (verdict === 'cut-down') {
    return 'a smaller limit will not change this: the repair is in the pagination, ' +
      'which must follow response_metadata.next_cursor rather than stopping on a ' +
      `short page. Send limit=${rec} and loop`;
  }
  if (verdict === 'defaulted') {
    return `send limit=${rec} explicitly for ${method} rather than inheriting 100`;
  }
  return `nothing to change for ${method}`;
}

async function probe(token, method, spec, limit, channel) {
  const params = new URLSearchParams({ ...spec.params, limit: String(limit) });
  if (spec.needsChannel) params.set('channel', channel);
  const res = await fetch(`${API}${method}?${params}`, {
    headers: { Authorization: `Bearer ${token}` },
  });
  return res.json();
}

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

async function main() {
  const args = process.argv.slice(2);
  const limit = arg(args, '--limit', '1000');
  const channel = arg(args, '--channel', '');

  let bad = 0;
  for (const [method] of METHODS) {
    const [verdict, detail] = limitVerdict(method, limit);
    const line = `${method.padEnd(22)} ${'configured'.padEnd(11)} ` +
      `${verdict.padEnd(15)} ${detail}`;
    if (verdict === 'within-limits') {
      console.log(line);
    } else {
      console.warn(line);
      console.warn(`  repair: ${repairFor(method, verdict)}`);
      bad += 1;
    }
  }

  if (args.includes('--offline')) {
    console.log(`${bad} configured page size(s) worth changing`);
    process.exitCode = bad ? 1 : 0;
    return;
  }

  const tokenEnv = arg(args, '--token-env', 'SLACK_BOT_TOKEN');
  const token = process.env[tokenEnv];
  if (!token) {
    console.error(`set ${tokenEnv}, or pass --offline to check the constants only`);
    process.exitCode = 2;
    return;
  }

  let live = 0;
  for (const [method, spec] of METHODS) {
    if (spec.needsChannel && !channel) {
      console.log(`${method.padEnd(22)} ${'response'.padEnd(11)} ` +
        `${'skipped'.padEnd(15)} pass --channel to exercise this one`);
      continue;
    }
    const body = await probe(token, method, spec, limit, channel);
    const cursor = body.response_metadata?.next_cursor ?? '';
    const [verdict, detail] = pageVerdict(limit, (body[spec.items] ?? []).length,
      cursor, body.ok === true ? null : body.error);
    const line = `${method.padEnd(22)} ${'response'.padEnd(11)} ` +
      `${verdict.padEnd(15)} ${detail}`;
    if (verdict === 'honoured' || verdict === 'short-final') {
      console.log(line);
      continue;
    }
    console.warn(line);
    console.warn(`  repair: ${repairFor(method, verdict)}`);
    if (spec.note) console.warn(`  context: ${spec.note}`);
    live += 1;
  }

  console.log(`${live} method(s) not answering at the size they were asked`);
  process.exitCode = (live || bad) ? 1 : 0;
}

// Only run when invoked directly, so importing this module in the tests does
// not execute main() and fail the file on a missing token.
if (import.meta.url === `file://${process.argv[1]}`) {
  main().catch((err) => { console.error(err.message); process.exitCode = 2; });
}

Add a test

The two halves are tested separately because they answer different questions. limit_verdict is exercised with no network at all: 5000 is doomed, 1000 is legal and unwise, 200 is fine, and a method the table has never heard of produces an admission rather than a guess. page_verdict gets the case the whole note is about, which is a request for two hundred answered with fifteen and a cursor, asserted to come back as cut-down and never as the end of the data.

test_slack_page_size_audit.py
from slack_page_size_audit import limit_verdict, page_verdict, repair_for


def test_five_thousand_is_rejected_before_a_request_is_spent():
    verdict, detail = limit_verdict("conversations.list", 5000)
    assert verdict == "over-ceiling"
    assert "invalid_limit" in detail


def test_a_thousand_is_legal_and_still_worth_lowering():
    verdict, detail = limit_verdict("users.list", 1000)
    assert verdict == "over-recommended"
    assert "200" in detail


def test_the_recommended_value_passes_cleanly():
    assert limit_verdict("users.list", 200)[0] == "within-limits"
    assert limit_verdict("conversations.list", 100)[0] == "within-limits"


def test_an_unknown_method_is_admitted_rather_than_guessed_at():
    verdict, detail = limit_verdict("chat.scheduledMessages.list", 200)
    assert verdict == "unknown-method"
    assert "reference page" in detail


def test_sending_no_limit_at_all_is_its_own_finding():
    assert limit_verdict("users.list", None)[0] == "defaulted"
    assert limit_verdict("users.list", "")[0] == "defaulted"


def test_a_limit_that_is_not_a_page_size_is_caught_here_not_by_slack():
    assert limit_verdict("users.list", "two hundred")[0] == "not-a-number"
    assert limit_verdict("users.list", 0)[0] == "not-a-number"
    assert limit_verdict("users.list", -5)[0] == "not-a-number"


def test_the_full_page_you_asked_for_is_honoured():
    verdict, detail = page_verdict(200, 200, "dXNlcjpVMDYx")
    assert verdict == "honoured"
    assert "cursor set" in detail


def test_fifteen_messages_with_a_cursor_is_the_clamp_not_the_end():
    verdict, detail = page_verdict(200, 15, "dXNlcjpVMDYx")
    assert verdict == "cut-down"
    assert "Slack chose the page size" in detail


def test_fifteen_messages_with_no_cursor_is_correct_behaviour():
    verdict, detail = page_verdict(200, 15, "")
    assert verdict == "short-final"
    assert "the data ran out" in detail


def test_the_error_string_is_reported_as_a_rejection_not_a_short_page():
    verdict, detail = page_verdict(5000, 0, "", "invalid_limit")
    assert verdict == "rejected"
    assert "5000" in detail


def test_any_other_error_means_the_page_size_was_never_exercised():
    verdict, detail = page_verdict(200, 0, "", "missing_scope")
    assert verdict == "unreadable"
    assert "missing_scope" in detail


def test_a_clamp_is_not_repaired_by_lowering_the_constant():
    assert "next_cursor" in repair_for("conversations.history", "cut-down")
    assert "set limit=200" in repair_for("conversations.list", "over-ceiling")
    assert repair_for("conversations.list", "honoured").startswith("nothing to change")
slack-page-size-audit.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { limitVerdict, pageVerdict, repairFor } from './slack-page-size-audit.mjs';

test('five thousand is rejected before a request is spent', () => {
  const [verdict, detail] = limitVerdict('conversations.list', 5000);
  assert.equal(verdict, 'over-ceiling');
  assert.match(detail, /invalid_limit/);
});

test('a thousand is legal and still worth lowering', () => {
  const [verdict, detail] = limitVerdict('users.list', 1000);
  assert.equal(verdict, 'over-recommended');
  assert.match(detail, /200/);
});

test('the recommended value passes cleanly', () => {
  assert.equal(limitVerdict('users.list', 200)[0], 'within-limits');
  assert.equal(limitVerdict('conversations.list', 100)[0], 'within-limits');
});

test('an unknown method is admitted rather than guessed at', () => {
  const [verdict, detail] = limitVerdict('chat.scheduledMessages.list', 200);
  assert.equal(verdict, 'unknown-method');
  assert.match(detail, /reference page/);
});

test('sending no limit at all is its own finding', () => {
  assert.equal(limitVerdict('users.list', null)[0], 'defaulted');
  assert.equal(limitVerdict('users.list', '')[0], 'defaulted');
});

test('a limit that is not a page size is caught here not by slack', () => {
  assert.equal(limitVerdict('users.list', 'two hundred')[0], 'not-a-number');
  assert.equal(limitVerdict('users.list', 0)[0], 'not-a-number');
  assert.equal(limitVerdict('users.list', -5)[0], 'not-a-number');
});

test('the full page you asked for is honoured', () => {
  const [verdict, detail] = pageVerdict(200, 200, 'dXNlcjpVMDYx');
  assert.equal(verdict, 'honoured');
  assert.match(detail, /cursor set/);
});

test('fifteen messages with a cursor is the clamp not the end', () => {
  const [verdict, detail] = pageVerdict(200, 15, 'dXNlcjpVMDYx');
  assert.equal(verdict, 'cut-down');
  assert.match(detail, /Slack chose the page size/);
});

test('fifteen messages with no cursor is correct behaviour', () => {
  const [verdict, detail] = pageVerdict(200, 15, '');
  assert.equal(verdict, 'short-final');
  assert.match(detail, /the data ran out/);
});

test('the error string is reported as a rejection not a short page', () => {
  const [verdict, detail] = pageVerdict(5000, 0, '', 'invalid_limit');
  assert.equal(verdict, 'rejected');
  assert.match(detail, /5000/);
});

test('any other error means the page size was never exercised', () => {
  const [verdict, detail] = pageVerdict(200, 0, '', 'missing_scope');
  assert.equal(verdict, 'unreadable');
  assert.match(detail, /missing_scope/);
});

test('a clamp is not repaired by lowering the constant', () => {
  assert.match(repairFor('conversations.history', 'cut-down'), /next_cursor/);
  assert.match(repairFor('conversations.list', 'over-ceiling'), /set limit=200/);
  assert.match(repairFor('conversations.list', 'honoured'), /^nothing to change/);
});

FAQ

What is the actual maximum for limit?

1000 on the cursor-paginated methods, and each method's own reference page is the authority. There is no global constant and no response header that reports it, which is why the script keeps a small table for the offline check and then measures the real answer with one request per method rather than trusting the table.

Why did my call return fewer items without an error?

Because Slack decided the page size instead of refusing your number. The commonest cause in 2026 is the history clamp on apps that are not on the Slack Marketplace, which returns fifteen objects per call regardless of the request. A short page with a non-empty next_cursor always means there is more data, whatever the reason the page was small.

Is a large limit faster than paginating?

Usually not, and past a point it is slower. A request for a thousand users takes longer to assemble server side and is more likely to time out than five requests for two hundred, which is why Slack's own guidance recommends 200 for users.list on large workspaces. The page size chosen to avoid writing a loop is often the one that makes the call unreliable.

Should the audit follow the cursor to check the totals?

No, on purpose. One call per method is enough to compare the request against the response, and walking every page would spend the rate budget of the very method being measured, on a Tier 2 or Tier 3 limit, to answer a question one request already answered. Whether your application follows cursors correctly is a separate check.

The constant is fine now. Will this stay fixed?

The constant will. The clamp will not: Slack has changed per-method page ceilings before, and the change arrives as smaller pages rather than as an error, so nothing in your code will notice. Running this check on a schedule costs six requests and catches a ceiling that moved under you.

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.