Discord webhook message format: JSON fields, embeds, and limits
A Discord webhook message is a JSON object sent as an HTTP POST to the channel’s webhook URL. It must include at least one of content (plain text up to 2000 characters), embeds (up to 10 rich cards), a file, components, or a poll. Discord replies 204 No Content, or 200 with the created message when you add ?wait=true.
Creating the webhook and sending a first plain-text message is covered in how to make a webhook in Discord. This page is the reference for everything after that: each field, each limit, and what Discord’s errors mean.
Top-level fields
| Field | What it does |
|---|---|
content | Message text, up to 2000 characters, rendered as Discord markdown |
username, avatar_url | Override the webhook’s name and icon for this message only |
tts | Reads the message aloud for users with text-to-speech enabled |
embeds | Up to 10 embed objects |
allowed_mentions | Controls which mentions in the text notify anyone |
flags | Bitfield; webhooks may set only SUPPRESS_EMBEDS (4), SUPPRESS_NOTIFICATIONS (4096), and IS_COMPONENTS_V2 |
attachments, files[n] | File uploads, sent as multipart/form-data |
thread_name, applied_tags | Create a new post when the webhook belongs to a forum or media channel |
components | Buttons and layout blocks; ignored on webhooks your app does not own unless you add ?with_components=true, and then only non-interactive ones |
poll | A poll request object |
Three query string parameters change how Discord handles the request rather than what the message contains. wait=true returns the created message instead of an empty 204. thread_id posts into a thread inside the webhook’s channel and unarchives it if needed. with_components is the switch mentioned in the table.
The embed object
An embed is the bordered card with a colored stripe down its left edge. Send it in the embeds array, alone or alongside content:
{
"username": "Deploy bot",
"embeds": [
{
"title": "Deploy 1.4.2 finished",
"url": "https://ci.example.com/builds/1842",
"description": "Rolled out to production <t:1792486800:R>.",
"color": 5793266,
"timestamp": "2026-10-20T09:00:00.000Z",
"fields": [
{ "name": "Service", "value": "checkout-api", "inline": true },
{ "name": "Commit", "value": "a41f9c2", "inline": true },
{ "name": "Migrations", "value": "2 applied" }
],
"footer": { "text": "example-ci" }
}
]
}
url turns the title into a link, and description accepts markdown. color is a decimal integer, not a hex string, so #5865F2 becomes 5793266. timestamp must be ISO 8601 and renders in the card’s footer. Each field needs both name and value, and inline: true lets consecutive fields sit side by side. footer requires text and author requires name when you include them. image and thumbnail take an object with a url that is either an http(s) link or an uploaded attachment.
Every limit Discord enforces
| Limit | Value |
|---|---|
content | 2000 characters |
| Embeds per message | 10 |
Embed title | 256 characters |
Embed description | 4096 characters |
| Fields per embed | 25 |
Field name / value | 256 / 1024 characters |
footer.text | 2048 characters |
author.name | 256 characters |
| All embed text in one message | 6000 characters |
IDs in allowed_mentions.users or allowed_mentions.roles | 100 each |
| Whole request, files included | 25 MiB |
The 6000-character total counts every title, description, field name, field value, footer text, and author name across all embeds in the message, so each embed can pass its own checks while the message still fails. Discord trims leading and trailing whitespace before checking, which means a field value that is only spaces becomes empty and fails the required-field rule. Breaking any one limit rejects the entire message with a 400, not just the offending embed.
Clamp a payload before you send it
When a message is built from data you do not control, such as exception text or a user’s comment, trim it to the limits yourself. Otherwise the alert you most need is the one Discord rejects:
const cut = (s, max) => (s && s.length > max ? s.slice(0, max - 1) + "…" : s);
function clampEmbed(embed) {
return {
...embed,
title: cut(embed.title, 256),
description: cut(embed.description, 4096),
footer: embed.footer && { ...embed.footer, text: cut(embed.footer.text, 2048) },
author: embed.author && { ...embed.author, name: cut(embed.author.name, 256) },
fields: (embed.fields ?? []).slice(0, 25).map((f) => ({
...f,
name: cut(f.name, 256),
value: cut(f.value, 1024),
})),
};
}
function embedLength(e) {
const parts = [e.title, e.description, e.footer?.text, e.author?.name];
for (const f of e.fields) parts.push(f.name, f.value);
return parts.reduce((n, s) => n + (s?.length ?? 0), 0);
}
function buildMessage({ content, embeds = [] }) {
const kept = [];
let budget = 6000;
for (const embed of embeds.slice(0, 10).map(clampEmbed)) {
const size = embedLength(embed);
if (size > budget) break;
budget -= size;
kept.push(embed);
}
return { content: cut(content, 2000), embeds: kept, allowed_mentions: { parse: [] } };
}
Embeds that would push the message past 6000 characters are dropped rather than cut mid-sentence, so put the most important card first. A link in the last kept embed to the full record covers whatever was trimmed.
Mentions, silent messages, and timestamps
Discord parses mentions out of content by default, so a message that echoes user input can ping a role or the whole server. allowed_mentions controls that separately from the text:
{
"content": "Alert raised by <@1234567890> for <@&9876543210>",
"allowed_mentions": { "parse": ["users"] }
}
parse accepts "users", "roles", and "everyone", and an empty array notifies nobody while still rendering the mentions. To allow specific people or roles, list up to 100 IDs in users or roles instead, but do not also put that type in parse, because combining them is a validation error. The mention syntax itself is <@USER_ID> for users, <@&ROLE_ID> for roles, and <#CHANNEL_ID> for channels.
Two flags cover the other common requests. "flags": 4096 (SUPPRESS_NOTIFICATIONS) posts the message without push or desktop notifications, which suits low-priority logs. "flags": 4 (SUPPRESS_EMBEDS) stops Discord generating link previews for URLs in content.
For times, <t:1792486800:R> renders as a relative time such as “in 3 hours”, in each reader’s own timezone. The value is Unix seconds, and the style letter can also be t or T for a time, d or D for a date, or f or F for both.
Attach a file and show it in an embed
Files switch the request to multipart/form-data. Each file goes in a part named files[n], the JSON body moves into a payload_json part, and an embed references the upload with an attachment:// URL:
curl -X POST "https://discord.com/api/webhooks/{webhook_id}/{webhook_token}" \
-F 'payload_json={"embeds":[{"title":"Nightly error report","image":{"url":"attachment://errors.png"}}],"attachments":[{"id":0,"filename":"errors.png"}]}' \
-F 'files[0]=@errors.png'
The id in attachments matches the n in files[n]. Embed images must be .jpg, .jpeg, .png, .webp, or .gif. A request that is too large comes back with error code 40005.
What Discord’s errors mean
A failed request returns a JSON body with a numeric code and a message. The codes you will actually see from a webhook:
| Code | Meaning | Usual cause |
|---|---|---|
50006 | Cannot send an empty message | No content, embeds, file, components, or poll, often a template that rendered to an empty string |
50035 | Invalid form body | A field broke a limit or type rule, or the Content-Type is wrong; the errors object in the response says which |
50109 | Invalid JSON | A body that does not parse, such as one with a trailing comma or an unescaped quote |
40005 | Request entity too large | Files or payload over the size limit |
10015 | Unknown webhook | The webhook was deleted, so stop sending to it |
A 404 deserves special care. Discord’s rate limit documentation says to stop using a webhook that returns one, because repeated requests to it lead to a temporary restriction.
Discord does not publish a fixed request rate for webhooks. Each webhook gets its own bucket, and responses carry X-RateLimit-Limit, X-RateLimit-Remaining, and X-RateLimit-Reset-After headers so you can pace yourself. A 429 comes with a retry_after value in seconds, which can include decimals, and a global flag. Read those rather than hard-coding a number. Responses with status 401, 403, and 429 also count toward a limit of 10,000 invalid requests per IP every 10 minutes, after which Discord’s edge blocks that IP temporarily, so a retry loop without backoff can take down every Discord integration on the same server. A rate limit and a queue on the sending side prevent both problems.
Edit, delete, and post into threads
Add ?wait=true and Discord answers 200 with the message object, including its id. Keep that id and you can rewrite the message with a PATCH to /webhooks/{webhook_id}/{webhook_token}/messages/{message_id}, or remove it with a DELETE to the same path. That is how a status message updates in place from “deploying” to “deployed” instead of posting three times. Messages older than an hour have a separate edit allowance, and exceeding it returns code 30046.
?thread_id= posts into an existing thread, and you pass the same parameter when editing or deleting a message inside one. In a forum or media channel every message must start or join a thread, so send thread_name in the body to create a post, or thread_id to reply to one.
Reusing a Slack or GitHub payload
Discord also accepts two other formats at sibling URLs. Append /slack to the webhook URL and you can send a Slack incoming-webhook payload unchanged, except that Discord ignores Slack’s channel, icon_emoji, mrkdwn, and mrkdwn_in fields. Append /github and paste the result into a GitHub repository’s webhook settings as the payload URL; Discord formats push, pull_request, issues, release, and 14 other GitHub events itself. If GitHub’s deliveries to the plain URL are failing, see webhook error 400 from GitHub to Discord.
Formatting for Discord is a bounded problem, because Discord publishes the schema and enforces it the same way every time. Sending events to your own customers is the harder version of the same job, since you own the retries, the signatures, and the delivery log. Svix handles that side.
Frequently asked questions
What is the character limit for a Discord webhook message?
The content field allows 2000 characters. Embeds have their own limits: 256 for a title, 4096 for a description, 1024 for a field value, and 6000 characters of embed text combined across all embeds in one message. Exceeding any limit rejects the whole message with a 400.
Why does my embed have no color?
The color field takes a decimal integer, not a hex string. Convert the hex value first: #5865F2 is 5793266. A string like "#5865F2" will not render the stripe.
Do I need a bot token to send a Discord webhook message?
No. The token inside the webhook URL is the only credential, so a plain HTTP POST with no Authorization header works. That also means the URL is a secret: anyone holding it can post to the channel.