RE:NODE

Discord webhooks explained: create, send, edit, secure

By RE:NODE Team
16 min read


A Discord webhook is a URL that posts messages into one channel. You create it in the channel's settings, you send it an HTTP POST with a small JSON body, and a message appears under whatever name and avatar you chose. There is no bot account, no token to register, no library to install and no process to keep running. That makes it the right tool for one-way messages from software - a deploy finished, a form was submitted, a backup failed, a new video went up - and the wrong tool for anything that has to listen or reply.

Everything below works from any language and any machine that can make an HTTPS request. The parts people get wrong come later: the URL is the only credential and it never expires, a few query parameters (wait, thread_id) change the behaviour completely, and a webhook that is rate limited tells you exactly how long to wait if you read the response. If what you want is a live status message for a game server, Discord webhooks for game server alerts covers that use case end to end; this post is the general reference it builds on.

What a webhook is, and the URL that is the whole thing#

A webhook belongs to a channel. It has a numeric id and a long random token, and the URL is simply both of them joined:

code
https://discord.com/api/webhooks/1290000000000000000/Xy7k...the-token...Qp2

Anything that can send that URL a request with the right body can post to the channel. There is no second factor, no login, no scope beyond the one channel and no expiry. The URL is the password, and it stays valid until someone deletes the webhook. That single fact drives most of the security advice at the end of this post.

A message posted through a webhook shows the webhook's name and avatar (or the ones you override per message), plus a small tag next to the name that marks it as automated. It is not a member of the server, it does not appear in the member list, and it has no roles. It cannot be mentioned, cannot receive a direct message and cannot see anything in the channel - including the messages it posted itself, except through the specific endpoints that fetch, edit or delete them.

Webhooks also turn up in two places you might not expect:

  • Following an announcement channel. When a server follows another server's announcement channel, Discord creates a webhook in the destination channel to deliver the cross-posts. It shows up in the integrations list like any other.
  • Integrations that post on your behalf. Many services (CI systems, monitoring tools, GitHub itself) ask you for a Discord webhook URL rather than asking you to install a bot, precisely because a webhook needs no permissions beyond one channel.

Creating a webhook in channel settings#

You need the Manage Webhooks permission in that channel, which server owners and administrators have by default.

  1. Hover over the channel and click the gear icon (Edit Channel), or right-click the channel and choose Edit Channel.
  2. Open Integrations, then Webhooks.
  3. Click New Webhook. Discord creates one immediately with a generated name.
  4. Click it to expand, set a name and optionally upload an avatar, and check the channel dropdown is the one you want.
  5. Click Copy Webhook URL, then Save Changes if you changed anything.

Server Settings, Integrations, Webhooks shows the same thing for the whole server: every webhook in every channel, with who created it. That page is where you go to audit or clean up, and the server's audit log records webhook creation, changes and deletion.

A few practical limits apply at the time of writing: a channel holds up to 15 webhooks, a webhook name must be between 1 and 80 characters, and names containing the words discord or clyde are rejected. Discord adjusts these occasionally, so if creation fails with an error about limits, the developer documentation is the place to check.

Name the webhook after its source - CI, Backups, Shop orders - rather than leaving "Captain Hook" or whatever was generated. The name is the default author of every message and the label you will be scanning for in the integrations list when something misbehaves.

Sending your first message with curl#

The smallest valid request is a POST with a JSON body that has content:

bash
$ curl -X POST "$WEBHOOK_URL" \    -H "Content-Type: application/json" \    -d '{"content": "Deploy finished on main."}'

On success Discord answers 204 No Content with an empty body. That is not an error and it is not empty-handed by accident: by default the API does not wait for the message to be stored before answering. Add ?wait=true and you get 200 OK with the full message object back, including its id, which you need to edit or delete it later.

The fields you will use on almost every message:

FieldWhat it does
contentMessage text, up to 2000 characters, Discord markdown
usernameOverrides the webhook's name for this message only
avatar_urlOverrides the avatar, a direct link to an image
embedsUp to 10 embed objects (cards with title and fields)
allowed_mentionsControls which mentions actually ping
flags4096 sends silently, without notifications
ttsReads the message aloud to people with TTS enabled

At least one of content, embeds, a file or a poll must be present, or the request fails with 400. A full example with an overridden identity and mentions switched off:

bash
$ curl -X POST "$WEBHOOK_URL?wait=true" \    -H "Content-Type: application/json" \    -d '{      "username": "Shop",      "avatar_url": "https://example.com/shop-icon.png",      "content": "New order #1042 - 2 items, paid by card",      "allowed_mentions": {"parse": []}    }'

