Upload Files and Photos via Telegram Bot API in PHP

Sending media files through the Telegram Bot API in PHP requires choosing the correct transport mechanism based on file size, origin, and delivery speed requirements. The Bot API accepts files through three distinct mechanisms: direct upload using multipart/form-data, passing an HTTP URL for Telegram servers to pull, or supplying a cached file_id returned by previous uploads.

This guide demonstrates how to build a robust, native PHP implementation for sendDocument and sendPhoto endpoints using the cURL extension. We will cover strict cURL error checking, JSON payload validation, size limits, and file_id reuse strategies. This guide does not cover setting up a custom, self-hosted Telegram Bot API server or local file storage management.

---

Technical Overview: Upload Mechanisms and File Limits

Selecting the right delivery mechanism impacts both your server's outbound bandwidth and the responsiveness of your bot.

1. Multipart Form Upload (multipart/form-data): - How it works: Your server uploads the file stream directly to Telegram during the API call using CURLFile. - Limits: 50 MB for sendDocument, 10 MB for sendPhoto (photos are automatically compressed and re-encoded by Telegram). - Use cases: Uploading dynamic, user-generated, or locally processed files (e.g., generated PDF invoices, rendered charts, export archives).

2. External HTTP URL: - How it works: You supply a publicly accessible HTTP/HTTPS URL. Telegram's infrastructure downloads the file directly from the remote host. - Limits: 20 MB for documents, 5 MB for photos. - Use cases: Media hosted on a CDN or S3 bucket where transferring bytes through your application backend is unnecessary overhead.

3. Telegram file_id: - How it works: Every file uploaded to Telegram receives a unique file_id in the API response. Passing this string in subsequent API calls instantly delivers the media without re-uploading bytes. - Limits: Up to 2 GB for existing files. - Use cases: Sending static assets, broadcast media, standard documents, or reusable user attachments across multiple chats.

---

Step 1: Uploading Local Files via multipart/form-data

When uploading local files, you must use multipart/form-data. In PHP, this is achieved by passing an associative array containing a CURLFile instance to CURLOPT_POSTFIELDS.

Do not set Content-Type: application/json or Content-Type: multipart/form-data manually in the cURL headers when using CURLFile. Allowing cURL to generate the boundary headers automatically is required for correct multi-part formatting.

<?php

declare(strict_types=1);

function sendTelegramDocument(
string $botToken,
int|string $chatId,
string $filePath,
string $caption = ''
): array {
if (!file_exists($filePath) || !is_readable($filePath)) {
throw new InvalidArgumentException("File not found or not readable: {$filePath}");
}

$url = sprintf('https://api.telegram.org/bot%s/sendDocument', $botToken);

$mimeType = mime_content_type($filePath) ?: 'application/octet-stream';
$postData = [
'chat_id' => $chatId,
'document' => new CURLFile($filePath, $mimeType, basename($filePath)),
'caption' => htmlspecialchars($caption, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'),
'parse_mode' => 'HTML',
];

$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => $url,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $postData,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT => 10,
CURLOPT_TIMEOUT => 60,
]);

$response = curl_exec($ch);
$curlErrno = curl_errno($ch);
$curlError = curl_error($ch);
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);

if ($curlErrno !== 0) {
throw new RuntimeException("cURL error ({$curlErrno}): {$curlError}");
}

$decoded = json_decode((string) $response, true);
if (json_last_error() !== JSON_ERROR_NONE) {
throw new RuntimeException('Failed to decode JSON response: ' . json_last_error_msg());
}

if ($httpCode !== 200 || !isset($decoded['ok']) || $decoded['ok'] !== true) {
$description = $decoded['description'] ?? 'Unknown error';
$errorCode = $decoded['error_code'] ?? $httpCode;
throw new RuntimeException("Telegram API Error [{$errorCode}]: {$description}");
}

return $decoded['result'];
}

---

Step 2: Caching and Reusing file_id

Re-uploading the same image or document for every recipient depletes server bandwidth and increases request latency. When Telegram processes a media upload, the JSON response contains a file_id property inside the file entity.

For sendPhoto, Telegram returns an array of PhotoSize objects representing different resolutions. Select the highest resolution photo (the last element in the array) to extract its file_id.

Here is an implementation of a media delivery service that uses a repository interface to look up cached file_id values before falling back to local file upload.

<?php

declare(strict_types=1);

interface MediaCacheInterface
{
public function getFileId(string $fileHash): ?string;
public function setFileId(string $fileHash, string $fileId): void;
}

