A Discord webhook is a URL that turns an HTTP POST into a message in one channel. There is no bot, no token, no gateway connection and no login: you create the URL in channel settings, you POST some JSON to it, and a message appears. For server status, restart alerts, backup confirmations and "the Minecraft server is back up", that is the entire mechanism, and it takes about four minutes to set up.
The parts worth knowing are the ones people get wrong afterwards. A webhook can only write, so anything that needs to read chat or answer a command needs a real bot. Posting a message every minute fills a channel with noise nobody reads; editing the same message keeps one live status line instead. Discord rate-limits webhooks and returns a 429 with the exact number of seconds to wait, which most home-made scripts ignore. And the URL is a credential - anyone holding it can post as your server, forever, until you delete the webhook.
What a webhook is, and what it cannot do#
A webhook belongs to a channel, not to you. It has an id and a token, and the two together are the whole URL:
https://discord.com/api/webhooks/1234567890123456789/aBcD...tokenThere is no other authentication. That URL is the credential, and it is the only credential. Anyone who has it can post to that channel as that webhook, with any name and any avatar they choose, until somebody deletes the webhook.
What a webhook can do:
- Post a message to its one channel, with
content, embeds, files, or all three. - Override the display name and avatar per message, so one webhook can post as "Survival" and as "Lobby".
- Post into a thread in that channel, with a
thread_idquery parameter. - Edit and delete messages it previously sent.
What it cannot do:
- Read anything. Not chat, not reactions, not the member list.
- Respond to a slash command, a button or a mention.
- Post anywhere except its own channel.
- Assign roles, kick, ban, or do anything an administrator does.
That list is the whole decision. One-way notifications are a webhook. Two-way anything is a bot with a token, a gateway connection and an intents configuration, which is a different piece of software with different hosting requirements - hosting a Discord bot 24/7 covers that side, and picking a plan for a Discord bot covers what it costs to keep one online.
Creating one and sending the first message#
In Discord: right-click the channel, Edit Channel, Integrations, Webhooks, New Webhook. Name it after the server it reports on rather than "Webhook 1", because that name shows up as the message author by default. Copy the URL. A channel can hold up to 15 webhooks, which is more than enough to give every server its own.
The simplest possible message:
$ curl -X POST "$WEBHOOK_URL" \ -H "Content-Type: application/json" \ -d '{"content": "Survival server is back up."}'A successful post returns 204 No Content and an empty body. If you want the message back - specifically its id, so you can edit it later - add ?wait=true and you get 200 with the full message object:
$ curl -X POST "$WEBHOOK_URL?wait=true" \ -H "Content-Type: application/json" \ -d '{"content": "Restarting for updates in 5 minutes."}'Two flags are worth setting on nearly every automated message. allowed_mentions with an empty parse array stops a log line that happens to contain @everyone from pinging six hundred people, which is the classic console-bridge accident. And flags: 4096 suppresses link previews, so a message containing a URL does not drag an unwanted card into the channel.
{ "username": "Survival", "content": "Backup finished: 412 MB in 38s", "allowed_mentions": { "parse": [] }, "flags": 4096}Embeds: the JSON that makes it readable#
Plain content is fine for one-line alerts. For a status panel you want an embed: a bordered card with a coloured stripe, a title, fields laid out in columns and a timestamp. The structure is fixed and the limits are real.
{ "username": "Valheim", "embeds": [ { "title": "Longship Crew", "description": "Online - world saved 4 minutes ago", "color": 3066993, "fields": [ { "name": "Players", "value": "3 / 10", "inline": true }, { "name": "Version", "value": "0.220.5", "inline": true }, { "name": "Uptime", "value": "6d 4h", "inline": true } ], "footer": { "text": "checked every 60s" }, "timestamp": "2026-09-21T14:03:00.000Z" } ], "allowed_mentions": { "parse": [] }}Things that catch people out:
- `color` is a decimal integer, not a hex string.
0x2ECC71is3066993. A string here is a400. - `timestamp` must be ISO 8601. Discord renders it in each viewer's local time, which is the single best reason to use an embed for a status message - nobody has to work out your timezone.
- `inline: true` puts fields side by side, three to a row on desktop and fewer on mobile. Mixing inline and non-inline fields produces layouts you did not intend, so pick one per section.
The hard limits, which return 400 rather than truncating:
| Field | Limit |
|---|---|
content | 2000 characters |
embeds per message | 10 |
title | 256 characters |
description | 4096 characters |
fields | 25 |
field name / value | 256 / 1024 characters |
footer.text | 2048 characters |
| All text across all embeds | 6000 characters |
The 6000 character total is the one that bites a console bridge. A long stack trace pasted into a description exceeds it quietly during normal operation and then fails during the incident you built the bridge for. Truncate to a fixed length on your side, and put the full text somewhere that holds full text - see logs worth keeping.
Two compatibility shortcuts exist and are genuinely useful. Appending /slack to a webhook URL accepts a Slack-format payload, and /github accepts a GitHub one. That means a tool that only knows how to talk to Slack - a monitoring system, a CI runner - can post to Discord with no adapter at all. Point it at https://discord.com/api/webhooks/ID/TOKEN/slack and it works.
One message, edited, instead of a hundred posts#
The default design for a status webhook is to post every minute. After a day that is 1,440 messages, the channel is unreadable, and anyone with notifications on has muted it. The better design is one message that is edited in place.
Post once with ?wait=true, keep the returned id, then PATCH it:
# First run: create and remember the id$ MESSAGE_ID=$(curl -s -X POST "$WEBHOOK_URL?wait=true" \ -H "Content-Type: application/json" \ -d @status.json | python3 -c "import json,sys; print(json.load(sys.stdin)['id'])")# Every run after that: edit the same message$ curl -s -X PATCH "$WEBHOOK_URL/messages/$MESSAGE_ID" \ -H "Content-Type: application/json" \ -d @status.jsonStore the message id somewhere that survives a restart - a file next to the script is enough. If the id is gone or the message was deleted, the PATCH returns 404 and you post a new one. That is the whole state machine:
- Read the saved message id. If there is none, go to step 4.
PATCHthe message with the new status.- If the response is
404, forget the id and go to step 4. Otherwise stop. POSTwith?wait=true, save the returned id, stop.
An edited message does not notify anyone, which is exactly right for a status line that changes every minute. Keep a second webhook, in a different channel, for the events that should notify: the server went down, a backup failed, the disk is nearly full. Editing for state, posting for events, is the distinction that makes people keep the channel unmuted.
Rate limits, 429 and the retry you must honour#
Discord rate-limits every route. For a webhook the practical figures are roughly five requests every two seconds per webhook, and around thirty messages a minute into one channel. Those numbers are not contractual and Discord changes them, so do not encode them as a sleep and call it done. Handle the response instead.
When you exceed a limit you get 429 with a JSON body and headers:
HTTP/1.1 429 Too Many RequestsX-RateLimit-Limit: 5X-RateLimit-Remaining: 0X-RateLimit-Reset-After: 0.529Retry-After: 1{"message": "You are being rate limited.", "retry_after": 0.529, "global": false}retry_after in the body is seconds as a float, and it is the authoritative number. Sleep for that long and retry once. If global is true you have hit the account-wide limit - 50 requests a second - and you have a bigger problem than this one message.
Three rules that keep a webhook out of trouble for good:
- Never retry in a tight loop. A script that retries immediately on
429turns a half-second delay into a sustained flood and can get the webhook disabled. - Batch. Ten log lines in one message with newlines is one request. Ten messages is ten. A console bridge should buffer for a second or two and send the batch.
- Back off on `5xx` as well. Discord returns
500and502under load. Exponential backoff with a cap, then give up and log locally. A monitoring system that falls over because its notification channel is busy is worse than no monitoring at all.
Where the status comes from: polling the game server#
The webhook is the easy half. Something has to know how many players are online, and that something is a query to the game server itself.
| Game family | Protocol | Where |
|---|---|---|
| Minecraft Java | Server List Ping, TCP | Game port, usually 25565 |
| Source and GoldSrc | A2S_INFO, UDP | Query port |
| Valheim | Steam query, UDP | Game port plus one, 2457 by default |
| Most Unreal and Unity games | A2S or a REST endpoint | Varies, check the game |
| Anything with RCON | RCON, TCP | RCON port |
Libraries exist for all of these, and writing your own A2S parser is not a good use of an evening. What matters more is where the poller runs. It cannot usefully run on the game server it is watching, because the interesting case is the game server being down. It needs to be a separate, small, always-on process.
A scheduled task on a game server plan runs console commands, backups and power actions - it cannot make an HTTP request. So the poller belongs somewhere that runs your own code: a Node or Python app plan, a VDS, or a machine you already have. On RE:NODE, app plans start at $4 a month and give you the same panel, a console, and deploy-from-GitHub, which is enough for a fifty-line script on a timer. Check the app hosting page for the current tiers, and picking a plan for a Discord bot for how small "small" can safely be.
The shape of the script, in any language, is the same:
import json, os, urllib.requestdef post(payload, url=os.environ["WEBHOOK_URL"]): body = json.dumps(payload).encode() req = urllib.request.Request( url, data=body, headers={"Content-Type": "application/json"} ) with urllib.request.urlopen(req) as res: return res.statusPoll on a fixed interval, compare the result with the previous one, edit the status message every time, and post to the alerts channel only when the state changed. That last condition is the difference between a useful channel and a muted one. Monitoring that tells you something is the longer argument for building it that way.
Console bridges, plugins and when you need a real bot#
Most people do not write any of the above, because a plugin already exists.
- Minecraft. DiscordSRV is the established chat bridge. It uses a bot token rather than a webhook for its core function - because it needs to read Discord messages and put them in game chat, which a webhook cannot do - and then uses webhooks internally so in-game players appear with their own names and skins. That hybrid is why it needs both.
- FiveM. Resources that post joins, leaves, bans and admin actions to a webhook are standard, and txAdmin can report to Discord. FiveM and txAdmin covers the setup.
- Source games. SourceMod plugins post match results and admin actions to a webhook.
- Anything else. Tail the log file and post the lines that match a pattern. This is the universal fallback, it works on any game, and it is about twenty lines of code.
Be selective with a bridge. Full console output to Discord is unreadable within a day and burns rate limit continuously. Pick the events that would make you get up: the server stopped, a crash line, a player count crossing a threshold, a backup that failed. If your server is restarting often enough to be noisy, the alert is not the problem - why your game server keeps restarting is.
The moment you want any of the following, you need a bot rather than a webhook: a /status command, a button that restarts the server, reading Discord chat into the game, assigning a role when somebody links their account, or reacting to a message. That is a real application with a token to protect and a process to keep alive.
Treat the URL as a password, and send alerts worth reading#
A webhook URL is a bearer credential with no expiry and no scope beyond the channel. Handle it accordingly.
- Never commit it. Put it in an environment variable, read it at start-up, and keep it out of the repository. The panel's Startup tab holds environment variables for exactly this. Environment variables and secrets covers the general case.
- Never paste it into a Discord message, a screenshot, a pastebin or a support ticket. If you need somebody to look at your config, redact the token half of the URL.
- Rotate by deleting. There is no password reset for a webhook. If one leaks, delete it in channel settings and create a new one; the old URL stops working immediately.
- One webhook per purpose. Separate webhooks for status, alerts and the console bridge mean a leak or a mistake affects one channel, and you can see at a glance which script is noisy.
Then be ruthless about what you send. The failure mode of alerting is not missing an alert; it is sending so many that people stop looking. A channel that posts once a week is read. A channel that posts every ten minutes is muted within a fortnight, and it will be muted on the night something actually breaks.
A good default set for a game server: server stopped unexpectedly, server came back, a backup failed, disk above 85 per cent, player count crossed a threshold you care about, and an update was applied. That is six event types, most of which fire rarely. Everything else can live in the edited status message where it changes silently, or in the log where you can go and read it when you have a reason to.
FAQ#
Do I need a bot token to use a webhook?
No. The webhook URL contains its own token and needs nothing else. You only need a bot token when something has to read from Discord - chat bridges in both directions, slash commands, buttons or role management.
How often can I post to a webhook?
Roughly five requests every two seconds per webhook, and about thirty messages a minute into a channel, but treat those as guidance. The reliable approach is to handle the 429 response and sleep for the retry_after value it gives you.
Can I update a message instead of posting a new one?
Yes. Post with ?wait=true, keep the message id from the response, then PATCH the webhook URL with /messages/{id} appended. Editing does not notify anyone, which makes it ideal for a live status line.
Why is my webhook pinging everyone?
Because a message body contained @everyone or a role mention and nothing suppressed it. Send "allowed_mentions": {"parse": []} on every automated message, especially anything that forwards chat or log text.
Where should the status poller run?
Not on the server it is watching, because you need it working when that server is down. A small app plan, a VDS or any always-on machine is right. A scheduled task on a game server plan can run console commands and backups, but it cannot make an HTTP request.
My webhook stopped working. What happened?
Either it was deleted in channel settings, the channel was deleted, or the URL was regenerated. There is no expiry and no rate-limit ban that persists. Check the webhook still exists, then check you are sending the whole URL including the token.




Comments
Completely anonymous: no account, no email, no cookie. We store the name you type, the text and the time - nothing else. Links are limited and markup is not rendered.