Messages

HTML and Markdown

We're diving into HTML and MarkdownV2 in the Telegram Bot API: how to escape user input, format links, spoilers, code blocks and catch parsing errors. Examples in PHP with cURL.

Telegram Bot API supports two markup modes in the text method (and also in caption when sending media): HTML and MarkdownV2. The mode is set via parameter parse_mode. If it is not passed — the text passes through as-is; Telegram does not escape or parse anything.

Preparation: helper telegramApi()

We use the function telegramApi() that we created in chapter Первый запрос: getMe. It makes a POST request to api.telegram.org, checks the HTTP code, json_last_error() and field ok in the response. We only call it here — we do not copy the implementation.

<?php
// reminder: telegramApi() из главы first-request
// function telegramApi(string $token, string $method, array $params = []): array { ... }

$token = getenv('TELEGRAM_BOT_TOKEN') ?: exit('TELEGRAM_BOT_TOKEN not set');
$chatId = 123456789; // замените на реальный chat_id (см. главу chat-user-ids)

HTML mode: the most predictable for web developers

Supported tags: <b>, <i>, <u>, <s>, <strike>, <del>, <code>, <pre>, <a href="...">, <tg-spoiler>. A line break is a normal \n. Tag <br> does not work — it will appear literally in the text.

Rule #1: always escape user input via htmlspecialchars(). Otherwise the name <script>alert(1)</script> will break the markup or return an error 400 Bad Request: can't parse entities.

<?php
$safe = static fn(string $s): string =
htmlspecialchars($s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');

$name = "Ivan <script>alert('xss')</script>";
$comment = "Нормальный комментарий\nсо строкой";

$response = telegramApi($token, 'sendMessage', [
'chat_id' => $chatId,
'parse_mode' => 'HTML',
'text' => "<b>Новая заявка</b>\n"
. "Имя: {$safe($name)}\n"
. "Комментарий: {$safe($comment)}",
]);

// Как посмотреть ответ API в терминале:
var_dump($response);
// или для читаемости:
echo json_encode($response, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);

Example of a successful API response:

{
"ok": true,
"result": {
"message_id": 42,
"from": {
"id": 987654321,
"is_bot": true,
"first_name": "MyBot",
"username": "mybot"
},
"chat": {
"id": 123456789,
"first_name": "Ivan",
"type": "private"
},
"date": 1700000000,
"text": "<b>Новая заявка</b>\nИмя: Ivan <script>alert('xss')</script>\nКомментарий: Нормальный комментарий\nсо строкой"
}
}

The ok field indicates success. In result arrives an object Message with message_id (needed for editing/deletion, see chapter Редактирование и удаление) and escaped text as Telegram saw it.

Links and spoilers

We use the function telegramApi() from chapter Первый запрос: getMe.

<?php
$orderId = 123;
$details = "Секретная информация для клиента";

$response = telegramApi($token, 'sendMessage', [
'chat_id' => $chatId,
'parse_mode' => 'HTML',
'text' => "<a href=\"https://example.com/order/{$orderId}\">Заказ #{$orderId}</a>\n"
. "<tg-spoiler>{$safe($details)}</tg-spoiler>",
]);

if (!$response['ok']) {
// Ошибка парсинга — смотрим description
error_log('sendMessage failed: ' . ($response['description'] ?? 'unknown'));
}

A typical error of invalid HTML (forgot to escape < in user text):

{
"ok": false,
"error_code": 400,
"description": "Bad Request: can't parse entities: Character '<' is reserved and must be escaped with the corresponding HTML entity"
}

In description people often write the symbol number (byte offset) — this helps find the problem in long text.

MarkdownV2: more powerful, but capricious

MarkdownV2 supports quotes (>), lists (-, 1.), inline-code (`code`), code blocks (```php ... ```). But it requires: any special character _ * [ ] ( ) ~ ` > # + - = | { } . ! in the text must be escaped with a backslash \.

We use the function telegramApi() from chapter Первый запрос: getMe.

<?php
$escapeV2 = static fn(string $s): string =
addcslashes($s, '_*[]()~`>#+-=|{}.!');

$name = "O'Reilly & Co.";
$sum = "1_000.50";
$orderId = 456;

$response = telegramApi($token, 'sendMessage', [
'chat_id' => $chatId,
'parse_mode' => 'MarkdownV2',
'text' => "*Заказ \\#{$orderId}*\n"
. "Клиент: {$escapeV2($name)}\n"
. "Сумма: {$escapeV2($sum)} руб.",
]);

var_dump($response);

If you forget to escape _ in the total count 1_000.50 — Telegram will return 400: can't parse entities and indicate the position of the error. In MarkdownV2 even a period at the end of a sentence must be escaped if it is not part of the markup.

Code blocks with syntax highlighting

In HTML mode use <pre language="php">...</pre> — Telegram will highlight the syntax. Inside <pre> escaping is not required (the tag protects the content), but external data before inserting into <pre> should still be passed through htmlspecialchars in case of </pre> in the text.

We use the function telegramApi() from chapter Первый запрос: getMe.

<?php
$code = '<?php echo "Hello"; ?>';

$response = telegramApi($token, 'sendMessage', [
'chat_id' => $chatId,
'parse_mode' => 'HTML',
'text' => "Пример кода:\n<pre language=\"php\">{$safe($code)}</pre>",
]);

echo json_encode($response, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);

In MarkdownV2 code blocks are formatted with triple backticks specifying the language: ```php\ncode\n```. Escaping is not required inside.

What to choose: checklist

  • HTML — if there are user data in the message (names, comments, addresses). One function htmlspecialchars handles it, fewer surprises.
  • MarkdownV2 — if the text is entirely under your control (static templates, helpers, menus) and you need lists/quotes/monospace code inline.
  • No parse_mode — simple one-line notifications. \n still work as line breaks.

Typical errors and how to catch them

  • Forgot to escape — 400 Bad Request: can't parse entities. Look at description in the response; it often contains the byte offset (character number).
  • <br> instead of \n — the tag appears literally in the text. In HTML/Markdown only \n works.
  • Passed both parse_mode and entities — parameter conflict, returns 400.
  • Text longer than 4096 characters — need to split into parts or use sendDocument with a file (see chapter Фото, документы, media group).

For debugging always log the full response:

We use the function telegramApi() from chapter Первый запрос: getMe.

<?php
$response = telegramApi($token, 'sendMessage', [
'chat_id' => $chatId,
'text' => 'Test',
]);

if (!$response['ok']) {
// В description часто есть подсказка: "can't parse entities at byte offset 42"
error_log('Telegram API error: ' . json_encode($response, JSON_UNESCAPED_UNICODE));
// Можно прислать админу в отдельный чат
$adminChatId = 999999999;
telegramApi($token, 'sendMessage', [
'chat_id' => $adminChatId,
'text' => "⚠️ Ошибка отправки: {$response['description']}",
]);
}

Next: Фото, документы, media group.

"}