Catch Silent Outbound Failures When Dispatching Telegram Messages via PHP cURL

When an order is placed on your web application or a user requests a notification through a Telegram bot, your backend needs to send an outbound message using the Telegram Bot API. Developers often reach for quick implementations like file_get_contents or a basic curl_exec script. On the surface, if the token is correct and the target chat ID is valid, the message delivers instantly. However, in production environments, this naïve implementation creates silent failures that leave customer orders unconfirmed and alert channels silent without raising backend exceptions.

An outbound HTTP request to Telegram can fail at four distinct layers: network socket timeouts, HTTP-level non-200 responses, malformed JSON bodies, and Telegram API-level rejection flags (ok: false). If your PHP script only verifies that curl_exec returned a non-false value, you will completely miss cases where Telegram rejects your payload due to broken HTML formatting, where a user has blocked the bot, or where API rate limits have been triggered.

The Failing Path: Why Basic cURL Calls Drop Messages

Consider a standard e-commerce webhook script processing a new purchase. It formats an HTML order summary with the buyer's input and fires off a POST request to sendMessage.

<?php
// Bad Example: Do not use in production
$token = getenv('BOT_TOKEN');
$chatId = $_POST['user_id'];
$userName = $_POST['user_name']; // e.g., "AT&T / Customer <John>"

$message = "<b>New Order!</b>\nCustomer: " . $userName;

$ch = curl_init("https://api.telegram.org/bot{$token}/sendMessage");
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => [
'chat_id' => $chatId,
'text' => $message,
'parse_mode' => 'HTML'
]
]);

$result = curl_exec($ch);
curl_close($ch);

// Flawed check: $result is a JSON string, which evaluates to boolean true
if ($result) {
echo "Message sent successfully!";
}

When this script runs under normal conditions with clean customer names, it appears to work. But as soon as a customer inputs special characters like AT&T or enters an unescaped name like Customer <John>, the entire request breaks. Because parse_mode is set to HTML, Telegram expects valid XML-style tags. When it encounters raw ampersands or unclosed angle brackets, Telegram returns an HTTP 400 status with {"ok":false,"error_code":400,"description":"Bad Request: can't parse entities"}.

Because the naïve script only checks if ($result), it interprets Telegram's HTTP 400 response string as a successful execution. The variable $result contains the JSON string returned by Telegram, which evaluates to true when cast to a boolean condition in PHP. As a result, your order processing logic assumes the notification was sent, logs no errors, and moves forward, leaving the user with no confirmation message.

Furthermore, if Telegram's API endpoints experience transient latency or packet loss, cURL without explicit socket timeouts will hang indefinitely. If your PHP-FPM pool has 20 workers, 20 hanging cURL requests will quickly exhaust your server's process pool, crashing your backend application for all users.

Sanitizing Dynamic Input for Telegram HTML Mode

Telegram supports a limited subset of HTML tags: <b>, <i>, <a>, <code>, <pre>, and <u>. Any symbol outside these supported tags—specifically <, >, and &—must be converted to their corresponding HTML entities. Using strip_tags() is dangerous because it strips out legitimate text that resembles HTML tags, such as customer notes containing mathematically styled text or custom codes.

To safely inject user-generated content into an HTML-formatted message, you must run every dynamic variable through htmlspecialchars() before interpolating it into your message template. Do not pass the entire completed message string to htmlspecialchars(), as this will escape your intended structural tags like <b> into &lt;b&gt; text.

<?php
declare(strict_types=1);

function formatOrderNotification(string $userName, string $productName, float $amount): string
{
// Escape dynamic parameters individually before embedding
$safeName = htmlspecialchars($userName, ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML5, 'UTF-8');
$safeProduct = htmlspecialchars($productName, ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML5, 'UTF-8');
$formattedAmount = number_format($amount, 2);

return "<b>New Order Received</b>\n\n" .
"<b>Customer:</b> {$safeName}\n" .
"<b>Item:</b> {$safeProduct}\n" .
"<b>Total:</b> \${$formattedAmount}";
}

Notice the flags passed to htmlspecialchars: ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML5. ENT_QUOTES ensures both single and double quotes are escaped, ENT_SUBSTITUTE replaces invalid code unit sequences with a Unicode replacement character instead of returning an empty string, and ENT_HTML5 ensures entity conversion conforms to modern standards.

Building a Four-Stage Verification cURL Dispatcher

To guarantee that an outbound Telegram message was received and accepted by Telegram's infrastructure, your PHP wrapper must execute four sequential validation steps:

1. cURL Transport Level: Check curl_errno($ch) to ensure the HTTP request reached Telegram's servers without network timeouts, DNS lookup failures, or SSL handshake drops. 2. HTTP Status Code Level: Verify curl_getinfo($ch, CURLINFO_HTTP_CODE). Telegram returns HTTP 200 on success, HTTP 400 for bad payloads, HTTP 403 when the bot was blocked by the user, and HTTP 429 when hit by rate limits. 3. JSON Parsing Level: Execute json_decode($response, true) and immediately verify json_last_error() === JSON_ERROR_NONE. Malformed responses or upstream proxy error pages returning raw HTML will break subsequent array checks if unvalidated. 4. Telegram API Level: Check $data['ok'] === true. If ok is false, extract $data['description'] and $data['error_code'] to trigger domain-specific fallback logic.

