Discord Webhooks with discord.py (discord.Webhook Complete Guide)
Send, edit, delete and stream files via Discord webhooks using discord.py. Async-first examples with aiohttp covering embeds, threads, files, and view components.
When your stack already runs on discord.py for a bot, you don’t need to drop down to raw requests for webhooks. The library exposes discord.Webhook which integrates with the same async event loop, embed objects, and file helpers you already use. No bot token required — just the webhook URL.
This guide is a complete walkthrough for [email protected] (the maintained branch in 2026): send, edit, delete, files, threads, and the small async patterns that keep webhook code clean.
Install
pip install discord.py
discord.Webhook is part of the core package — no extras.
Sync vs Async — Choose One
discord.py offers two adapters:
discord.Webhook(async, default) — backed byaiohttp, works inside an event loopdiscord.SyncWebhook(sync) — backed byrequests, blocks the calling thread
Use async if you’re inside async def, building a bot, or running an asyncio app. Use sync only for short scripts and one-shot CI hooks.
Minimal Send (Async)
import asyncio
import aiohttp
import discord
WEBHOOK_URL = "https://discord.com/api/webhooks/ID/TOKEN"
async def main():
async with aiohttp.ClientSession() as session:
webhook = discord.Webhook.from_url(WEBHOOK_URL, session=session)
await webhook.send("Hello from discord.py!")
asyncio.run(main())
Always pass a session — webhooks reuse the connection; spawning a new session per call adds latency.
Minimal Send (Sync)
import discord
webhook = discord.SyncWebhook.from_url(
"https://discord.com/api/webhooks/ID/TOKEN"
)
webhook.send("Hello from sync discord.py!")
Identical API surface; no await, no event loop. Good enough for a notify.py you run once and exit.
Sending Embeds
import discord
embed = discord.Embed(
title="Deployment finished",
description="All services healthy",
color=0x57f287,
timestamp=discord.utils.utcnow(),
)
embed.add_field(name="Duration", value="2m 14s", inline=True)
embed.add_field(name="Commit", value="abc1234", inline=True)
embed.set_footer(text="CI bot")
await webhook.send(embed=embed)
# Multiple embeds:
await webhook.send(embeds=[embed1, embed2])
discord.Embed validates limits when adding fields — add_field with a 1025-char value raises ValueError immediately.
Custom Username & Avatar
await webhook.send(
content="CI build failed",
username="CI Reporter",
avatar_url="https://example.com/ci-avatar.png",
)
These override the webhook’s defaults (set in Discord UI) per call.
Edit & Delete
To get a message back you can later edit, pass wait=True:
msg = await webhook.send("Working…", wait=True)
# Edit
await msg.edit(content="Done")
# Delete
await msg.delete()
Without wait=True, send returns None and you have no handle to edit later. The WebhookMessage object returned has .edit() and .delete() methods that target the original message correctly.
If you persisted the message ID and need to act later (e.g. across process restarts):
msg = await webhook.fetch_message(message_id=123456789012345678)
await msg.edit(content="Updated after restart")
Sending Files
discord.File accepts a path, file-like object, or BytesIO:
import io
import discord
# From path
file1 = discord.File("./report.pdf", filename="report.pdf")
# From bytes in memory
buf = io.BytesIO(b"plain text content")
file2 = discord.File(buf, filename="note.txt")
await webhook.send(content="Reports attached", files=[file1, file2])
For files near the 25 MB limit, prefer streaming from disk (open with "rb") over loading into memory.
Embed with Attached Image
Reference the file inside an embed via attachment://:
chart = discord.File("./chart.png", filename="chart.png")
embed = discord.Embed(title="Q4 Revenue")
embed.set_image(url="attachment://chart.png")
await webhook.send(file=chart, embed=embed)
The filename in attachment://chart.png must match the filename= argument exactly.
Threads & Forum Channels
If your channel has threads, pass a thread (object) or thread=discord.Object(id=...):
await webhook.send(
"Posting into a thread",
thread=discord.Object(id=987654321098765432),
)
For forum channels, create a new post with thread_name:
await webhook.send(
content="Body of the new forum post",
thread_name="Daily build",
)
Edit/delete inside threads need the same thread:
msg = await webhook.send("text", thread=discord.Object(id=tid), wait=True)
await msg.edit(content="updated") # auto-uses the thread the message is in
Components — Buttons & Select Menus
Webhooks can attach View components:
import discord
class ApprovalView(discord.ui.View):
@discord.ui.button(label="Open Builder",
style=discord.ButtonStyle.link,
url="https://discord-webhook.com/app")
async def open_builder(self, interaction, button):
pass # link buttons don't fire callbacks
view = ApprovalView()
await webhook.send(content="Deploy ready", view=view)
Note:
customIdbuttons with callbacks need a connected bot to handle the interaction. Webhook-only senders should useButtonStyle.link(no callback) or pair with a bot listening tointeraction_create.
Rate Limits — Built-in Handling
Webhook.send is wired into discord.py’s HTTPClient, which:
- Tracks
X-RateLimit-Remainingand waits when hitting 0 - Honors
Retry-Afterfrom 429 with auto-retry - Backs off on 5xx with bounded retries
For high throughput (>30/min on a single webhook), serialize through your own queue or asyncio.Semaphore:
import asyncio
sem = asyncio.Semaphore(1) # one in-flight at a time
async def queued_send(content):
async with sem:
await webhook.send(content)
Or read our rate limits guide for token-bucket patterns.
Type Hints (mypy / pyright)
discord.py ships type stubs. With strict checking:
from typing import Optional
import aiohttp
import discord
async def notify(session: aiohttp.ClientSession, message: str) -> Optional[discord.WebhookMessage]:
webhook = discord.Webhook.from_url(WEBHOOK_URL, session=session)
return await webhook.send(message, wait=True)
WebhookMessage is the return type when wait=True; None otherwise. Pyright/mypy enforce this.
Error Handling
Errors come as discord.HTTPException (and subclasses):
import discord
try:
await webhook.send(content="x" * 2001)
except discord.HTTPException as e:
# e.status — HTTP status (400, 404, …)
# e.code — Discord error code (50035 = Invalid Form Body)
# e.text — full message
print(f"HTTP {e.status} code={e.code} {e.text}")
Common subclasses:
discord.NotFound(404) — webhook deleted, message ID wrongdiscord.Forbidden(403) — channel permissions changeddiscord.RateLimited— only raised if you setwait=Falseon rate limit logic; default behavior is to wait silently
Pattern: One Session, Many Webhooks
Reusing the same aiohttp.ClientSession across webhooks gives you connection pooling:
import asyncio
import aiohttp
import discord
WEBHOOKS = {
"alerts": "https://discord.com/api/webhooks/A/X",
"deploys": "https://discord.com/api/webhooks/B/Y",
"audit": "https://discord.com/api/webhooks/C/Z",
}
async def main():
async with aiohttp.ClientSession() as session:
hooks = {
name: discord.Webhook.from_url(url, session=session)
for name, url in WEBHOOKS.items()
}
await asyncio.gather(
hooks["alerts"].send("Alert: high CPU"),
hooks["deploys"].send("Deploy started"),
hooks["audit"].send("Audit log entry"),
)
Three calls, one TLS handshake.
Try It Live
Build payloads visually in the Discord Webhook builder and translate to discord.Embed via the field-by-field mapping shown there. For raw-HTTP Python, see the requests-based guide; for other languages, discord.js guide and PHP.
References
- discord.py docs: Webhook
- discord.py docs: Embed
- Discord developer docs: Webhook Resource
Try it in our tool
Open Discord Webhook Builder