Skip to main content
All articles
· Updated April 25, 2026

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.

discord.pywebhookpythonasyncioaiohttptutorialdiscord apiasync
Discord Webhooks with discord.py (discord.Webhook Complete Guide)

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 by aiohttp, works inside an event loop
  • discord.SyncWebhook (sync) — backed by requests, 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: customId buttons with callbacks need a connected bot to handle the interaction. Webhook-only senders should use ButtonStyle.link (no callback) or pair with a bot listening to interaction_create.

Rate Limits — Built-in Handling

Webhook.send is wired into discord.py’s HTTPClient, which:

  • Tracks X-RateLimit-Remaining and waits when hitting 0
  • Honors Retry-After from 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 wrong
  • discord.Forbidden (403) — channel permissions changed
  • discord.RateLimited — only raised if you set wait=False on 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