Skip to content

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 keyboardInline keyboard
Attached toA user, in a chatOne message
Pressing itSends a visible text messageSends a callback_query
LifetimeUntil removed or replacedUntil the message is deleted or edited
In channelsNot availableAvailable
Bot learnsOnly the text, as a new messageWhich 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 height
  • one_time_keyboard — hide it after one press
  • selective — show it only to users mentioned in the text, or to the user being replied to
  • is_persistent — keep it visible even after the user collapses it
  • input_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:

ButtonOn press
request_contactSends the user's contact card, after a confirmation prompt
request_locationSends the user's location, after a confirmation prompt
request_pollOpens the poll composer, optionally forced to quiz mode
web_appOpens a Mini App from the keyboard
request_users / request_chatOpens 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.id is 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

ButtonOn press
urlOpens a link; external destinations get a confirmation prompt
login_urlSeamless authorization flow into your own site
switch_inline_queryOpens the chat picker and pre-fills a query — see inline mode
switch_inline_query_current_chatThe same, without leaving the chat
web_appOpens a Mini App
copy_textCopies a string to the clipboard
callback_gameLaunches a game
payOpens 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