~ / guides / How to Scrape Discord (Python Guide)

How to Scrape Discord (Python Guide)

MR
Marcus Reed
Founder & lead tester · about the author
the short version
  • Discord has no scrapable HTML. I fetched discord.com/channels and got a JavaScript app shell (1 div, 92 script tags, a <noscript> 'enable JavaScript' message). requests + BeautifulSoup return zero messages.
  • The supported route is the official HTTP API with a bot token. I hit GET /channels/{id}/messages with no token and got 401 Unauthorized, reproducible 3/3. With a bot token in the server, that endpoint returns messages as JSON, capped at 100 per call.
  • Scraping logged-in Discord with a user account (a 'self-bot') is banned by the Terms of Service and gets accounts terminated. Use a bot application instead.
  • For public Discord pages outside the API (invite splash, discovery, a server's public web view) a scraper API like ChocoData renders the JavaScript so you get HTML back. The in-app message API still needs a bot token.

Discord is the awkward target where the obvious scrape does not exist. There is no listings HTML to parse, because the web client is a JavaScript application that renders everything client-side. I fetched a Discord channel URL and the entire page is one <div> and 92 <script> tags. The supported path is Discord’s own HTTP API, which I also tested: it returns 401 Unauthorized until you put a bot token on the request. This guide shows both real results, explains why a logged-in “self-bot” scraper gets you banned, and builds the bot-token API client that actually returns messages. I ran every fetch below in June 2026 and pasted the real output.

You can read Discord data through its official API with a bot token, and that is legal and supported; scraping it with a logged-in user account is banned and is what gets people in trouble. The line is the account type, so it is worth being precise.

Discord’s Terms of Service prohibit “scraping our services without our written consent, including by using any robot, spider, crawler, scraper, or other automatic device, process, or software.” They also bar “using any unauthorized software designed to modify the services” and “trying to gain access to another user’s account or any non-public portions of the services.” In practice the community calls automating a normal user account a “self-bot,” and Discord terminates accounts for it. So the prohibited activity is point-and-scrape against the logged-in web client.

The supported activity is the opposite: register a bot application in the Developer Portal, agree to the Developer Terms of Service, invite the bot to a server you have permission to be in, and read messages through the documented API. That is written consent by design. A bot token is authorization; a hijacked user session is not.

Two more layers stack on top. First, message content is personal data. Other people’s Discord messages are user-generated content, so storing or processing them pulls in GDPR and CCPA. Second, even with a bot you only see channels your bot has the VIEW_CHANNEL and READ_MESSAGE_HISTORY permissions for, which is the platform enforcing access at the API level. My honest read: build on the bot API for servers you are entitled to read, never automate a user account, and get legal advice before storing other users’ messages commercially. My legal overview covers the wider public-data picture.

What data can you get from Discord?

Through the bot API, a single message comes back as a rich JSON object. These are the documented fields on the Message object, which is what GET /channels/{channel.id}/messages returns an array of:

FieldTypeWhat it holds
idsnowflakeid of the message (also encodes the timestamp)
channel_idsnowflakeid of the channel the message was sent in
authoruser objectthe author of this message
contentstringcontents of the message (gated by MESSAGE_CONTENT intent)
timestampISO8601when this message was sent
edited_timestamp?ISO8601when edited, or null if never
attachmentsarrayattached files not referenced in embeds
embedsarrayembedded content (link previews, bot embeds)
mentionsarray of usersusers specifically mentioned
reactionsarrayreactions to the message
pinnedbooleanwhether the message is pinned
typeintegermessage type (default text, system, reply, etc.)

The trap is content. Since 2022 it is a privileged intent: your bot must enable MESSAGE_CONTENT in the Developer Portal, and once the bot is in 100+ servers Discord verifies that intent before granting it. Without it, you still get id, author, timestamp, and structure, but content, embeds, and attachments come back empty on an otherwise successful 200. That is the single most common “my Discord scraper returns blank messages” cause.

How does Discord block scrapers?

Discord blocks the DIY web scrape two ways: the page has no server-rendered content, and the API refuses unauthenticated requests. I tested both, and here is what actually came back.

The web client is JavaScript-only. I fetched https://discord.com/channels/@me with a normal browser User-Agent:

import requests, re

UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
      "(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36")

r = requests.get("https://discord.com/channels/@me",
                 headers={"User-Agent": UA}, timeout=25)
print("STATUS:", r.status_code)
print("LEN:", len(r.text))
print("<div> count:", r.text.lower().count("<div"))
print("<script> count:", r.text.lower().count("<script"))
m = re.search(r"<noscript>(.*?)</noscript>", r.text, re.S | re.I)
print("NOSCRIPT:", re.sub(r"\s+", " ", m.group(1)).strip() if m else None)

Real output from my run:

