Execute Telegram sendMessage Requests in Raw PHP: Timeouts, JSON Parsing, and HTML Sanitization

Making standard HTTP requests to the Telegram Bot API using raw PHP requires more than a simple file_get_contents() call. Production environments demand explicit timeout controls, network error verification, valid JSON parsing, handling of Telegram's API-level error payloads, and strict output escaping for formatted text.

In this tutorial, we will build a production-ready PHP function to execute sendMessage requests over cURL. We will cover network timeouts, HTTP status validation, JSON decoding safety, Telegram-level error handling, and escaping dynamic strings when sending HTML-formatted messages. We will not cover setting up webhook handlers, processing inbound updates, or building background queue processors.

Sanitizing Dynamic Inputs for Telegram HTML

When using parse_mode=HTML, Telegram supports a subset of HTML tags (<b>, <i>, <a>, <code>, <pre>). If dynamic data inserted into your template contains characters such as <, >, or &, the Telegram API will fail to parse the entity tree and return a 400 Bad Request error with the description Bad Request: can't parse entities.

Use htmlspecialchars() with ENT_QUOTES | ENT_SUBSTITUTE to encode raw user inputs before interpolating them into formatted message strings:

function sanitizeTelegramHtml(string $text): string
{
return htmlspecialchars($text, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}

// Usage example:
$userInput = 'User <script>alert(1)</script> & "special"';
$safeUser = sanitizeTelegramHtml($userInput);

$formattedMessage = "New lead received:\n<b>Name:</b> {$safeUser}";

Building the cURL Messenger

To make reliable HTTP POST requests, configure native cURL with connection and total execution timeouts. The script should explicitly check for three layers of failure:

1. Transport Layer: cURL errors (e.g., DNS resolution failure, network timeout). 2. HTTP Layer: Non-200 HTTP response codes. 3. Application Layer: Telegram API standard JSON structure where ok is false.

Here is the complete implementation:

<?php

declare(strict_types=1);

/**
* Sends a message to a Telegram chat using cURL.
*
* @param int|string $chatId Unique identifier for the target chat or username
* @param string $text Text of the message to be sent
* @param string $parseMode Optional formatting (e.g., 'HTML')
* @return array<string, mixed>
* @throws RuntimeException On transport, HTTP, or Telegram API failures
*/
function sendTelegramMessage(int|string $chatId, string $text, string $parseMode = 'HTML'): array
{
$botToken = getenv('TELEGRAM_BOT_TOKEN');
if (!$botToken) {
throw new RuntimeException('TELEGRAM_BOT_TOKEN environment variable is missing.');
}

$url = sprintf('https://api.telegram.org/bot%s/sendMessage', $botToken);

$payload = [
'chat_id' => $chatId,
'text' => $text,
];

if ($parseMode !== '') {
$payload['parse_mode'] = $parseMode;
}

$ch = curl_init();

curl_setopt_array($ch, [
CURLOPT_URL => $url,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => http_build_query($payload),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT => 5, // Max time allowed to establish connection
CURLOPT_TIMEOUT => 10, // Max total time allowed for request execution
CURLOPT_HTTPHEADER => [
'Content-Type: application/x-www-form-urlencoded',
],
]);

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

// 1. Check cURL network transport errors
if ($curlErrno !== 0) {
throw new RuntimeException(sprintf('cURL error [%d]: %s', $curlErrno, $curlError));
}

if (!is_string($response) || $response === '') {
throw new RuntimeException('Received empty response from Telegram API server.');
}

// 2. Decode and validate JSON format
$data = json_decode($response, true);
if (json_last_error() !== JSON_ERROR_NONE) {
throw new RuntimeException(sprintf(
'Failed to parse Telegram JSON response. Error: %s. Raw response: %s',
json_last_error_msg(),
$response
));
}

// 3. Check Telegram Application level status
if (!isset($data['ok']) || $data['ok'] !== true) {
$errorCode = $data['error_code'] ?? $httpCode;
$description = $data['description'] ?? 'Unknown API error occurred.';
throw new RuntimeException(sprintf(
'Telegram API Error [%d]: %s',
$errorCode,
$description
));
}

return $data;
}

Executing the Code

You can run this function inside your request handler or CLI script by setting the environment variable:

export TELEGRAM_BOT_TOKEN="123456789:ABCdefGHIjklMNOpqrsTUVwxyZ"
php send_message.php
try {
$chatId = 123456789;
$rawMessage = "Order update: Status changed to <b>Processing</b>.";

$result = sendTelegramMessage($chatId, $rawMessage, 'HTML');
echo "Message sent successfully. Message ID: " . $result['result']['message_id'];
} catch (RuntimeException $e) {
error_log("Telegram delivery failed: " . $e->getMessage());
}

Production Considerations

- Rate Limits (HTTP 429): If Telegram responds with HTTP status 429 (Too Many Requests), inspect the parameters.retry_after payload field. If this occurs regularly, delegate outgoing messages to a database or Redis queue rather than executing cURL inside synchronous web HTTP requests. - User Blocks (HTTP 403): If a user has blocked your bot or deleted the chat, Telegram returns 403 Forbidden with the description Forbidden: bot was blocked by the user. Catch this error specifically to mark user records as inactive in your database. - JSON Payload Alternative: The example above uses application/x-www-form-urlencoded. If your payload includes complex nested arrays (like reply_markup inline keybaords), encode the whole payload with json_encode() and send it with Content-Type: application/json header instead.

Need to scale out custom automation, Webhooks, or serverless infrastructure for messaging systems? Contact BotCreator — studio that ships Telegram bots / Mini Apps.

New articles on Telegram

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