DEV Community

Serhii
Serhii

Posted on Originally published at botservice.biz

Build Interactive Telegram Inline Keyboards in PHP

Telegram inline keyboards allow bots to attach interactive buttons directly to messages. When a user clicks an inline button, Telegram delivers a callback_query update to your webhook or polling worker instead of sending a standard text message. Designing a reliable inline keyboard workflow requires understanding callback query handling, message editing, and strict payload constraints.

In this tutorial, we will build a production-ready PHP handler for Telegram InlineKeyboardMarkup. We will cover constructing inline keypads, handling callback_query updates, invoking answerCallbackQuery to prevent UI hangs, and modifying messages in-place with editMessageText. We will also examine strategies to stay strictly under Telegram's 64-byte limit for callback_data payloads.

We will not cover high-level bot frameworks, database ORMs, or polling implementations. This guide relies strictly on native PHP cURL execution and explicit array structures.

The 64-Byte Callback Data Constraint

Every inline button with a callback_data property transmits a payload back to your server when pressed. Telegram imposes a strict upper limit of 64 bytes on the callback_data string. This is a byte-count limit, not a character-count limit. If you include multi-byte UTF-8 characters (such as localized text or emojis), the character capacity drops significantly.

Attempting to send JSON blobs or extended state models inside callback_data will cause API errors. For example, a JSON payload like {"action":"view_item","item_id":"987654321","category":"electronics"} is 63 characters long, but any added metadata or multi-byte string will break the request.

To manage state efficiently within 64 bytes, adopt compact encoding schemes:

  1. Delimiter-separated short strings: Structure actions with short prefixes, such as act:id (e.g., v:101 for view item 101, e:101 for edit, d:101 for delete).
  2. Bitwise or positional parameters: Pass multiple parameters as colon-separated or pipe-separated integers (e.g., p:14:2:1 representing page 14, filter 2, sort 1).
  3. Server-side session keys: If the payload must carry complex filters or form state, store the data in Redis or a database against a short random hash (e.g., s:a1f8c9e2) generated via bin2hex(random_bytes(4)). Read the full state server-side when the callback arrives.

Delivering Messages with Inline Keyboards

An InlineKeyboardMarkup consists of an array of button rows, where each row is an array of InlineKeyboardButton objects. Below is a reusable PHP function that sends a message containing an inline keyboard using standard cURL execution.

<?php

declare(strict_types=1);

function sendTelegramRequest(string $method, array $payload): array
{
    $botToken = getenv('TELEGRAM_BOT_TOKEN');
    if (!$botToken) {
        throw new RuntimeException('TELEGRAM_BOT_TOKEN environment variable is not set.');
    }

    $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'],
        CURLOPT_POSTFIELDS => $jsonPayload,
        CURLOPT_TIMEOUT => 10,
    ]);

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

    if ($response === false) {
        throw new RuntimeException('cURL request failed: ' . $error);
    }

    $decoded = json_decode($response, true);
    if (json_last_error() !== JSON_ERROR_NONE) {
        throw new RuntimeException('Failed to decode Telegram 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'];
}

