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_datastays under the 64-byte limit the Bot API enforces, - receive an
Updatewith acallback_query, - call
answerCallbackQueryso 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:falseenvelope 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,, and a script that
"error_code":400,"description":"Bad Request: ..."}
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, before you splice it into the HTML. The Bot API does not
'UTF-8')
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_tokenwithsetWebhook. Telegram will send it in theX-Telegram-Bot-Api-Secret-Tokenheader. 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 highestupdate_idyou 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. WraptgApiso 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=payloadis only fired on/start, not on inline keyboards. If your keyboard needs to open a private chat with a payload, generate anhttps://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. -
answerCallbackQueryis called within ~30 seconds, even on errors. - HTTP status is 200 and JSON body has
"ok":truebefore 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.
Top comments (0)