Appearance
Buttons and keyboards
There are two kinds of keyboard and confusing them is the most common structural mistake in a bot.
A reply keyboard replaces the user's own on-screen keyboard. Its buttons send ordinary text messages, it belongs to a user in a chat rather than to a message, and it stays there until something removes it.
An inline keyboard is attached under one specific message. Its buttons send data straight back to the bot without putting anything in the chat, and it disappears with the message it belongs to.
Both are passed as reply_markup on sendMessage and the other send methods.
Nothing here is running
The gateway that would receive these presses is live as a route, but no bot token exists to send them with, so nothing here can be exercised. What follows is the specified behaviour, not a description of a working system. Implementation status.
Choosing between them
| Reply keyboard | Inline keyboard | |
|---|---|---|
| Attached to | A user, in a chat | One message |
| Pressing it | Sends a visible text message | Sends a callback_query |
| Lifetime | Until removed or replaced | Until the message is deleted or edited |
| In channels | Not available | Available |
| Bot learns | Only the text, as a new message | Which button, on which message, from whom |
The practical rule: if the bot needs to know which button was pressed rather than what it said, use an inline keyboard. Reply-keyboard presses arrive as plain text, indistinguishable from a user typing the same words by hand — including a user who copies the label out of an old screenshot months later.
Only inline keyboards work in channels.
Reply keyboards
json
{
"chat_id": 1337,
"text": "Pick a city",
"reply_markup": {
"keyboard": [
[{"text": "Tashkent"}, {"text": "Samarkand"}],
[{"text": "Share my location", "request_location": true}]
],
"resize_keyboard": true,
"one_time_keyboard": true,
"input_field_placeholder": "or type a city name"
}
}Rows are an array of arrays; each row may hold a different number of buttons. The flags:
resize_keyboard— shrink the keyboard to fit its buttons instead of filling the default heightone_time_keyboard— hide it after one pressselective— show it only to users mentioned in the text, or to the user being replied tois_persistent— keep it visible even after the user collapses itinput_field_placeholder— placeholder text for the composer
one_time_keyboard hides, it does not disable
The keyboard collapses after a press, but the user can reopen it and press again, and old messages carrying it stay valid. Anything destructive behind a reply-keyboard button must be idempotent or re-validated server-side. This is not a mechanism for single-use actions.
To take a keyboard away, send {"remove_keyboard": true}. To force the user into replying to a specific message, send {"force_reply": true}. Both accept selective as well.
Button types that do more than send their own label:
| Button | On press |
|---|---|
request_contact | Sends the user's contact card, after a confirmation prompt |
request_location | Sends the user's location, after a confirmation prompt |
request_poll | Opens the poll composer, optionally forced to quiz mode |
web_app | Opens a Mini App from the keyboard |
request_users / request_chat | Opens a peer picker — see below |
Every one of these asks the user for permission first. A declined prompt produces no update at all, which means "the user has not answered yet" and "the user said no" look identical to the bot. Do not block a flow waiting for a contact that may never arrive.
Inline keyboards and callback_query
json
{
"chat_id": 1337,
"text": "Order #8842 — 2 items",
"reply_markup": {
"inline_keyboard": [
[{"text": "Confirm", "callback_data": "o:8842:ok"},
{"text": "Cancel", "callback_data": "o:8842:no"}],
[{"text": "Track parcel", "url": "https://example.com/t/8842"}]
]
}
}A press produces a callback_query update:
json
{
"update_id": 100042,
"callback_query": {
"id": "4382bfdwdsb323b2d9",
"from": {"id": 1337, "is_bot": false, "first_name": "Aziz"},
"message": {"message_id": 7, "chat": {"id": 1337, "type": "private"}},
"data": "o:8842:ok"
}
}Always answer
While a callback is in flight the client shows a spinner on the pressed button. It clears when the bot calls answerCallbackQuery — or, if the bot never does, when the query times out after roughly 20 seconds.
A bot that forgets to answer therefore does not fail loudly. It leaves every user staring at a spinning button for twenty seconds, and the effect is worst in exactly the case you would least notice: the handler that succeeded, updated the message, and returned without acknowledging.
Answering with no text is normal and correct when the result is visible elsewhere:
python
# Acknowledge first. The id dies after ~20s, so this goes before slow work.
requests.post(f"{API}/answerCallbackQuery",
json={"callback_query_id": cq["id"]})
order = slow_lookup(cq["data"]) # may take seconds
requests.post(f"{API}/editMessageText",
json={"chat_id": cq["message"]["chat"]["id"],
"message_id": cq["message"]["message_id"],
"text": f"Order confirmed — {order.eta}"})Acknowledge, then work. Answering at the end of a long handler is how a working bot still shows spinners, and handing the query id to a background worker usually means it has expired before the worker starts.
Use show_alert: true for failures the user must actually read; a toast that fades on its own is right for routine confirmations. cache_time lets the client reuse an answer for the same button without calling the bot again — keep it at 0 whenever the answer depends on state that can change between presses.
callback_data is 64 bytes
Not 64 characters. The limit is on the encoded size, so a Cyrillic or emoji label inside your payload costs two to four bytes per character and blows the budget far earlier than counting characters suggests.
Sixty-four bytes is enough for identifiers and nothing else. State belongs on your server, under a key:
python
# Wrong — will not survive a long id, a UUID plus fields, or non-Latin text
data = json.dumps({"action": "confirm", "order": order_id, "user": user_id})
# Right — short opaque key, everything else server-side
key = secrets.token_urlsafe(8)
redis.setex(f"cb:{key}", 3600, json.dumps({...}))
data = f"c:{key}"Two more properties of callback_data that bite in production:
- It is client-supplied on arrival. The bytes come back from a client, so treat them as input. Encode a key, look up the authoritative record, and re-check that
callback_query.from.idis allowed to act on it. A button visible to one user in a group can be pressed by another. - Old messages keep working. Buttons stay live as long as their message exists. A callback can arrive weeks later, after the referenced order has shipped, been refunded, or ceased to exist. Handle the missing case; do not assume the record is still there.
To take buttons away once they are spent, edit them off with editMessageReplyMarkup — omitting reply_markup, or sending {"inline_keyboard": []}, strips them. Note that on editMessageText the same omission also removes the keyboard: pass the old markup again if you meant to change only the text.
Other inline button types
| Button | On press |
|---|---|
url | Opens a link; external destinations get a confirmation prompt |
login_url | Seamless authorization flow into your own site |
switch_inline_query | Opens the chat picker and pre-fills a query — see inline mode |
switch_inline_query_current_chat | The same, without leaving the chat |
web_app | Opens a Mini App |
copy_text | Copies a string to the clipboard |
callback_game | Launches a game |
pay | Opens a payment invoice — see payments, where the currency model is undecided |
Only one pay button is meaningful per keyboard, and it must be the first button in the first row.
Button styles
The specification adds a visual style to buttons beyond the Telegram surface: a background flag — primary (blue), danger (red) or success (green) — plus an optional custom-emoji icon shown before the label. At most one background flag applies at a time, and clients are expected to adapt the colour to the active theme.
Custom-emoji icons are restricted to bots whose owner holds a premium subscription, or bots on specially purchased usernames.
The wire format for styles is undecided
The specification describes styles at the client-protocol level and does not define how they are expressed in an HTTP Bot API button object — no field name, no accepted values, no behaviour for a client that does not understand them. Anything you see elsewhere claiming a concrete JSON shape for this is guessing. Build keyboards that read correctly without colour, and treat styling as decoration to add once it is specified.
Peer request buttons
A reply-keyboard button can ask the user to pick a user, group or channel and hand the choice back to the bot. Each carries a button_id you choose, which comes back with the answer so you can tell which of several requests was satisfied.
The request declares what it wants:
- A user — filterable by whether the target is a bot, and by premium status
- A group — filterable by whether the user created it, whether the bot is already a member, whether it has a username, whether it is a forum, and by the rights the user or the bot must hold
- A channel — the same, minus the group-only filters
It also declares how much detail to return (name, username, photo) and how many peers may be selected at once. Chats failing a filter are not offered in the picker, so a request that matches nothing presents an empty list rather than an error.
Where the request permits it, the picker may also offer to create a new chat, granting the bot the rights that were asked for at creation time.
Rights you asked for are not rights you got
This applies to peer requests and to setMyDefaultAdministratorRights alike: both only pre-tick boxes in a dialog the user can change. Read getChatMember on the bot's own id and branch on what you actually hold, rather than assuming the request was honoured in full.
See also
- Commands — the other way users drive a bot
- Inline mode — buttons that work outside a chat with your bot
answerCallbackQuery· Errors · Limits