Do these 3 things before closing this tab:
1Repair Windows errors before they cause bigger problems2Fix the driver behind crashes, sound loss and screen glitches3Clear out junk files and repair common Windows errorsTo send a Telegram bot message from PHP with cURL, POST to the sendMessage method, then judge the outcome in four separate checks: did the cURL transfer complete, what HTTP status came back, does the body decode as JSON, and does Telegram’s ok field report success. A response body is not proof that Telegram accepted the message. curl_exec() only tells you that the transfer finished, and a successful transfer can still carry an error from Telegram.
Request shape: the URL and the body formats
Telegram Bot API methods are called over HTTPS at https://api.telegram.org/bot<token>/METHOD_NAME, where <token> is your bot token and METHOD_NAME is the method, such as sendMessage. The API accepts GET and POST requests. Parameters can travel in the query string, as form-encoded fields, as a JSON body, or as multipart form data. Multipart is the format for file uploads; text-only calls such as sendMessage can use JSON.
As an Amazon Associate I earn from qualifying purchases.
- Query string (GET): suits simple read-only calls with short parameter lists.
- Form-encoded (POST): a conventional choice when you build the body from key-value pairs.
- JSON body (POST with
Content-Type: application/json): the format used in the example below. Nested values such as reply markup can be sent without manual encoding. - Multipart form data (POST): required when you upload a file, for example a photo or document.
The bot token sits inside the URL path, which has a direct consequence for logging. Anything that records the URL records the token. The logging section covers how to avoid that.
A sendMessage call in PHP
The example below sends a text message as JSON and performs the checks in the order a failure would surface: transport first, then HTTP status, then JSON decoding, then Telegram’s own result.
#1 Best Overall
<?php
declare(strict_types=1);
$url = 'https://api.telegram.org/bot' . $token . '/sendMessage';
$payload = json_encode([
'chat_id' => $chatId,
'text' => $text,
], JSON_THROW_ON_ERROR);
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $payload,
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 20,
]);
$body = curl_exec($ch);
if ($body === false) {
$errno = curl_errno($ch);
$error = curl_error($ch);
curl_close($ch);
throw new RuntimeException("cURL transport failure ($errno): $error");
}
$httpStatus = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
try {
$response = json_decode($body, true, 512, JSON_THROW_ON_ERROR);
} catch (JsonException $e) {
throw new RuntimeException("Telegram response was not valid JSON (HTTP $httpStatus)", 0, $e);
}
if (!is_array($response)) {
throw new RuntimeException("Telegram response JSON was not an object (HTTP $httpStatus)");
}
if (($response['ok'] ?? false) !== true) {
$code = $response['error_code'] ?? 'unknown';
$description = $response['description'] ?? 'No description supplied';
throw new RuntimeException("Telegram API error ($code): $description; HTTP $httpStatus");
}
$message = $response['result'];
This is a starting point, not a complete production client. It throws on every failure so that the caller decides what to do next. Retry logic, queueing, and user-facing messages belong in the layer that calls it.
Layer 1: the cURL transport result
The first question is whether the request completed at all. With CURLOPT_RETURNTRANSFER set to true, curl_exec() returns the response body as a string when the transfer succeeds, and returns false on a transport failure. Transport failures include DNS resolution errors, refused or dropped connections, TLS problems, and timeouts.
When the return value is false, read the failure with curl_errno() and curl_error() before calling curl_close(). The example does this. A timeout, for instance, appears here as a cURL error rather than as an HTTP status, so a timed-out request never reaches the status check.
A non-false return value means only that bytes arrived. It says nothing about whether the status was 200, whether the body is JSON, or whether Telegram accepted the call.
Rank #2
Layer 2: the HTTP status code
Read the status separately with curl_getinfo($ch, CURLINFO_HTTP_CODE), and read it before closing the handle. The PHP manual for curl_exec states: “Note that response status codes which indicate errors (such as 404 Not found) are not regarded as failure. curl_getinfo() can be used to check for these.”
In practice, a 404 or 500 does not make curl_exec() return false. Your code has to decide what a non-2xx status means. The example records the status and passes it into the error message, but it does not branch on it. That is a deliberate gap: some applications will treat a non-2xx status as a hard failure, while others will rely on the JSON body for detail. Choose one policy and apply it consistently.
Keep the status even when the body is useful. The status is the only transport-level evidence of what the server said, and it is what makes an error message diagnosable later.
Layer 3: decoding the body safely
Do not assume the body is JSON. Use json_decode() with JSON_THROW_ON_ERROR, which makes PHP throw a JsonException instead of setting the global JSON error state that older code had to check with json_last_error(). Wrap the call in a try block, as the example does, so that the failure is handled in one place.
Malformed bodies happen in practice. An empty body, a plain-text or HTML page returned by a proxy or an intermediate server, or a truncated response will all fail to decode. Such a body can arrive with any status, including a 200, when something other than Telegram answered. Treat a decode failure as its own error category, and keep the HTTP status with it so you can tell a proxy error page from a Telegram error.
When you log a decode failure, keep a short excerpt of the body, for example the first few hundred bytes, so you can see what arrived. Bound the excerpt; do not log whole bodies by default.
$excerpt = substr($body, 0, 300);
error_log("Telegram non-JSON response, HTTP $httpStatus, excerpt: $excerpt");
The example also checks that the decoded value is an array. Valid JSON is not necessarily an object, and an unexpected scalar would otherwise cause confusing notices at the next step.
What’s actually slowing this PC down?
Pick the symptom - the matching free tool is one click away.
Layer 4: Telegram’s ok field, result, and error details
Telegram’s Bot API reference states: “The response contains a JSON object, which always has a Boolean field ‘ok’ and may have an optional String field ‘description’ with a human-readable description of the result.” This is the authoritative check for whether the method succeeded. A 200 status with ok set to false is a failed call, and an HTTP error with a valid JSON body still carries Telegram’s explanation.
Rank #4
| Field | Present when | What it means for your code |
|---|---|---|
ok |
Always | Boolean. true means the method succeeded; false means it failed. Compare with !== true, not a truthiness test on another field. |
result |
ok is true |
The method’s output. For sendMessage, this is the sent message object. Check that the key exists before reading it. |
description |
Optional; supplied on failures and may be present otherwise | A human-readable explanation. Use it for logs and diagnostics, not as a fixed string to match on. |
error_code |
Returned on failures | An integer. Telegram’s reference says its contents are subject to change in the future, so do not build a permanent mapping from numbers to behavior. |
parameters |
Optional | An object that can help automate error handling. Inspect the keys your real responses contain rather than assuming a fixed set. |
Because error_code is not a stable catalogue, this article does not provide a complete table of error codes or the correct response to each one. Log the code and the description together, and when you add a specific branch, base it on a response you have actually observed.
Note also that the example uses ?? 'unknown' and similar fallbacks. Those fallbacks are there because the description is optional and the integer may change, so a missing field should never crash the error path.
What to log, and what to keep out of logs
A useful failure log records enough to reproduce the decision without exposing credentials. For each failed call, record:
- the cURL error number and message, when Layer 1 failed;
- the HTTP status code, whenever it was read;
- whether the body decoded as JSON, plus a bounded excerpt if it did not;
- Telegram’s
error_codeanddescription, when present; - the method name and your own request identifier, not the token.
Never log the full URL, because it contains the bot token. Watch for the less obvious routes too. Setting CURLOPT_VERBOSE writes request details, including the URL, to the output stream. Some cURL error strings can also echo the URL, so scrub the token from any message before it is written. A simple approach is to replace the token with a fixed marker before logging:
$safeMessage = str_replace($token, '***', $error);
error_log("cURL transport failure ($errno): $safeMessage");
Timeouts, retries, and duplicate messages
The example sets CURLOPT_TIMEOUT to 20 seconds. That value is a placeholder for your own latency budget, not a recommendation for every deployment. A timeout is a Layer 1 failure, but it does not tell you whether Telegram processed the request. The message may have been sent even though your code never received the response.
That uncertainty decides retry behavior for sendMessage. Retrying after a timeout can deliver the same text twice. Before adding retries, decide whether a duplicate is acceptable for your application, or whether you can detect one, for example by recording a local idempotency key and checking your own store before resending. Retry only the failures you have chosen to retry, and do not treat every non-2xx status or every ok failure as safe to repeat without that decision.
Whatever policy you choose, keep the Layer 2 and Layer 4 values in the log entry. They are the evidence you will need when the policy is wrong.
Free tools Windows power users keep installed
One-click scans. No signup required.
Troubleshooting by symptom
| Symptom | Layer to check first | What to read |
|---|---|---|
curl_exec() returns false |
1: transport | curl_errno() and curl_error(), read before curl_close() |
| Body returns, but status is not 200 | 2: HTTP status | curl_getinfo($ch, CURLINFO_HTTP_CODE), then the JSON body for Telegram’s description |
| Exception about invalid JSON | 3: decoding | The bounded body excerpt and the HTTP status; check for proxy or HTML error pages |
Valid JSON with ok set to false |
4: Telegram result | description, error_code, and any parameters object |
Valid JSON with ok set to true, but no result |
4: Telegram result | Log the full decoded shape without the token; treat the response as unexpected |
Following the layers in order keeps each diagnosis narrow. A transport failure tells you nothing about Telegram, and a Telegram error tells you nothing about the network. Keeping the checks separate is what lets you tell which side of the request failed.
Quick Recap
Product prices and availability are accurate as of the date/time indicated and are subject to change. Any price and availability information displayed on Amazon at the time of purchase will apply.




