DEV Community

How to Build a Telegram Bot Inline Keyboard with Python

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)
Enter fullscreen mode Exit fullscreen mode

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(),
)
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

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,
    )
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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(),
)
Enter fullscreen mode Exit fullscreen mode

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,
    )
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

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)