Slack Error Guide: 'invalid_cursor' — Fix Broken Pagination
Fix the Slack API invalid_cursor error: pass the exact next_cursor from response_metadata, URL-encode it, and keep query params identical across pages.
- #slack
- #api
- #troubleshooting
- #errors
Stuck on this Slack error? Get the free incident triage checklist
A one-page PDF — the exact steps to isolate, fix, and verify a production error like this one. No spam, unsubscribe anytime.
Overview
The invalid_cursor error means you called a paginated Slack method (like conversations.list, conversations.history, users.list, or conversations.members) with a cursor value that Slack does not recognize. Cursor-based pagination in Slack works by returning an opaque next_cursor string inside response_metadata; you must pass that exact string back, unmodified, to fetch the next page. Any mutation — truncation, double URL-encoding, stripping the trailing =, or reusing a cursor from a different query — produces invalid_cursor.
Slack returns HTTP 200 with ok:false and this body:
{
"ok": false,
"error": "invalid_cursor"
}
It occurs while iterating pages: the first call succeeds and returns a cursor, but the follow-up call carrying that cursor is rejected.
Symptoms
- The first page of results returns fine (
ok:true), the next page fails withinvalid_cursor. - Pagination loops that worked in a REST client fail when the cursor is passed through shell/URL encoding.
- Cursors that end in
=(base64 padding) break when the padding is dropped or the value is truncated. - Reusing a saved cursor after changing
limit,channel, or filter params.
Common Root Causes
1. Not URL-encoding the cursor in a GET request
Cursors are base64-ish strings often ending in = and containing characters that must be percent-encoded in a query string. Passing a raw cursor breaks it.
2. Dropping or altering the trailing padding
Trimming whitespace, stripping =, or lowercasing the cursor makes it unrecognizable.
3. Reusing a cursor across different queries
A cursor is only valid for the exact same method + parameters that produced it. Change limit or the target channel and the cursor no longer applies.
4. Passing an empty string instead of omitting the cursor
Some clients send cursor= on the first call. An empty cursor should simply be omitted, not sent blank.
5. Reading the wrong field
The cursor lives at response_metadata.next_cursor, not at the top level. Grabbing the wrong field yields garbage.
Diagnostic Workflow
Step 1: Capture the cursor from the first page
curl -s "https://slack.com/api/conversations.list?limit=100&types=public_channel" \
-H "Authorization: Bearer $SLACK_BOT_TOKEN" \
| jq '{ok, next: .response_metadata.next_cursor}'
{
"ok": true,
"next": "dGVhbTpDMDYxRkE1UEI="
}
An empty next string means there are no more pages — stop, do not send a blank cursor.
Step 2: Reproduce the failure with a raw (unencoded) cursor
curl -s "https://slack.com/api/conversations.list?limit=100&cursor=dGVhbTpDMDYxRkE1UEI=" \
-H "Authorization: Bearer $SLACK_BOT_TOKEN" | jq .error
"invalid_cursor"
The trailing = is interpreted as a query delimiter, corrupting the value.
Step 3: URL-encode the cursor
CURSOR="dGVhbTpDMDYxRkE1UEI="
curl -s -G "https://slack.com/api/conversations.list" \
--data-urlencode "limit=100" \
--data-urlencode "types=public_channel" \
--data-urlencode "cursor=$CURSOR" \
-H "Authorization: Bearer $SLACK_BOT_TOKEN" | jq '{ok, next: .response_metadata.next_cursor}'
{
"ok": true,
"next": "dGVhbTpDMDgxSlA2QTk="
}
Using --data-urlencode (or POSTing form params) preserves the cursor exactly.
Step 4: Loop until the cursor is empty
CURSOR=""
while : ; do
RESP=$(curl -s -G "https://slack.com/api/conversations.list" \
--data-urlencode "limit=200" \
--data-urlencode "cursor=$CURSOR" \
-H "Authorization: Bearer $SLACK_BOT_TOKEN")
echo "$RESP" | jq -r '.channels[].name'
CURSOR=$(echo "$RESP" | jq -r '.response_metadata.next_cursor')
[ -z "$CURSOR" ] && break
done
Omit the cursor when it is empty (the first iteration sends cursor= which Slack treats as absent).
Example Root Cause Analysis
A nightly job exports every channel for an audit. It builds the next URL by string-concatenating ?cursor= + the raw cursor, and fails on page two:
curl -s "https://slack.com/api/conversations.list?cursor=dGVhbTpDMDYxRkE1UEI=" \
-H "Authorization: Bearer $SLACK_BOT_TOKEN" | jq .error
"invalid_cursor"
The cursor ends in =, and because it is concatenated raw into the query string, the shell/HTTP layer treats the trailing = as part of query parsing, so Slack receives a truncated value.
Fix: encode the cursor with --data-urlencode (or send params as a POST body) so the full value reaches Slack:
curl -s -G "https://slack.com/api/conversations.list" \
--data-urlencode "cursor=dGVhbTpDMDYxRkE1UEI=" \
-H "Authorization: Bearer $SLACK_BOT_TOKEN" | jq .ok
true
With the cursor properly encoded, every page loads and the export completes.
Prevention Best Practices
- Always read the cursor from
response_metadata.next_cursorand pass it back byte-for-byte — treat it as opaque. - URL-encode the cursor (
--data-urlencode) or send it as a POST form/JSON body so padding characters survive. - Stop paging when
next_cursoris an empty string; never send a blank or fabricated cursor. - Keep every other query parameter identical across pages — a cursor is bound to its original method and params.
- Prefer an official Slack SDK’s paginator, which handles encoding and termination for you.
- For ad-hoc triage, the free incident assistant can spot a mangled cursor in a failing export. See more in Slack guides.
Quick Command Reference
# First page: capture next_cursor
curl -s "https://slack.com/api/conversations.list?limit=200" \
-H "Authorization: Bearer $SLACK_BOT_TOKEN" | jq '.response_metadata.next_cursor'
# Next page: cursor URL-encoded
curl -s -G "https://slack.com/api/conversations.list" \
--data-urlencode "limit=200" \
--data-urlencode "cursor=$CURSOR" \
-H "Authorization: Bearer $SLACK_BOT_TOKEN" | jq .ok
# Detect end of pagination
[ -z "$(echo "$RESP" | jq -r '.response_metadata.next_cursor')" ] && echo "done"
Conclusion
invalid_cursor is a pagination-hygiene problem, not a permissions or payload error. The usual root causes:
- Passing the cursor unencoded in a GET query so the trailing
=corrupts it. - Trimming or altering the opaque cursor value.
- Reusing a cursor across different methods or parameters.
- Sending a blank cursor instead of omitting it.
- Reading the wrong field instead of
response_metadata.next_cursor.
Treat the cursor as an opaque token: read it from response_metadata, URL-encode it, keep every other parameter constant, and stop when it comes back empty.
Fixed it? Get 500 Slack & DevOps AI prompts — free
500 battle-tested, copy-paste AI prompts engineered by a senior systems engineer — every one with fill-in placeholders and safety/back-out notes. Drop your email and it's yours.
- 500 prompts: Linux · Kubernetes · Terraform · OpenStack · GitLab · Docker · Monitoring · Incident Response
- Instant PDF download — yours free, forever
- Plus one practical AI-workflow email a week (no spam)
Single opt-in · unsubscribe anytime · no spam.
Did this fix your issue?
Get 500 Battle-Tested DevOps AI Prompts — Free
500 battle-tested, copy-paste AI prompts engineered by a senior systems engineer — every one with fill-in placeholders and safety/back-out notes. Drop your email and it's yours.
- 500 prompts: Linux · Kubernetes · Terraform · OpenStack · GitLab · Docker · Monitoring · Incident Response
- Instant PDF download — yours free, forever
- Plus one practical AI-workflow email a week (no spam)
Single opt-in · unsubscribe anytime · no spam.