Back to Blog
Guide

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.

Slack Green Team
October 2, 2026
October 4, 2026
3 min read
Share:
slack api
developers
python

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 sentResponse
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.

Always Active

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

Guide

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 Green Team
Guide

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.

Slack Green Team
Guide

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.

Slack Green Team