function sendInlineMenu(int|string $chatId, string $text): array
{
    $keyboard = [
        'inline_keyboard' => [
            [
                ['text' => 'Option A', 'callback_data' => 'opt:a'],
                ['text' => 'Option B', 'callback_data' => 'opt:b'],
            ],
            [
                ['text' => 'Refresh Menu', 'callback_data' => 'ref:main'],
            ]
        ]
    ];

    return sendTelegramRequest('sendMessage', [
        'chat_id' => $chatId,
        'text' => htmlspecialchars($text, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'),
        'parse_mode' => 'HTML',
        'reply_markup' => $keyboard,
    ]);
}
Enter fullscreen mode Exit fullscreen mode

The cURL helper verifies HTTP status codes, ensures JSON parsing succeeds, and explicitly checks for Telegram's ok: true field. Passing unvalidated requests to Telegram can fail silently or mask server errors.

Processing callback_query and answerCallbackQuery

When a user taps an inline button, Telegram sends an update object containing a callback_query dictionary. This payload includes a unique id for the query, the from user object, the original message where the button was attached, and the string passed in callback_data.

You must call the answerCallbackQuery API method for every incoming callback query. If you omit this call, the client interface displays a loading spinner on the button for up to 60 seconds, creating an unresponsive user experience. answerCallbackQuery allows you to stop the spinner, show a temporary toast notification, or present a modal alert dialog.

<?php

declare(strict_types=1);

function answerCallbackQuery(string $callbackQueryId, ?string $text = null, bool $showAlert = false): array
{
    $payload = [
        'callback_query_id' => $callbackQueryId,
    ];

    if ($text !== null) {
        $payload['text'] = $text;
        $payload['show_alert'] = $showAlert;
    }

    return sendTelegramRequest('answerCallbackQuery', $payload);
}
Enter fullscreen mode Exit fullscreen mode

Setting show_alert => true presents a full modal popup requiring explicit dismissal, while show_alert => false (the default) displays a brief banner at the top of the chat interface.

Editing Messages in Place with editMessageText

Instead of cluttering the chat history with new messages when options are selected, update the existing message text and reply markup using editMessageText. To modify a message originated by the bot, pass both chat_id and message_id along with the revised content.

<?php

declare(strict_types=1);

function updateMenuState(int|string $chatId, int $messageId, string $newText, array $newKeyboard): array
{
    return sendTelegramRequest('editMessageText', [
        'chat_id' => $chatId,
        'message_id' => $messageId,
        'text' => htmlspecialchars($newText, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'),
        'parse_mode' => 'HTML',
        'reply_markup' => $newKeyboard,
    ]);
}
Enter fullscreen mode Exit fullscreen mode

Telegram's API returns a 400 Bad Request: message is not modified error if you attempt to call editMessageText with content and keypads identical to the message's current state. Always verify that state has changed before invoking this endpoint, or catch the exception gracefully.

Unified Webhook Action Handler

Below is a complete, single-file script demonstrating how to receive a webhook request, validate the incoming payload type, answer the callback query immediately, parse short parameters, and update the message UI.

<?php

declare(strict_types=1);

// Read incoming JSON body from Telegram Webhook
$rawInput = file_get_contents('php://input');
if (!$rawInput) {
    http_response_code(400);
    exit('Empty payload');
}

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

// Handle Callback Queries
if (isset($update['callback_query'])) {
    $callbackQuery = $update['callback_query'];
    $callbackId    = $callbackQuery['id'];
    $callbackData  = $callbackQuery['data'] ?? '';
    $message       = $callbackQuery['message'] ?? null;

    if (!$message) {
        // Handled inline message or obsolete callback
        answerCallbackQuery($callbackId, 'Message context unavailable.');
        exit;
    }

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

    // Parse delimited payload (e.g. "act:value")
    $parts  = explode(':', $callbackData, 2);
    $action = $parts[0] ?? '';
    $param  = $parts[1] ?? '';

    try {
        switch ($action) {
            case 'opt':
                // Acknowledge the press via Toast banner
                answerCallbackQuery($callbackId, "Selected option " . strtoupper($param));

                $updatedText = sprintf("Current state: <b>Option %s</b> selected.", strtoupper($param));
                $nextKeyboard = [
                    'inline_keyboard' => [
                        [
                            ['text' => 'Switch to A', 'callback_data' => 'opt:a'],
                            ['text' => 'Switch to B', 'callback_data' => 'opt:b'],
                        ],
                        [
                            ['text' => 'Reset', 'callback_data' => 'ref:main'],
                        ]
                    ]
                ];

                updateMenuState($chatId, $messageId, $updatedText, $nextKeyboard);
                break;

            case 'ref':
                answerCallbackQuery($callbackId, "Menu refreshed", false);

                $resetText = "Main Menu. Select an option below:";
                $defaultKeyboard = [
                    'inline_keyboard' => [
                        [
                            ['text' => 'Option A', 'callback_data' => 'opt:a'],
                            ['text' => 'Option B', 'callback_data' => 'opt:b'],
                        ]
                    ]
                ];

                updateMenuState($chatId, $messageId, $resetText, $defaultKeyboard);
                break;

            default:
                answerCallbackQuery($callbackId, "Unknown action", true);
                break;
        }
    } catch (Throwable $e) {
        // Log error locally and provide non-blocking feedback to user
        error_log('Callback processing error: ' . $e->getMessage());
        answerCallbackQuery($callbackId, "An error occurred while processing your selection.", true);
    }

    http_response_code(200);
    echo 'OK';
    exit;
}

// Non-callback updates can be handled here...
http_response_code(200);
echo 'OK';
Enter fullscreen mode Exit fullscreen mode

Production Edge Cases and Best Practices

  1. Webhook Security: Validate the X-Telegram-Bot-Api-Secret-Token header sent by Telegram against your stored secret before parsing incoming payloads. Reject any request missing the correct token with an HTTP 403 status.
  2. Idempotency: Webhook delivery guarantees at-least-once delivery. High network latency can lead Telegram to retry sending an update. Record processed update_id values in a fast key-value store like Redis with a 24-hour expiration window. Ignore updates whose IDs have already been stored.
  3. HTML Sanitization: When using 'parse_mode' => 'HTML', always wrap dynamic variable values (such as names, user input, or system identifiers) inside htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'). Malformed tags will cause Telegram's API to reject the message edit with a 400 Bad Request: can't parse entities error.
  4. Expired Messages: If an inline message is very old or deleted by the user, editMessageText will fail. Wrap menu state updates in try/catch blocks and handle error responses gracefully.

For production deployments requiring complex custom user interfaces, workflows, or Mini Apps, BotCreator ships tailored Telegram bots and integrations. You can also refer to technical guides on the Telegram Bot API to learn more about backend integration patterns.

Top comments (0)