Slack API Pagination: next_cursor, the Last Page and invalid_cursor, Tested
Slack paginates list methods with a cursor: pass response_metadata.next_cursor back as cursor until it is empty. We paged 27 real messages on 2 October 2026 and broke the cursor five ways to see which ones fail.
On this page
Slack's list methods (conversations.history, conversations.list, users.list and most others) page with a cursor. Send limit, read response_metadata.next_cursor from the response, and send it back as cursor on the next call. Stop when next_cursor is an empty string or missing. Do not stop on a short page, and do not compare cursors.
We tested this on 2 October 2026 in a test workspace: a channel with 25 messages posted by a test bot plus 2 join messages, paged with limit=10, then the same calls with broken cursors.
A full paged run
import os, httpx
TOKEN = os.environ["SLACK_BOT_TOKEN"]
CHANNEL = os.environ["CHANNEL_ID"]
cursor, page, total = None, 0, 0
while True:
params = {"channel": CHANNEL, "limit": 10}
if cursor:
params["cursor"] = cursor
r = httpx.get("https://slack.com/api/conversations.history",
headers={"Authorization": f"Bearer {TOKEN}"}, params=params, timeout=30).json()
page += 1
total += len(r["messages"])
cursor = r.get("response_metadata", {}).get("next_cursor", "")
print(f"page {page}: {len(r['messages'])} messages, has_more={r['has_more']}, "
f"next_cursor={cursor[:16] + '...' if cursor else repr(cursor)}")
if not cursor:
break
print("total", total)
Output:
page 1: 10 messages, has_more=True, next_cursor=bmV4dF90czoxNzkw...
page 2: 10 messages, has_more=True, next_cursor=bmV4dF90czoxNzkw...
page 3: 7 messages, has_more=False, next_cursor=''
total 27
On the last page there was no response_metadata key at all, not an empty one. This is everything the last response held apart from the messages:
{
"ok": true,
"has_more": false,
"is_limited": false,
"pin_count": 0,
"channel_actions_ts": null,
"channel_actions_count": 0
}
Code that reads r["response_metadata"]["next_cursor"] crashes with a KeyError on the last page of history. Use .get() as above. For conversations.history, has_more agrees with the cursor. Our users.list and conversations.list responses had no has_more field at all, so the cursor is the one check that works everywhere.
What a cursor is
Our first cursor was bmV4dF90czoxNzkwOTA0Njk2MjM1ODM5. It is base64. Decoded:
next_ts:1790904696235839
That is the timestamp of message 15, 1790904696.235839, without the dot: the first message of page 2. The second cursor decoded to the ts of message 05, the first message of page 3. We built a cursor by hand from message 10's ts, and Slack accepted it and returned messages 10, 09 and 08. Other methods use other keys: our users.list cursor dXNlcjpVMEI3TDRZSzQyMA== decoded to user:U0B7L4YK420, the next member's ID. Do not build cursors yourself in real code; the format is not documented and can change. It does explain the results in the next section.
Never appear "away" on Slack again
Cloud-based. No downloads. Works 24/7 even when your laptop is off.
Five broken cursors, one error
| Cursor we sent | Response |
|---|---|
notacursor | {"ok": false, "error": "invalid_cursor"} |
| The real cursor with its last 4 characters cut off | "ok": true, 0 messages, no error |
base64 of next_ts:abc | "ok": true, the 10 newest messages, as if no cursor was sent |
| A cursor from this channel, sent with another channel's ID | "ok": true, 10 messages from the other channel |
| An empty string | "ok": true, the 10 newest messages |
Only plain garbage gave invalid_cursor. The cut-off cursor decoded to next_ts:1790904696235, which Slack read as a time in January 1970, so it found nothing older and said so with a normal empty page. If a paged export ends early with no error, check that the cursor was not truncated or URL-mangled on the way. Pass it as a form or query parameter and let the HTTP library encode it.
Changing limit between calls is allowed. Page 2 fetched with limit=5 instead of 10 returned messages 15 to 11, exactly where page 1 ended.
limit: fewer calls, same data
With slack_sdk, a response object is iterable: each loop step fetches the next page with the cursor for you.
import os
from slack_sdk import WebClient
client = WebClient(token=os.environ["SLACK_BOT_TOKEN"])
for limit in (1, 200):
calls, members = 0, []
for page in client.users_list(limit=limit): # SlackResponse is iterable: one item per page
calls += 1
members += page["members"]
print(f"users.list limit={limit}: {calls} calls, {len(members)} members")
msgs = [m for page in client.conversations_history(channel=os.environ["CHANNEL_ID"], limit=10)
for m in page["messages"]]
print("conversations.history limit=10:", len(msgs), "messages, newest:", msgs[0]["text"])
Output:
users.list limit=1: 14 calls, 14 members
users.list limit=200: 1 calls, 14 members
conversations.history limit=10: 27 messages, newest: paging test message 25
Same 14 members, 14 calls instead of 1. Every call counts against the method's rate limit, so use the largest limit the method allows (Slack suggests 200 for most list methods) and see Slack API rate limits for what happens when a long paged run hits a 429. Leaving limit out, or sending limit=0, returned all 27 messages from our small channel in one page.
For reading a whole channel with thread replies, get messages from a Slack channel builds on this loop. For channel lists, conversations.list shows what a bot token can see.
FAQ
Does the cursor expire? Ours still worked when reused minutes later, which fits what it holds: a message timestamp or user ID, not a session. The format is undocumented, so do not store cursors for later anyway.
Stop Jiggling Your Mouse.
Join hundreds of remote workers who never worry about their Slack status. Set it up once, stay green forever.
Related Articles
Slack RTM API Deprecated: What rtm.connect Returns for a New App, and the Socket Mode Fix
A Slack app created today cannot use the RTM API. We called rtm.connect and rtm.start with every token a new app gets on 2 October 2026, tried to request the rtm:stream scope, and ran the same bot over Socket Mode.
Slack Bot Icon and Name Per Message: icon_emoji, icon_url and username, Tested
icon_emoji, icon_url and username only work with the chat:write.customize scope, and Slack ignores them silently without it. We tested every case on 2 October 2026, plus webhooks and the app icon upload limits.
Slack as_user: What It Does Now, Tested With Bot and User Tokens
We sent chat.postMessage with as_user=true, as_user=false and no as_user, with a bot token and a user token. as_user=true changed nothing, as_user=false was rejected, and only the user token posted under the person's name.