At a glance
- Create it in Server Settings > Integrations > Webhooks; you need the Manage Webhooks permission.
contentholds up to 2,000 characters, and one message carries up to 10 embeds.- The default reply is 204 No Content; add
?wait=trueto get the created message back. - On a 429, sleep for
retry_afterseconds; the global cap is 50 requests per second.
Create a webhook URL
You need the Manage Webhooks permission on the server or channel.
- Open Server Settings > Integrations, or open a channel's Edit Channel > Integrations.
- Click Create Webhook (New Webhook if the server already has some).
- Pick the channel it posts to and give it a name.
- 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:
usernameoverrides the webhook's name for this one message.avatar_urloverrides its avatar.allowed_mentionswith{"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.
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.
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.
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.
| Field | Limit |
|---|---|
content | 2,000 characters |
| Embeds per message | 10 |
| Embed title | 256 characters |
| Embed description | 4,096 characters |
| Fields per embed | 25 |
| Field name | 256 characters |
| Field value | 1,024 characters |
| Footer text | 2,048 characters |
| Author name | 256 characters |
| All embeds in one message combined | 6,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.
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.
pip install tqdm-liveSources
- Discord developer docs: Webhook resource and Execute Webhook docs.discord.com
- Discord developer docs: Message resource and embed limits docs.discord.com
- Discord developer docs: Rate limits docs.discord.com
- Requests docs: Quickstart requests.readthedocs.io