Imagine building a real-time order management dashboard inside Telegram for an e-commerce platform or restaurant network. When a customer places an order, your server dispatches an alert message to a staff channel. Attached to this message is an inline keyboard featuring two action buttons: Accept Order and Reject Order.
When a store manager taps Accept Order, the bot must execute three operations: acknowledge the user's touch feedback, update the order status in the database, and replace the inline action buttons with a static confirmation text such as *Order #8491 accepted by Manager Alex*.
If you implement this naively, two critical technical failures will break your production system: 1. Payload truncation errors: Telegram strictly limits the callback_data field of inline keyboard buttons to 64 bytes. If you attempt to pass verbose JSON payloads, query strings, or uncompressed UUIDs like action=accept_order&order_id=9f82d103-5182-4112-9102-182930192841, Telegram rejects the message creation with a 400 Bad Request: BUTTON_DATA_INVALID response. 2. UI freezing and timeouts: When a user taps an inline button, the Telegram client displays a continuous loading spinner over the button until the server explicitly calls the answerCallbackQuery Telegram API method. If your PHP code spends time querying databases or calling third-party API webhooks before acknowledging the event, the client interface hangs for up to 60 seconds before failing with a timeout error.
In this walkthrough, we will design a robust, clean PHP implementation that encodes compressed, ultra-lightweight payload structures, instantly answers incoming callback queries to eliminate client-side UI lag, and safely updates message layouts in-place using editMessageText.
Designing Short Callbacks Under Telegram's 64-Byte Limit
Telegram's InlineKeyboardButton object allows developers to associate arbitrary string data with a button using the callback_data parameter. However, this parameter is subject to an unforgiving strict boundary: maximum 64 bytes in UTF-8 encoding.
Developers migrating from traditional Web development frequently try to pass serialized state or RESTful query strings inside buttons. Let's look at why standard serialized formats fail under this limit:
// Fails: 78 bytes (exceeds 64-byte limit)
{"action":"approve_refund","customer_id":82914,"transaction_id":"tx_928104820"}
// Fails: 68 bytes (exceeds 64-byte limit)
action=approve_refund&customer_id=82914&transaction_id=tx_928104820
To safely operate within Telegram's constraints, you must adopt a segmented, prefix-based schema using single-character delimiters (such as colons or pipes) and shortened action verb mappings.
For example, instead of action=approve_refund, use a short code like a_ref. Instead of storing long database primary keys or descriptive strings in the payload, look up contextual information on the server side using short integer IDs or condensed hexadecimal keys.
// Valid: 21 bytes (well under the 64-byte limit)
ord:acc:82914:928104
The following PHP helper script demonstrates how to assemble a clean inline keyboard payload matrix using cURL to send a message to Telegram, complete with proper status code validation and error checking:
<?php
declare(strict_types=1);
function sendOrderNotification(string $chatId, int $orderId, string $customerName, float $totalAmount): array
{
$botToken = getenv('TELEGRAM_BOT_TOKEN');
if (!$botToken) {
throw new RuntimeException('TELEGRAM_BOT_TOKEN environment variable is missing.');
}
// Construct compact callback payloads: verb:action:id
// "ord:acc:8491" = 12 bytes
// "ord:rej:8491" = 12 bytes
$acceptPayload = sprintf('ord:acc:%d', $orderId);
$rejectPayload = sprintf('ord:rej:%d', $orderId);
if (strlen($acceptPayload) > 64 || strlen($rejectPayload) > 64) {
throw new InvalidArgumentException('callback_data string exceeds Telegram 64-byte limit.');
}
$keyboard = [
'inline_keyboard' => [
[
['text' => '✅ Accept Order', 'callback_data' => $acceptPayload],
['text' => '❌ Reject Order', 'callback_data' => $rejectPayload],
]
]
];
$payload = [
'chat_id' => $chatId,
'text' => sprintf("<b>New Order #%d</b>\nCustomer: %s\nTotal: $%.2f", $orderId, htmlspecialchars($customerName, ENT_QUOTES, 'UTF-8'), $totalAmount),
'parse_mode' => 'HTML',
'reply_markup' => $keyboard,
];
$ch = curl_init(sprintf('https://api.telegram.org/bot%s/sendMessage', $botToken));
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($payload),
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 5,
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
if ($response === false) {
$error = curl_error($ch);
curl_close($ch);
throw new RuntimeException('cURL request failed: ' . $error);
}
curl_close($ch);
$decoded = json_decode($response, true);
if (json_last_error() !== JSON_ERROR_NONE || $httpCode !== 200 || !($decoded['ok'] ?? false)) {
throw new RuntimeException(sprintf('Telegram API Error [%d]: %s', $httpCode, $decoded['description'] ?? 'Unknown error'));
}
return $decoded;
}
Handling Callback Queries and Silencing Client UI Spinners
When a user clicks an inline keyboard button, Telegram does not deliver a standard message update object to your webhook. Instead, it dispatches a callback_query object. This object contains: - id: A unique query identifier required to answer the click event. - from: The user object who pressed the button. - message: The original message object to which the keyboard was attached. - data: The exact string payload defined in callback_data.
When Telegram sends this event to your webhook, the client app shows a loading spinner on the button. The app expects your server to call answerCallbackQuery using the unique callback_query_id.
If your code takes too long or forgets to trigger answerCallbackQuery, the Telegram client interface will hang indefinitely until it times out, creating a bad user experience. To fix this, your PHP script must handle the callback query immediately upon processing the incoming webhook.
The snippet below demonstrates reading the raw PHP input stream, validating the callback query payload, and firing an immediate answerCallbackQuery acknowledgment back to Telegram:
<?php
declare(strict_types=1);
function handleIncomingWebhook(): void
{
$input = file_get_contents('php://input');
if (!$input) {
http_response_code(400);
exit('Empty request body');
}
$update = json_decode($input, true);
if (json_last_error() !== JSON_ERROR_NONE) {
http_response_code(400);
exit('Invalid JSON');
}
// Ensure this update contains a callback query
if (!isset($update['callback_query'])) {
http_response_code(200);
echo 'Ignored non-callback update';
return;
}
$callbackQuery = $update['callback_query'];
$callbackId = $callbackQuery['id'];
$rawPayload = $callbackQuery['data'] ?? '';
$userId = $callbackQuery['from']['id'];
$userName = $callbackQuery['from']['first_name'] ?? 'Staff';
// Parse our compressed payload schema: verb:action:id
$parts = explode(':', $rawPayload);
if (count($parts) !== 3 || $parts[0] !== 'ord') {
// Acknowledge invalid button taps with a notification popup alert
sendAnswerCallbackQuery($callbackId, 'Invalid button action.', true);
return;
}
$action = $parts[1]; // 'acc' or 'rej'
$orderId = (int)$parts[2];
// Send immediate acknowledgement toast to clear client loading spinner
$toastText = ($action === 'acc') ? 'Processing order acceptance...' : 'Processing order rejection...';
sendAnswerCallbackQuery($callbackId, $toastText, false);
// Proceed to edit message content and update business logic
processOrderAction($callbackQuery, $orderId, $action, $userName);
}
function sendAnswerCallbackQuery(string $callbackQueryId, string $text = '', bool $showAlert = false): bool
{
$botToken = getenv('TELEGRAM_BOT_TOKEN');
$payload = [
'callback_query_id' => $callbackQueryId,
'text' => $text,
'show_alert' => $showAlert,
];
$ch = curl_init(sprintf('https://api.telegram.org/bot%s/answerCallbackQuery', $botToken));
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($payload),
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 3,
]);
$response = curl_exec($ch);
curl_close($ch);
return $response !== false;
}
Modifying Message UI In-Place with editMessageText
Once answerCallbackQuery acknowledges the user's action, you must update the interface state in Telegram to prevent duplicate taps or double-processing.
Instead of deleting the old message and issuing a new sendMessage call (which clutters chat history and sends duplicate push notifications), use editMessageText. This method allows you to alter the message body and simultaneously pass an empty reply_markup array, stripping the interactive buttons from the chat.
However, a common edge case occurs when calling editMessageText: if you attempt to edit a message with the exact same text and inline keyboard layout that it already contains, Telegram throws an HTTP 400 error containing the string Bad Request: message is not modified.
Your code must gracefully capture and handle this specific error condition so that secondary taps or concurrent requests do not trigger unhandled exceptions in your application error logs.
Here is how to update the original message in-place and remove the action buttons safely:
<?php
declare(strict_types=1);
function processOrderAction(array $callbackQuery, int $orderId, string $action, string $staffName): void
{
$botToken = getenv('TELEGRAM_BOT_TOKEN');
$chatId = $callbackQuery['message']['chat']['id'];
$messageId = $callbackQuery['message']['message_id'];
$originalText = $callbackQuery['message']['text'] ?? '';
$statusFormatted = ($action === 'acc')
? sprintf("\n\n<b>Status:</b> ✅ Accepted by %s", htmlspecialchars($staffName, ENT_QUOTES, 'UTF-8'))
: sprintf("\n\n<b>Status:</b> ❌ Rejected by %s", htmlspecialchars($staffName, ENT_QUOTES, 'UTF-8'));
$updatedText = $originalText . $statusFormatted;
$payload = [
'chat_id' => $chatId,
'message_id' => $messageId,
'text' => $updatedText,
'parse_mode' => 'HTML',
'reply_markup' => ['inline_keyboard' => []] // Remove keyboard buttons entirely
];
$ch = curl_init(sprintf('https://api.telegram.org/bot%s/editMessageText', $botToken));
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($payload),
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 5,
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($response === false) {
error_log(sprintf('Failed to edit message %d in chat %d', $messageId, $chatId));
return;
}
$decoded = json_decode($response, true);
// Handle Telegram's identical content edit exception gracefully
if ($httpCode !== 200 || !($decoded['ok'] ?? false)) {
$description = $decoded['description'] ?? '';
if (str_contains($description, 'message is not modified')) {
// Message was already updated by another concurrent manager request; safely ignore.
return;
}
error_log(sprintf('Telegram editMessageText error: %s', $description));
}
}
Production Considerations: Race Conditions, Idempotency, and Failures
When deploying inline keyboards to production environments, technical bugs usually occur around concurrency, network instability, and strict state transitions. Keep these operational realities in mind:
### Concurrent Button Taps (Race Conditions) In a shared manager channel, two team members might hit Accept Order at the exact same fraction of a second. If both requests reach your webhook before the first editMessageText call strips the buttons, your backend logic could process the order twice.
To prevent this issue, wrap state transitions inside atomic database operations or Redis distributed locks:
// Example atomic state reservation
$affectedRows = $db->execute(
'UPDATE orders SET status = :status, processed_by = :user WHERE id = :id AND status = "pending"',
[':status' => 'accepted', ':user' => $staffName, ':id' => $orderId]
);
if ($affectedRows === 0) {
// Another manager already claimed or updated this order
sendAnswerCallbackQuery($callbackId, 'Order has already been processed by another team member.', true);
return;
}
### Expiry of Historical Message Keys Telegram inline keyboards do not expire automatically. A user can scroll back through chat history and tap an inline button attached to an order message created three months ago. Ensure your server routing handles stale primary keys cleanly without throwing unhandled exceptions or fatal database errors.
### Network Delays and Webhook Deadlocks Always issue answerCallbackQuery calls with low timeout thresholds (2–3 seconds). If your application performs external HTTP integrations, database transactions, or PDF document generation, perform those long-running tasks asynchronously *after* delivering the HTTP 200 status code response to Telegram's webhook dispatcher.
If you need a team to build reliable, high-throughput Telegram integrations without running into these API limits, reach out to BotCreator — studio that ships Telegram bots / Mini Apps.