Skip to main content

Discord Webhook Markdown: Complete Formatting Reference

Discord webhook markdown reference: headings, code blocks, spoilers, masked links, timestamps <t:unix:R>, mentions, allowed_mentions, emoji, escaping.

8 min read Also available in Русский
On this page

Everything a webhook sends goes through the same markdown renderer as a message typed in the chat box, with a couple of extras only bots and webhooks get (masked links in plain content, for one) and a few things that behave differently inside embeds. This is the full Discord webhook markdown reference: text styles, headings, lists, code, quotes, spoilers, links, timestamps, mentions, custom emoji, and how to escape all of it when the text comes from users or logs.

All examples are JSON bodies you POST to https://discord.com/api/webhooks/{id}/{token} with Content-Type: application/json. One rule for the whole article: a line break inside a JSON string is \n, not a literal Enter, and a double quote is \".

Text styles

MarkdownResult
**bold**bold
*italic* or _italic_italic
__underline__underlined text
~~strikethrough~~strikethrough
***bold italic***bold italic
__**underline bold**__underlined bold
`inline code`inline code
||spoiler||hidden until clicked

Styles nest as long as you close them in the reverse order you opened them. Markers must hug the text: * italic * with spaces inside renders as literal asterisks.

{
  "content": "**Deploy finished** — *v2.4.1* is live on __production__.\n~~Rollback planned~~ Not needed. Build: `a1f9c3e`"
}

Headings, lists and quotes

Headings use #, ## and ### at the start of a line, followed by a space. There is no fourth level; #### renders as plain text. Lists start a line with - or * , numbered lists with 1. ; indent two spaces to nest.

{
  "content": "## Release 2.4.1\n### Changes\n- Faster webhook delivery\n- New `/status` command\n  - Shows queue length\n### Fixes\n1. Crash on empty embed\n2. Wrong timezone in footer"
}

Quotes come in two forms. > at the start of a line quotes that one line. >>> quotes everything from that point to the end of the message, so put it last:

{
  "content": "Reported by QA:\n> Login button does nothing on Safari\n\nFull log:\n>>> 12:01 click\n12:02 no request sent\n12:03 console error"
}

Markdown tables are not part of Discord’s renderer. A | a | b | table arrives as literal pipes. Use embed fields with inline: true for columns, or a code block for fixed-width alignment.

Code

Inline code uses single backticks; blocks use three backticks and an optional language name for syntax highlighting:

{
  "content": "Request body:\n```json\n{ \"user\": 42, \"plan\": \"pro\" }\n```\nUse `diff` for red and green lines:\n```diff\n- retries: 3\n+ retries: 5\n```"
}

Inside a code block nothing else is parsed: no bold, no mentions, no emoji. That makes it the safest container for log output. The one thing you cannot put in a code block is three backticks in a row; if the log itself contains them, send it as a file instead.

||text|| hides the text until the reader clicks it. It wraps any inline content, including custom emoji and masked links. For attachments, prefix the filename with SPOILER_ when uploading.

Masked links, [label](https://example.com), are something regular users cannot type in chat, but webhooks can put them straight into content, and they work in embed descriptions and field values too:

{
  "content": "Build ||failed|| — see the [pipeline log](<https://ci.example.com/job/1842>) and the [runbook](<https://wiki.example.com/deploy>).\nNo preview: <https://ci.example.com/job/1842>"
}

A bare URL in content makes Discord fetch a link preview under the message, and so does the URL inside a masked link. Wrap the URL in angle brackets, <https://…> or [label](<https://…>), to suppress it, or send "flags": 4 (SUPPRESS_EMBEDS) to suppress every preview on that message.

Timestamps: <t:unix:STYLE>

The <t:UNIX:STYLE> tag is the only way to show a time that every reader sees in their own timezone and locale. UNIX is seconds since 1970-01-01 UTC (not milliseconds), STYLE is one letter:

StyleTagRenders as (en-US)
t<t:1789646400:t>12:00 PM
T<t:1789646400:T>12:00:00 PM
d<t:1789646400:d>09/17/2026
D<t:1789646400:D>September 17, 2026
f<t:1789646400:f>September 17, 2026 12:00 PM
F<t:1789646400:F>Thursday, September 17, 2026 12:00 PM
R<t:1789646400:R>in 3 hours / 2 days ago

Omit the style and you get f. R updates live, which is what you want for “starts in”, “expires in” and “last seen” lines. Get the number from the shell with date +%s, or in code:

const startsAt = Math.floor(Date.now() / 1000) + 3600; // one hour from now

await fetch(process.env.WEBHOOK_URL, {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    content: `Maintenance window starts <t:${startsAt}:R> (<t:${startsAt}:F>)`,
    allowed_mentions: { parse: [] },
  }),
});
import os
import time
import requests

starts_at = int(time.time()) + 3600
requests.post(os.environ["WEBHOOK_URL"], json={
    "content": f"Maintenance window starts <t:{starts_at}:R> (<t:{starts_at}:F>)"
})

Don’t confuse this with the embed-level timestamp field: that one is an ISO 8601 string ("2026-09-17T12:00:00Z"), is rendered in the footer, and doesn’t accept <t:…> syntax. The tag itself works inside embed descriptions and field values.

Mentions and allowed_mentions

Mentions are IDs in angle brackets: <@userId> for a user, <@&roleId> for a role, <#channelId> for a channel, plus the literal @everyone and @here. Get IDs by enabling Developer Mode in Discord’s user settings and choosing Copy ID from the context menu.

