How to send Discord webhook messages from code

A webhook is a URL that posts into one Discord channel. No bot, no token, no gateway connection: your code sends one HTTP request and a message appears. Here is how to make one and send to it, with the details that trip people up.

7 min readIntermediateUpdated

When a webhook is the right tool

A webhook can only post (and edit or delete what it posted) in the one channel it belongs to. It cannot read messages, react, answer commands or see members. That makes it ideal for notifications: a deploy finished, a form was sent, a server went down, a new video is out. If you need to respond to people, you need a bot.

1. Create the webhook

  1. In Discord, open the channel's settings (the cog next to its name) and go to Integrations, then Webhooks. You need the Manage Webhooks permission.
  2. Press New Webhook, give it a name and an avatar if you like, and press Copy Webhook URL.

The URL looks like https://discord.com/api/webhooks/123456789012345678/AbC.... The long part at the end is a password: anyone who has the URL can post in that channel as the webhook. Keep it in an environment variable or a .env file, never in code you share or commit. If it leaks, delete the webhook and make a new one.

2. Send a message

A webhook takes a JSON body with at least content (up to 2,000 characters) or embeds. From the command line:

Terminal
curl -H "Content-Type: application/json" \
  -d '{"content": "Deploy finished"}' \
  "$WEBHOOK_URL"

From Python, with the requests package:

Python
import os

import requests

url = os.environ["WEBHOOK_URL"]
r = requests.post(url, json={"content": "Deploy finished"}, timeout=10)
r.raise_for_status()

From Node.js 18 or newer, with the built-in fetch:

JavaScript
const res = await fetch(process.env.WEBHOOK_URL, {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ content: "Deploy finished" }),
});
if (!res.ok) throw new Error(`Discord answered ${res.status}: ${await res.text()}`);

A successful post answers 204 No Content. Add ?wait=true to the URL to get the created message back as JSON instead, which you need to edit it later.

3. A different name and avatar per message

username and avatar_url override the webhook's own name and picture for that one message, so one webhook can post as "Deploys", "Uptime" and "Shop":

JSON
{
  "username": "Uptime",
  "avatar_url": "https://example.com/uptime.png",
  "content": "The website is back up after 4 minutes."
}

4. Embeds

An embed is the boxed message with a coloured edge, a title, fields and images. A message can carry up to 10, sent as a list:

JSON
{
  "embeds": [{
    "title": "New order",
    "description": "Flurry plan, monthly",
    "color": 5814783,
    "fields": [
      {"name": "Customer", "value": "Sam", "inline": true},
      {"name": "Total", "value": "$3.00", "inline": true}
    ],
    "timestamp": "2026-10-23T09:30:00Z"
  }]
}

color is a number, not a hex string: 0x58B9FF is 5814783. The embed builder builds this JSON for you, shows the result as Discord will, and counts the limits as you type.

5. Stop it pinging everyone

If any text in a webhook message comes from outside, a form, an error message, a username, someone can put @everyone in it. Say which mentions are allowed, and the rest are shown but ping nobody:

JSON
{
  "content": "New report from @everyone",
  "allowed_mentions": {"parse": []}
}

"parse": [] turns off every ping. "parse": ["users"] allows user mentions only.

6. Attach a file

Files go as a multipart form instead of JSON: the file in a part called files[0], and the rest of the message as JSON in a part called payload_json:

Python
import json
import os

import requests

with open("report.csv", "rb") as fh:
    r = requests.post(
        os.environ["WEBHOOK_URL"],
        data={"payload_json": json.dumps({"content": "Today's report"})},
        files={"files[0]": ("report.csv", fh, "text/csv")},
        timeout=30,
    )
r.raise_for_status()

An embed can show an attached image with "image": {"url": "attachment://chart.png"}, using the file's name.

7. Edit or delete a message

Post with ?wait=true and keep the id from the answer. Then PATCH or DELETE the webhook URL followed by /messages/ and that id:

Python
r = requests.post(url + "?wait=true", json={"content": "Deploying..."}, timeout=10)
message_id = r.json()["id"]

requests.patch(f"{url}/messages/{message_id}", json={"content": "Deployed."}, timeout=10)

A status message that edits itself instead of posting again keeps a channel readable. A webhook can only edit its own messages.

8. Rate limits

Discord limits how fast a webhook may post, and answers 429 Too Many Requests past that, with a JSON body that says how many seconds to wait in retry_after. Wait that long and try again; do not retry straight away in a loop, which only extends the block. The X-RateLimit-Remaining and X-RateLimit-Reset-After headers on every answer tell you how close you are. For bursts, such as one message per log line, collect them and send one message every few seconds instead. The rate limits guide has the details.

From a bot library

If the code already uses discord.py or discord.js, both can send through a webhook URL without logging in a bot:

Python
# discord.py
import aiohttp
import discord

async with aiohttp.ClientSession() as session:
    webhook = discord.Webhook.from_url(url, session=session)
    await webhook.send("Deploy finished", username="Deploys")
JavaScript
// discord.js
const { WebhookClient } = require("discord.js");

const webhook = new WebhookClient({ url: process.env.WEBHOOK_URL });
await webhook.send({ content: "Deploy finished", username: "Deploys" });

Common errors

  • 401 or 404 ("Unknown Webhook"): the URL is wrong or the webhook was deleted. Copy it again from the channel's Integrations.
  • 400 ("Cannot send an empty message"): the body has neither content, embeds nor a file, often because Content-Type: application/json is missing and Discord did not read the body.
  • 400 naming embeds.0.color: the colour was sent as a string like "#58B9FF". Send the number.
  • 413: the file is larger than the server allows for uploads.
  • The message pings people it should not: add allowed_mentions (section 5).

Press / or Ctrl K on any page to search.