STATUS: 200
LEN: 22901
<div> count: 1
<script> count: 92
NOSCRIPT: You need to enable JavaScript to run this app.

A 200 looks like success, but the body is an empty app shell: one <div> for React to mount into, 92 scripts, and a <noscript> telling you to enable JavaScript. There are zero messages in that HTML to parse with BeautifulSoup. The messages load later over a WebSocket after the JavaScript boots and authenticates, so a static fetch never sees them.

The API refuses you without a token. I called the documented messages endpoint with no Authorization header:

import requests

r = requests.get(
    "https://discord.com/api/v10/channels/1234567890/messages?limit=5",
    headers={"User-Agent": "DiscordBot (https://example.com, 1.0)"},
    timeout=25,
)
print("STATUS:", r.status_code)
print("BODY:", r.text)

Real output, reproduced 3/3 in the same session:

STATUS: 401
BODY: {"message": "401: Unauthorized", "code": 0}

Beyond the wall, the API enforces a rate limit of 50 requests per second globally per bot, plus per-route buckets exposed through X-RateLimit-Limit, X-RateLimit-Remaining, and X-RateLimit-Reset headers. Exceed a bucket and you get a 429 with a retry_after value in seconds. The docs are explicit that you should read those headers rather than hard-code limits. So the defense is layered: no HTML to scrape, a hard 401 without auth, and rate-limit buckets once you are authed.

Method 1: read Discord messages with the official bot API (Python)

The supported route is a bot token plus GET /channels/{channel.id}/messages, and it is genuinely simple once the token exists. The setup, in order:

  1. Create an application at discord.com/developers/applications, add a Bot, and copy its token.
  2. In the Bot settings, enable the Message Content Intent if you need the content field.
  3. Invite the bot to your server via the OAuth2 URL generator with the bot scope and Read Messages/View Channels + Read Message History permissions.
  4. Grab the channel ID (Developer Mode on, right-click a channel, Copy Channel ID).

Then the client. The token goes in the Authorization header as Bot <token>, and the docs require a DiscordBot (...) User-Agent or Cloudflare may block you:

import os
import csv
import time
import requests

API = "https://discord.com/api/v10"
TOKEN = os.environ["DISCORD_BOT_TOKEN"]        # never hard-code the token
CHANNEL_ID = "000000000000000000"              # your channel id

HEADERS = {
    "Authorization": f"Bot {TOKEN}",           # 'Bot ' prefix is required
    "User-Agent": "DiscordBot (https://example.com, 1.0)",
}


def get_messages(channel_id, limit=100, before=None):
    """One page of up to 100 messages, newest first."""
    params = {"limit": min(limit, 100)}
    if before:
        params["before"] = before              # before/after/around are mutually exclusive
    r = requests.get(f"{API}/channels/{channel_id}/messages",
                     headers=HEADERS, params=params, timeout=25)
    if r.status_code == 429:                    # rate limited: honor retry_after
        wait = r.json().get("retry_after", 1)
        time.sleep(wait)
        return get_messages(channel_id, limit, before)
    r.raise_for_status()
    return r.json()


rows = []
batch = get_messages(CHANNEL_ID, limit=100)
for m in batch:
    rows.append({
        "id": m["id"],
        "author": m["author"]["username"],
        "timestamp": m["timestamp"],
        "content": m.get("content", ""),        # empty if MESSAGE_CONTENT intent is off
    })

with open("discord_messages.csv", "w", newline="", encoding="utf-8") as f:
    w = csv.DictWriter(f, fieldnames=["id", "author", "timestamp", "content"])
    w.writeheader()
    w.writerows(rows)

print(f"Wrote {len(rows)} messages")

I did not paste live output for this script because it requires a bot token tied to a real server, and faking message rows would defeat the point of this site. The two facts I verified directly are the ones above: the same endpoint returns 401 without the token (real, 3/3), and the field names match Discord’s documented Message object. With a valid token in DISCORD_BOT_TOKEN, this returns up to 100 messages per call.

Paging full channel history

Each call caps at 100 messages, so you walk backwards with the before parameter, passing the oldest ID you have seen until a page comes back empty:

def get_all_messages(channel_id, hard_cap=5000):
    collected, before = [], None
    while len(collected) < hard_cap:
        page = get_messages(channel_id, limit=100, before=before)
        if not page:
            break                               # empty page = start of channel
        collected.extend(page)
        before = page[-1]["id"]                 # oldest id in this page
        time.sleep(0.3)                          # stay under 50 req/s
    return collected

The before, after, and around parameters are mutually exclusive, so pick one direction. time.sleep(0.3) keeps you comfortably under the 50-requests-per-second global limit; for heavier jobs, read X-RateLimit-Remaining and back off when it nears zero. For real-time capture (messages as they arrive) you would use the Gateway WebSocket instead of polling, but for history this REST loop is the right tool. The fundamentals are in the Python web scraping guide.