avatar_url has to point straight at an image file that Discord can fetch - a page that contains the image will not do, and a link that needs a login will silently fall back to the default avatar. username and avatar_url apply only to that one message; the webhook's stored name and avatar are unchanged.

Content takes ordinary Discord markdown: **bold**, inline code, code blocks, masked links like [changelog](https://example.com), user mentions as <@USER_ID>, role mentions as <@&ROLE_ID>, and timestamps such as <t:1760000000:R> that render as "3 hours ago" in each reader's own timezone. Whether a mention actually pings is decided by allowed_mentions. An empty parse array, as above, means nothing pings at all, which is the safe default for anything that forwards text you did not write yourself. To ping one specific role and nothing else, list it explicitly:

json
{  "content": "<@&123456789012345678> the nightly build failed",  "allowed_mentions": { "parse": [], "roles": ["123456789012345678"] }}

When something is wrong, Discord replies 400 with a JSON body naming the field it rejected. Read it before changing anything else - it is nearly always more specific than you expect.

Sending from Python and JavaScript#

There is nothing in a webhook that needs a Discord library. Any HTTP client will do, and the code is short enough that a dependency is optional.

Python with requests:

python
import osimport requestsurl = os.environ["WEBHOOK_URL"]payload = {    "username": "Backups",    "content": "Nightly backup finished: 412 MB in 38 s",    "allowed_mentions": {"parse": []},}r = requests.post(url, json=payload, params={"wait": "true"}, timeout=10)r.raise_for_status()print("message id:", r.json()["id"])

If the project already uses discord.py 2.x, it has a synchronous webhook client that needs no bot and no event loop:

python
from discord import SyncWebhookhook = SyncWebhook.from_url(os.environ["WEBHOOK_URL"])msg = hook.send("Deploy finished", username="CI", wait=True)print(msg.id)

JavaScript on Node 18 or later has fetch built in, so the plain version needs no packages:

javascript
const res = await fetch(`${process.env.WEBHOOK_URL}?wait=true`, {  method: "POST",  headers: { "Content-Type": "application/json" },  body: JSON.stringify({    username: "CI",    content: "Build 512 passed",    allowed_mentions: { parse: [] },  }),});if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);const message = await res.json();console.log(message.id);

discord.js has the equivalent in WebhookClient, created with new WebhookClient({ url }) and used with send(). It handles rate limits for you, which is worth having if the script sends a lot.

In every language, keep the URL in an environment variable and never in source. The rest of the code is the same three steps: build a dictionary, send it as JSON, check the status code. Environment variables and secrets covers where the value should live on a server.

Embeds, files and the limits that return 400#

Plain content is right for one-line notices. For anything with structure - an order with several fields, a build with a commit and an author, a report with a colour that means good or bad - use an embed:

json
{  "username": "CI",  "embeds": [{    "title": "Build 512 passed",    "url": "https://example.com/builds/512",    "description": "main - fix login redirect",    "color": 3066993,    "fields": [      { "name": "Duration", "value": "2m 14s", "inline": true },      { "name": "Tests", "value": "418 passed", "inline": true }    ],    "footer": { "text": "runner-02" },    "timestamp": "2026-10-15T09:30:00.000Z"  }]}

Two details catch nearly everyone. color is a decimal integer, so the hex colour #2ECC71 is sent as 3066993; a string there is a 400. And timestamp must be ISO 8601, which Discord then shows in each reader's local time.

Limits are enforced, not truncated: 2000 characters of content, 10 embeds per message, 256 characters of embed title, 4096 of description, 25 fields with 256-character names and 1024-character values, and 6000 characters across all embeds in one message. The server status post has the full limits table and what happens when a stack trace blows past it. Truncate on your side before sending.

Files go up as multipart/form-data rather than JSON. The JSON body moves into a form field called payload_json, and each file is a field named files[0], files[1] and so on:

bash
$ curl -X POST "$WEBHOOK_URL" \    -F 'payload_json={"content": "Error log from the failed run"}' \    -F 'files[0]=@build.log'

The upload size limit depends on the server's boost level and Discord has changed it more than once; on an unboosted server it is currently 10 MB per message. Logs bigger than that belong in object storage or a log system, with a link in the message.

Two compatibility endpoints save writing an adapter. Appending /slack to the webhook URL accepts a Slack-format payload, and /github accepts GitHub's webhook format, so you can paste https://discord.com/api/webhooks/ID/TOKEN/github into a repository's webhook settings (content type application/json) and get commit and pull request notices with no code.

