Handle Telegram InlineKeyboardMarkup in PHP: callback_query, answerCallbackQuery, and the 64-byte payload limit
In this tutorial we build a small, self-contained PHP handler that shows how to use InlineKeyboardMarkup from the Telegram Bot API correctly. We will:
- build a keyboard of buttons whose callback_data stays under the 64-byte limit the Bot API enforces, - receive an Update with a callback_query, - call answerCallbackQuery so the user stops seeing the loading spinner on the button, - edit the original message with editMessageText, - keep all calls inside a tiny cURL wrapper that checks HTTP status and the ok:false envelope that Telegram returns on errors.
We will not try to be a full bot framework. There is no DI container, no queue, no webhook secret token here — those are separate problems. The goal is the smallest working path that you can paste into a staging endpoint and then extend.
1. The Bot API client
Two things matter when you call the Telegram Bot API from PHP: the HTTP status code returned by transport, and the ok flag inside the JSON body. Telegram will happily return HTTP 200 with {"ok":false, "error_code":400,"description":"Bad Request: ..."}, and a script that only checks curl_getinfo($ch, CURLINFO_HTTP_CODE) will miss it.
<?php
// telegram.php
function tgApi(string $method, array $params, ?string $token = null): array
{
$token = $token ?? getenv('TELEGRAM_BOT_TOKEN');
if (!$token) {
throw new RuntimeException('TELEGRAM_BOT_TOKEN is not set');
}
$url = 'https://api.telegram.org/bot' . $token . '/' . $method;
$body = http_build_query($params, '', '&');
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $body,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 10,
CURLOPT_CONNECTTIMEOUT => 5,
]);
$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);
if ($status !== 200) {
throw new RuntimeException('HTTP ' . $status . ' from ' . $method);
}
$decoded = json_decode($raw, true);
if (json_last_error() !== JSON_ERROR_NONE) {
throw new RuntimeException('JSON decode failed: ' . json_last_error_msg());
}
if (!is_array($decoded) || !($decoded['ok'] ?? false)) {
$desc = $decoded['description'] ?? 'unknown error';
$code = $decoded['error_code'] ?? 0;
throw new RuntimeException('Telegram ok:false (' . $code . '): ' . $desc);
}
return $decoded['result'];
}
The token is loaded from the environment with getenv. Never hardcode it, never commit it, and never echo it back in an error response. If you prefer a config file, read it once at boot and inject it into this function; the contract stays the same.
2. Building an InlineKeyboardMarkup
InlineKeyboardMarkup is just a JSON array of rows, each row being an array of InlineKeyboardButton objects. A button has a text and one of several action fields: callback_data, url, web_app, or a switch_inline_query_* field. For this tutorial we only use callback_data, which is what fires a callback_query update that the bot receives on its webhook.
The hard constraint: callback_data is a UTF-8 string of at most 64 bytes. Count bytes, not characters. A common bug is to put a numeric id and a label into a JSON-encoded structure and ship it as callback_data — the JSON alone is more than 64 bytes for almost any non-trivial object, and your keyboard will be silently dropped with a Bad Request: BUTTON_DATA_INVALID error.
The clean approach is to ship a short opaque token, then resolve it server-side. Below we map like:42 (7 bytes) to a stored row in PHP, and keep a separate human-readable label for the button text.
<?php
// keyboards.php
require_once __DIR__ . '/telegram.php';
/**
* Build an InlineKeyboardMarkup for a "like / dislike" pair on a post.
* $postId is the integer id of a post in your application.
* callback_data is kept strictly under 64 bytes.
*/
function likeDislikeKeyboard(int $postId, ?int $currentVote = null): array
{
// "l:1234567890" -> 1 byte + ':' + up to 10 digits = 12 bytes max.
// Plenty of headroom under 64. If your ids are larger, switch to hex.
$likePayload = 'l:' . $postId;
$dislikePayload = 'd:' . $postId;
$likeText = $currentVote === 1 ? '👍 Liked' : '👍 Like';
$dislikeText = $currentVote === -1 ? '👎 Disliked' : '👎 Dislike';
return [
'inline_keyboard' => [
[
['text' => $likeText, 'callback_data' => $likePayload],
['text' => $dislikeText, 'callback_data' => $dislikePayload],
],
],
];
}
/** Send a post message with the keyboard attached. */
function sendPostWithKeyboard(int $chatId, string $text, int $postId): array
{
return tgApi('sendMessage', [
'chat_id' => $chatId,
'text' => $text,
'reply_markup' => json_encode(likeDislikeKeyboard($postId)),
]);
}
A production variant uses bin2hex(random_bytes(7)) (14 hex chars) for short-lived operations like "confirm delete" prompts, where you generate the id, INSERT a row that maps it to your real entity, then send the keyboard. Never reverse: send the keyboard before the row exists, or two concurrent clicks can race.
3. The webhook: receiving callback_query
Telegram delivers updates as JSON over HTTPS. For callback_query, the shape is:
{
"update_id": 123456789,
"callback_query": {
"id": "...",
"from": {"id": 111, "is_bot": false, "first_name": "..."},
"chat_instance": "...",
"message": {
"message_id": 17,
"chat": {"id": 111, "type": "private"},
"date": 1700000000,
"text": "..."
},
"data": "l:42"
}
}
You must do two things after receiving it: call answerCallbackQuery with the query id, and either edit the message or send a new one. The answerCallbackQuery call is what closes the "loading…" indicator on the button; the user sees a tiny notification (text passed as text parameter) and the spinner goes away. Skipping it is not optional in production: the user will see their button stuck until the Telegram client times out.
<?php
// webhook.php (front controller)
declare(strict_types=1);
require_once __DIR__ . '/telegram.php';
require_once __DIR__ . '/keyboards.php';
header('Content-Type: application/json');
$raw = file_get_contents('php://input');
if ($raw === false || $raw === '') {
http_response_code(400);
echo json_encode(['error' => 'empty body']);
return;
}
$update = json_decode($raw, true);
if (json_last_error() !== JSON_ERROR_NONE || !is_array($update)) {
http_response_code(400);
echo json_encode(['error' => 'bad json']);
return;
}
try {
if (isset($update['callback_query'])) {
handleCallback($update['callback_query']);
} elseif (isset($update['message']['text']) && $update['message']['text'] === '/start') {
// Demo: send a post with like/dislike.
sendPostWithKeyboard(
(int) $update['message']['chat']['id'],
'Demo post — try the buttons.',
42
);
}
} catch (Throwable $e) {
// Log and still return 200 so Telegram does not retry forever.
error_log('[telegram] ' . $e->getMessage());
}
http_response_code(200);
echo '{"ok":true}';
function handleCallback(array $cq): void
{
$queryId = (string) $cq['id'];
$data = (string) ($cq['data'] ?? '');
$message = $cq['message'] ?? null;
$chatId = (int) ($message['chat']['id'] ?? 0);
$messageId = (int) ($message['message_id'] ?? 0);
// Parse "l:42" / "d:42". Anything malformed -> answer with an alert.
if (!preg_match('/^([ld]):(\d{1,10})$/', $data, $m)) {
tgApi('answerCallbackQuery', [
'callback_query_id' => $queryId,
'text' => 'Unknown action.',
'show_alert' => true,
]);
return;
}
$vote = $m[1] === 'l' ? 1 : -1;
$postId = (int) $m[2];
// Persist the vote. Implementation depends on your storage; the point
// here is that we resolve the opaque payload into a domain object.
$current = recordVote($chatId, $postId, $vote);
// 1) Always answer the callback so the client stops spinning.
tgApi('answerCallbackQuery', [
'callback_query_id' => $queryId,
'text' => 'Recorded.',
]);
// 2) Optionally edit the original message so the button reflects state.
tgApi('editMessageReplyMarkup', [
'chat_id' => $chatId,
'message_id' => $messageId,
'reply_markup' => json_encode(likeDislikeKeyboard($postId, $current)),
]);
}
function recordVote(int $chatId, int $postId, int $vote): int
{
// Placeholder: real implementation would UPSERT into your DB and
// return the resulting vote state for this (chatId, postId) pair.
// We just echo the new state so the keyboard updates visibly.
static $store = [];
$store[$chatId][$postId] = ($store[$chatId][$postId] ?? 0) === $vote ? 0 : $vote;
return $store[$chatId][$postId];
}
Three things to notice.
First, answerCallbackQuery is called unconditionally even if the follow-up editMessageReplyMarkup throws. Wrap the edit in its own try/catch if you want to keep the answer regardless. The Telegram docs are explicit: a callback query must be answered within ~30 seconds or the client stops waiting.
Second, editMessageText and editMessageReplyMarkup will both raise Bad Request: message is not modified if you submit identical content. That is not an error worth retrying; treat it as a no-op. You can detect it by inspecting the thrown exception message before logging.
Third, when you build a new message text with parse_mode=HTML, escape user input with htmlspecialchars($s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') before you splice it into the HTML. The Bot API does not parse Markdown in any safe way, and HTML injection from a chat is a real risk if you splice names into the response.
4. Production notes (labeled as such)
These are the items that turn the snippet above into something you can ship. None of them are required for the tutorial to work in staging.
- Webhook secret. Set a secret_token with setWebhook. Telegram will send it in the X-Telegram-Bot-Api-Secret-Token header. Reject any request where the header is absent or does not match. This stops random actors from posting forged updates at your endpoint. - Idempotency. Telegram may redeliver the same update_id. Store the highest update_id you have processed (DB or Redis) and drop duplicates. Do not rely on a single in-memory cursor; it is lost on restart and breaks the moment you run more than one worker. - Rate limits. The Bot API returns 429 with parameters.retry_after. Wrap tgApi so it sleeps and retries a bounded number of times; do not loop forever. For high-volume bots, push calls into an outbound queue keyed by chat id, since Telegram also limits per-chat floods. - callback_data design. Stay well under 64 bytes; budget the prefix too. If you need to encode more than one field, prefer a short token over a verbose string and resolve it server-side. Hex from bin2hex(random_bytes(7)) gives 14 chars of entropy, which is plenty for a confirmation prompt and trivially fits. - Deep links. t.me/YourBot?start=payload is only fired on /start, not on inline keyboards. If your keyboard needs to open a private chat with a payload, generate an https://t.me/YourBot?start=... URL button instead of a callback button.
5. Quick sanity checklist
- callback_data ≤ 64 bytes, counted as UTF-8 bytes, not characters. - answerCallbackQuery is called within ~30 seconds, even on errors. - HTTP status is 200 and JSON body has "ok":true before you treat the call as successful. - HTML-escape anything user-controlled before parse_mode=HTML. - Webhook secret checked, duplicate updates dropped by update_id.
That is the whole loop: keyboard on send, callback on receive, answer + edit, short payload. Once those pieces click, the rest of the inline-keyboard surface (switch_inline_query, web_app, paginated lists) is just variations on the same plumbing.
If you end up shipping a non-trivial bot around these primitives — custom keyboards, Mini Apps, payment flows — BotCreator is a studio that ships Telegram bots and Mini Apps end to end. For a quick API reference alongside this tutorial, the Telegram Bot API docs page is a reasonable bookmark.