Rendering and pinging are separate things. The blue mention chip appears from the markdown alone; whether anyone gets notified is decided by allowed_mentions. Always send it explicitly:

{
  "content": "<@&123456789012345678> deploy finished, <@987654321098765432> please verify. @everyone stays quiet.",
  "allowed_mentions": {
    "parse": [],
    "roles": ["123456789012345678"],
    "users": ["987654321098765432"]
  }
}

"parse": [] turns off automatic parsing of @everyone, @here, all users and all roles; the roles and users arrays then whitelist exactly who to ping. For text that comes from outside (commit messages, form input), {"parse": []} alone is the safest default. Mentions inside embeds render but never ping anyone. The full set of rules is in the webhook mentions guide.

Custom emoji

Standard Unicode emoji go into the string as-is ("content": "Deployed 🚀"). Custom server emoji need the ID form: <:name:id> for static, <a:name:id> for animated. To find it, type the emoji in Discord with a backslash before it (\:deploy:) and send; the message shows the raw tag.

{
  "content": "<:deploy:123456789012345678> Production is live <a:party:234567890123456789>"
}

Use emoji from the server the webhook belongs to. For emoji from other servers, check the Discord developer docs before relying on them.

Content vs embeds: what changes

The same markdown is parsed differently depending on where it sits:

FeaturecontentEmbed description, field valuesEmbed title, author, footer
Bold, italic, code, quotes, spoilersYesYesNo (plain text)
Headings and listsYesYesNo
Masked linksYesYesNo
Timestamps <t:…>YesYesNo
MentionsRender and pingRender, no pingNo
Custom emojiYesYesNo
Link previews for bare URLsYesNoNo
Length limit20004096 / 1024256 / 256 / 2048

Two practical consequences. If you need a heading-style line in an embed field, use the field’s name, which is bold already. And if the point of the message is a ping, put the mention in content and the details in the embed, not the other way round. Colour, images and the rest of the embed layout are covered in the embed builder guide and embed colors.

Escaping

A backslash before a markdown character makes it literal: \*not bold\*, \|\|not a spoiler\|\|, \# not a heading (at line start), \> not a quote.

In JSON the backslash itself must be escaped, so the string becomes "\\*not bold\\*". That is the part people miss: a single \* inside a JSON string is invalid JSON, and Discord answers 400 Bad Request.

When the text comes from somewhere you don’t control (commit messages, ticket titles, user input), escape it before interpolating:

function escapeMarkdown(text) {
  return text.replace(/([\\*_~`|])/g, '\\$1').replace(/^([>#-])/gm, '\\$1');
}

const commit = 'fix: *very* important __thing__ #123';
const content = `New commit: ${escapeMarkdown(commit)}`;
// New commit: fix: \*very\* important \_\_thing\_\_ #123
import re

def escape_markdown(text: str) -> str:
    text = re.sub(r"([\\*_~`|])", r"\\\1", text)
    return re.sub(r"^([>#-])", r"\\\1", text, flags=re.M)

Two things escaping doesn’t cover. Mentions aren’t neutralised by a backslash; use allowed_mentions: {"parse": []} for that. And URLs inside the text will still unfurl unless you wrap them in <…>.

Common errors

  • Newlines don’t appear. You sent a literal line break inside a JSON string, or forgot \n. Every line break in content and embed text must be \n.
  • Markdown shows as asterisks. Spaces between the marker and the text (* text *), or the text is in an embed title, footer or author, where markdown isn’t rendered.
  • <@123456789012345678> appears literally. The ID is wrong, or it’s a username instead of an ID. Copy the ID with Developer Mode.
  • Mention renders but nobody is pinged. allowed_mentions doesn’t include that user or role, or the mention is inside an embed.
  • Timestamp renders a nonsense date. You passed milliseconds. Divide Date.now() by 1000 and round down.
  • >>> swallowed the rest of the message. That’s what it does. Use > per line if you need text after the quote.
  • 400 Bad Request with a JSON error. Usually content over 2000 characters, an embed description over 4096, or an unescaped backslash or quote in the JSON. The response body names the field; see webhook errors for the full list.
  • A | table | came out as pipes. Discord has no table syntax; use inline fields or a code block.

FAQ

How do I make a Discord timestamp that shows in everyone’s timezone?

Use <t:UNIX:STYLE> where UNIX is seconds since the epoch and STYLE is one of t, T, d, D, f, F or R. <t:1789646400:R> renders as “in 3 hours” and updates live; F gives the full weekday, date and time.

Yes. Webhooks can put [label](https://url) directly in content, and the same syntax works in embed descriptions and field values. Both bare URLs and masked links generate link previews; wrap the URL in <> to suppress that.

Why does my mention render but not notify anyone?

Because pinging is controlled by allowed_mentions, not by the markdown. Add the user or role ID to allowed_mentions.users or allowed_mentions.roles, and keep the mention in content, since mentions inside embeds never ping.

Can I use markdown tables in Discord?

No. Discord’s renderer has no table syntax. Use embed fields with inline: true for a grid, or a code block for monospaced columns.

Next steps

Paste any snippet into the Discord Webhook builder to see how it renders in content and embeds, then export the payload as JSON, discord.js or discord.py code. Next: the webhook mentions guide for every allowed_mentions case, the embed builder guide for embed layout, and embed colors for the sidebar colour values.

Tags: discord webhook markdowndiscord timestamp formatdiscord webhook formattingmarkdownallowed_mentionsembedsdiscord api

Related articles

All articles

Build it in the visual editor

Embeds, Components V2, buttons and polls with a live Discord preview. Free, no signup.