wait=true, threads and forum channels#

The query string on the execute URL changes behaviour, and two parameters do most of the work.

ParameterEffect
wait=trueAnswer 200 with the stored message, not 204
thread_id=IDPost into an existing thread in the webhook's channel

Use wait=true whenever you will touch the message again, or whenever you want to be certain it was actually stored before your script moves on. Without it, a 204 tells you the request was accepted and nothing more.

thread_id lets one webhook feed many threads. The thread has to belong to the webhook's channel - a webhook in #builds can post into any thread under #builds, not into a thread elsewhere. A common pattern is one thread per release or per incident, with every related message posted into it, so the parent channel stays readable.

Forum and media channels have no plain message area, only posts, so a webhook there has to say which post it means. Either pass thread_id to reply in an existing post, or send thread_name in the body to create a new post with your message as its first message:

bash
$ curl -X POST "$WEBHOOK_URL?wait=true" \    -H "Content-Type: application/json" \    -d '{"thread_name": "Incident 2026-10-15", "content": "API returning 502 since 09:12"}'

The response to that request contains the message, and its channel_id is the id of the new thread - keep it if you will post follow-ups. A request to a forum webhook with neither thread_id nor thread_name fails with 400. If the forum requires tags, applied_tags takes a list of tag ids.

Editing, deleting and managing messages#

A webhook can only change messages it sent itself, and it addresses them by id under its own URL:

bash
# Read a message the webhook sent$ curl "$WEBHOOK_URL/messages/$MESSAGE_ID"# Edit it - only the fields you send are changed$ curl -X PATCH "$WEBHOOK_URL/messages/$MESSAGE_ID" \    -H "Content-Type: application/json" \    -d '{"content": "Deploy finished on main (rolled back 09:40)"}'# Delete it$ curl -X DELETE "$WEBHOOK_URL/messages/$MESSAGE_ID"

If the message lives in a thread, add ?thread_id=THREAD_ID to each of those URLs as well, or Discord looks for it in the parent channel and returns 404. An edit leaves the original author name and avatar as they were; username and avatar_url are not accepted on edit. To clear embeds, send "embeds": []; to clear text, send "content": "" (the message still needs something left in it).

Edits do not notify anyone. That makes edit-in-place the right shape for anything that changes repeatedly - a progress line, a queue length, a live status card - and a new post the right shape for events people should notice. The server status post builds a small state machine around exactly this.

The webhook itself is managed through the same URL:

  • GET $WEBHOOK_URL returns the webhook object - its name, channel id, server id and avatar hash. When fetched with the token alone it leaves out the user who created it. It is a cheap way to check that a stored URL still works.
  • PATCH $WEBHOOK_URL with name and/or avatar changes the stored identity. avatar is a data URI (data:image/png;base64,...), not a link. Moving a webhook to another channel needs a bot or user token, not the webhook token.
  • DELETE $WEBHOOK_URL deletes the webhook. Every request to the URL then returns 404, permanently.

That last one matters: anybody holding the URL can delete the webhook, which is useful when you find a leaked one in someone else's paste and annoying when a careless script runs it by mistake.

Rate limits and the 429 response#

Discord rate-limits every route, webhooks included. The practical ceiling for one webhook is in the region of five requests every two seconds, and a channel takes roughly thirty messages a minute regardless of how many webhooks post to it. Those figures are not published as fixed numbers and they move, so do not hard-code a sleep and consider it handled. Read the response.

When you go over, the reply is 429 Too Many Requests with headers and a body:

json
{ "message": "You are being rate limited.", "retry_after": 0.53, "global": false }

retry_after is in seconds and may be fractional. Wait that long, then retry the same request once. The headers X-RateLimit-Remaining and X-RateLimit-Reset-After let a careful client slow down before it ever hits the wall. A minimal Python retry:

python
import time, requestsdef send(url, payload, attempts=5):    for _ in range(attempts):        r = requests.post(url, json=payload, timeout=10)        if r.status_code != 429:            r.raise_for_status()            return r        time.sleep(float(r.json().get("retry_after", 1)))    raise RuntimeError("still rate limited")

What gets webhooks into trouble is not the occasional 429 but the reaction to it. A script that retries instantly in a loop turns a half-second wait into a flood, and repeated invalid requests (401, 403 and 429 responses) can earn the sending IP a temporary block from Discord's API as a whole. Batch where you can - ten log lines joined with newlines are one request - and back off on 5xx errors too, which Discord returns under load.

