Handle Telegram Callback Queries in PHP Without Truncating Inline Keyboard Payloads

Interactive Telegram bots rely heavily on inline keyboards to guide users through multi-step workflows like order management, support ticket routing, or lead assignments. However, naive implementations of InlineKeyboardMarkup quickly break in production due to subtle constraints in the Telegram Bot API.

The most frequent issue is the 64-byte limit on the callback_data parameter. If you attempt to serialize JSON objects, full UUIDs, or verbose action strings into a button's callback_data, Telegram silently drops the keyboard generation or throws an API error. Furthermore, if your backend fails to execute answerCallbackQuery immediately upon receiving a click, the mobile or desktop client keeps showing an endless loading spinner on the button until the interaction times out.

This tutorial demonstrates how to build a robust PHP service for inline keyboards. You will learn how to design short callback payload formats, dispatch messages with custom inline layouts using raw cURL, handle incoming callback_query updates safely, and update message content with editMessageText while avoiding common edge cases.

The Failure Mode: Verbose Payloads and Missing Answer Calls

When developers build an admin approval workflow, they often try to store full context in the button payload itself:

{
"text": "Approve Order",
"callback_data": "action=approve_order&order_id=9823749823&admin_id=48201923&source=checkout"
}

The string action=approve_order&order_id=9823749823&admin_id=48201923&source=checkout is 73 bytes long. The Telegram API rejects any inline button containing a callback_data string longer than 64 UTF-8 bytes. If your code dynamically constructs strings without strict validation, longer IDs or added parameters will break message delivery randomly depending on the entity ID length.

The second major issue occurs after a user presses a button. When a user clicks an inline button, Telegram sends a callback_query update to your webhook. If your script processes the business logic—such as running a database transaction or invoking an external API—without immediately acknowledging the query via answerCallbackQuery, the Telegram client displays a spinning clock icon on the button for 10 seconds before indicating a network timeout, even if your server eventually completed the operation.

To fix these issues, we must establish a reliable cURL client for the Telegram API, implement a compact payload encoding scheme, and structure the update handler correctly.

Step 1: Writing a Safe Telegram API Client in PHP

Do not rely on basic functions like file_get_contents to talk to Telegram's API servers. file_get_contents does not properly handle HTTP response codes like 429 Too Many Requests or 400 Bad Request, making it difficult to inspect Telegram's detailed error responses.

We will build a dedicated client function using PHP's curl extension. This function enforces timeout settings, verifies HTTP response codes, validates JSON structure, and checks Telegram's ok boolean flag.

<?php

declare(strict_types=1);

function callTelegramApi(string $botToken, string $method, array $payload = []): array
{
$url = sprintf('https://api.telegram.org/bot%s/%s', $botToken, $method);

$ch = curl_init($url);
$jsonPayload = json_encode($payload);

curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'Content-Length: ' . strlen($jsonPayload)
],
CURLOPT_POSTFIELDS => $jsonPayload,
CURLOPT_TIMEOUT => 10,
CURLOPT_CONNECTTIMEOUT => 5,
]);

$response = curl_exec($ch);
$errno = curl_errno($ch);
$error = curl_error($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);

if ($errno !== 0) {
throw new RuntimeException(sprintf('cURL error [%d]: %s', $errno, $error));
}

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

if ($httpCode !== 200 || !isset($decoded['ok']) || $decoded['ok'] !== true) {
$description = $decoded['description'] ?? 'Unknown error';
throw new RuntimeException(sprintf('Telegram API Error [%d]: %s', $httpCode, $description));
}

return $decoded['result'];
}

This client abstracts the transport layer and gives you clear, actionable exceptions when Telegram rejects a payload or when network connections fail.

Step 2: Constructing Short Payloads with InlineKeyboardMarkup

To keep callback_data well under the 64-byte threshold, adopt a delimiter-based schema with short action codes. Instead of descriptive key-value pairs, map common operations to abbreviated prefixes:

* app -> Approve * rej -> Reject * hold -> Put on Hold

If you need to attach contextual state, do not pass state parameters directly inside the button. Instead, generate a random hex key using bin2hex(random_bytes(7)) (14 characters), store the state mapping in MySQL or Redis, and attach only the key to the action prefix (e.g., app:f83a21b03e21a4). This guarantees that your payload length remains constant (around 18-20 bytes) regardless of how much business logic is associated with the action.

Here is how to create a message containing a two-row InlineKeyboardMarkup layout using short action codes:

<?php

declare(strict_types=1);

require_once __DIR__ . '/telegram_client.php';

