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.
Where a format can be used
Section titled “Where a format can be used”| 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"}}Rules shared by all operations
Section titled “Rules shared by all operations”| 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  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).
Entity model
Section titled “Entity model”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 ; 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.
Markdown dialect
Section titled “Markdown dialect”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 | |
 |
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 ** bstaysa ** 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``` aftergives textprint(1) afterand onepreentity with languagepy.
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\nafterbecomesbefore\nhidden\nmore\nafterwith a collapsedblockquoteoverhidden\nmore.
Markdown does not decode HTML entities, and <, & are ordinary characters.
Markdown errors
Section titled “Markdown errors”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 |
HTML dialect
Section titled “HTML dialect”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:
<,>,&,",', (U+00A0) and numericA/😀. 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<. Comments (<!-- -->), doctype and processing instructions are not supported.
HTML errors
Section titled “HTML errors”Message (invalid) |
Trigger |
|---|---|
a '<' that starts no tag (write <) |
< 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 |
Explicit entities
Section titled “Explicit 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.