A leaked webhook URL: what to do#

Webhook URLs leak in boring ways: committed in a config file, pasted into a support chat, visible in a screenshot, printed in a CI log, embedded in client-side JavaScript of a website. The last one is the most common and the most avoidable - any URL that reaches a browser is public, and there are scrapers that look for exactly this pattern and use it to post spam or phishing links under your server's banner.

When one leaks, the clean-up is short and should be done in this order:

  1. Delete the webhook. Channel settings, Integrations, Webhooks, select it, Delete Webhook. Or curl -X DELETE "$WEBHOOK_URL" from anywhere. The old URL stops working at once. There is no "reset token" option; deleting and recreating is the rotation.
  2. Create a new one in the same channel and put the new URL into the environment variable or secret store your sender reads. Restart the sender so it picks it up.
  3. Clean the channel. Delete anything the attacker posted. Messages already sent stay after the webhook is gone, and with no webhook left you delete them as a moderator, by hand.
  4. Remove the source. Rewrite the file, revoke the paste, rotate the CI log. If it was in a Git repository, deleting it in a new commit is not enough - it is still in history, and the leaked URL is dead anyway, so focus on making sure the new one never goes the same way.
  5. Audit the rest. Server Settings, Integrations, Webhooks lists every webhook in the server. Delete any that nobody can account for.

Treat the URL like a database password: an environment variable on the server that sends, redacted in screenshots and logs, and one per sender so that one leak is one deletion.

Webhook or bot: picking the right one#

The decision is about direction. A webhook writes into a channel. A bot connects to Discord's gateway, receives events and can act on them.

You want toUse
Post notices from a script, CI or a websiteWebhook
Edit or delete those notices laterWebhook
Post under several names and avatarsWebhook
Respond to slash commands or buttonsBot
Read chat, reactions or member joinsBot
Assign roles, moderate, send DMsBot
Post in many servers you do not ownBot, invited to each

A webhook needs nothing running between messages, so it costs nothing to keep. A bot is a long-running process with a token, intents and a gateway connection that must stay up; if it stops, it shows as offline and its commands fail. That is a hosting problem in its own right, covered in hosting a Discord bot 24/7 for discord.js and hosting a discord.py bot for Python.

Two hybrids are worth knowing. A bot can create webhooks itself and use them to post under other identities - which is how chat bridges show in-game players with their own names and avatars. And a webhook created by an application (rather than by a person in channel settings) can send interactive components such as buttons, because the interactions go back to that application. A webhook you make in the channel settings cannot receive anything, so buttons on it would have nowhere to report.

There is also a separate feature Discord calls webhook events: an application can register an HTTPS endpoint in the developer portal and receive certain events, such as the app being authorised, as incoming requests. That is the reverse direction - Discord calling you - and it is for application developers, not a way to read a channel.

Where the sender runs is the last decision. A webhook only needs something that can make a request when the event happens: a CI job, a cron task, a backend that already exists. If it does not exist yet, a small always-on app server is enough. RE:NODE app plans for Node.js and Python start at $4 a month, with the webhook URL kept as an environment variable on the Startup tab and deploys straight from GitHub.

FAQ#

Do I need to code to use a Discord webhook?

No. Many services take a webhook URL and do the sending for you - paste it in and they post. To send your own messages, a single curl command is enough, and there are also web tools that build the JSON for you. Code is only needed when your own program decides what and when to post.

Why does my webhook return 204 and no message id?

Because 204 No Content is the default success reply. Add ?wait=true to the URL and Discord returns 200 with the full message, including the id you need to edit or delete it.

Can a webhook post to several channels?

No. Each webhook belongs to one channel, and it can only reach threads inside that channel through thread_id. For several channels, create one webhook in each, which is better for leaks and noise anyway.

Can I make a webhook ping a role?

Yes. Put <@&ROLE_ID> in content and include the id in allowed_mentions.roles, keeping parse empty so nothing else pings. The role id comes from right-clicking the role with Developer Mode switched on in Discord's Advanced settings, which is also how you copy user, channel and message ids.

Do webhook URLs expire?

No. A webhook works until it is deleted, its channel is deleted, or the server removes it. A URL that worked last year and returns 404 today was deleted by someone - check Server Settings, Integrations, Webhooks.

Why does the webhook reject my username?

Names must be 1 to 80 characters and cannot contain discord or clyde. The 400 response body says which rule it hit; shorten the name or drop the word.