What this tutorial covers and what it does not
We build a small, dependency-free PHP helper that calls the Telegram Bot API sendMessage method over cURL, validates the response carefully, and escapes HTML so the message you intend is the message Telegram renders. The helper is meant to be dropped into a webhook handler, a cron job, or a form-processor script.
This post does not cover webhook setup, signature verification, or rate-limit strategy. Those are separate problems. We focus on the HTTP call itself: status codes, JSON decoding, the ok:false envelope, parse modes, and timeouts.
The wrapper
We keep the function small and explicit. Two arguments matter: the chat id and the text. Everything else is configuration that 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. Will be escaped for HTML parse_mode.
* @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);
// Body is required: empty response is a failure we want to surface.
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 200 even when ok:false. Always 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 things to notice:
- We use
getenv('TELEGRAM_BOT_TOKEN'). The token is read from the environment, never from source. In production, source it from a.envfile (parsed by your framework) or from the platform's secret store. - We check the cURL handle with
curl_error()only aftercurl_execreturnedfalse. Afalsereturn fromcurl_execis the only signal that the request itself failed at the transport layer. - Telegram returns HTTP 200 with a JSON envelope of
{"ok":false,"error_code":...,"description":"..."}for application-level failures (chat not found, message too long, parse error). We treatok:falseas a failure regardless of HTTP status.
HTML escaping before sending
If you set parse_mode: HTML, Telegram parses a small subset of HTML: <b>, <i>, <u>, <s>, <code>, <pre>, <a href="...">. Anything that looks like HTML but is not valid in this subset causes a 400 Bad Request: can't parse entities. User-supplied text in particular needs escaping.
function tgHtml(string $s): string
{
// Order matters: escape & first, then the rest.
return htmlspecialchars($s, ENT_QUOTES | ENT_HTML5, 'UTF-8');
}
// In a form-to-Telegram pipeline:
$name = tgHtml($_POST['name'] ?? '');
$message = tgHtml($_POST['message'] ?? '');
$text = "<b>New lead</b>\nName: {$name}\nMessage: {$message}";
$res = tgSendMessage($chatId, $text);
If you escape user input before composing the text and only allow the tags you actually need, the parser will not see stray < and > and your message will not break. If you need to preserve formatting, compose it from safe parts rather than concatenating raw input.
Handling failures you can actually retry
sendMessage is idempotent only if you give it the same message_id to edit — sending the same sendMessage call twice creates two messages. Do not retry blindly. The wrapper returns structured data so the caller can decide.
$res = tgSendMessage($chatId, $text);
if (!$res['ok']) {
// Log only what is safe to log: do not echo $body verbatim.
error_log(sprintf(
'[tg] status=%d error=%s',
$res['status'],
$res['error'] ?? 'unknown'
));
// Application errors: 400 chat not found, 429 too many requests.
// 5xx and transport errors: 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;
}
// $res['result']['message_id'] is available for follow-up edits.
$messageId = $res['result']['message_id'];
The status field is 0 for transport failures (DNS, TLS, timeout, connection refused). 0 is not a real HTTP code, but it is a useful bucket for "never reached Telegram" retries.
Timeouts and connection caps
Two timeouts matter:
-
CURLOPT_CONNECTTIMEOUT— how long we wait to establish a TCP/TLS connection. -
CURLOPT_TIMEOUT— total request budget, including DNS, TLS, send, wait, receive.
For a webhook handler, keep both small. Telegram is fast; a five-second connect and ten-second total is generous. Long timeouts inside a web request translate into hung workers and slow pages. If you need delivery guarantees, push the message into a queue (Redis, database, SQS) and have a worker call tgSendMessage from a CLI process where you can afford longer budgets.
If you batch sends, set a sane CURLOPT_MAXCONNECTS and reuse handles with curl_multi_*. The wrapper above is single-shot because that is the shape that fits into most code paths.
Production notes (optional, treat as such)
-
Disable
parse_modewhen you do not need it. Plain text bypasses the entity parser entirely and removes a whole class of errors. -
Keep messages under 4096 characters. That is the
sendMessagelimit. Telegram will return400 message is too longif you exceed it. For long content, usesendDocumentwith a generated.txtor.htmlfile. -
Use
disable_web_page_preview: truewhen sending URLs you do not control, to avoid accidentally previewing malicious link targets in operator chats. -
Read errors from
$decoded['description']rather than HTTP status. Telegram'serror_codemirrors HTTP, butdescriptionis human-actionable ("Bad Request: message is too long", "Forbidden: bot was blocked by the user"). -
Watch for
429. Telegram rate-limits per chat and per bot. Back off and retry afterretry_after(seconds) the API includes in the error envelope.
Quick checklist
- Token is read from the environment.
-
curl_execfalseis treated as transport failure with the cURL error string. -
json_last_error()is checked afterjson_decode. - Telegram's
ok:falseenvelope is treated as failure even on HTTP 200. - HTML user input is passed through
htmlspecialcharsbefore composition. - Timeouts are short for in-request calls, longer for background workers.
A small helper like this is often enough. Once you have a single well-behaved call site, the second and third call sites become trivial, and you stop losing messages to silent ok:false responses.
If you want a reference for the full set of sendMessage parameters and reply markup shapes, the Telegram Bot API reference is the canonical place to look.
This pattern — small cURL wrapper, strict envelope checks, HTML escaping, short timeouts — is the same one BotCreator uses when shipping Telegram bots and Mini Apps for clients.
Top comments (0)