class TelegramMediaSender
{
public function __construct(
private string $botToken,
private MediaCacheInterface $cache
) {}

public function sendPhoto(int|string $chatId, string $localFilePath, string $caption = ''): string
{
if (!file_exists($localFilePath)) {
throw new InvalidArgumentException("Local file missing: {$localFilePath}");
}

$fileHash = md5_file($localFilePath);
if ($fileHash === false) {
throw new RuntimeException("Unable to calculate hash for: {$localFilePath}");
}

$cachedFileId = $this->cache->getFileId($fileHash);
$cleanCaption = htmlspecialchars($caption, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');

if ($cachedFileId !== null) {
$payload = [
'chat_id' => $chatId,
'photo' => $cachedFileId,
'caption' => $cleanCaption,
'parse_mode' => 'HTML',
];
$result = $this->executeApi('sendPhoto', $payload, false);
} else {
$payload = [
'chat_id' => $chatId,
'photo' => new CURLFile($localFilePath),
'caption' => $cleanCaption,
'parse_mode' => 'HTML',
];
$result = $this->executeApi('sendPhoto', $payload, true);

if (isset($result['photo']) && is_array($result['photo'])) {
$largestPhoto = end($result['photo']);
if (isset($largestPhoto['file_id'])) {
$this->cache->setFileId($fileHash, (string) $largestPhoto['file_id']);
}
}
}

return (string) $result['message_id'];
}

private function executeApi(string $method, array $params, bool $isMultipart): array
{
$url = sprintf('https://api.telegram.org/bot%s/%s', $this->botToken, $method);

$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 5);
curl_setopt($ch, CURLOPT_TIMEOUT, 30);

if ($isMultipart) {
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, $params);
} else {
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($params, JSON_THROW_ON_ERROR));
}

$response = curl_exec($ch);
$curlErrno = curl_errno($ch);
$curlError = curl_error($ch);
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);

if ($curlErrno !== 0) {
throw new RuntimeException("HTTP request failed with cURL error ({$curlErrno}): {$curlError}");
}

$decoded = json_decode((string) $response, true);
if (json_last_error() !== JSON_ERROR_NONE || !is_array($decoded)) {
throw new RuntimeException('Invalid JSON received from Telegram: ' . json_last_error_msg());
}

if ($httpCode !== 200 || !($decoded['ok'] ?? false)) {
$desc = $decoded['description'] ?? 'Unknown error';
throw new RuntimeException("Telegram API Error ({$httpCode}): {$desc}");
}

return $decoded['result'];
}
}

---

Step 3: Sending Files via External HTTP URL

When the source file is hosted on an external server or CDN, passing the URL string directly is faster than downloading the file to your server and re-uploading it.

Note the payload difference: the parameter receives a URL string rather than a CURLFile instance, allowing the request payload to be formatted as standard JSON (application/json).

<?php

declare(strict_types=1);

function sendPhotoUrl(string $botToken, int|string $chatId, string $imageUrl, string $caption = ''): array
{
$url = sprintf('https://api.telegram.org/bot%s/sendPhoto', $botToken);

$payload = [
'chat_id' => $chatId,
'photo' => $imageUrl,
'caption' => htmlspecialchars($caption, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'),
'parse_mode' => 'HTML',
];

$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
CURLOPT_POSTFIELDS => json_encode($payload, JSON_THROW_ON_ERROR),
CURLOPT_CONNECTTIMEOUT => 5,
CURLOPT_TIMEOUT => 15,
]);

$response = curl_exec($ch);
$curlErrno = curl_errno($ch);
$curlError = curl_error($ch);
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);

if ($curlErrno !== 0) {
throw new RuntimeException("cURL failed: {$curlError}");
}

$decoded = json_decode((string) $response, true);
if (json_last_error() !== JSON_ERROR_NONE) {
throw new RuntimeException("JSON Parse error: " . json_last_error_msg());
}

if ($httpCode !== 200 || !($decoded['ok'] ?? false)) {
$error = $decoded['description'] ?? 'Failed to send photo via URL';
throw new RuntimeException("Telegram API error [{$httpCode}]: {$error}");
}

return $decoded['result'];
}

---

Production Notes & Error Handling

### 1. Handling HTTP 429 (Rate Limits) When sending bulk media or large files to multiple users, Telegram may return HTTP status code 429 Too Many Requests with a JSON payload containing parameters.retry_after. Your background worker processing uploads should catch this error code and pause processing for the returned duration:

if ($httpCode === 429 && isset($decoded['parameters']['retry_after'])) {
$retryAfterSeconds = (int) $decoded['parameters']['retry_after'];
sleep($retryAfterSeconds);
// Re-attempt request delivery
}

### 2. Adjusting cURL Timeouts for Large Uploads Default short timeouts (e.g., 5 to 10 seconds) will cause failures when uploading 30–50 MB documents over limited bandwidth. Set socket timeouts according to file volume:

- CURLOPT_CONNECTTIMEOUT: Keep short (e.g., 5-10 seconds) to detect network connection failures quickly. - CURLOPT_TIMEOUT: Scale relative to file size (e.g., 60 to 180 seconds for 50 MB uploads).

### 3. Memory Optimization with CURLFile CURLFile reads data directly from the disk stream into the socket buffer during HTTP execution. Avoid loading file contents into memory variables using functions like file_get_contents() prior to making upload requests, as doing so will cause high RAM overhead when running multiple concurrent processes.

---

Need custom integration architecture, webhook handlers, or complex media processing pipelines for Telegram? BotCreator — studio that ships Telegram bots / Mini Apps. You can also explore technical details in their guide at

New articles on Telegram

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