Slack API: Get Messages From a Channel, With the Output We Got
Call conversations.history, follow next_cursor, then fetch each thread with conversations.replies. We read 40 seeded messages and 6 replies from a test channel and show the script, its output, and what limit, oldest and latest did.
On this page
To get messages from a Slack channel with the API, call conversations.history with the channel ID and follow response_metadata.next_cursor until it comes back empty. Thread replies are not in that list. For each message with a reply_count, call conversations.replies with its ts. On 30 September 2026 we posted 40 messages and 6 thread replies into a test channel and read them back with the script below. It returned 41 messages in 3 pages, then the 6 replies. The output is copied from that run.
The script we ran
It needs a token with channels:history (groups:history for private channels, im:history and mpim:history for DMs) and the channel ID, which starts with C. Our guide to finding a Slack channel ID shows where to copy it. We used limit=15 so the paging shows up with only 41 messages.
import os, sys, time, requests
TOKEN = os.environ["SLACK_TOKEN"]
CHANNEL = sys.argv[1]
API = "https://slack.com/api/"
def call(method, **params):
while True:
r = requests.get(API + method, params=params,
headers={"Authorization": f"Bearer {TOKEN}"}, timeout=30)
if r.status_code == 429:
time.sleep(int(r.headers.get("Retry-After", 1)))
continue
data = r.json()
if not data["ok"]:
raise RuntimeError(f"{method}: {data['error']}")
return data
messages, cursor, page = [], None, 0
while True:
params = {"channel": CHANNEL, "limit": 15}
if cursor:
params["cursor"] = cursor
data = call("conversations.history", **params)
page += 1
messages += data["messages"]
cursor = data.get("response_metadata", {}).get("next_cursor")
print(f"page {page}: {len(data['messages'])} messages, has_more={data['has_more']}, "
f"next_cursor={'yes' if cursor else 'none'}")
if not cursor:
break
replies = 0
for m in messages:
if m.get("reply_count"):
thread = call("conversations.replies", channel=CHANNEL, ts=m["ts"])
replies += len(thread["messages"]) - 1
print(f"thread {m['ts']}: reply_count={m['reply_count']}, "
f"replies returned={len(thread['messages']) - 1}")
print(f"total: {len(messages)} top-level messages, {replies} thread replies, {page} pages")
print("newest:", messages[0]["text"], "| oldest:", messages[-1]["text"])
Output, run as SLACK_TOKEN=xoxp-... python fetch_history.py C0C5RTM8YKE:
page 1: 15 messages, has_more=True, next_cursor=yes
page 2: 15 messages, has_more=True, next_cursor=yes
page 3: 11 messages, has_more=False, next_cursor=none
thread 1790771773.848419: reply_count=3, replies returned=3
thread 1790771751.551069: reply_count=3, replies returned=3
total: 41 top-level messages, 6 thread replies, 3 pages
newest: seed message 40 of 40 | oldest: <@U0B7L4YK420> has joined the channel
What the output tells you
- • 41, not 40. The oldest item is the system message
<@U0B7L4YK420> has joined the channel, withsubtype: channel_join. History returns system messages too. Filter onsubtypeif you only want what people typed. - • Newest first. Page 1 starts at message 40. Reverse the list if you want reading order.
- • Replies stay in their threads. The 6 replies never showed up in
conversations.history. The parent message carriesreply_count: 3,latest_replyandreply_users.conversations.repliesreturns the parent first and then the replies, which is why the script subtracts 1. - •
has_moreandnext_cursoragree. Page 3 hadhas_more=Falseand an empty cursor. Loop on the cursor, as the script does.
Each ts is a Unix time in seconds with a 6-digit suffix. It is also the message ID you pass to conversations.replies, chat.update or chat.delete. Paste one into the Slack timestamp tool to see it as a date.
Limit, oldest and latest
We called conversations.history on the same channel with other parameters:
| Parameters | Messages returned | has_more |
|---|---|---|
no limit | 41 | false |
limit=1000 | 41 | false |
limit=0 | 41 | false |
limit=1 | 1 | true |
oldest=ts of message 10, latest=ts of message 20 | 9 (messages 11 to 19) | false |
the same, plus inclusive=true | 11 (messages 10 to 20) | false |
oldest and latest leave out the two boundary messages unless you add inclusive=true. Slack's docs put the default at 100, the maximum at 999 and the recommended page size at 200 or fewer. Our limit=1000 call did not return an error. To fetch one day, set oldest and latest to Unix seconds for that day's start and end.
Never appear "away" on Slack again
Cloud-based. No downloads. Works 24/7 even when your laptop is off.
The same thing with slack_sdk
The official Python SDK pages for you when you loop over the response. We ran this with slack_sdk 3.44.1 on the same channel:
import os, sys
from slack_sdk import WebClient
client = WebClient(token=os.environ["SLACK_TOKEN"])
channel = sys.argv[1]
pages = 0
messages = []
for page in client.conversations_history(channel=channel, limit=15):
pages += 1
messages += page["messages"]
replies = []
for m in messages:
if m.get("reply_count"):
for page in client.conversations_replies(channel=channel, ts=m["ts"]):
replies += page["messages"][1:]
print(pages, "pages,", len(messages), "messages,", len(replies), "replies")
It printed 3 pages, 41 messages, 6 replies. That matches the plain requests version.
Token type and the 15-message limit
We read the channel with a user token (xoxp-) from an internal app, so it could see every channel the user is in. A bot token (xoxb-) sees only channels the bot has joined. In a channel it has not joined, the call fails with not_in_channel, and the not_in_channel fix covers that.
Slack's changelog of 29 May 2025 cut conversations.history and conversations.replies for commercially distributed apps that are not in the Slack Marketplace. The cut is 1 request a minute and at most 15 messages per call. Internal apps keep the normal Tier 3 limit. We tried to trigger the cut. We turned on public distribution for our test app, revoked the token and installed the app again. Back in the same workspace, three calls with limit=40 in one second each returned 40 messages with has_more=true, and none got a 429. So the cut did not reach an install in the app's own workspace. We did not test an install in a second workspace. For the normal limits, our Slack API rate limit test got 150 conversations.history calls through before the first 429, which came with Retry-After: 10.
FAQ
Can I export a whole channel without code?
Workspace owners and admins can export public channel history from the admin export page. The API route above is for your own scripts, and it reads only what your token can see.
Why do my messages come back with bot_id even though a person sent them?
We posted the seed messages with a user token from our app. conversations.history returned them with user set to the person, plus bot_id, app_id and a bot_profile for the app that posted them. Use user to find the author. Use app_id to tell which messages came through an app.
How far back does history go on the free plan?
Slack's free plan shows the last 90 days of messages, per Slack's plan page. We did not test what the API returns for older, hidden messages.
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 chat.scheduleMessage: Limits and Errors We Measured
We called chat.scheduleMessage with a bot token for times from 10 seconds to 121 days ahead, listed and deleted the results, and watched which messages still arrived. The limits, the exact errors, and a delete that returned ok but did not stop the post.
Slack Invite Link Expiration: 30 Days, and How to Check One
Slack invite links expire after 30 days and have no longer option. We made links in a test workspace, deactivated them, and found a signed-out check that tells a live link from a dead one.
Slack Pinned Messages: Where Pins Are Now, and the 100-Pin Limit
In the current Slack desktop and web app, pinned messages sit in a Pins tab at the top of the channel. We pinned, edited, deleted and searched pins in a test workspace, and pinned messages until Slack refused the 101st.