DEV Community

Serhii
Serhii

Posted on Originally published at botservice.biz

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}";
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
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());
}
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)