<?php
declare(strict_types=1);

class TelegramResponse
{
public function __construct(
public readonly bool $success,
public readonly int $httpCode,
public readonly ?array $data = null,
public readonly ?string $errorMessage = null
) {}
}

function sendTelegramMessage(string $botToken, int|string $chatId, string $htmlText): TelegramResponse
{
$url = "https://api.telegram.org/bot{$botToken}/sendMessage";

$payload = json_encode([
'chat_id' => $chatId,
'text' => $htmlText,
'parse_mode' => 'HTML',
'disable_web_page_preview' => true
]);

$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $payload,
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
CURLOPT_CONNECTTIMEOUT => 5, // Timeout for connection setup
CURLOPT_TIMEOUT => 10, // Timeout for total execution
]);

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

// Step 1: Check cURL Transport Error
if ($curlErrno !== 0) {
return new TelegramResponse(false, 0, null, "cURL Network Error ({$curlErrno}): {$curlError}");
}

// Step 2: Decode JSON
/** @var array|null $decoded */
$decoded = json_decode((string) $rawResponse, true);
if (json_last_error() !== JSON_ERROR_NONE) {
return new TelegramResponse(
false,
$httpCode,
null,
"JSON Parse Error: " . json_last_error_msg() . " | Raw: " . substr((string)$rawResponse, 0, 100)
);
}

// Step 3 & 4: Check HTTP Code and Telegram API Flag
if ($httpCode !== 200 || !isset($decoded['ok']) || $decoded['ok'] !== true) {
$apiDescription = $decoded['description'] ?? 'Unknown API error';
$errorCode = $decoded['error_code'] ?? $httpCode;
return new TelegramResponse(
false,
$httpCode,
$decoded,
"Telegram API Error [{$errorCode}]: {$apiDescription}"
);
}

return new TelegramResponse(true, $httpCode, $decoded);
}

Sending JSON formatted bodies via CURLOPT_POSTFIELDS with explicit Content-Type: application/json headers is preferred over sending raw POST arrays. Arrays trigger multipart/form-data encoding inside cURL, which increases payload size and introduces potential array-to-string conversion bugs when passing boolean or nested array flags.

Production Handling: Retries, Blocked Users, and Sanitized Logs

In production systems, network calls inevitably fail due to transient issues or rate limits. Your dispatching architecture must handle specific Telegram status codes gracefully without crashing your queue workers or leaking environment credentials.

### Rate Limiting (HTTP 429) Telegram enforces limits on API calls (typically 30 messages per second globally across all chats, or 1 message per second in a specific chat). When you exceed these limits, Telegram returns HTTP 429 along with a parameters object containing retry_after.

### Bot Blocked or User Deactivated (HTTP 403) When a user blocks your bot or deletes their Telegram account, Telegram returns HTTP 403 with the description Forbidden: bot was blocked by the user. Do not continue attempting to message this user on every scheduled background job. Intercept this status code and update your database to mark the user's notification status as disabled.

### Sanitizing Log Files Bot API tokens embedded directly inside target URLs (such as https://api.telegram.org/bot<TOKEN>/sendMessage) present a security risk if logged raw to system files or external monitoring platforms. When logging failed cURL calls or transport errors, always redact the bot token before saving to disk.

<?php
declare(strict_types=1);

function processOutboundMessage(string $token, int $userId, string $messageHtml, PDO $db): void
{
$response = sendTelegramMessage($token, $userId, $messageHtml);

if ($response->success) {
// Message delivered successfully
return;
}

$logContext = [
'user_id' => $userId,
'http_code' => $response->httpCode,
'error' => $response->errorMessage,
];

// Specific Error Handling
if ($response->httpCode === 403) {
// User blocked the bot: disable future notifications in DB
$stmt = $db->prepare("UPDATE users SET telegram_notifications_enabled = 0 WHERE telegram_id = :id");
$stmt->execute([':id' => $userId]);
error_log("Telegram notification disabled for user {$userId}: User blocked bot.");
return;
}

if ($response->httpCode === 429) {
// Rate limited: extract retry_after parameter
$retryAfter = $response->data['parameters']['retry_after'] ?? 5;
error_log("Telegram Rate Limit Hit. Retry allowed after {$retryAfter} seconds.");
// Here, re-queue the job with a delay matching $retryAfter
return;
}

// Fallback: log error while sanitizing sensitive tokens
$safeMessage = str_replace($token, '[REDACTED_TOKEN]', $response->errorMessage ?? '');
error_log("Failed to send Telegram message to {$userId}: " . $safeMessage);
}

Handling outbound Telegram communication reliably in PHP requires treating every layer of the HTTP lifecycle with explicit checks rather than assuming successful transmission. Inspecting cURL transport errors, verifying HTTP response codes, parsing JSON safely, and escaping user input ensures your background workers run cleanly without dropping critical updates.

When building complex automation workflows or enterprise integrations, engineering teams rely on BotCreator — a studio that ships Telegram bots and Mini Apps with high-availability architectures and bulletproof backend integrations.

New articles on Telegram

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