Handle Telegram InlineKeyboardMarkup in PHP: callback_query, answerCallbackQuery, and the 64-byte payload limit

InlineKeyboardMarkup in PHP

This tutorial builds a small PHP handler for Telegram inline keyboards. You will:

  • Build buttons whose callback_data stays under the 64-byte Bot API limit
  • Receive an update with callback_query
  • Call answerCallbackQuery so the button spinner stops
  • Update the message with editMessageReplyMarkup
  • Use a tiny cURL wrapper that checks HTTP status and Telegram's ok flag

This is not a full bot framework. No DI container, queue, or webhook secret here — those are separate topics. The goal is a pasteable staging path you can extend.

1. Bot API client

Check both the HTTP status and the JSON ok field. Telegram often returns HTTP 200 with {"ok":false,...}.

<?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'];
}

Load the token from the environment. Never hardcode it or echo it in errors.

2. Building InlineKeyboardMarkup

InlineKeyboardMarkup is a JSON array of rows of buttons. Each button has text and one action field: callback_data, url, web_app, or a switch_inline_query_* field. Here we use callback_data, which fires a callback_query on your webhook.

Hard limit: callback_data is at most 64 UTF-8 bytes (bytes, not characters). JSON blobs usually blow that budget and you get BUTTON_DATA_INVALID.

Ship a short opaque token and resolve it server-side. Below, l:42 / d:42 stay well under 64 bytes.

<?php
// keyboards.php

require_once __DIR__ . '/telegram.php';

function likeDislikeKeyboard(int $postId, ?int $currentVote = null): array
{
// "l:1234567890" -> at most ~12 bytes
$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],
],
],
];
}

function sendPostWithKeyboard(int $chatId, string $text, int $postId): array
{
return tgApi('sendMessage', [
'chat_id' => $chatId,
'text' => $text,
'reply_markup' => json_encode(likeDislikeKeyboard($postId)),
]);
}

For one-shot confirmations, prefer bin2hex(random_bytes(7)) (14 hex chars): insert a mapping row first, then send the keyboard.

3. Webhook: receiving callback_query

Telegram posts JSON over HTTPS. A typical callback_query looks like:

{
"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"
}
}

Always call answerCallbackQuery with the query id, then edit or send a follow-up. Skipping the answer leaves the button spinning until the client times out.

<?php
// webhook.php

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') {
sendPostWithKeyboard(
(int) $update['message']['chat']['id'],
'Demo post — try the buttons.',
42
);
}
} catch (Throwable $e) {
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);

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];
$current = recordVote($chatId, $postId, $vote);

tgApi('answerCallbackQuery', [
'callback_query_id' => $queryId,
'text' => 'Recorded.',
]);

tgApi('editMessageReplyMarkup', [
'chat_id' => $chatId,
'message_id' => $messageId,
'reply_markup' => json_encode(likeDislikeKeyboard($postId, $current)),
]);
}

function recordVote(int $chatId, int $postId, int $vote): int
{
static $store = [];
$store[$chatId][$postId] = ($store[$chatId][$postId] ?? 0) === $vote ? 0 : $vote;
return $store[$chatId][$postId];
}

Three practical points:

  • Answer the callback even if the later edit fails. Telegram expects an answer within about 30 seconds.
  • editMessageText / editMessageReplyMarkup can return “message is not modified” for identical content — treat that as a no-op, not a retry.
  • With parse_mode=HTML, escape user input with htmlspecialchars before splicing it into HTML.

4. Production notes

  • Webhook secret. Set secret_token via setWebhook and verify X-Telegram-Bot-Api-Secret-Token.
  • Idempotency. Persist processed update_id values and drop duplicates.
  • Rate limits. On 429, honor parameters.retry_after. For volume, queue outbound calls per chat.
  • callback_data design. Stay under 64 bytes. Prefer short tokens over verbose strings.
  • Deep links. t.me/Bot?start=payload only applies on /start. To open a private chat with a payload from a keyboard, use a URL button.

5. Checklist

  • callback_data ≤ 64 UTF-8 bytes
  • answerCallbackQuery within ~30 seconds, even on errors
  • HTTP 200 and "ok":true before treating a call as success
  • HTML-escape user-controlled text when using parse_mode=HTML
  • Webhook secret checked; duplicate updates dropped by update_id

Keyboard on send, callback on receive, answer plus edit, short payload — that is the whole loop.

Need more than a pasteable snippet for keyboards, Mini Apps, or payments? Start at botservice.biz.

New articles on Telegram

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