Skip to main content

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.

Sending webhooks?
Svix is the enterprise-ready webhook sending service. It handles signing, retries, and delivery observability, so you can ship a reliable webhook platform in minutes instead of months. Start sending webhooks with Svix.

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​

FieldWhat it does
contentMessage text, up to 2000 characters, rendered as Discord markdown
username, avatar_urlOverride the webhook’s name and icon for this message only
ttsReads the message aloud for users with text-to-speech enabled
embedsUp to 10 embed objects
allowed_mentionsControls which mentions in the text notify anyone
flagsBitfield; 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_tagsCreate a new post when the webhook belongs to a forum or media channel
componentsButtons and layout blocks; ignored on webhooks your app does not own unless you add ?with_components=true, and then only non-interactive ones
pollA 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​

LimitValue
content2000 characters
Embeds per message10
Embed title256 characters
Embed description4096 characters
Fields per embed25
Field name / value256 / 1024 characters
footer.text2048 characters
author.name256 characters
All embed text in one message6000 characters
IDs in allowed_mentions.users or allowed_mentions.roles100 each
Whole request, files included25 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:

CodeMeaningUsual cause
50006Cannot send an empty messageNo content, embeds, file, components, or poll, often a template that rendered to an empty string
50035Invalid form bodyA field broke a limit or type rule, or the Content-Type is wrong; the errors object in the response says which
50109Invalid JSONA body that does not parse, such as one with a trailing comma or an unescaped quote
40005Request entity too largeFiles or payload over the size limit
10015Unknown webhookThe 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.