Telegram sendMessage in PHP: cURL, 3 Validation Levels, and HTML parse_mode Protection

Отправка сообщений via the sendMessage method is the most frequent operation when working with the Telegram Bot API. However, in practice, developers often limit themselves to calling file_get_contents without checking the HTTP status code or validating JSON. In production, this approach leads to silent crashes, hanging scripts, and broken markup due to unescaped characters.

In this guide, we will look at how to build reliable message delivery in pure PHP using cURL, implement three-level error handling, and eliminate parsing failures with parse_mode=HTML.

Why file_get_contents Hurts Production

Using file_get_contents("https://api.telegram.org/bot...") is a classic anti-pattern in PHP for the following reasons:

  • Lack of timeout control: By default, PHP will wait for a response until default_socket_timeout expires (usually 60 seconds). If Telegram or a backbone provider experiences network congestion, your PHP-FPM worker processes will quickly block the entire socket pool.
  • Difficulty handling HTTP errors: If Telegram returns a 400 Bad Request or 429 Too Many Requests status, file_get_contents will trigger a PHP Warning and return false, losing the response body where Telegram explains the error cause in detail (the parameters array with the retry_after field).
  • Lack of flexible SSL configuration: You cannot explicitly configure connection and TLS handshake timeouts.

The solution is to use the cURL extension with explicit settings for CURLOPT_CONNECTTIMEOUT and CURLOPT_TIMEOUT.

Three-Level Validation of Telegram API Response

A successful network request does not mean the message has been delivered. When processing Telegram's response, you need to go through three verification stages sequentially:

  1. Transport layer (cURL): Checking curl_errno(). DNS errors, connection timeouts (error code 28), issues with SSL session resets.
  2. HTTP protocol layer: Checking the response code via CURLINFO_HTTP_CODE. Telegram returns 200 OK on success, but sends 400 (invalid parameters), 401 (invalid token), 403 (bot blocked by user), 409 (webhook conflict), or 429 (rate limit exceeded) on errors.
  3. Application layer (JSON Payload): Validating the response structure via json_last_error() and checking the $response['ok'] === true field.

Below is a basic transport module that implements this algorithm:

<?php

declare(strict_types=1);

function executeTelegramApi(string $method, array $payload): array
{
$token = getenv('TELEGRAM_BOT_TOKEN');
if (!$token) {
throw new RuntimeException('TELEGRAM_BOT_TOKEN is not set in environment');
}

$url = "https://api.telegram.org/bot{$token}/{$method}";
$ch = curl_init();

curl_setopt_array($ch, [
CURLOPT_URL => $url,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($payload, JSON_UNESCAPED_UNICODE),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
CURLOPT_CONNECTTIMEOUT => 3,
CURLOPT_TIMEOUT => 10,
CURLOPT_SSL_VERIFYPEER => true,
]);

$rawResponse = curl_exec($ch);
$curlErrno = curl_errno($ch);
$curlError = curl_error($ch);
$httpCode = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);

// 1. Проверка cURL ошибки
if ($curlErrno !== 0) {
return [
'ok' => false,
'error_type' => 'transport',
'description' => "cURL error ({$curlErrno}): {$curlError}",
];
}

// 2. Декодирование JSON и проверка json_last_error
$responseData = json_decode((string)$rawResponse, true);
if (json_last_error() !== JSON_ERROR_NONE) {
return [
'ok' => false,
'error_type' => 'json',
'description' => 'JSON decode error: ' . json_last_error_msg(),
'raw_body' => $rawResponse,
];
}

// 3. Проверка флага ok от Telegram API и HTTP статуса
if ($httpCode !== 200 || empty($responseData['ok'])) {
return [
'ok' => false,
'error_type' => 'api',
'http_code' => $httpCode,
'error_code' => $responseData['error_code'] ?? $httpCode,
'description' => $responseData['description'] ?? 'Unknown Telegram Error',
'parameters' => $responseData['parameters'] ?? [],
];
}

return [
'ok' => true,
'result' => $responseData['result'],
];
}

Escaping Special Characters with parse_mode=HTML

When sending formatted messages with the option 'parse_mode' => 'HTML', Telegram expects a valid HTML subset of tags (<b>, <i>, <a>, <code>, <pre>). If dynamic data (username, product title, comment text) contains characters like <, >, &, or ", Telegram will return a 400 Bad Request: can't parse entities error, and the message will not be sent.

To protect the text, you must pass ALL external dynamic variables through htmlspecialchars() with a specific set of flags:

<?php

declare(strict_types=1);

/**
* Безопасное экранирование текста для Telegram HTML parse_mode
*/
function telegramEscape(string $text): string
{
return htmlspecialchars($text, ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML5, 'UTF-8');
}

/**
* Пример формирования сообщения с динамическими данными
*/
function buildOrderMessage(int $orderId, string $clientName, string $userComment): string
{
$safeName = telegramEscape($clientName);
$safeComment = telegramEscape($userComment);

return "<b>Новый заказ №{$orderId}</b>
" .
"Клиент: <i>{$safeName}</i>
" .
"Комментарий: <code>{$safeComment}</code>";
}

Integration: Sending a Message with Handling 429 Rate Limits

Let's bring it all together: we'll create a scenario for sending a service notification that accounts for proper handling of parse_mode, response status checking, and analysis of the retry_after delay when exceeding Telegram API rate limits.

<?php

declare(strict_types=1);

$chatId = (int)getenv('TELEGRAM_NOTIFY_CHAT_ID');
$rawUserInput = 'ООО "Вектор & Ко" <info@vector.test>'; // Данные с опасными символами

$messageText = buildOrderMessage(10854, $rawUserInput, 'Просьба перезвонить до 18:00');

$response = executeTelegramApi('sendMessage', [
'chat_id' => $chatId,
'text' => $messageText,
'parse_mode' => 'HTML',
'disable_web_page_preview' => true,
]);

if (!$response['ok']) {
// Обработка ошибок
if ($response['error_type'] === 'api') {
// Ошибка 429: Too Many Requests
if ($response['http_code'] === 429 && isset($response['parameters']['retry_after'])) {
$waitSeconds = (int)$response['parameters']['retry_after'];
error_log("Telegram Rate Limit Hit. Need to wait {$waitSeconds} seconds.");
// Здесь можно отправить задачу повторно в очередь (RabbitMQ / Redis / Yii Queue)
} else {
error_log(sprintf(
"Telegram API Error [%d]: %s",
$response['error_code'],
$response['description']
));
}
} else {
error_log("Network/JSON Error: " . $response['description']);
}
} else {
$sentMessageId = $response['result']['message_id'];
echo "Сообщение успешно отправлено! ID: {$sentMessageId}
";
}

Common Production Pitfalls

  • Forgotten Content-Type: If you pass json_encode without specifying the Content-Type: application/json header, cURL will send the data as multipart/form-data or application/x-www-form-urlencoded, causing unpredictable behavior from the Telegram parser.
  • Missing the ENT_QUOTES option: Standard htmlspecialchars($str) without flags does not escape single quotes, which with certain markup can break <a href="..."> links.
  • Ignoring error_code 403: If a user has blocked the bot, the API will return 403 Forbidden: bot was blocked by the user. You should catch this status and mark the user as inactive in your database so as not to waste your request quota.

If you need assistance designing fault-tolerant bot architecture or building complex Telegram integrations, contact the specialists at BotCreator.

New articles on Telegram

We explain what to automate in your business and how it works in practice. No spam.