SMeetBot API

SMeet Bot API

Messages and buttons

A bot talks in plain text with buttons under its messages. This page covers what arrives, what you can send, and the rules that keep old or copied buttons from acting in the wrong place.

Receiving messages#

A person's message arrives as an update of type message:

JSON
{
  "update_id": "43",
  "event_id": "c2a1f5d0-0b7e-4a52-9f11-5a8c3e2d7b64",
  "type": "message",
  "date": "2026-09-26T10:00:04Z",
  "message": {
    "message_id": "7302",
    "chat": {"id": "550", "type": "private"},
    "from": {"id": "812", "display_name": "Anna", "username": "anna", "is_bot": false},
    "date": "2026-09-26T10:00:04Z",
    "text": "Where is my order A-1001?",
    "reply_to_message_id": "7300"
  }
}
  • chat.id is what you pass as chat_id to answer. Every chat is private: one person and your bot.
  • from carries the person's display name and, when they have one, their public address. E-mail, phone, other chats and sessions are never included.
  • text is the readable text. reply_to_message_id is present when the person replied to a message.
  • attachments lists photos and documents (Photos and documents).
  • has_unsupported_content: true means the message also carries something version 1 does not pass on: a voice or video message, a location, a poll or a checklist, or files while file support is switched off. text then holds whatever readable part there is.

A bot never receives its own messages, messages of other bots or system notices, so two bots cannot answer each other forever. A message the person forwarded to the bot arrives as ordinary text, without the original chat or author.

Commands#

A command is a message whose text starts with /, for example /help or /track A-1001. It reaches the bot as an ordinary message; your program decides what it means. The form /help@support_helper_bot is also possible, so take the part before @.

The command menu people see after typing "/" is set in BotFather or with setMyCommands (Managing your bot). It is only a hint: people can type any command.

SMeet itself handles /stop: the chat stops (Organisations and permissions), the message stays in the history, and the bot receives only chat_access_changed with stopped, not the message.

A link to a bot can carry a parameter: https://messenger.scrile.com/u/support_helper_bot?start=A-1001. The parameter is 0 to 64 characters of A-Z a-z 0-9 _ -. When the person opens the link and presses Start, the bot receives two updates:

  1. chat_access_changed with "status": "started" and "start_param": "A-1001";
  2. the person's message with the text /start A-1001 (just /start without a parameter).

React to one of the two, not both. Most bots use the message, so that a link and a typed /start A-1001 behave alike. Pressing Start in a chat that is already started sends only the message. A person who stopped the bot can also type /start in the chat to start it again.

Sending a message#

Shell
curl -s https://messenger.scrile.com/bot-api/v1/sendMessage \
  -H "Authorization: Bearer $SMEET_BOT_TOKEN" \
  -H "Idempotency-Key: c2a1f5d0-0b7e-4a52-9f11-5a8c3e2d7b64:reply" \
  -H "Content-Type: application/json" \
  -d '{"chat_id": "550", "text": "Your order A-1001 ships tomorrow.", "reply_to_message_id": "7302"}'
JSON
{
  "ok": true,
  "result": {
    "message_id": "7303",
    "chat": {"id": "550", "type": "private"},
    "from": {"id": "5001", "display_name": "Support helper", "username": "support_helper_bot", "is_bot": true},
    "date": "2026-09-26T10:00:05Z",
    "text": "Your order A-1001 ships tomorrow.",
    "reply_to_message_id": "7302"
  }
}
  • The sender is always the bot of the token; there is no parameter to send as someone else.
  • The text is plain text, 1 to 4096 characters. It may not start with __META__: or __SYSTEM__, and control characters other than line feed and tab are refused.
  • The Idempotency-Key is required. Build it from the update you answer, as above, and a repeated update never produces a second message (Idempotency-Key).
ErrorWhen
404 CHAT_NOT_FOUNDThe chat is not a chat of this bot. Chats of other bots look the same as chats that do not exist.
403 BOT_STOPPED_OR_BLOCKEDThe person stopped or blocked the bot, or left its space. Stop writing to this chat.
404 MESSAGE_NOT_FOUNDreply_to_message_id is not a message of this chat.
400 INVALID_REQUESTBad text or buttons; error.field names the parameter.
429 RATE_LIMITEDToo fast; wait retry_after and repeat with the same key.

