A Discord webhook is a URL tied to one channel. POST JSON to it and the JSON becomes a message in that channel. There is no bot to write, no gateway socket to keep open, no process to host, and no token that can read anything. That narrowness is the whole point: it makes publishing to Discord roughly as hard as calling any other HTTP endpoint.
Everything below comes from building the Discord integration in Cadencz, which publishes scheduled posts to customers' channels this way. The traps in the last section are the ones that cost us real debugging time, not a list transcribed from the documentation.
Webhook or bot? Answer this first
Almost every Discord integration question resolves to one decision, and getting it wrong costs weeks. A webhook can only speak. A bot can listen, respond, read history, and see reactions — and in exchange it needs a token, a hosting story, a gateway connection, and Discord's verification process once it reaches 100 servers.
| You want to… | Webhook | Bot |
|---|---|---|
| Post a scheduled or automated message | Yes — this is exactly what it is for | Works, but is far more machinery than the job needs |
| Post as a custom name and avatar per message | Yes, set `username` and `avatar_url` in the body | No — a bot posts as itself |
| Read replies, reactions, or member counts | No. There is no read endpoint at all | Yes, with the right gateway intents |
| Respond to a slash command | No | Yes |
| Upload a file | Only via a multipart request | Yes |
| Be installed by a non-technical user | Yes — the OAuth flow hands back a ready webhook | Requires the bot to be invited and permissioned |
| Survive the person who set it up leaving | Yes, the URL is not tied to a user account | Yes |
Creating one: two routes
By hand, for your own server: open the channel, Edit Channel, Integrations, Webhooks, New Webhook. Copy the URL. It looks like `https://discord.com/api/webhooks/{id}/{token}` and it is a credential — anyone holding it can post to that channel, so treat it exactly like an API key.
In a product, where the user is installing your integration: use the OAuth2 flow with the `webhook.incoming` scope. Discord shows the user a channel picker as part of authorization, and the token response comes back with a fully-formed webhook object — id, channel, and URL — already created against the channel they chose. You never handle their credentials and they never paste a URL. This is how Cadencz connects Discord: the user picks a channel, and what we store is the webhook URL itself.
The request that publishes a message
One POST, JSON body, no authentication header — the token is already in the URL. The `content` field is the message text. `username` and `avatar_url` override the webhook's configured identity for that single message, which is how one webhook can post as several different senders.
- Always send `?wait=true`. Discord's default is fire-and-forget: 204, no body, no id. With it you get 200 and the full message object.
- `content` is optional if you send `embeds`, and vice versa — but a request with neither is rejected.
- A 2xx means Discord accepted the message, not that every member can see it; channel permissions still apply.
The minimum that works
POST https://discord.com/api/webhooks/{id}/{token}?wait=true Content-Type: application/json { "content": "Deploy finished. 4 minutes, no errors." }
With a per-message identity and inline images
{ "content": "This week's shipped list", "username": "Release Bot", "avatar_url": "https://example.com/icon.png", "embeds": [ { "image": { "url": "https://example.com/chart.png" } } ] }
The version that loses the message id
POST https://discord.com/api/webhooks/{id}/{token} { "content": "Deploy finished." } → 204 No Content, empty body. The message posted, but you have no id, so you can never edit or delete it programmatically.
The five limits that actually break things
- 2,000 characters. Over that, Discord converts the message into a `message.txt` attachment rather than rejecting it. Nothing errors, and the channel sees a file where a post should be — so validate length before sending, not after.
- Ten embeds per message. An eleventh is dropped silently. If you are rendering a gallery, cap it yourself so the omission is a decision rather than a surprise.
- No file upload from a plain JSON request. Attaching a real file needs `multipart/form-data`. The shortcut that avoids it entirely: host the image and send one embed per image URL, which renders inline and costs no extra round trip.
- Rate limits are per webhook and per channel, and Discord answers a 429 with a `retry_after` value in the body. Honour that number; retrying immediately extends the block rather than clearing it.
- Deleting the channel deletes the webhook. The URL then returns 404 forever, which is indistinguishable from a revoked webhook — so surface it to the user as "reconnect this channel" rather than as a transient failure.
Why you get no Discord analytics
This surprises people who have wired up publishing and then go looking for engagement numbers. There is no webhook endpoint that returns reactions, replies, or views — not one that is undocumented or rate-limited, one that does not exist. Reading anything back from a Discord channel requires a bot token with the appropriate gateway intents, which is a different integration with a different install flow.
That is a permanent property of the transport, not a gap someone will close. It is worth knowing before you promise a customer a Discord engagement chart. Cadencz records it explicitly for that reason: Discord is a publish-only channel, and the analytics page says so rather than showing an empty graph.