Diagnostic Slack
conversations.history clamped to 15 objects and 1 per minute
A backfill that used to take an hour now takes weeks, and nothing in your code changed. conversations.history still returns ok: true, still returns a valid page, still gives you a cursor — it just returns 15 messages when you asked for 200, and refuses the second call inside the same minute. This is Slack's May 2025 rate-limit change for apps that are not approved for the Marketplace, and it is working exactly as designed.
Call conversations.history?channel=C...&limit=200 once. If messages.length comes back as exactly 15 and response_metadata.next_cursor is a non-empty string, the clamp is on. Confirm by calling again inside the same minute: a clamped app gets ok: false with error: "ratelimited" and a Retry-After of about 60.
This note is detect-only. There is no setting, header or parameter that lifts the clamp. The real options are Marketplace approval, reclassifying the app as internal, or moving off history polling and onto the Events API.
The problem in plain words
On 29 May 2025 Slack changed the rate limits on conversations.history and conversations.replies for commercially distributed apps that are not approved for the Slack Marketplace. Those apps get 1 request per minute, and a maximum and default limit of 15 objects per request, down from Tier 3 — 50+ requests a minute with limit up to 1000. It took effect immediately for newly created unlisted apps and for net-new installations of existing ones, and rolled across existing installations between 2 September 2025 and 3 March 2026. Internal, customer-built apps are excluded.
The change is a factor of roughly 3,000 in throughput, and it arrives without a single error. ok stays true, the response shape is unchanged, pagination still works. An archiver that reads a busy channel simply falls behind, forever, and the only symptom anyone sees is a dashboard that is a bit more out of date every week.
Why it happens
A clamped page is indistinguishable from a small page. Slack does not return invalid_limit when you ask for 200 and it will only give you 15; it silently returns 15. The only thing separating “the clamp is on” from “the channel only has 15 messages” is whether a next_cursor came back with it.
The 1-per-minute part is easy to misread as a transient throttle. A ratelimited error with Retry-After: 60 looks like ordinary backpressure, and a client with retry logic absorbs it silently. It is not transient; it is the steady state for this app on this method.
It is per method family, not global. conversations.list and users.list are untouched, so a health check that exercises those comes back clean. Contrasting a history call against a list call is what proves the problem is the clamp and not a workspace-wide throttle or a shared quota between your own workers.
Nothing in the app can fix it. This is the unusual case where detection is the entire deliverable. Every real remedy is a change to what the app is — a Marketplace listing, an internal-app reclassification, or an architecture that does not poll history — and pretending otherwise wastes a day looking for a setting that was never there.
The fix, as a flow
The script asks for 200 messages and counts what comes back, then checks the cursor, because a page of 15 with no cursor is a small channel and a page of 15 with a cursor is the clamp.
How to fix it
Ask for more than the cap, deliberately
The probe only works if the request is larger than the clamp. Call conversations.history?channel=C...&limit=200. If your code already asks for 15, or for the default, you cannot tell the difference and the script says so rather than guessing.
Count the page and read the cursor together
Exactly 15 messages plus a non-empty response_metadata.next_cursor is the clamp: Slack has more to give and is giving you 15. Exactly 15 with no cursor means the channel has 15 messages left and proves nothing. Pick a busy channel, and if the result is inconclusive, pick a busier one.
Confirm with a second call in the same minute
Immediately repeat the call. A clamped app gets ok: false, error: "ratelimited", and a Retry-After header near 60. An unclamped app on Tier 3 answers normally. Read Retry-After from the headers on both a real 429 and a 200 body, because Slack uses both shapes.
Contrast against a method that was not changed
conversations.list?limit=200 should still return up to 200. If that is clamped too, you are looking at something else entirely — an IP allow list, a workspace-wide throttle, or your own workers sharing one per-method quota — and the history clamp is not your problem.
Choose one of the three real remedies
Submit the app to the Slack Marketplace and get it approved, which restores Tier 3. Or, if it only ever runs inside one organisation, reclassify it as an internal customer-built app, which is exempt. Or redesign away from polling: subscribe to message.channels and message.groups, maintain your own store, and let history become a rare backfill. While you decide, drop any hardcoded limit=1000 to 15 so the pagination logic stops assuming pages it will not get.
How to check it worked
Re-run the probe after the app's status changes. A healthy app returns a full page and answers the second call.
python3 slack_history_clamp_probe.py --channel C0123456789
# unclamped C0123456789 asked for 200, got 200. Tier 3 limits intact.
The full code
Three GET calls, all reads: auth.test, one conversations.history probe repeated once inside the minute, and a conversations.list control. Scopes are channels:read and channels:history. The pure function takes the numbers the probe collected — requested, returned, cursor, whether the second call was refused — and names the state, including the two states that mean “this probe cannot tell you”.
"""Detect Slack's non-Marketplace clamp on conversations.history.
Read only, and detect-only: there is no setting that lifts this clamp, so the
script reports what it found and prints the three real remedies. A bot token
with channels:read and channels:history is enough.
"""
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_history_clamp_probe")
API = "https://slack.com/api"
# The documented ceiling for a non-Marketplace app on conversations.history and
# conversations.replies since 29 May 2025: 15 objects, one request per minute.
CAP = 15
def verdict(probe, *, cap=CAP):
"""Name the state of one history probe. Pure, so the rule is testable
offline.
`probe` carries what the two calls observed:
requested the limit that was asked for
returned how many messages came back
next_cursor response_metadata.next_cursor, or ""
second_call_error body.error from an immediate repeat call, or ""
Returns (state, detail). Two of the states exist to say the probe cannot
tell: asking for 15 or fewer proves nothing, and a page of exactly 15 with
no cursor is a channel that ran out of messages, not a clamp. Reporting
either as clamped sends somebody to the Marketplace over a quiet channel.
"""
requested = int(probe.get("requested") or 0)
returned = int(probe.get("returned") or 0)
cursor = str(probe.get("next_cursor") or "").strip()
throttled = str(probe.get("second_call_error") or "").strip() == "ratelimited"
if requested <= cap:
return ("not-probed",
"asked for %d, which is at or below the %d-object cap. Ask for "
"more than %d or the answer means nothing."
% (requested, cap, cap))
if returned > cap:
return ("unclamped",
"asked for %d, got %d. Tier 3 limits intact."
% (requested, returned))
if returned == cap and cursor and throttled:
return ("clamped-confirmed",
"asked for %d, got exactly %d with more pages waiting, and the "
"second call inside the minute was refused with ratelimited. "
"That is the non-Marketplace clamp." % (requested, cap))
if returned == cap and cursor:
return ("clamped",
"asked for %d, got exactly %d and a cursor, so Slack has more "
"and is handing over %d. The second call was not refused; "
"repeat the probe to confirm the 1-per-minute half."
% (requested, cap, cap))
if returned == cap:
return ("inconclusive",
"got exactly %d with no cursor. A clamped page and a channel "
"with %d messages left look identical here. Probe a busier "
"channel." % (cap, cap))
if cursor:
return ("short-page",
"got %d of %d with a cursor still set. Fewer than the clamp "
"would give, so this is not it: look at the channel, the "
"oldest/latest window, or a shared quota."
% (returned, requested))
return ("small-channel",
"got %d of %d and no cursor. The channel simply has that many "
"messages; nothing is clamped." % (returned, requested))
def call(session, method, **params):
"""One Web API read. Returns (body, retry_after). Unlike the other scripts
in this section, a ratelimited answer here is the finding rather than an
error, so it is returned instead of raised. Slack sends it both as a real
429 with a Retry-After header and as a 200 carrying ok false, so both are
handled."""
r = session.get("%s/%s" % (API, method), params=params, timeout=30)
retry_after = r.headers.get("Retry-After")
if r.status_code == 429:
return ({"ok": False, "error": "ratelimited"}, retry_after)
r.raise_for_status()
body = r.json()
if not body.get("ok") and body.get("error") != "ratelimited":
raise SystemExit("%s: %s (needed=%s provided=%s)"
% (method, body.get("error"), body.get("needed"),
body.get("provided")))
return (body, retry_after)
def pick_channel(session):
body, _ = call(session, "users.conversations", limit=200,
types="public_channel")
channels = body.get("channels") or []
if not channels:
return None
# The busiest channel available is the one least likely to give an
# inconclusive answer, and message count is the closest proxy on hand.
channels.sort(key=lambda c: int(c.get("num_members") or 0), reverse=True)
return channels[0]
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--channel", help="channel id to probe. Default: the "
"largest channel the bot is a member of")
ap.add_argument("--limit", type=int, default=200,
help="page size to ask for; must exceed 15 to mean anything")
args = ap.parse_args()
token = os.environ.get("SLACK_BOT_TOKEN")
if not token:
log.error("set SLACK_BOT_TOKEN (channels:read and channels:history)")
return 2
session = requests.Session()
session.headers.update({"Authorization": "Bearer " + token})
me, _ = call(session, "auth.test")
log.info("authenticated as %s in %s", me.get("user"), me.get("team"))
channel = args.channel
if not channel:
picked = pick_channel(session)
if not picked:
log.error("no channels available; pass --channel")
return 2
channel = picked["id"]
log.info("probing #%s (%s)", picked.get("name"), channel)
first, _ = call(session, "conversations.history", channel=channel,
limit=args.limit)
if not first.get("ok"):
log.error("first call was already ratelimited; wait a minute and retry")
return 2
second, retry_after = call(session, "conversations.history",
channel=channel, limit=args.limit)
state, detail = verdict({
"requested": args.limit,
"returned": len(first.get("messages") or []),
"next_cursor": (first.get("response_metadata") or {}).get("next_cursor"),
"second_call_error": second.get("error"),
})
log.info("%-18s %s %s", state, channel, detail)
if retry_after:
log.info(" Retry-After on the second call: %s", retry_after)
control, _ = call(session, "conversations.list", limit=200,
exclude_archived="true")
n = len(control.get("channels") or [])
log.info(" control: conversations.list?limit=200 returned %d", n)
if n <= CAP and state.startswith("clamped"):
log.warning(" the control is short too, so this may be a wider "
"throttle rather than the history clamp alone")
if state.startswith("clamped"):
log.warning(" no setting lifts this. The three real remedies:")
log.warning(" 1. get the app approved for the Slack Marketplace, "
"which restores Tier 3")
log.warning(" 2. if it runs inside one organisation only, reclassify "
"it as an internal customer-built app, which is exempt")
log.warning(" 3. stop polling history: subscribe to message.channels "
"and message.groups, keep your own store, and let history "
"become a rare backfill")
log.warning(" meanwhile drop any hardcoded limit=1000 to %d so "
"pagination stops assuming pages it will not get", CAP)
return 1
return 0
if __name__ == "__main__":
sys.exit(main())
/**
* Detect Slack's non-Marketplace clamp on conversations.history.
*
* Read only, and detect-only: there is no setting that lifts this clamp, so the
* script reports what it found and prints the three real remedies. A bot token
* with channels:read and channels:history is enough.
*/
const API = 'https://slack.com/api';
// The documented ceiling for a non-Marketplace app on conversations.history and
// conversations.replies since 29 May 2025: 15 objects, one request per minute.
export const CAP = 15;
/**
* Name the state of one history probe. Pure, so the rule is testable offline.
*
* Two of the states exist to say the probe cannot tell: asking for 15 or fewer
* proves nothing, and a page of exactly 15 with no cursor is a channel that ran
* out of messages rather than a clamp. Reporting either as clamped sends
* somebody to the Marketplace over a quiet channel.
*/
export function verdict(probe, { cap = CAP } = {}) {
const requested = Number(probe.requested ?? 0);
const returned = Number(probe.returned ?? 0);
const cursor = String(probe.next_cursor ?? '').trim();
const throttled = String(probe.second_call_error ?? '').trim() === 'ratelimited';
if (requested <= cap) {
return ['not-probed',
`asked for ${requested}, which is at or below the ${cap}-object cap. ` +
`Ask for more than ${cap} or the answer means nothing.`];
}
if (returned > cap) {
return ['unclamped', `asked for ${requested}, got ${returned}. Tier 3 limits intact.`];
}
if (returned === cap && cursor && throttled) {
return ['clamped-confirmed',
`asked for ${requested}, got exactly ${cap} with more pages waiting, and ` +
'the second call inside the minute was refused with ratelimited. That is ' +
'the non-Marketplace clamp.'];
}
if (returned === cap && cursor) {
return ['clamped',
`asked for ${requested}, got exactly ${cap} and a cursor, so Slack has ` +
`more and is handing over ${cap}. The second call was not refused; ` +
'repeat the probe to confirm the 1-per-minute half.'];
}
if (returned === cap) {
return ['inconclusive',
`got exactly ${cap} with no cursor. A clamped page and a channel with ` +
`${cap} messages left look identical here. Probe a busier channel.`];
}
if (cursor) {
return ['short-page',
`got ${returned} of ${requested} with a cursor still set. Fewer than the ` +
'clamp would give, so this is not it: look at the channel, the ' +
'oldest/latest window, or a shared quota.'];
}
return ['small-channel',
`got ${returned} of ${requested} and no cursor. The channel simply has ` +
'that many messages; nothing is clamped.'];
}
/**
* One Web API read. Returns { body, retryAfter }. A ratelimited answer is the
* finding here rather than an error, so it is returned instead of thrown, and
* Slack sends it both as a real 429 and as a 200 carrying ok false.
*/
async function call(token, method, params = {}) {
const url = new URL(`${API}/${method}`);
for (const [k, v] of Object.entries(params)) {
if (v !== undefined && v !== null && v !== '') url.searchParams.set(k, v);
}
const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
const retryAfter = res.headers.get('retry-after');
if (res.status === 429) return { body: { ok: false, error: 'ratelimited' }, retryAfter };
if (!res.ok) throw new Error(`${res.status} from ${method}`);
const body = await res.json();
if (!body.ok && body.error !== 'ratelimited') {
throw new Error(`${method}: ${body.error} (needed=${body.needed} ` +
`provided=${body.provided})`);
}
return { body, retryAfter };
}
async function main() {
const token = process.env.SLACK_BOT_TOKEN;
if (!token) {
console.error('set SLACK_BOT_TOKEN (channels:read and channels:history)');
process.exitCode = 2;
return;
}
const limit = 200;
const { body: me } = await call(token, 'auth.test');
console.log(`authenticated as ${me.user} in ${me.team}`);
let channel = process.argv.slice(2).find((a) => !a.startsWith('-'));
if (!channel) {
const { body } = await call(token, 'users.conversations',
{ limit: 200, types: 'public_channel' });
const channels = [...(body.channels ?? [])]
.sort((a, b) => Number(b.num_members ?? 0) - Number(a.num_members ?? 0));
if (channels.length === 0) {
console.error('no channels available; pass a channel id');
process.exitCode = 2;
return;
}
channel = channels[0].id;
console.log(`probing #${channels[0].name} (${channel})`);
}
const { body: first } = await call(token, 'conversations.history', { channel, limit });
if (!first.ok) {
console.error('first call was already ratelimited; wait a minute and retry');
process.exitCode = 2;
return;
}
const { body: second, retryAfter } = await call(token, 'conversations.history',
{ channel, limit });
const [state, detail] = verdict({
requested: limit,
returned: (first.messages ?? []).length,
next_cursor: first.response_metadata?.next_cursor,
second_call_error: second.error,
});
console.log(`${state.padEnd(18)} ${channel} ${detail}`);
if (retryAfter) console.log(` Retry-After on the second call: ${retryAfter}`);
const { body: control } = await call(token, 'conversations.list',
{ limit: 200, exclude_archived: 'true' });
console.log(` control: conversations.list?limit=200 returned ` +
`${(control.channels ?? []).length}`);
if (state.startsWith('clamped')) {
console.warn(' no setting lifts this. The three real remedies:');
console.warn(' 1. get the app approved for the Slack Marketplace, which ' +
'restores Tier 3');
console.warn(' 2. if it runs inside one organisation only, reclassify it ' +
'as an internal customer-built app, which is exempt');
console.warn(' 3. stop polling history: subscribe to message.channels and ' +
'message.groups, keep your own store, and let history become a ' +
'rare backfill');
console.warn(` meanwhile drop any hardcoded limit=1000 to ${CAP} so ` +
'pagination stops assuming pages it will not get');
process.exitCode = 1;
}
}
// 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
Most of these tests are about refusing to answer. A page of exactly 15 with no cursor, and a probe that asked for 15 in the first place, both have to come back as “cannot tell” — because the cost of a false positive here is somebody spending a week on a Marketplace submission for a quiet channel.
from slack_history_clamp_probe import verdict
def test_a_full_page_is_unclamped():
state, detail = verdict({"requested": 200, "returned": 200,
"next_cursor": "dXNlcjpV"})
assert state == "unclamped"
assert "Tier 3" in detail
def test_exactly_fifteen_with_a_cursor_is_the_clamp():
state, _ = verdict({"requested": 200, "returned": 15,
"next_cursor": "dXNlcjpV"})
assert state == "clamped"
def test_a_refused_second_call_confirms_it():
state, detail = verdict({"requested": 200, "returned": 15,
"next_cursor": "dXNlcjpV",
"second_call_error": "ratelimited"})
assert state == "clamped-confirmed"
assert "ratelimited" in detail
def test_exactly_fifteen_with_no_cursor_is_not_a_finding():
# A channel that has fifteen messages left looks identical to a clamped
# page. Calling this clamped is the expensive mistake.
state, detail = verdict({"requested": 200, "returned": 15, "next_cursor": ""})
assert state == "inconclusive"
assert "busier channel" in detail
def test_asking_for_fifteen_proves_nothing():
state, _ = verdict({"requested": 15, "returned": 15, "next_cursor": "abc"})
assert state == "not-probed"
def test_a_short_quiet_channel_is_not_clamped():
state, _ = verdict({"requested": 200, "returned": 4, "next_cursor": ""})
assert state == "small-channel"
def test_fewer_than_the_cap_with_a_cursor_is_something_else():
state, _ = verdict({"requested": 200, "returned": 9, "next_cursor": "abc"})
assert state == "short-page"
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { verdict } from './slack-history-clamp-probe.mjs';
test('a full page is unclamped', () => {
const [state, detail] = verdict({
requested: 200, returned: 200, next_cursor: 'dXNlcjpV',
});
assert.equal(state, 'unclamped');
assert.match(detail, /Tier 3/);
});
test('exactly fifteen with a cursor is the clamp', () => {
assert.equal(
verdict({ requested: 200, returned: 15, next_cursor: 'dXNlcjpV' })[0],
'clamped',
);
});
test('a refused second call confirms it', () => {
const [state, detail] = verdict({
requested: 200, returned: 15, next_cursor: 'dXNlcjpV',
second_call_error: 'ratelimited',
});
assert.equal(state, 'clamped-confirmed');
assert.match(detail, /ratelimited/);
});
test('exactly fifteen with no cursor is not a finding', () => {
const [state, detail] = verdict({ requested: 200, returned: 15, next_cursor: '' });
assert.equal(state, 'inconclusive');
assert.match(detail, /busier channel/);
});
test('asking for fifteen proves nothing', () => {
assert.equal(
verdict({ requested: 15, returned: 15, next_cursor: 'abc' })[0],
'not-probed',
);
});
test('a short quiet channel is not clamped', () => {
assert.equal(
verdict({ requested: 200, returned: 4, next_cursor: '' })[0],
'small-channel',
);
});
test('fewer than the cap with a cursor is something else', () => {
assert.equal(
verdict({ requested: 200, returned: 9, next_cursor: 'abc' })[0],
'short-page',
);
});
FAQ
Which apps are affected by the clamp?
Commercially distributed apps that are not approved for the Slack Marketplace, on conversations.history and conversations.replies. Internal customer-built apps are excluded. It applied immediately to newly created unlisted apps and to net-new installations of existing ones, and rolled across existing installations between 2 September 2025 and 3 March 2026.
Is there a header or parameter that turns it off?
No. There is no setting, no allow list you can request, and no plan tier that changes it. That is why this note is detect-only: the script tells you the clamp is on and prints the three real remedies, which are Marketplace approval, internal-app reclassification, or an architecture that does not poll history.
Why does the script call the same endpoint twice on purpose?
Because the clamp has two halves and the page size only shows you one. The second call inside the same minute is what proves the 1-request-per-minute half: a clamped app is refused with ratelimited and a Retry-After near 60, and a Tier 3 app answers normally.
My probe returned 15 messages and the script said inconclusive.
Then no cursor came back, which means Slack had nothing more to give: the channel really does have fifteen messages left. A clamped page always has a next_cursor, because the clamp truncates a page Slack could have filled. Probe a busier channel.
What does the Events API buy me here?
It inverts the data flow. Instead of asking Slack for history on a schedule and being metered, you subscribe to message.channels and message.groups and write each message into your own store as it happens. History then becomes a rare backfill rather than the primary data path, and one request per minute stops mattering.
Related field notes
- next_cursor is ignored, so you see one page
- The same message posted three times
- Every failure arrives as HTTP 200
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.
- Rate limit changes for non-Marketplace apps — Slack changelog
- More clarity on the rate limit changes — Slack changelog
- Rate limits — Slack API
- conversations.history method — Slack API
If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.