Method 2: use a scraper API for Discord’s public web pages

A scraper API helps with the public Discord pages the bot API does not cover, because those pages are also JavaScript-rendered. The in-app message history needs a bot token, full stop. But Discord has public surfaces that load over the same JS client and return an empty shell to a plain requests call: a server’s invite splash page, the public Discovery directory, and server pages exposed to the open web. For those, a scraper API runs a real browser, executes the JavaScript, and hands you the rendered HTML.

ChocoData is the service I reach for here. It exposes a universal endpoint (one URL for any site) plus 453 dedicated endpoints across many sites, and the universal endpoint with JavaScript rendering is the route for Discord’s public web pages: you give it the URL and parse the returned HTML as usual.

import requests
from bs4 import BeautifulSoup

API_KEY = "cd_live_YOUR_KEY"   # free tier: 1,000 requests/month, no card
target = "https://discord.com/servers"   # public discovery directory (JS-rendered)

endpoint = "https://api.chocodata.com/api/v1/universal/get"
params = {
    "api_key": API_KEY,
    "url": target,
    "render": "true",     # execute JavaScript, then return rendered HTML
    "country": "us",
}

resp = requests.get(endpoint, params=params, timeout=90)
print("STATUS:", resp.status_code)

soup = BeautifulSoup(resp.text, "html.parser")
# now the server cards exist in the DOM, unlike a raw requests fetch
print("TITLE:", soup.title.get_text() if soup.title else None)

The honest scope: this solves the rendering problem (JavaScript executes, so the HTML is populated) for Discord’s public web pages, and it routes through a residential IP so a server doing repeated pulls is not throttled like a datacenter IP would be. It does not get you into private channel history. Nothing does except a bot token with the right permissions. Per ChocoData’s docs the free tier is 1,000 requests per month with no card, billed on successful responses. I did not paste live API output because it needs a key and I will not fake numbers; the endpoint, parameters, and free-tier limits above come from the ChocoData docs. For the general anti-blocking picture, see how to scrape without getting blocked.

Which method should you use?

Use the bot API for message data, full stop; use a scraper API only for Discord’s public JavaScript-rendered web pages. Here is the trade-off from what I tested:

ApproachWhat you getAuth neededReliabilityBest for
requests + BeautifulSoup on the web pageEmpty JS shell (my test: 1 div, 0 messages)NoneUseless: no content in HTMLNothing; it does not work
Official bot APIMessages as JSON, 100/page, full fieldsBot token + server permissionHigh: documented, supportedAll in-app message data
Scraper API (universal, render)Rendered HTML of public pagesAPI keyHigh for public pagesInvite/discovery pages, not private channels

My honest take from the test: there is no HTML scrape of Discord. The web page returned a 200 that contained one <div> and 92 scripts, and the API returned a clean 401 until I supplied a token, reproduced 3/3. The right build is a bot application reading GET /channels/{id}/messages with the MESSAGE_CONTENT intent enabled, paging backwards with before, and respecting the 50-requests-per-second limit. Never automate a logged-in user account; that self-bot path is the one Discord’s Terms ban and the one that gets accounts terminated. A scraper API earns its place only on Discord’s public JavaScript-rendered pages, where it executes the JS the bot API never touches.

To go deeper, the Python web scraping guide covers the language fundamentals, BeautifulSoup handles the rendered HTML from public pages, and Scrapy is worth it if you are polling many channels and want built-in throttling. The web scraping pillar ties the whole workflow together.

FAQ

Is scraping Discord legal?

Reading messages through the official API with a bot token and the server's permission is supported and within the Developer Terms. Scraping with a logged-in user account (a self-bot) breaks Discord's Terms of Service and is the conduct that gets accounts banned. The data side adds privacy law: Discord messages are user-generated personal data, so GDPR and CCPA apply if you store or process other people's messages. I am a tester, not a lawyer; get advice before anything commercial.

Does Discord have an official API?

Yes. Discord publishes a full HTTP REST API plus a Gateway WebSocket for real-time events. The REST base is https://discord.com/api/v10. Reading channel history uses GET /channels/{channel.id}/messages with a bot token in the Authorization header. This is the supported way to get Discord data and the only route I would build on.

Why is the content field empty in my API response?

Since 2022 the message content is a privileged intent. Your bot application must enable the MESSAGE_CONTENT intent in the Developer Portal (and pass verification once your bot is in 100+ servers) or the content, embeds, and attachments fields come back empty even on a 200. The message IDs, authors, and timestamps still populate; only the text is gated.

MR
Marcus Reed
I've built and run web scrapers for the better part of a decade. On this site I put scraper APIs and scraping tools through real jobs against real targets, then write up what actually holds up.