Replies#

Pass reply_to_message_id with a message of the same chat, and the person sees your message as a reply with a quote of the original. SMeet builds the quote itself, from up to 200 characters of the original text. The quote counts towards the 4096 characters, so a very long reply can be refused with INVALID_REQUEST and "field": "text".

Buttons#

Buttons go under a message in reply_markup.inline_keyboard, as rows of buttons:

JSON
{
  "chat_id": "550",
  "text": "Choose a question:",
  "reply_markup": {
    "inline_keyboard": [
      [{"text": "Opening hours", "callback_data": "faq:hours"}, {"text": "Delivery", "callback_data": "faq:delivery"}],
      [{"text": "About SMeet bots", "url": "https://messenger.scrile.com/docs/bots/en/"}]
    ]
  }
}

Each button has a text and exactly one of:

  • callback_data, 1 to 64 bytes: a press is sent to the bot as a callback_query with this data. The person's app never sees the data; SMeet keeps it on the server and gives each button its own identifier.
  • url, an https:// link without a user name or password, up to 2048 characters: the app opens it, and the bot is not told.

Up to 10 rows, 8 buttons per row and 40 buttons in total; button text is 1 to 64 characters and not only spaces. Buttons work in the web app and in SMeet for iOS and macOS.

In apps without bot support#

Apps without bot support (SMeet for Android until its bot release, and older versions of the web app and of SMeet for iOS) cannot press Start. In a chat that is already started they show the bot's messages as plain text without buttons, and the person can still reply with text and type commands. So give every important button action a command in the menu as well, for example /status next to a Status button, and mention it in the text: the chat then stays usable in those apps.

Button presses#

A press on a callback button arrives as a callback_query:

JSON
{
  "update_id": "45",
  "event_id": "5b9e0c3a-2d41-4f7a-8e6b-1c2d3e4f5a6b",
  "type": "callback_query",
  "date": "2026-09-26T10:00:09Z",
  "callback_query": {
    "id": "cb_EXAMPLE",
    "from": {"id": "812", "display_name": "Anna", "is_bot": false},
    "message": {"message_id": "7304", "chat": {"id": "550", "type": "private"}},
    "data": "faq:hours",
    "date": "2026-09-26T10:00:09Z"
  }
}

Answer it with answerCallbackQuery, then do the work, for example edit the message:

  • The person's app shows a waiting indicator. Without an answer within 15 seconds it shows The bot did not respond, try again later; an answer that comes later still takes effect while the chat is open.
  • A press can be answered once, within 1 hour. Repeating the same answer is harmless; a different second answer gets 409 IDEMPOTENCY_CONFLICT; after the hour, 404 CALLBACK_QUERY_NOT_FOUND.
  • text, up to 200 characters, is shown to the person as a short notice; "show_alert": true shows it as a dialog.

This is how python/faq_bot.py handles a press:

Pythondocs/bots/examples/python/faq_bot.py
def handle(client: smeet.Client, update: dict) -> None:
    event_id = update["event_id"]
    if update["type"] == "message":
        chat_id = update["message"]["chat"]["id"]
        client.send_message(chat_id, MENU_TEXT, reply_markup=menu_markup(),
                            idempotency_key=smeet.action_key(event_id, "menu"))
        log.info("Menu sent to chat %s", chat_id)
        return
    if update["type"] != "callback_query":
        return  # access changes, edits and unknown types need no answer here

    query = update["callback_query"]
    chat_id = query["message"]["chat"]["id"]
    message_id = query["message"]["message_id"]
    data = query["data"]
    if data == "menu":
        text, markup = MENU_TEXT, menu_markup()
    elif data.startswith("faq:") and data[len("faq:"):] in FAQ:
        title, answer = FAQ[data[len("faq:"):]]
        text, markup = f"{title}\n\n{answer}", BACK_MARKUP
    else:
        # A button from an older version of this bot: say so instead of leaving the user waiting.
        client.answer_callback_query(query["id"], text="This button is out of date. Send /start.",
                                     idempotency_key=smeet.action_key(event_id, "answer"))
        return

    # Answer the press first: it stops the waiting indicator in the app. Then edit the message.
    client.answer_callback_query(query["id"], idempotency_key=smeet.action_key(event_id, "answer"))
    client.edit_message_text(chat_id, message_id, text, reply_markup=markup,
                             idempotency_key=smeet.action_key(event_id, "edit"))
    log.info("Button %s handled in chat %s", data, chat_id)