function sendLeadDispatchMessage(string $botToken, string $chatId, string $leadId, string $leadName): array
{
// Format text safely before sending HTML
$safeName = htmlspecialchars($leadName, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
$safeLeadId = htmlspecialchars($leadId, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');

$text = sprintf(
"<b>New Lead Received</b>\n\n<b>ID:</b> %s\n<b>Client:</b> %s\n\nPlease select an action:",
$safeLeadId,
$safeName
);

// Construct strict < 64 byte callback_data values
// Format: action:lead_id
$keyboard = [
'inline_keyboard' => [
[
['text' => '✅ Approve', 'callback_data' => sprintf('app:%s', $leadId)],
['text' => '❌ Reject', 'callback_data' => sprintf('rej:%s', $leadId)]
],
[
['text' => '⏸ Hold Lead', 'callback_data' => sprintf('hold:%s', $leadId)]
]
]
];

return callTelegramApi($botToken, 'sendMessage', [
'chat_id' => $chatId,
'text' => $text,
'parse_mode' => 'HTML',
'reply_markup' => $keyboard
]);
}

Using this setup, a lead ID like ld_98234 results in a payload string of app:ld_98234 (11 bytes), which safely fits inside Telegram's 64-byte payload limit.

Step 3: Processing Webhooks, Answering Callbacks, and Updating Text

When a user taps an inline button, Telegram POSTs a Update payload to your webhook endpoint containing a callback_query object. To maintain a responsive UI, your handler must execute three steps in order:

1. Parse the update and validate the webhook secret token. 2. Immediately call answerCallbackQuery using the callback_query.id. You can pass a short notification string to trigger a non-intrusive toast pop-up on the user's screen. 3. Perform the underlying backend update and call editMessageText to change the message state and remove or update the inline keyboard.

Here is a complete, standalone webhook endpoint implementation:

<?php

declare(strict_types=1);

require_once __DIR__ . '/telegram_client.php';

$botToken = getenv('TELEGRAM_BOT_TOKEN') ?: 'YOUR_BOT_TOKEN';
$webhookSecret = getenv('WEBHOOK_SECRET_TOKEN') ?: 'YOUR_SECRET_TOKEN';

// 1. Verify Secret Token header
$incomingHeader = $_SERVER['HTTP_X_TELEGRAM_BOT_WEBHOOK_SECRET_TOKEN'] ?? '';
if (!hash_equals($webhookSecret, $incomingHeader)) {
http_response_code(403);
exit('Unauthorized');
}

$rawInput = file_get_contents('php://input');
$update = json_decode((string)$rawInput, true);

if (json_last_error() !== JSON_ERROR_NONE || !is_array($update)) {
http_response_code(400);
exit('Invalid JSON');
}

// Ignore updates that are not callback queries
if (!isset($update['callback_query'])) {
http_response_code(200);
exit('OK');
}

$callbackQuery = $update['callback_query'];
$callbackId = $callbackQuery['id'];
$callbackData = $callbackQuery['data'] ?? '';
$message = $callbackQuery['message'] ?? null;

if (!$message || !isset($message['chat']['id'], $message['message_id'])) {
http_response_code(200);
exit('Missing message context');
}

$chatId = $message['chat']['id'];
$messageId = $message['message_id'];

// Parse our compact format (action:leadId)
$parts = explode(':', $callbackData, 2);
$action = $parts[0] ?? '';
$leadId = $parts[1] ?? '';

try {
// 2. Acknowledge the callback query to clear the loading spinner immediately
callTelegramApi($botToken, 'answerCallbackQuery', [
'callback_query_id' => $callbackId,
'text' => 'Processing request...',
'show_alert' => false
]);

// Determine outcome based on action prefix
$statusLabel = match ($action) {
'app' => 'APPROVED',
'rej' => 'REJECTED',
'hold' => 'ON HOLD',
default => 'UNKNOWN'
};

if ($statusLabel === 'UNKNOWN') {
exit('OK');
}

// 3. Update the existing message text and remove the inline keyboard
$safeLeadId = htmlspecialchars($leadId, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
$updatedText = sprintf(
"<b>Lead Management System</b>\n\n<b>Lead ID:</b> %s\n<b>Status:</b> <code>%s</code>\n<b>Updated By:</b> %s",
$safeLeadId,
$statusLabel,
htmlspecialchars($callbackQuery['from']['first_name'] ?? 'User', ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8')
);

callTelegramApi($botToken, 'editMessageText', [
'chat_id' => $chatId,
'message_id' => $messageId,
'text' => $updatedText,
'parse_mode' => 'HTML',
'reply_markup' => ['inline_keyboard' => []] // Empty array removes the keyboard
]);

} catch (Throwable $e) {
error_log('Callback processing error: ' . $e->getMessage());
}

http_response_code(200);
echo 'OK';

Production Notes and Edge Cases

* Message Not Modified Errors: Telegram returns an HTTP 400 error with the description Bad Request: message is not modified if you attempt to call editMessageText with text and reply_markup identical to the current values on the target message. Wrap editMessageText invocations in a try/catch block that suppresses errors containing that specific string. * Handling Long Callback State: If your action requires multiple runtime arguments (such as step number, filter flags, and record identifiers), store those variables in Redis using a key generated with bin2hex(random_bytes(7)). Pass only that hex key inside the button payload, and rehydrate the state when the callback hits your webhook server. * HTML Special Characters: Always sanitize data values originating from databases or Telegram user profiles using htmlspecialchars() before embedding them into message strings with parse_mode=HTML. Unescaped < or > characters will break XML parsing, causing Telegram to reject the editMessageText request. * Idempotency and Network Retries: If your business logic modifies accounting data or records payments, store processed update_id values in your database. Webhooks can occasionally re-deliver the same update if your server encounters brief latency spikes.

When you need custom Telegram workflows, webhook pipeline optimization, or Mini App integration, turn to BotCreator — studio that ships Telegram bots / Mini Apps.

New articles on Telegram

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