tg-rich-messages
Sending requires network access to api.telegram.org and a Telegram bot token.
Overview
Bot API 10.1 introduced Rich Messages. Bot API 10.2 (July 14, 2026) added direct
outgoing block JSON and explicit media bindings. Rich messages are document-grade content
delivered directly in chat. Think Instant View articles — headings, tables, collage,
slideshow, map, math, collapsible blocks — but sent by a bot via sendRichMessage.
Use sendRichMessage instead of sendMessage when you need:
| Need | Mechanism |
|---|---|
| Section headings (h1–h6) | RichBlockSectionHeading |
| Tables with header rows / spanning cells | RichBlockTable |
| Collapsible sections | RichBlockDetails |
| Photo grid or slide deck | RichBlockCollage / RichBlockSlideshow |
| Embedded map | RichBlockMap |
| LaTeX math (inline or block) | RichTextMathematicalExpression / RichBlockMathematicalExpression |
| Streaming AI reply with "Thinking…" block | sendRichMessageDraft + RichBlockThinking |
| Footnotes / anchor links | RichTextReference / RichTextAnchorLink |
Plain sendMessage with parse_mode: HTML still covers bold/italic/code/spoiler/links
for simple formatting. Move to sendRichMessage only when you need structural blocks.
Sending: InputRichMessage
sendRichMessage takes an InputRichMessage object in its rich_message field.
Choose exactly one content field:
| Field | When to use |
|---|---|
html |
Rich HTML with Telegram-specific tags |
markdown |
GitHub-Flavored Markdown + Telegram extensions |
blocks |
Direct InputRichBlock[] JSON for deterministic programmatic composition |
Do not pass multiple content fields. With html or markdown, add optional media bindings
for uploaded files, Telegram file_id values, or HTTP URLs.
Optional fields on InputRichMessage:
| Field | Type | Purpose |
|---|---|---|
is_rtl |
Boolean | Render message right-to-left |
skip_entity_detection |
Boolean | Disable auto-detection of URLs, emails, mentions, etc. |
media |
InputRichMessageMedia[] | Resolve tg://photo, tg://video, and tg://audio references in markup |
Media bindings for HTML and Markdown
Reference media in markup:



Then bind each ID:
{
"markdown": "## Launch\n\n",
"media": [
{
"id": "cover",
"media": {"type": "photo", "media": "AgAC...file_id"}
}
]
}
Media IDs are 1–64 characters using only letters, digits, _, and -. The nested
InputMedia*.media accepts a Telegram file_id, an HTTP/HTTPS URL, or
attach://<part_name>. Use JSON for file_id and URLs. Use multipart/form-data for
attach:// uploads and provide a file part with the same name.
InputRichMessageMedia.media may be InputMediaAnimation, InputMediaAudio,
InputMediaPhoto, InputMediaVideo, or InputMediaVoiceNote. Markup exposes three
reference schemes: photo, video, and audio; animation travels through video, and
voice note through audio.
Captions and player metadata
The caption comes from the markup, not from the binding — the title argument of the
Markdown image syntax (or <figcaption> in HTML):