Editing messages#

editMessageText changes the text and the buttons of a message this bot sent in that chat; any other message gets 404 MESSAGE_NOT_FOUND.

  • text is required and replaces the text. A reply keeps its quote.
  • reply_markup replaces the buttons. Without it the message has no buttons after the edit.
  • New buttons replace the old ones as a whole. A press on the old buttons, for example from a second device that has not refreshed yet, is refused, and the person sees that the button is no longer active.
  • People's apps update the message at once. The result carries edit_date. The bot receives no update about its own edit.
  • The chat must still be started: after Stop the bot cannot edit its messages either.

Edited and deleted messages#

  • When a person edits a message, the bot receives message_edited with the whole new text in edited_message and an edit_date. It is a correction, not a new command: do not run a command again because its message was edited.
  • When a person deletes a message that the bot has not received yet, its update is cancelled and never delivered. If the bot has already received it, a message_deleted update follows with the message_id; the text is not repeated.
  • Files of a deleted message become unavailable to the bot at once.

Old, forwarded and scheduled messages#

  • Nothing a person sends before pressing Start reaches the bot. After Stop and a new Start, updates cancelled by the Stop do not come back.
  • A press works only on the current buttons of a message that still exists, in a chat that is started. Old versions of a message's buttons, deleted messages and chats after Stop refuse presses.
  • A forwarded copy of a bot's message has no buttons: forwarding cannot move an action into another chat.
  • A message a person schedules for later becomes an update only when it is actually sent. If it is deleted before that, the bot never hears of it.

Notifications need consent#

Pressing Start allows a conversation, not a stream of messages. A bot that sends notifications (an order status, a reminder) should ask for consent explicitly, for example with a Notify me button, and store the subscription only after that press. python/status_bot.py shows the whole flow.

When the bot receives chat_access_changed with stopped, blocked or removed, it should delete the subscriptions of that chat. If a notification crosses a Stop the bot has not seen yet, SMeet answers 403 BOT_STOPPED_OR_BLOCKED, and the bot should react the same way:

Pythondocs/bots/examples/python/status_bot.py
def set_chat_active(db: sqlite3.Connection, chat_id: str, active: bool) -> None:
    """Mark a chat as reachable or not. A chat that is not reachable loses all its subscriptions."""
    with db:  # one transaction
        db.execute("INSERT OR REPLACE INTO chats (chat_id, active, updated_at) VALUES (?, ?, ?)",
                   (chat_id, 1 if active else 0, time.time()))
        if not active:
            db.execute("DELETE FROM subscriptions WHERE chat_id = ?", (chat_id,))

Derive the Idempotency-Key of a notification from what it is about, so that running the same notification twice sends nothing new:

Pythondocs/bots/examples/python/status_bot.py
def notification_key(application_id: str, status: str, chat_id: str, change_id: Optional[str]) -> str:
    raw = "\n".join((application_id, status, change_id or "", chat_id))
    return "notify-" + hashlib.sha256(raw.encode("utf-8")).hexdigest()[:40]

Limits#

WhatLimit
Text of a message1 to 4096 characters; not starting with __META__: or __SYSTEM__; no control characters except line feed and tab
Caption of a photo or documentUp to 1024 characters, same rules
Quote of a replyUp to 200 characters of the original, counted in the 4096
ButtonsUp to 10 rows, 8 per row, 40 in total
Button text1 to 64 characters, not only spaces
callback_data1 to 64 bytes in UTF-8
url of a buttonhttps:// only, up to 2048 characters, no user name or password
Answer to a pressOnce, within 1 hour; notice up to 200 characters
Command menuUp to 100 commands; a command is 1 to 32 characters of a-z 0-9 _
Sending rate1 message per second per chat (burst 3), 10 per second per bot (burst 20), 20 per second for all bots of one owner (burst 40)