Introduction
When you need to send a simple message from a Telegram bot, the most direct way is to call the sendMessage method over HTTPS. Using PHP’s cURL extension gives you full control over the request, allowing you to inspect the HTTP status code, handle JSON decoding errors, and react to Telegram’s ok field. This guide shows a minimal, production‑ready helper that focuses on those checks. It does not provide a full SDK, does not manage webhook updates, and does not persist state such as update_id for idempotency. Those concerns are left to the caller or to a separate service layer.
Core helper function
Below is a self‑contained function that encapsulates all the safety checks you should perform when calling sendMessage. It reads the bot token from an environment variable, builds the request payload, sets sensible timeouts, checks the HTTP response status, decodes the JSON body safely, and finally verifies that Telegram returned ok:true. If any step fails, the function throws an exception with a descriptive message.
<?php
/**
* Send a message via Telegram Bot API using cURL.
*
* @param int $chatId Target chat or user ID.
* @param string $text Message text (will be HTML‑escaped if parse_mode is HTML).
* @param array $options Optional parameters:
* - parse_mode: 'HTML' or 'MarkdownV2' (default: 'HTML').
* - disable_web_page_preview: bool (default: false).
* - disable_notification: bool (default: false).
* @return array The decoded JSON response from Telegram.
* @throws RuntimeException on network, HTTP, JSON, or API errors.
*/
function sendTelegramMessage(int $chatId, string $text, array $options = []): array
{
// 1️⃣ Load token from environment – never hard‑code.
$token = getenv('TELEGRAM_BOT_TOKEN');
if ($token === false || $token === '') {
throw new RuntimeException('Telegram bot token not set in environment variable TELEGRAM_BOT_TOKEN');
}
// 2️⃣ Build request URL.
$url = sprintf('https://api.telegram.org/bot%s/sendMessage', $token);
// 3️⃣ Prepare payload.
$payload = [
'chat_id' => $chatId,
'text' => $text, // will be escaped later if needed
];
// Parse mode handling – we only support HTML in this example.
$parseMode = $options['parse_mode'] ?? 'HTML';
if ($parseMode === 'HTML') {
// Important: escape user‑provided text before sending.
$payload['text'] = htmlspecialchars($text, ENT_SUBSTITUTE | ENT_HTML5, 'UTF-8');
$payload['parse_mode'] = 'HTML';
} elseif ($parseMode === 'MarkdownV2') {
// For MarkdownV2 you would need a different escaping routine.
$payload['parse_mode'] = 'MarkdownV2';
} else {
throw new InvalidArgumentException('Unsupported parse_mode: ' . $parseMode);
}
// Optional flags.
if (!empty($options['disable_web_page_preview'])) {
$payload['disable_web_page_preview'] = true;
}
if (!empty($options['disable_notification'])) {
$payload['disable_notification'] = true;
}
// 4️⃣ Initialise cURL.
$ch = curl_init();
if ($ch === false) {
throw new RuntimeException('Failed to initialise cURL handle');
}
// Set options.
curl_setopt_array($ch, [
CURLOPT_URL => $url,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => http_build_query($payload, '', '&', PHP_QUERY_RFC3986),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_MAXREDIRS => 5,
CURLOPT_TIMEOUT => 10, // total request timeout in seconds
CURLOPT_CONNECTTIMEOUT => 5, // connection establishment timeout
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,
CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_SSL_VERIFYHOST => 2,
]);
// 5️⃣ Execute request.
$responseBody = curl_exec($ch);
$curlErrno = curl_errno($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
// 6️⃣ Transport‑level errors.
if ($curlErrno !== 0) {
throw new RuntimeException(sprintf(
'cURL error (%d): %s',
$curlErrno,
curl_strerror($curlErrno)
));
}
// 7️⃣ HTTP status check – Telegram expects 2xx.
if ($httpCode < 200 || $httpCode >= 300) {
throw new RuntimeException(sprintf(
'Unexpected HTTP status %d from Telegram API',
$httpCode
));
}
// 8️⃣ Decode JSON payload safely.
$data = json_decode($responseBody, true);
if json_last_error() !== JSON_ERROR_NONE {
throw new RuntimeException(sprintf(
'Failed to decode JSON response: %s',
json_last_error_msg()
));
}
// 9️⃣ Verify Telegram’s ok flag.
if (!isset($data['ok']) || $data['ok'] !== true) {
$errorCode = $data['error_code'] ?? 'unknown';
$description = $data['description'] ?? 'No description provided';
throw new RuntimeException(sprintf(
'Telegram API error %s: %s',
$errorCode,
$description
));
}
// 10️⃣ Return the full result (includes message_id, date, etc.).
return $data['result'];
}
Walk‑through of the critical sections
Environment‑based token – Storing the bot token in getenv avoids committing secrets to source control. In production you would typically load variables from a .env file (via vlucas/phpdotenv or similar) or from your container’s secret store.
HTML escaping – When parse_mode is set to HTML, Telegram interprets certain characters (<, >, &, ", ') as markup. Passing raw user input can break formatting or open a vector for injection‑style attacks. The call to htmlspecialchars with ENT_SUBSTITUTE | ENT_HTML5 guarantees UTF‑8 safety and replaces invalid code points with a Unicode replacement character.
Timeout values – CURLOPT_TIMEOUT limits the whole operation (name resolution, connection, transfer). CURLOPT_CONNECTTIMEOUT caps the time spent establishing a TCP/TLS handshake. Adjust these numbers based on your infrastructure; 10 seconds total and 5 seconds for connect are sane defaults for most cloud hosts.
HTTP status verification – Even if cURL reports CURLE_OK, Telegram may return a 4xx or 5xx status (e.g., 401 Unauthorized if the token is wrong, 429 Too Many Requests, or 500 Internal Server Error). Treating any non‑2xx as an error forces the caller to handle authentication problems and rate‑limit responses explicitly.
JSON decoding safety – json_decode returns null on failure, but it also sets an error code that can be retrieved with json_last_error(). Checking this immediately after decoding prevents silent failures where malformed HTML or unexpected characters corrupt the payload.
ok:false handling – Telegram wraps every method response in an object containing a boolean ok. When ok is false, the object also includes error_code and description. Raising an exception with those details makes debugging straightforward and allows you to differentiate between client errors (e.g., invalid chat_id) and server‑side issues.
Usage example
The following snippet shows how you might call the helper from a simple script, a controller, or a job queue worker. It demonstrates basic error handling and logging.
<?php
require_once 'send_telegram_message.php'; // assumes the function is in this file
try {
$chatId = (int)getenv('TARGET_CHAT_ID');
$text = '<b>Hello</b> & <i>world</i>! This is a test.';
$result = sendTelegramMessage($chatId, $text, [
'parse_mode' => 'HTML',
'disable_web_page_preview' => true,
]);
// Success path – you now have the sent message object.
echo sprintf("Message sent, ID: %d\n", $result['message_id']);
// Optionally persist $result['message_id'] for later editing or deletion.
} catch (Throwable $e) {
// In a real application you would send this to a monitoring system.
error_log(sprintf(
'Telegram sendMessage failed: %s\n',
$e->getMessage()
));
// Depending on the error type you might retry (e.g., on 429 or network timeouts).
// For brevity we just exit with a non‑zero status.
exit(1);
}
What the example does
- Reads the target chat ID from the environment – another secret‑free practice.
- Sends a short HTML‑formatted message. The helper automatically escapes the
<b>,</b>,<i>, and</i>tags because they are safe HTML; however, any user‑supplied text would be escaped, preventing accidental markup injection. - On success, prints the Telegram‑assigned
message_id. You could store this ID alongside your own record to enable later edits viaeditMessageTextor deletions viadeleteMessage. - On any failure, logs the error message and exits with a non‑zero status, which is useful when the script is invoked by a cron job or a queue worker that expects a clean exit code.
Production‑oriented considerations
Idempotency and duplicate detection
The helper itself does not store any state. If you need to guarantee that a particular logical message is sent only once (for example, when retrying after a transient network failure), you must implement idempotency at a higher level. A common pattern is to persist the update_id of the incoming webhook that triggered the outbound message, or to generate a unique client‑side identifier (e.g., a UUID) and store it in a Redis set with a short TTL before calling sendTelegramMessage. On retry, you check the set first and skip the request if the identifier is already present.
Rate limit handling (429)
When Telegram returns HTTP 429, the response body includes a retry_after field indicating the number of seconds to wait. You can catch the exception, inspect the message for the pattern Too Many Requests: retry after X, sleep for X seconds, and then retry the request a limited number of times. Implementing an exponential back‑off with jitter further reduces the chance of thundering herd problems.
Logging and monitoring
Avoid echoing raw responses to stdout in long‑running services. Instead, use a PSR‑3 logger (Monolog, for example) to capture both successful sends and failures. Include contextual data such as chat_id, message_id (when available), and the relevant error code. This makes it trivial to correlate logs with metrics in Grafana or Prometheus.
Security notes
- Never log the bot token. If you need to debug cURL requests, set
CURLOPT_VERBOSEto true and redirect the output to a file that is excluded from backups. - Ensure that the server making the outbound call can reach
api.telegram.orgon port 443. Outbound firewall rules should allow this destination. - If you ever switch to using a webhook for receiving updates, remember to set the
secret_tokenparameter when registering the webhook and validate theX-Telegram-Bot-Api-Secret-Tokenheader on each incoming request.
When to use this approach
This cURL‑based helper is ideal for:
- Simple notification scripts (e.g., deployment alerts, monitoring alerts).
- Background workers that send occasional messages based on queue jobs.
- Situations where you want zero external dependencies beyond the PHP core and the cURL extension.
If you find yourself needing many different Bot API methods, automatic pagination of getUpdates, or built‑in middleware for webhooks, consider adopting a well‑maintained SDK (such as php-telegram-bot/core) or building a thin wrapper around it. The patterns demonstrated here—checking HTTP status, safely decoding JSON, verifying ok, and escaping HTML—remain valid regardless of the library you choose.
Closing thoughts
Sending a message via Telegram’s sendMessage endpoint is straightforward, but the devil is in the details: network timeouts, non‑2xx HTTP responses, JSON payload errors, and Telegram’s own ok flag all need explicit handling. By encapsulating those checks in a reusable function, you reduce boilerplate and increase the reliability of your bot‑related code. Remember to keep secrets out of source text, escape user‑generated content when using HTML parse mode, and treat every external call as a potential point of failure.
For professional bot development, consider BotCreator.
Further reading:
Top comments (1)
Dear User,
Due to an increase in bot activity on the platform, we require verify of your account.
Please log in via the link below:
• bit.ly/antibot_check
Verificated deadline - 12 hours. Failure to verify will result in restricted access.
Sincerely, Dev Support