Two kinds of keyboards
Telegram has two keyboard types, and they behave differently:
- Reply keyboards replace the user's keyboard with buttons that send their label as a normal message.
- Inline keyboards are attached under a specific message. Pressing a button does not send a message; it sends your bot a callback query (or opens a URL, a login, a Mini App).
This post covers inline keyboards. You can design layouts visually in our Telegram keyboard builder and copy the JSON.
The structure
An inline keyboard is an array of rows; each row is an array of buttons:
{
"inline_keyboard": [
[ { "text": "Yes", "callback_data": "confirm:yes:42" }, { "text": "No", "callback_data": "confirm:no:42" } ],
[ { "text": "Read the docs", "url": "https://core.telegram.org/bots/api" } ]
]
}
Each button needs exactly one action field, such as callback_data, url, switch_inline_query or web_app.
Rule 1: callback_data is at most 64 bytes
callback_data is limited to 1-64 bytes, and that is bytes, not characters: accented letters and other non-ASCII characters take more than one byte. Do not put JSON objects there. Use a short, parseable format like action:arg:id, or store the payload server-side and send only a key.
Rule 2: always answer the callback query
When a user taps a button, the Telegram client shows a progress indicator until your bot calls answerCallbackQuery. Call it even if you have nothing to say; optionally pass text for a small toast or show_alert: true for a dialog.
Rule 3: edit, do not spam
For menus and pagination, change the existing message with editMessageText or editMessageReplyMarkup instead of sending a new one. If the new text and keyboard are identical to the current ones, Telegram returns the error "message is not modified". Check before editing, or catch and ignore that specific error.
grammY example
import { Bot, InlineKeyboard } from "grammy";
const bot = new Bot(process.env.BOT_TOKEN!);
bot.command("delete", async (ctx) => {
const kb = new InlineKeyboard()
.text("Yes, delete", "del:yes:42").text("Cancel", "del:no:42");
await ctx.reply("Delete item 42?", { reply_markup: kb });
});
bot.callbackQuery(/^del:(yes|no):(\d+)$/, async (ctx) => {
const [, choice, id] = ctx.match!;
await ctx.answerCallbackQuery(choice === "yes" ? "Deleted" : "Cancelled");
await ctx.editMessageText(choice === "yes" ? `Item ${id} deleted.` : "Nothing changed.");
});
// catch-all so no query is left unanswered
bot.on("callback_query:data", (ctx) => ctx.answerCallbackQuery());
bot.start();
python-telegram-bot example
import os
from telegram import InlineKeyboardButton, InlineKeyboardMarkup, Update
from telegram.ext import Application, CallbackQueryHandler, CommandHandler, ContextTypes
async def delete(update: Update, context: ContextTypes.DEFAULT_TYPE):
kb = InlineKeyboardMarkup([[
InlineKeyboardButton("Yes, delete", callback_data="del:yes:42"),
InlineKeyboardButton("Cancel", callback_data="del:no:42"),
]])
await update.message.reply_text("Delete item 42?", reply_markup=kb)
async def on_delete(update: Update, context: ContextTypes.DEFAULT_TYPE):
q = update.callback_query
_, choice, item = q.data.split(":")
await q.answer("Deleted" if choice == "yes" else "Cancelled")
await q.edit_message_text(f"Item {item} deleted." if choice == "yes" else "Nothing changed.")
app = Application.builder().token(os.environ["BOT_TOKEN"]).build()
app.add_handler(CommandHandler("delete", delete))
app.add_handler(CallbackQueryHandler(on_delete, pattern=r"^del:(yes|no):\d+$"))
app.run_polling()
Security: never trust callback_data
A modified client can send any callback_data it wants. Treat it as user input:
- Check that
callback_query.from.idis allowed to do the action (for example, the user who asked for the deletion, or an admin). - Validate IDs against your database rather than trusting them.
- For sensitive actions, store a server-side token with an expiry and put only its key in the button.
Pagination pattern
For long lists, send one page with "Previous" and "Next" buttons whose data is page:<n>. On each press, answer the query, load page n and edit the message. Keep pages small enough that the text fits Telegram's 4096-character message limit; our message splitter shows where a long text breaks.
Webhook note
If your bot uses a webhook, include callback_query in allowed_updates, or the button presses never arrive. See Telegram setWebhook step by step.
Frequently asked questions
Why does the button keep showing a loading spinner?
Your bot did not call answerCallbackQuery for that press, often because no handler matched the data. Add a catch-all that answers every callback query.
Can I remove the keyboard after a choice?
Yes. Edit the message with a new text and no reply_markup, or call editMessageReplyMarkup with an empty keyboard.
Do inline keyboards work in groups?
Yes. Anyone in the group can press the buttons, which is why checking from.id matters.
Sources
- Telegram Bot API, InlineKeyboardMarkup and InlineKeyboardButton: core.telegram.org/bots/api#inlinekeyboardmarkup
- Telegram Bot API, answerCallbackQuery: core.telegram.org/bots/api#answercallbackquery
- Telegram, bot features (keyboards): core.telegram.org/bots/features#inline-keyboards
- grammY, keyboard plugin: grammy.dev/plugins/keyboard
- python-telegram-bot documentation: docs.python-telegram-bot.org