Skip to content

Text formatting

Telegram formats text with entities: ranges of the text that carry a type. The host never passes markup to the app. It converts the markup in the text argument into plain text plus entities (ParseMarkup), so the app only ever sees those. This page lists the exact syntax the two dialects accept, how it maps to entities and when it fails.

Operation Arguments
messages.send text, format, entities, custom_emoji
messages.edit text, format, entities
messages.send_media, messages.edit_media caption, format
messages.set_outgoing text, format, entities (hook message.outgoing)
messages.set_incoming text, format, entities (hook message.incoming)
translate.reply items, format (every item is parsed separately)
ai.reply text, format

format is a text: "plain" (the default, the text is used as it is), "markdown" or "html"; any other value is invalid. The Python wrappers take markdown=True or "markdown" for the markdown dialect and markdown="html" for html; on_translate(markdown=...) and on_ai(markdown=...) do the same for the answers.

freshgram.messages.send(chat, "**Hello** [site](https://example.org)", markdown=True)
freshgram.messages.send(chat, "<b>Hello</b> <a href='https://example.org'>site</a>", markdown="html")
# raw: {"op": "messages.send", "a": {"chat": 1, "text": "<b>Hello</b>", "format": "html"}}
Rule Error
With format other than plain, the entities argument must not be given invalid “entities cannot be combined with a format” (for messages.set_outgoing and set_incoming: invalid “entities”)
With a format, custom_emoji (the offset list) must not be given; use ![alt](tg://emoji?id=N) or <emoji id="N"> invalid “custom_emoji (with a format use …)”
The text without its markup must not be empty invalid “the text is empty without its markup”
The source text is at most 4096 bytes (caption 4096 bytes). ai.reply: text up to 64 KiB. translate.reply: each item up to 32 KiB, at most 20 items and 96 KiB in total invalid when one text is too long; limit “the answer is too large” for more than 20 items or 96 KiB in total
The result has at most 256 entities invalid “the text has too many formatted parts”
Any syntax error invalid with the message listed below

For plain, the text is untouched and entities (if any) are passed as given after validation (see explicit entities).

Offsets and lengths are counted in UTF-16 code units (as in Telegram): an emoji outside the BMP counts as 2, a Cyrillic letter as 1. The parser returns entities sorted by offset ascending, and for equal offsets by length descending (outer before inner). A range that would be empty (for example ****) produces no entity.

Entity type Extra fields Created by
bold markdown **x**; html <b>, <strong>
italic markdown __x__; html <i>, <em>
underline html <u>, <ins> only
strike markdown ~~x~~; html <s>, <strike>, <del>
spoiler markdown ||x||; html <tg-spoiler>, <spoiler>, <span class="tg-spoiler">
code markdown `x`; html <code>
pre language (may be empty) markdown fenced block; html <pre>
blockquote collapsed (bool, expandable) markdown > and **> lines; html <blockquote>
text_url url markdown [x](url); html <a href>
mention_name id (user id) markdown [x](tg://user?id=N); html <a href="tg://user?id=N">
custom_emoji id (emoji id) markdown ![x](tg://emoji?id=N); html <emoji id="N">, <tg-emoji emoji-id="N">

Allowed URLs are http://, https:// and tg:// (at least one character after the scheme, at most 2048 bytes). tg://user?id=N with 1 to 18 digits is read as a user mention instead.

Processing is a single pass over the text. These constructs are recognized:

Syntax Result Notes
**text** bold
__text__ italic __ is italic here, not underline. There is no underline in markdown
~~text~~ strike
||text|| spoiler
`text` code up to the next backtick; contents are literal (no markup inside). An empty pair or a lone backtick stays literal
```lang\ncode\n``` pre see below
[text](url) text_url text may contain markup (nesting depth up to 16)
[text](tg://user?id=N) mention_name
![alt](tg://emoji?id=N) custom_emoji over alt alt must be non-empty and is inserted as is; N must be a non-zero number
> line blockquote see below
**> line blockquote with collapsed
\x the character x only when x is ASCII punctuation (!"#$%&'()*+,-./:;<=>?@[\]^_` and the like); otherwise the backslash is literal

Pairing rules for **, __, ~~, ||:

  • A marker closes the innermost open marker of the same kind. Otherwise it opens a new one only if the same marker occurs again later in the same range; if not, it is plain text (a ** b stays a ** b).
  • Different kinds may nest (**a __b__ c**). The same kind cannot nest inside itself.
  • Markers must close in order. **a __b** c__ is an error: “** closes after formatting that was opened inside it” (the message names the marker). A marker that found a later partner but was never closed (for example the partner was escaped) ends with “unclosed ** (escape it with a backslash)”.
  • Unmatched or malformed constructs are not errors: an invalid link ([x](javascript:alert), [y](nothing), no closing bracket or parenthesis) or an emoji with a bad id stays as literal text.

Fenced code:

  • Opens with three backticks and closes with the next three backticks. With no closing fence the three backticks are literal.
  • The first line after the opening fence is the language if it is empty or consists of up to 32 characters from A-Z a-z 0-9 + - # .; it is then removed from the text. If the first line is anything else, it is kept as code. A single trailing newline before the closing fence is removed. Contents are literal.
  • Example: ```py\nprint(1)\n``` after gives text print(1) after and one pre entity with language py.

Quotes:

  • A line that starts with > (optionally followed by one space) is a quote line; consecutive quote lines form one blockquote. A line that starts with **> begins an expandable (collapsed) quote that continues over the following > lines; another **> line starts a new quote.
  • The marker must be the very first characters of the line. Lines inside a fenced block are never quotes.
  • Each quote and each run of non-quote lines is parsed on its own, so inline markers cannot be opened in one and closed in another. Newlines between lines are kept in the text and are outside the quote entity.
  • before\n**> hidden\n> more\nafter becomes before\nhidden\nmore\nafter with a collapsed blockquote over hidden\nmore.

Markdown does not decode HTML entities, and <, & are ordinary characters.

Message (invalid) Trigger
<marker> closes after formatting that was opened inside it crossing markers such as **a __b** c__
unclosed <marker> (escape it with a backslash) a marker was opened but never closed
the markup is nested too deep link text nested over 16 levels
the text has too many formatted parts over 256 entities

Only the tags below are accepted. Tag and attribute names are case-insensitive. Text between tags is kept as it is, including newlines and spaces (HTML whitespace is not collapsed); use <br> or a newline for a line break.

Tag Entity Notes
<b>, <strong> bold
<i>, <em> italic
<u>, <ins> underline
<s>, <strike>, <del> strike
<code> code inside <pre> as its first child it only sets the language (below)
<pre> pre
<pre><code class="language-xxx">...</code></pre> pre with language xxx <code> must start right where <pre> content starts. A bad language (empty, over 32 characters, or characters outside A-Z a-z 0-9 + - # .): “a bad code language”
<tg-spoiler>, <spoiler>, <span class="tg-spoiler"> spoiler other <span> forms are errors
<blockquote> blockquote the attribute expandable (no value needed) makes it collapsed
<a href="url"> text_url href must be http://, https:// or tg:// (up to 2048 bytes)
<a href="tg://user?id=N"> mention_name
<emoji id="N">x</emoji>, <tg-emoji emoji-id="N">x</tg-emoji> custom_emoji N is a non-zero number of 1 to 18 digits; the tag must contain the emoji text
<br>, <br/> none becomes \n; </br> is ignored

Other rules:

  • Tags must nest properly (<b><i>x</i></b>) and be closed. At most 32 tags may be open at once. A self-closing form of an entity tag (<b/>) produces nothing.
  • Attributes: names of letters, digits, -, _; values quoted with " or ', or unquoted up to whitespace or >; a value may be omitted. The first occurrence of a repeated attribute wins.
  • Character references decode in text and attribute values: &lt;, &gt;, &amp;, &quot;, &apos;, &nbsp; (U+00A0) and numeric &#65; / &#x1F600;. A reference that is unknown, too long (more than 10 characters before ;), zero, above U+10FFFF or a surrogate stays as the literal &. A lone & (AT&T) is fine.
  • A literal < must be written &lt;. Comments (<!-- -->), doctype and processing instructions are not supported.
Message (invalid) Trigger
a '<' that starts no tag (write &lt;) < not followed by a tag name, for example 1 < 2
the tag <x is not finished the text ends inside a tag
bad attributes in <x> a character that cannot start an attribute
an unclosed quote in <x> an attribute quote without its pair
the tags are nested too deep more than 32 open tags
the tag <x> is not supported any tag outside the table (<font>, <div>, …)
<span> is only supported as <span class="tg-spoiler"> other spans
<a> needs href <a> without href
<a> has an unsafe or too long href scheme other than http, https, tg, or more than 2048 bytes
<emoji> needs a numeric emoji id missing, non-numeric or zero id (the message names the tag)
an emoji tag needs the emoji inside an emoji tag with no text
a bad code language see the table above
</x> does not match <y> / </x> does not match an open tag wrong or extra closing tag
<x> is not closed a tag left open at the end
the text has too many formatted parts over 256 entities

With format plain you may pass entities yourself (never together with a markup format). Each entry is validated; the whole list is invalid if any entry is bad.

Key Type Rule
type text one of bold, italic, underline, strike, code, pre, spoiler, blockquote, text_url, mention_name, custom_emoji, or the detected types url, mention, hashtag, cashtag, bot_command, email, phone
offset integer 0 or more, in UTF-16 units
length integer more than 0, in UTF-16 units
url text http://, https:// or tg:// plus at least one character, up to 2048 bytes. Required for text_url
language text up to 64 bytes (for pre)
id integer more than 0. Required for custom_emoji and mention_name
collapsed bool only for blockquote

At most 256 entities. When the message is sent, entities that Telegram finds itself (url, mention, hashtag, cashtag, bot_command, email, phone) are skipped, entities that reach past the end of the text are skipped, and a mention_name for a user the app has not loaded is skipped. Messages read from the app carry the same entity shapes in their entities field.

For messages.send with plain text, custom_emoji is an alternative list of {"offset", "length", "id"} entries (up to 100; offset 0 or more, length and id above 0, offset and length at most 4096), valid only with format plain.