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.
The Ruby and Node clients can follow the cursor for you: conversations_history with a block in slack-ruby-client, and client.paginate in @slack/web-api.
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
How to See All Your DMs in Slack, Including the Hidden Ones
The DMs tab lists your direct messages with people. App DMs, Slackbot and DMs with deleted accounts sit elsewhere. We compared the tab with the full list the Slack API returned.
Slack Images Not Showing? 5 Causes We Reproduced
We uploaded images to a Slack test channel and then switched each image setting off, ran /collapse, and posted from a bot that was not in the channel. Five causes, each reproduced.
How to Message Yourself on Slack (and What It Notifies)
Your own name under Direct messages opens a private DM with yourself. We sent it a note, a file, a scheduled message and a reminder, and checked which ones raised a badge.