Send Telegram Bot API messages with PHP cURL: status checks, HTML escaping, and timeouts

What this tutorial covers

We build a small, dependency-free PHP helper that calls Telegram Bot API sendMessage over cURL, validates the response carefully, and escapes HTML so Telegram renders what you intend. Drop it into a webhook, cron job, or form processor.

This post does not cover webhook setup, signature verification, or rate-limit strategy. Those are separate problems. Focus here: HTTP status, JSON decoding, the ok:false envelope, parse modes, and timeouts.

The wrapper

Keep the function small. Two arguments matter: chat id and text. Everything else is configuration you can change without touching call sites.

<?php

declare(strict_types=1);

/**
* Send a message via the Telegram Bot API using cURL.
*
* @param int|string $chatId Telegram chat id.
* @param string $text Message text (escape for HTML parse_mode yourself or via helper).
* @param array $extra Optional overrides: reply_markup, reply_to_message_id, etc.
*
* @return array{ok:bool, status:int, error?:string, result?:array}
*/
function tgSendMessage(int|string $chatId, string $text, array $extra = []): array
{
$token = getenv('TELEGRAM_BOT_TOKEN');
if (!$token) {
return ['ok' => false, 'status' => 0, 'error' => 'TELEGRAM_BOT_TOKEN is not set'];
}

$url = "https://api.telegram.org/bot{$token}/sendMessage";

$payload = array_merge([
'chat_id' => $chatId,
'text' => $text,
'parse_mode' => 'HTML',
], $extra);

$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => http_build_query($payload),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT => 5,
CURLOPT_TIMEOUT => 10,
CURLOPT_HTTPHEADER => ['Content-Type: application/x-www-form-urlencoded'],
]);

$body = curl_exec($ch);

if ($body === false) {
$err = curl_error($ch) ?: 'unknown cURL error';
curl_close($ch);
return ['ok' => false, 'status' => 0, 'error' => "cURL: {$err}"];
}

$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);

if ($body === '' || $body === null) {
return ['ok' => false, 'status' => $status, 'error' => 'empty response body'];
}

$decoded = json_decode($body, true);
if (json_last_error() !== JSON_ERROR_NONE) {
return [
'ok' => false,
'status' => $status,
'error' => 'invalid JSON: ' . json_last_error_msg(),
];
}

if (!is_array($decoded)) {
return ['ok' => false, 'status' => $status, 'error' => 'non-array JSON payload'];
}

// Telegram returns HTTP 200 even when ok:false. Trust the envelope.
if (empty($decoded['ok'])) {
return [
'ok' => false,
'status' => $status,
'error' => (string) ($decoded['description'] ?? 'Telegram returned ok:false'),
];
}

return ['ok' => true, 'status' => $status, 'result' => $decoded['result'] ?? []];
}

Three points to notice:

  1. The token comes from getenv('TELEGRAM_BOT_TOKEN'), never from source.
  2. Read curl_error() only when curl_exec returns false — that is the transport failure signal.
  3. Telegram can return HTTP 200 with {"ok":false,"description":"..."}. Treat ok:false as failure regardless of status.

HTML escaping before sending

With parse_mode: HTML, Telegram accepts a small subset: <b>, <i>, <u>, <s>, <code>, <pre>, <a href="...">. Invalid markup yields 400 Bad Request: can't parse entities. Escape user-supplied text.

<?php

function tgHtml(string $s): string
{
return htmlspecialchars($s, ENT_QUOTES | ENT_HTML5, 'UTF-8');
}

$name = tgHtml($_POST['name'] ?? '');
$message = tgHtml($_POST['message'] ?? '');

$text = "<b>New lead</b>\nName: {$name}\nMessage: {$message}";

$res = tgSendMessage($chatId, $text);

Escape user input before composition and only add tags you control. If you need formatting, build it from safe parts rather than concatenating raw input.

Handling failures you can retry

sendMessage is not idempotent: calling it twice creates two messages. Do not retry blindly. The wrapper returns structured data so the caller decides.

<?php

$res = tgSendMessage($chatId, $text);

if (!$res['ok']) {
error_log(sprintf(
'[tg] status=%d error=%s',
$res['status'],
$res['error'] ?? 'unknown'
));

// 400: chat not found, parse errors — usually do not retry.
// 429: rate limited — back off using retry_after.
// 5xx or status 0: transport / server — safe to retry with backoff.
if ($res['status'] >= 500 || $res['status'] === 0) {
// schedule a retry in your queue
} else {
// give up, notify an admin channel
}
return;
}

$messageId = $res['result']['message_id'];

Status 0 means the request never reached Telegram (DNS, TLS, timeout). Use it as the “retry transport” bucket.

Timeouts and connection caps

  • CURLOPT_CONNECTTIMEOUT — time to establish TCP/TLS.
  • CURLOPT_TIMEOUT — total budget including DNS, TLS, send, wait, and receive.

For a webhook handler, keep both small: five seconds to connect and ten seconds total is generous. Long timeouts hang workers. For delivery guarantees, enqueue the message and call tgSendMessage from a CLI worker with a longer budget.

Production notes

  • Skip parse_mode when you do not need formatting — plain text avoids entity parse errors.
  • Keep messages under 4096 characters. Longer content belongs in sendDocument.
  • Set disable_web_page_preview: true for untrusted URLs in operator chats.
  • Prefer description from the JSON envelope over HTTP status alone — it is actionable.
  • Watch for 429. Back off using parameters.retry_after from the error envelope.

Checklist

  • Token from the environment
  • curl_exec === false treated as transport failure
  • json_last_error() checked after decode
  • ok:false treated as failure even on HTTP 200
  • User HTML escaped with htmlspecialchars before composition
  • Short timeouts in request handlers; longer budgets in workers

Once one call site behaves, the next ones stay boring — and you stop losing messages to silent ok:false responses.

For the full sendMessage parameter set and reply markup shapes, browse the notes at botservice.biz.

New articles on Telegram

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