InputMedia*.caption inside the binding is ignored. What the binding does carry is
player metadata, and for audio it is what turns a raw file into a labelled track in the
message:
{
"id": "live_answer",
"media": {
"type": "audio",
"media": "attach://live_answer_file",
"duration": 4,
"performer": "Grok Voice 2.0",
"title": "Answer: 323"
}
}
| Type | Metadata worth setting |
|---|---|
audio |
duration, performer, title — shown in the inline player |
video |
duration, width, height, thumbnail, supports_streaming, has_spoiler |
photo |
has_spoiler |
Set duration even when Telegram can infer it: several short clips in one message render
as a consistent list only when every one of them reports its length.
Preflight: validate bindings before sending
Telegram reports a bad binding as one opaque Bad Request. Validate locally first and the
failure names the offending id. Four checks, all cheap:
- Every
tg://…?id=Xin markup has a binding. Missing → the media silently never renders or the call fails. - Every binding is referenced by the markup. An unused binding means an editing slip — a renamed id, a dropped paragraph. Telegram's behaviour here is unspecified; treat it as a build error of your own.
- The reference scheme matches the bound type.
tg://audio?id=Xacceptsaudioorvoice_note;tg://video?id=Xacceptsvideooranimation;tg://photo?id=Xacceptsphoto. A mismatch is a definite error. - Every
attach://<name>has a matching file part, and the file exists on disk.
import re
REF = re.compile(r"tg://(photo|video|audio)\?id=([A-Za-z0-9_-]+)")
ACCEPTS = {"photo": {"photo"}, "video": {"video", "animation"}, "audio": {"audio", "voice_note"}}
def check(markup: str, media: list[dict]) -> None:
bound = {item["id"]: item["media"]["type"] for item in media}
refs = set(REF.findall(markup))
referenced = {media_id for _, media_id in refs}
if missing := referenced - bound.keys():
raise ValueError(f"missing bindings: {sorted(missing)}")
if unused := bound.keys() - referenced:
raise ValueError(f"unused bindings: {sorted(unused)}")
for scheme, media_id in refs:
if bound[media_id] not in ACCEPTS[scheme]:
raise ValueError(f"{media_id}: tg://{scheme} cannot carry {bound[media_id]}")
Keep the file paths in a small manifest next to the payload — attachment name, path, MIME type — so the multipart step is a lookup rather than a second source of truth:
[
{"id": "cover", "attachment_name": "cover_file", "path": "cover.png", "mime_type": "image/png"}
]
Direct block JSON
Use blocks when code already has a structured document tree:
{
"blocks": [
{"type": "heading", "size": 2, "text": "Daily AI Brief"},
{"type": "paragraph", "text": ["One release matters today: ", {"type": "bold", "text": "Opus 5"}]},
{
"type": "photo",
"photo": {"type": "photo", "media": "AgAC...file_id"},
"caption": {"text": "Official launch image"}
}
]
}
Nested blocks use InputRichBlock. Media blocks embed InputMedia* directly. Put captions
in the outer block; captions inside nested InputMedia* are ignored.
Sending: HTTP calls
curl (HTML mode)
curl -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendRichMessage" \
-H "Content-Type: application/json" \
-d '{
"chat_id": '"$CHAT_ID"',
"rich_message": {
"html": "<h2>Weekly Report</h2><p>Highlights from the past week.</p><table bordered><tr><th>Item</th><th>Value</th></tr><tr><td>Sales</td><td><b>142</b></td></tr></table>"
}
}'
Python (stdlib, HTML mode)
import json
import os
import urllib.request
TOKEN = os.environ["TELEGRAM_BOT_TOKEN"]
CHAT_ID = int(os.environ["CHAT_ID"])
payload = {
"chat_id": CHAT_ID,
"rich_message": {
"html": (
"<h2>Weekly Report</h2>"
"<p>Highlights from the past week.</p>"
"<table bordered>"
"<tr><th>Item</th><th>Value</th></tr>"
"<tr><td>Sales</td><td><b>142</b></td></tr>"
"</table>"
)
},
}
req = urllib.request.Request(
f"https://api.telegram.org/bot{TOKEN}/sendRichMessage",
data=json.dumps(payload).encode(),
headers={"Content-Type": "application/json"},
method="POST",
)
with urllib.request.urlopen(req) as resp:
result = json.load(resp)
Python (stdlib, Markdown mode)
payload = {
"chat_id": CHAT_ID,
"rich_message": {
"markdown": (
"## Weekly Report\n\n"
"Highlights from the past week.\n\n"
"| Item | Value |\n"
"|------|-------|\n"
"| Sales | **142** |\n"
)
},
}
See examples.md for more complete examples including streaming drafts,
collages, details blocks, and a full multipart upload.
Delivery outcomes: what a failed call actually means
A rich message with uploads is a large multipart request, so the interesting failures are not validation errors — they are the ones where you do not know whether the message went out. Split the response three ways:
| Response | Meaning | Correct reaction |
|---|---|---|
ok: true |
Delivered | Store message_id — it is the handle for later editMessageText |
HTTP 4xx with ok: false |
Definite failure, nothing was posted | Fix the payload and resend |
| HTTP 429 or 5xx, or a transport timeout | Unknown — the message may already be in the channel | Do not blind-retry |
The third row is the one that bites. Retrying a timed-out sendRichMessage is how a channel
gets the same article twice, and there is no dedup on Telegram's side. Mark the send as
unresolved instead, and require a human (or a lookup of the channel's last message) to
decide before anything is sent again. If you retry automatically anywhere, retry only calls
that failed with a definite 4xx.
Block types — quick reference
Structural blocks
| Type string | HTML tag | Purpose | Key fields |
|---|---|---|---|
paragraph |
<p> |
Text paragraph | text: RichText |
heading |
<h1>–<h6> |
Section heading | text, size: 1–6 (1 = largest) |
pre |
<pre> |
Preformatted / code block | text, language?: string |
footer |
<footer> |
Footer text | text: RichText |
divider |
<hr/> |
Horizontal rule | (no content fields) |
anchor |
<a name="..."> |
Named anchor point | name: string |
mathematical_expression |
<tg-math-block> |
Block LaTeX formula | expression: string |
list |
<ul> / <ol> |
Ordered or unordered list | items: RichBlockListItem[] |
blockquote |
<blockquote> |
Block quotation | blocks: RichBlock[], credit?: RichText |
pullquote |
<aside> |
Centered pull quote | text, credit?: RichText |
details |
<details> |
Collapsible section | summary: RichText, blocks: RichBlock[], is_open?: true |
table |
<table> |
Data table | cells: RichBlockTableCell[][], is_bordered?, is_striped?, caption?: RichText |
map |
<tg-map> |
Static embedded map | location: Location, zoom: 13–20, width, height, caption? |
collage |
<tg-collage> |
Photo/video grid | blocks: RichBlock[], caption?: RichBlockCaption |
slideshow |
<tg-slideshow> |
Swipeable photo/video deck | blocks: RichBlock[], caption?: RichBlockCaption |
photo |
<img> |
Photo block | photo: PhotoSize[], has_spoiler?: true, caption? |
video |
<video> |
Video block | video: Video, has_spoiler?: true, caption? |
animation |
<video> (gif) |
Animation / GIF block | animation: Animation, has_spoiler?: true, caption? |
audio |
<audio> (mp3) |
Audio / music block | audio: Audio, caption? |
voice_note |
<audio> (ogg) |
Voice message block | voice_note: Voice, caption? |
thinking |
<tg-thinking> |
"Thinking…" placeholder (draft only) | text: RichText |
thinkingis only valid insendRichMessageDraft. It cannot appear insendRichMessageand is never stored in a Message.
Inline text types (RichText)
| Type string | HTML tag | Purpose | Extra fields |
|---|---|---|---|
bold |
<b> |
Bold | text |
italic |
<i> |
Italic | text |
underline |
<u> |
Underline | text |
strikethrough |
<s> |
Strikethrough | text |
spoiler |
<tg-spoiler> |
Hidden spoiler text | text |
marked |
<mark> |
Highlight | text |
code |
<code> |
Inline monospace | text |
subscript |
<sub> |
Subscript | text |
superscript |
<sup> |
Superscript | text |
custom_emoji |
<tg-emoji> |
Custom emoji | custom_emoji_id, alternative_text |
mathematical_expression |
<tg-math> |
Inline LaTeX | expression: string |
date_time |
<tg-time> |
Formatted timestamp | text, unix_time: int, date_time_format: string |
text_mention |
<a href="tg://user?id=..."> |
User mention by ID | text, user: User |
mention |
auto-detected | Username mention | text, username: string |
url |
<a href="https://..."> |
URL link | text, url: string |
email_address |
<a href="mailto:..."> |
Email link | text, email_address: string |
phone_number |
<a href="tel:..."> |
Phone link | text, phone_number: string |
bank_card_number |
auto-detected | Bank card highlight | text, bank_card_number: string |
hashtag |
auto-detected | Hashtag | text, hashtag: string |
cashtag |
auto-detected | Cashtag | text, cashtag: string |
bot_command |
auto-detected | Bot command | text, bot_command: string |
anchor |
<a name="..."> (inline) |
Inline anchor point | name: string |
anchor_link |
<a href="#..."> |
Link to anchor | text, anchor_name: string (empty = top) |
reference |
<tg-reference name="..."> |
Footnote definition | text, name: string |
reference_link |
<a href="#..."> (ref) |
Link to footnote | text, reference_name: string |
Markdown quick syntax
**bold** *italic* __bold__ _italic_
~~strikethrough~~ `code` ==marked== ||spoiler||
[Link text](https://example.com)
[Email](mailto:user@example.com)
[Phone](tel:+1234567890)
[Mention user](tg://user?id=123456789)


$inline LaTeX formula$
$$block LaTeX formula$$
# H1 ## H2 ... ###### H6
```python
code block with language
--- (divider)
- unordered item 1. ordered item
- task - [x] done task
blockquote line
# photo block # with caption # video block # audio block # voice note # animation
| Col 1 | Col 2 |
|---|---|
| left | right |
Text[^fn1]. [^fn1]: Footnote text.
Limits & traps
| Limit | Value |
|---|---|
| Max UTF-8 characters (text, alt-text, formulas) | 32 768 |
| Max blocks (including nested: list items, table rows, blockquote/details blocks) | 500 |
| Max nesting levels | 16 |
| Max media attachments (photos, videos, audio) | 50 |
| Max table columns | 20 |
| Map zoom range | 13–20 |
Critical constraints
Choose exactly one content representation. Use html, markdown, or blocks.
Markup media has two source paths.
- Media must be specified as separate blocks, not inline in text.
- Direct HTTP/HTTPS media URLs work without
media. tg://photo?id=,tg://video?id=, andtg://audio?id=require a matching entry inmedia.- Each bound
InputMedia*.mediaacceptsfile_id, HTTP/HTTPS URL, or multipartattach://. - Direct URL media type is inferred from MIME type and URL extension. Bound media type comes
from the nested
InputMedia*object.
Table cells: inline formatting only.
No blocks (<p>, <ul>, <img>, etc.) inside <td> / <th>. Only inline tags.
RichBlockThinking is draft-only.
Can only appear in sendRichMessageDraft. Never in sendRichMessage. Never stored in a
Message object.
sendRichMessageDraft is private-chat only.
chat_id must be an Integer. @username strings are not accepted.
editMessageText: text and rich_message are mutually exclusive.
Pass exactly one. Sending both (or neither) is an error.
Map is static, no markers.
RichBlockMap renders a static tile at the given location and zoom level. No pins,
no interactivity.
Block count includes nesting.
The 500-block limit counts every RichBlockListItem, every table row, every block inside
blockquote / details. Deep nesting exhausts the budget fast.
Collage and slideshow accept both photos and videos.
blocks can mix photo and video blocks. Both may also be placed inside a details
block as collapsible media.
Common mistakes
| Mistake | Fix |
|---|---|
Passing an array directly as rich_message |
Wrap it as {"blocks":[...]} |
Using tg://...id=cover without media |
Add an InputRichMessageMedia with id: "cover" |
| Leaving a bound id that markup no longer references | Drop the binding — an unused one is an editing slip, not a no-op |
Binding voice_note behind tg://video?id= |
Match the scheme: photo→photo, video→video/animation, audio→audio/voice_note |
Sending attach://cover in JSON only |
Switch the request to multipart/form-data and add the cover file part |
| Nesting the multipart form fields as JSON objects | Every non-file field is a string: json.dumps(rich_message) in one form field |
Putting the caption in InputMedia*.caption of a binding |
Captions come from markup — the image title argument or <figcaption> |
Shipping audio bindings without duration/title |
The inline player shows an unlabelled blob; set the metadata on the binding |
Retrying a sendRichMessage that timed out |
Outcome is unknown — retrying duplicates the post; resolve it before resending |
Putting an InputMedia* caption inside a direct media block |
Put the caption on InputRichBlockPhoto / Video / Audio |
Putting <img> or <video> inline in <p> |
Move media to its own block, outside <p> |
Adding block elements inside <td> |
Only inline tags inside table cells |
Using RichBlockThinking in sendRichMessage |
thinking is valid only in sendRichMessageDraft |
Calling sendRichMessageDraft with @username |
Use numeric chat_id (Integer) |
Sending text and rich_message together in editMessageText |
Pass exactly one |
Forgetting to call sendRichMessage after streaming |
Draft expires after 30 s — always finalize with full message |
| Assuming zoom 1–20 for map | Valid range is 13–20 |
Using skip_entity_detection for manual link formatting |
Set it to true to prevent double-detection when you already mark up URLs manually |
Full spec
All types, all fields, all format details: ../../reference/rich-messages-spec.md