Discord webhooks

Send Discord webhook messages from Python

A Discord webhook is a URL that posts into one channel with a single HTTP POST, no bot token needed. A message holds up to 2,000 characters of content or up to 10 embeds with 6,000 characters across them. Discord answers 204 by default, returns 429 with retry_after when you send too fast and publishes no fixed per-webhook rate.

4 min readNotifications and alerts

At a glance

  • Create it in Server Settings > Integrations > Webhooks; you need the Manage Webhooks permission.
  • content holds up to 2,000 characters, and one message carries up to 10 embeds.
  • The default reply is 204 No Content; add ?wait=true to get the created message back.
  • On a 429, sleep for retry_after seconds; the global cap is 50 requests per second.

Create a webhook URL

You need the Manage Webhooks permission on the server or channel.

  1. Open Server Settings > Integrations, or open a channel's Edit Channel > Integrations.
  2. Click Create Webhook (New Webhook if the server already has some).
  3. Pick the channel it posts to and give it a name.
  4. Click Copy Webhook URL.

The URL looks like https://discord.com/api/webhooks/{id}/{token}. Treat it like a password: anyone who has it can post into your channel. Keep it in an environment variable instead of in git, and delete the webhook in the same settings screen if it ever leaks.

Send a message with requests

Post JSON to the URL. Every message needs at least one of content, embeds, components, a file or a poll. For plain text, send a content string of up to 2,000 characters.

By default Discord does not send the message back; it answers 204 No Content. Add ?wait=true to the URL to get a 200 with the created message as JSON.

A few optional fields go in the same body:

  • username overrides the webhook's name for this one message.
  • avatar_url overrides its avatar.
  • allowed_mentions with {"parse": []} stops text you don't control from pinging @everyone, roles or users.

To post into a thread inside the webhook's channel, add ?thread_id=<id> to the URL. In a forum or media channel you must pass either thread_id or a thread_name, which starts a new post.

Python
import os

import requests

WEBHOOK_URL = os.environ["DISCORD_WEBHOOK_URL"]

payload = {
    "content": "Nightly backup finished: 1,284 files, 3.2 GB",
    "username": "Backup bot",
    "allowed_mentions": {"parse": []},
}
response = requests.post(WEBHOOK_URL, json=payload, timeout=10)
response.raise_for_status()

created = requests.post(WEBHOOK_URL, params={"wait": "true"}, json=payload, timeout=10)
print(created.status_code, created.json()["id"])

Send an embed

When plain text is not enough, put up to 10 embeds in the embeds list. You don't need content when you send embeds. Each embed is a dictionary with a title, description, link (url), color, fields, footer and timestamp.

color takes an integer, not a hex string, so write 0x57F287 for green and Python sends it as 5763719. Each field is an object with name, value and an optional inline flag that puts fields side by side.

Python
from datetime import datetime, timezone

embed = {
    "title": "Nightly ETL finished",
    "url": "https://example.com/runs/812",
    "description": "All 14 tables loaded without errors.",
    "color": 0x57F287,
    "fields": [
        {"name": "Rows", "value": "1,284,019", "inline": True},
        {"name": "Duration", "value": "42m 10s", "inline": True},
        {"name": "Exit code", "value": "0", "inline": True},
    ],
    "footer": {"text": "etl-01"},
    "timestamp": datetime.now(timezone.utc).isoformat(),
}
requests.post(WEBHOOK_URL, json={"embeds": [embed]}, timeout=10).raise_for_status()

Handle 429 rate limits

Send too fast and Discord rejects the request with HTTP 429, a Retry-After header and a JSON body like {"message": "You are being rate limited.", "retry_after": 64.57, "global": false}. retry_after is in seconds and can have decimals.

Every response also carries headers that describe your current bucket: X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset, X-RateLimit-Reset-After and X-RateLimit-Bucket.

The global limit is 50 requests per second; without an authorization header, as with webhook calls, it applies to your IP address. Discord publishes no official per-webhook number. Figures like "5 requests per 2 seconds" or "30 per minute" come from community observations, so read retry_after instead of hard-coding them.

Python
import time

import requests


def send(url, payload, attempts=5):
    for _ in range(attempts):
        response = requests.post(url, json=payload, timeout=10)
        if response.status_code != 429:
            response.raise_for_status()
            return response
        try:
            wait = float(response.json()["retry_after"])
        except (ValueError, KeyError):
            wait = float(response.headers.get("Retry-After", 1))
        time.sleep(wait)
    raise RuntimeError(f"still rate limited after {attempts} attempts")

Discord webhook limits

Break any of the embed limits and Discord answers with a Bad Request.

FieldLimit
content2,000 characters
Embeds per message10
Embed title256 characters
Embed description4,096 characters
Fields per embed25
Field name256 characters
Field value1,024 characters
Footer text2,048 characters
Author name256 characters
All embeds in one message combined6,000 characters

Use a webhook for job alerts

Webhooks are the simplest way to hear about scripts and background jobs. Send one message when the job ends and another from the except block, then re-raise so the traceback and exit code stay intact. The script notification guide covers SIGTERM and other exits too.

Python
try:
    run_backup()
except Exception as exc:
    send(WEBHOOK_URL, {"content": f"Backup failed: {exc!r}"[:2000]})
    raise
else:
    send(WEBHOOK_URL, {"content": "Backup finished"})

A process that hangs or loses its machine never reaches either branch.

FAQ

Do I need a bot to post to Discord from Python?

No. A webhook URL is enough to post messages and embeds into a channel with a plain HTTP POST. You don't need a bot token or a gateway connection.

Why does my Discord webhook return 204 with no body?

That is the default: Discord answers 204 No Content once it accepts the message. Add ?wait=true to the webhook URL to get a 200 with the created message as JSON.

What happens if I hit Discord's webhook rate limit?

Discord rejects the request with HTTP 429, a Retry-After header and a JSON body whose retry_after field gives the seconds to wait. Sleep for that long, then send the same request again.

How do I stop a webhook message from pinging @everyone?

Add "allowed_mentions": {"parse": []} to the JSON body. An empty parse list tells Discord not to turn @everyone, @here, role or user mentions in the text into notifications.

Is a Discord webhook URL secret?

Yes. The URL contains the webhook's ID and token, and anyone who has it can post to your channel. Keep it in an environment variable and delete the webhook if it leaks.

Check on long runs from your phone

Change one import and every tqdm bar prints a link you can open on any device. Get a Discord or Slack message when the run finishes, fails or stalls.

Get a live linkpip install tqdm-live

Sources