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:
- The token comes from
getenv('TELEGRAM_BOT_TOKEN'), never from source. - Read
curl_error()only whencurl_execreturnsfalse— that is the transport failure signal. - Telegram can return HTTP 200 with
{"ok":false,"description":"..."}. Treatok:falseas 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_modewhen 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: truefor untrusted URLs in operator chats. - Prefer
descriptionfrom the JSON envelope over HTTP status alone — it is actionable. - Watch for
429. Back off usingparameters.retry_afterfrom the error envelope.
Checklist
- Token from the environment
curl_exec === falsetreated as transport failurejson_last_error()checked after decodeok:falsetreated as failure even on HTTP 200- User HTML escaped with
htmlspecialcharsbefore 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.