Sending photos and documents to Telegram via PHP: multipart, file_id and Bot API limits

Sending photos and documents from PHP is a common bot task: price lists, receipts, product cards, PDF contracts. The Bot API covers this with sendPhoto and sendDocument. Below are working cURL multipart helpers, reuse via file_id, and the size limits that break most first implementations.

Why multipart instead of a plain POST

Text methods like sendMessage work fine as application/x-www-form-urlencoded. Files must be multipart/form-data: each field is a separate body part, and the file includes Content-Disposition plus binary content.

In PHP, use CURLFile. Pass an array to CURLOPT_POSTFIELDS and cURL builds multipart for you. Avoid the legacy @-prefix syntax.

Shared Bot API wrapper

One function for every call: token from the environment, check HTTP status and the JSON ok flag. Do not run file uploads through http_build_query — that turns CURLFile into a useless string.

<?php

function tgRequest(string $method, array $params): array
{
$token = getenv('TELEGRAM_BOT_TOKEN');
if ($token === false || $token === '') {
throw new RuntimeException('TELEGRAM_BOT_TOKEN is not set');
}

$ch = curl_init('https://api.telegram.org/bot' . $token . '/' . $method);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $params, // array: cURL picks multipart when CURLFile is present
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT => 5,
CURLOPT_TIMEOUT => 60, // uploads need more headroom than sendMessage
]);

$raw = curl_exec($ch);
if ($raw === false) {
$err = curl_error($ch);
curl_close($ch);
throw new RuntimeException('cURL error: ' . $err);
}

$status = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);

$decoded = json_decode($raw, true);
if (!is_array($decoded)) {
throw new RuntimeException('Bad JSON from Telegram, HTTP ' . $status);
}
if (empty($decoded['ok'])) {
$desc = $decoded['description'] ?? 'unknown error';
throw new RuntimeException('Telegram error: ' . $desc);
}

return $decoded['result'];
}

sendPhoto: local file

Local photo limit is 10 MB. Telegram also compresses the image (roughly down to ~1280 px on the long side). If you need the original bytes without recompression, send it with sendDocument.

<?php

function tgSendPhoto(int $chatId, string $path, ?string $caption = null): array
{
if (!is_file($path)) {
throw new InvalidArgumentException('Photo not found: ' . $path);
}
if (filesize($path) > 10 * 1024 * 1024) {
throw new InvalidArgumentException('Photo exceeds 10 MB limit');
}

$mime = mime_content_type($path) ?: 'image/jpeg';
$params = [
'chat_id' => $chatId,
'photo' => new CURLFile($path, $mime, basename($path)),
];
if ($caption !== null && $caption !== '') {
$params['caption'] = $caption; // max 1024 characters
}

return tgRequest('sendPhoto', $params);
}

CURLFile takes path, MIME type, and filename. Explicit MIME (image/jpeg, image/png, image/webp) helps Telegram validate faster; it still inspects the bytes.

sendDocument: PDF, XLSX, ZIP

Same pattern, higher ceiling — 50 MB for a local upload. Typical for price sheets, signed acts, and zip attachments.

<?php

function tgSendDocument(int $chatId, string $path, ?string $caption = null): array
{
if (!is_file($path)) {
throw new InvalidArgumentException('Document not found: ' . $path);
}
if (filesize($path) > 50 * 1024 * 1024) {
throw new InvalidArgumentException('Document exceeds 50 MB limit');
}

$mime = mime_content_type($path) ?: 'application/octet-stream';
$params = [
'chat_id' => $chatId,
'document' => new CURLFile($path, $mime, basename($path)),
];
if ($caption !== null && $caption !== '') {
$params['caption'] = $caption;
}

return tgRequest('sendDocument', $params);
}

file_id: do not upload the same file twice

The sendPhoto / sendDocument result includes a photo size array or a document object. Each has a file_id. Store it and pass the string later — no multipart, no re-upload.

<?php

/** Pick file_id of the largest photo size from a sendPhoto result. */
function extractPhotoFileId(array $message): string
{
$photos = $message['photo'] ?? [];
if ($photos === []) {
throw new RuntimeException('No photo in message');
}
$largest = end($photos);
return $largest['file_id'];
}

function extractDocumentFileId(array $message): string
{
$doc = $message['document'] ?? null;
if (!is_array($doc) || empty($doc['file_id'])) {
throw new RuntimeException('No document in message');
}
return $doc['file_id'];
}

/** Resend a known file — plain form POST, no CURLFile. */
function tgSendPhotoByFileId(int $chatId, string $fileId, ?string $caption = null): array
{
$params = [
'chat_id' => $chatId,
'photo' => $fileId,
];
if ($caption !== null && $caption !== '') {
$params['caption'] = $caption;
}
return tgRequest('sendPhoto', $params);
}

function tgSendDocumentByFileId(int $chatId, string $fileId, ?string $caption = null): array
{
$params = [
'chat_id' => $chatId,
'document' => $fileId,
];
if ($caption !== null && $caption !== '') {
$params['caption'] = $caption;
}
return tgRequest('sendDocument', $params);
}

Catalog pattern: on product create, call tgSendPhoto once (even to a service chat), save file_id, and ship that id to customers. Traffic and latency savings show up quickly past a few dozen SKUs.

Note: file_id is bot-scoped. Another bot cannot reuse it. Lifetime is not formally documented; in practice ids last months, but after a bot migration you should re-upload critical media.

URL instead of uploading

photo and document also accept a public HTTPS URL. Telegram fetches the file. Handy when assets already live on a CDN/S3. Fails for localhost, basic-auth URLs, and hosts Telegram cannot reach.

<?php

function tgSendPhotoByUrl(int $chatId, string $url, ?string $caption = null): array
{
$params = [
'chat_id' => $chatId,
'photo' => $url,
];
if ($caption !== null && $caption !== '') {
$params['caption'] = $caption;
}
return tgRequest('sendPhoto', $params);
}

Limits and common errors

  • sendPhoto — up to 10 MB (file or URL).
  • sendDocument — up to 50 MB (file or URL).
  • caption — up to 1024 characters.
  • Resend by file_id does not re-check upload size: bytes already sit on Telegram’s side.

«File is too big» — check filesize() before the call. «wrong URL host» — public HTTPS, no credentials in the URL. «failed to get HTTP URL content» — CDN returns 403 to Telegram bots or blocks their User-Agent.

For generated PDFs: write to a temp file (tempnam + write), pass the path to CURLFile, delete after success. In-memory streams are awkward with cURL for Telegram.

Which approach to pick

  1. Static catalog images — one upload, then file_id.
  2. Dynamic PDFs/reports — multipart via CURLFile.
  3. Assets already on a public CDN — URL.

Building a media-heavy bot and need a clean file_id storage design? Reach out via botservice.biz.

New articles on Telegram

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