Text commands are enough for a basic Telegram bot, but they become inconvenient as the number of features grows. An inline keyboard gives users a clearer interface by placing buttons directly below a message.
In this tutorial, we will build a small menu using Python and python-telegram-bot. The bot will display buttons, process callback queries, update the original message, and safely handle unknown actions.
What Is an Inline Keyboard?
An inline keyboard is a collection of buttons attached to a Telegram message. Unlike a reply keyboard, it does not replace the user's typing area.
Inline keyboards are useful for:
Navigation menus
Confirmation dialogs
Pagination
Language selection
Account settings
Order and service status checks
Links to websites or documentation
Group and channel administration
Telegram represents an inline keyboard with two main objects:
InlineKeyboardMarkup contains the complete button layout.
InlineKeyboardButton represents an individual button.
A keyboard is arranged as a list of rows. Each row contains one or more buttons.
[ Documentation ] [ Service Status ]
[ Open the Bot API Docs ]
A simplified API representation looks like this:
{
"inline_keyboard": [
[
{
"text": "Documentation",
"callback_data": "menu:docs"
},
{
"text": "Service Status",
"callback_data": "menu:status"
}
],
[
{
"text": "Open the Bot API Docs",
"url": "https://core.telegram.org/bots/api"
}
]
]
}
A button with callback_data sends a callback query to the bot. A button with a url opens the destination directly and does not trigger the same callback flow.
See Telegram's official InlineKeyboardMarkup documentation for the complete object definition.
Prerequisites
You will need:
Python 3.10 or newer
A Telegram account
A bot created through @botfather
The bot token stored securely
Basic familiarity with running Python from a terminal
If you still need the client, use this Telegram download guide to install it on desktop or mobile.
This tutorial uses the asynchronous python-telegram-bot interface.
Create the Project
Create a new directory:
mkdir telegram-inline-keyboard
cd telegram-inline-keyboard
Create a virtual environment:
python -m venv .venv
Activate it on macOS or Linux:
source .venv/bin/activate
Activate it in Windows PowerShell:
.venv\Scripts\Activate.ps1
Install or upgrade the library:
python -m pip install --upgrade python-telegram-bot
The current stable python-telegram-bot library uses an asynchronous API. Handler functions therefore use async def, and Telegram operations must be awaited.
Store the Bot Token Safely
Do not place a real token directly in source code.
On macOS or Linux:
export TELEGRAM_BOT_TOKEN='PASTE_YOUR_TOKEN_HERE'
In Windows PowerShell:
$env:TELEGRAM_BOT_TOKEN="PASTE_YOUR_TOKEN_HERE"
The environment variable exists only in the current terminal session. For production, use the secret-management feature provided by your hosting platform.
Never commit a .env file containing a token to a public repository. If a token is exposed, revoke it through BotFather and generate a new one.
Build the First Menu
Create a file named bot.py:
import os
from telegram import InlineKeyboardButton, InlineKeyboardMarkup, Update
from telegram.ext import Application, CommandHandler, ContextTypes
def build_home_keyboard() -> InlineKeyboardMarkup:
keyboard = [
[
InlineKeyboardButton(
"๐ Documentation",
callback_data="menu:docs",
),
InlineKeyboardButton(
"๐ข Service Status",
callback_data="menu:status",
),
],
[
InlineKeyboardButton(
"๐ Open the Bot API Docs",
url="https://core.telegram.org/bots/api",
)
],
]
return InlineKeyboardMarkup(keyboard)
async def start(
update: Update,
context: ContextTypes.DEFAULT_TYPE,
) -> None:
if update.message is None:
return
await update.message.reply_text(
"Choose an option:",
reply_markup=build_home_keyboard(),
)
def main() -> None:
token = os.environ.get("TELEGRAM_BOT_TOKEN")
if not token:
raise RuntimeError(
"The TELEGRAM_BOT_TOKEN environment variable is missing."
)
application = Application.builder().token(token).build()
application.add_handler(CommandHandler("start", start))
application.run_polling()
if name == "main":
main()
Run the bot:
python bot.py
Open the bot in Telegram and send:
/start
You should see two callback buttons and one URL button. The URL button already works, but the callback buttons do not yet have a handler.
Understanding callback_data
The callback_data value is a short string associated with a button:
InlineKeyboardButton(
"Service Status",
callback_data="menu:status",
)
When a user presses this button, Telegram sends a CallbackQuery update containing:
menu:status
A useful convention is to organize callback values with prefixes:
menu:home
menu:docs
menu:status
settings:language:en
page:articles:2
order:confirm:5821
Telegram limits callback_data to a small payload, so it should contain an action identifier rather than a complete data object.
Do not include the following information in callback data:
Bot tokens
Passwords
API keys
Payment information
Email addresses
Private user data
Complete JSON documents
Authorization credentials
For database-backed actions, include a short record identifier and load the actual data on the server after validating the request.
Add a Callback Query Handler
Update the imports:
from telegram.ext import (
Application,
CallbackQueryHandler,
CommandHandler,
ContextTypes,
)
Add the following function below start():
async def handle_button(
update: Update,
context: ContextTypes.DEFAULT_TYPE,
) -> None:
query = update.callback_query
if query is None:
return
action = query.data
if action == "menu:docs":
await query.answer()
keyboard = InlineKeyboardMarkup(
[
[
InlineKeyboardButton(
"โฌ
๏ธ Back",
callback_data="menu:home",
)
]
]
)
await query.edit_message_text(
text=(
"๐ Documentation\n\n"
"Use the official Bot API reference to review "
"available methods, objects, and update types."
),
reply_markup=keyboard,
)
elif action == "menu:status":
await query.answer("Status refreshed")
keyboard = InlineKeyboardMarkup(
[
[
InlineKeyboardButton(
"๐ Refresh",
callback_data="menu:status",
),
InlineKeyboardButton(
"โฌ
๏ธ Back",
callback_data="menu:home",
),
]
]
)
await query.edit_message_text(
text="๐ข The service is operating normally.",
reply_markup=keyboard,
)
elif action == "menu:home":
await query.answer()
await query.edit_message_text(
text="Choose an option:",
reply_markup=build_home_keyboard(),
)
else:
await query.answer(
text="Unknown action",
show_alert=True,
)
Register the callback handler in main():
application.add_handler(
CallbackQueryHandler(
handle_button,
pattern=r"^menu:",
)
)
The handler section should now look like this:
application.add_handler(CommandHandler("start", start))
application.add_handler(
CallbackQueryHandler(
handle_button,
pattern=r"^menu:",
)
)
The regular expression limits this handler to callback values beginning with menu:. Future features can use separate prefixes and handlers.
For example:
application.add_handler(
CallbackQueryHandler(
handle_settings,
pattern=r"^settings:",
)
)
This keeps a larger bot easier to maintain.
Why query.answer() Is Required
After a user presses an inline button, the Telegram client displays a loading indicator. The bot should answer the callback query even if no notification needs to be shown.
await query.answer()
Without this call, the loading indicator may continue spinning and make the interface appear broken.
To show a short notification:
await query.answer("Settings saved")
To display an alert dialog:
await query.answer(
text="You do not have permission to perform this action.",
show_alert=True,
)
Only answer each callback once. Decide whether the response should be silent, a short notification, or an alert.
Telegram explains this behavior in the official CallbackQuery documentation.
Complete Working Example
The following version includes logging, menu navigation, filtered callback handling, and a basic error handler:
import logging
import os
from telegram import InlineKeyboardButton, InlineKeyboardMarkup, Update
from telegram.ext import (
Application,
CallbackQueryHandler,
CommandHandler,
ContextTypes,
)
logging.basicConfig(
format=(
"%(asctime)s - %(name)s - "
"%(levelname)s - %(message)s"
),
level=logging.INFO,
)
logger = logging.getLogger(name)
def build_home_keyboard() -> InlineKeyboardMarkup:
keyboard = [
[
InlineKeyboardButton(
"๐ Documentation",
callback_data="menu:docs",
),
InlineKeyboardButton(
"๐ข Service Status",
callback_data="menu:status",
),
],
[
InlineKeyboardButton(
"๐ Open the Bot API Docs",
url="https://core.telegram.org/bots/api",
)
],
]
return InlineKeyboardMarkup(keyboard)
async def start(
update: Update,
context: ContextTypes.DEFAULT_TYPE,
) -> None:
if update.message is None:
return
await update.message.reply_text(
"Choose an option:",
reply_markup=build_home_keyboard(),
)
async def handle_button(
update: Update,
context: ContextTypes.DEFAULT_TYPE,
) -> None:
query = update.callback_query
if query is None:
return
action = query.data
if action == "menu:docs":
await query.answer()
keyboard = InlineKeyboardMarkup(
[
[
InlineKeyboardButton(
"โฌ
๏ธ Back",
callback_data="menu:home",
)
]
]
)
await query.edit_message_text(
text=(
"๐ Documentation\n\n"
"Review the Telegram Bot API reference "
"for available methods and objects."
),
reply_markup=keyboard,
)
elif action == "menu:status":
await query.answer("Status refreshed")
keyboard = InlineKeyboardMarkup(
[
[
InlineKeyboardButton(
"๐ Refresh",
callback_data="menu:status",
),
InlineKeyboardButton(
"โฌ
๏ธ Back",
callback_data="menu:home",
),
]
]
)
await query.edit_message_text(
text="๐ข The service is operating normally.",
reply_markup=keyboard,
)
elif action == "menu:home":
await query.answer()
await query.edit_message_text(
text="Choose an option:",
reply_markup=build_home_keyboard(),
)
else:
await query.answer(
text="Unknown action",
show_alert=True,
)
async def error_handler(
update: object,
context: ContextTypes.DEFAULT_TYPE,
) -> None:
logger.error(
"An exception occurred while processing an update.",
exc_info=context.error,
)
def main() -> None:
token = os.environ.get("TELEGRAM_BOT_TOKEN")
if not token:
raise RuntimeError(
"The TELEGRAM_BOT_TOKEN environment variable is missing."
)
application = Application.builder().token(token).build()
application.add_handler(CommandHandler("start", start))
application.add_handler(
CallbackQueryHandler(
handle_button,
pattern=r"^menu:",
)
)
application.add_error_handler(error_handler)
application.run_polling()
if name == "main":
main()
Restart the program after saving:
python bot.py
Send /start again and test every button.
The python-telegram-bot project also provides an official inline keyboard example that is useful when comparing implementation patterns.
URL Buttons vs. Callback Buttons
A URL button opens a destination:
InlineKeyboardButton(
"Open Documentation",
url="https://core.telegram.org/bots/api",
)
A callback button sends data to the bot:
InlineKeyboardButton(
"Check Status",
callback_data="menu:status",
)
Use a URL button when the only goal is navigation. Use a callback button when the server needs to validate an action, query a database, modify a message, or change the menu.
Do not use callback data as a substitute for server-side authorization. A user interface is not a security boundary.
Editing a Message vs. Sending a New One
A menu usually works best when it edits the existing message:
await query.edit_message_text(
text="Updated menu content"
)
This prevents a long trail of nearly identical menu messages.
For permanent results, notifications, receipts, or audit information, send a new message instead:
await query.message.reply_text(
"Your request has been completed."
)
Choose based on whether the user needs to retain the previous result.
Common Problems
The Button Keeps Loading
Confirm that every callback path calls one of the following:
await query.answer()
await query.answer("Completed")
await query.answer(
"Permission denied",
show_alert=True,
)
Unknown and error paths also need a callback response.
Nothing Happens After a Click
Check that:
CallbackQueryHandler is registered.
The callback value matches the handler pattern.
The bot process is still running.
The token belongs to the correct bot.
Another process is not consuming updates.
A webhook is not conflicting with polling.
The terminal does not contain an exception.
Message Is Not Modified
Telegram returns this error when the new text and keyboard are identical to the current message.
A refresh button should either produce changed content or handle this case without treating it as a fatal error.
A User Clicks an Old Button
Inline buttons can remain visible in chat history. A user may press one after the referenced database record has expired or changed.
Always verify:
The user is still authorized.
The record still exists.
The action is still valid.
The operation has not already been completed.
The callback belongs to the expected workflow.
Return a clear alert if the action is no longer available.
Duplicate Clicks Create Duplicate Operations
For sensitive actions such as payments, deletions, or order creation, add server-side protection:
Idempotency keys
Transaction checks
Permission validation
Current-state validation
Rate limiting
Audit logs
Confirmation steps
Disabling or replacing a button improves the interface, but it does not replace backend safeguards.
A Better Project Structure
A single file is acceptable for this tutorial. A larger bot should separate menu creation, callback routing, and business logic.
telegram-inline-keyboard/
โโโ bot.py
โโโ handlers/
โ โโโ commands.py
โ โโโ callbacks.py
โโโ keyboards/
โ โโโ main_menu.py
โโโ services/
โ โโโ status_service.py
โโโ requirements.txt
This structure makes it easier to test functions and add new menus without turning bot.py into a large collection of unrelated conditions.
Where to Go Next
Once the basic menu works, useful extensions include:
Paginated article lists
Language settings
Confirmation dialogs
Database-backed menus
Role-based administrator buttons
Image and document actions
Webhook deployment
Persistent user preferences
Rate limiting and audit logs
The core workflow remains the same:
Create an InlineKeyboardButton
โ assign callback_data
โ register CallbackQueryHandler
โ validate query.data
โ call query.answer()
โ update or send a message
Once this pattern is clear, a command-only Telegram bot can be turned into a practical interactive application.
็น้ซไธญๆๆ่ฆ
Inline Keyboard ๅฏไปฅๅจ Telegram Bot ่จๆฏไธๆนๅ ๅ ฅไบๅๆ้ใไฝฟ็จ่ ้ปๆๅซๆ callback_data ็ๆ้ๅพ๏ผ็จๅผๆๆถๅฐ Callback Queryใ่็ๅฝๅผๅฟ ้ ้ฉ่ญ่ณๆไธฆๅท่ก query.answer()๏ผๅฆๅๆ้ๅฏ่ฝๆ็บ้กฏ็คบ่ผๅ ฅ็ๆ ใ
ไธ่ฌ้ธๅฎ้ฉๅไฝฟ็จ edit_message_text() ๆดๆฐๅ่จๆฏ๏ผ้่ฆไฟ็็ๆไฝ็ตๆๅๅฏไปฅๅฆๅคๅณ้ๆฐ่จๆฏใๆถๅไปๆฌพใๅช้คๆ็ฎก็ๆฌ้ๆ๏ผไป้ๅจไผบๆๅจ็ซฏ้ฒ่ก่บซๅ่็ๆ ้ฉ่ญ๏ผไธ่ฝๅชไพ่ณดๆ้ไป้ขใ
Disclosure: This tutorial was drafted with AI assistance and reviewed against the Telegram Bot API and python-telegram-bot documentation. Test the code in a development bot before using it in production.

Top comments (0)