Gambu Bot API

Справочник для разработчиков ботов.

Бот — это особый аккаунт со своим владельцем и статическим токеном. Он живёт в тех же чатах, что и люди: отправляет сообщения и медиа, получает входящие события («апдейты»), рисует кнопки под своими сообщениями и может открывать мини-приложение.

Базовый URL: https://api.gambu.ru/bot/v1

Запросы и ответы — JSON в UTF-8. Пишущие методы — POST с телом, читающие — GET с параметрами в строке запроса.


1. Быстрый старт

Пять шагов от пустого места до первого ответа бота. Дальше — вебхук вместо опроса (§7), медиа (§9–10) и кнопки (§13).

Шаг 1. Создайте бота

Боты заводятся прямо в приложении Gambu: профиль → «Мои боты» → создать. Там же живут перевыпуск токена, переключатели поведения и удаление бота.

Имя бота (username) обязано оканчиваться на bot — например weather_bot.

Шаг 2. Сохраните токен

Токен показывается ровно один раз — при создании и при перевыпуске. В базе хранится только его хэш, восстановить потерянный токен нельзя, можно лишь перевыпустить (старый при этом перестаёт работать немедленно).

export TOKEN='bot_aZ12Qw34Er56:xK9...'

Шаг 3. Проверьте токен — getMe

curl -sS -H "Authorization: Bot $TOKEN" https://api.gambu.ru/bot/v1/getMe

Схема авторизации — Bot, не Bearer. Ответ с полями bot_id и username означает, что токен рабочий.

Шаг 4. Заберите входящие — getUpdates

Напишите боту из приложения, затем:

curl -sS -H "Authorization: Bot $TOKEN" \
  "https://api.gambu.ru/bot/v1/getUpdates?offset=0&timeout=25"

В ответе — массив updates. Возьмите из первого апдейта chat_id и update_id.

Шаг 5. Ответьте — sendMessage

curl -sS -X POST https://api.gambu.ru/bot/v1/sendMessage \
  -H "Authorization: Bot $TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"chat_id":"<chat_id из апдейта>","text":"Привет!"}'

Следующий вызов getUpdates делайте с offset, равным update_id уже обработанного апдейта: выдача идёт строго «больше offset», и тем же числом апдейты подтверждаются и убираются из очереди.

Готовый цикл на Python — в §20.


2. Токен и авторизация

Как создаётся бот

ПолеПравило
username3–32 символа, только a-z, 0-9, _; обязан оканчиваться на bot, перед суффиксом нужна осмысленная часть от 2 символов. Регистр приводится к нижнему
display_nameИмя, которое видно в чате, до 64 символов
aboutОписание, до 500 символов, необязательно

Суффикс bot для бота обязателен и человеку недоступен: значок «BOT» есть только в интерфейсе, а имя уходит в цитаты, упоминания, ссылки /u/<username>, уведомления и журналы — там бота от человека отличает только имя. Пространство имён общее для людей, каналов и ботов: занятое имя повторно не выдаётся.

Один владелец может держать до 20 ботов.

Как устроен токен

Токен выглядит как <bot_id>:<секрет>, например bot_aZ12Qw34Er56:xK9.... Часть до двоеточия — это bot_id, он же идентификатор отправителя в сообщениях бота; он неизменен.

Срока годности у токена нет, автоматической ротации тоже: утёкший токен действует, пока владелец не перевыпустит его вручную («Мои боты» → перевыпуск). Перевыпуск действует немедленно — токены нигде не кэшируются.

Заголовок авторизации

Каждый запрос к /bot/v1/* несёт заголовок:

Authorization: Bot <token>

Именно Bot. Регистр самого слова схемы значения не имеет (Bot, bot), а Bearer не принимается: такой заголовок разбирается как «схемы Bot нет» и даёт 401 missing_token.

КодerrorКогда
401missing_tokenЗаголовка нет, он пуст или начинается не с Bot (в том числе Bearer)
401invalid_tokenТокен не разбирается, бота нет, бот выключен или хэш не сошёлся
403feature_disabledПлатформа ботов недоступна владельцу этого бота
503feature_unwiredДоступность платформы проверить не удалось
403account_restrictedВладелец бота или сам бот ограничены модерацией (только на пишущих вызовах)
503try_laterПроверить ограничение не удалось (только на пишущих вызовах)
503storage_unavailableСтроку бота не удалось прочитать. Это не «токен плохой» — повторите запрос

Последние две строки важны на практике: 503 означает «проверить не получилось», а не «доступ закрыт». Перевыпускать токен в ответ на них не нужно — нужен повтор с задержкой.

Ограничение владельца

Если владельца бота ограничила модерация, ограничение распространяется и на его ботов: пишущие методы Bot API отвечают 403 account_restricted. Спрашивается мера и владельца, и самого бота.

Читающие методы (getMe, getUpdates, getMyCommands, getWebhookInfo) не ограничиваются. Ответ бота человеку, который сам ограничен, тоже разрешён.

Тело такого отказа шире обычного: кроме error в нём приходят status, until (unix-время окончания), reason (текст для человека) и appeal: "support" — обжалование подаётся через поддержку в приложении.


3. Формат ответов

Успешный ответ — JSON, состав зависит от метода. У пишущих методов он обычно выглядит так:

{"ok": true, "message_id": "b3f1...", "created_at": 1758300000}

Ошибка — HTTP-код и тело с машинным кодом причины:

{"error": "not_a_member"}

Общее правило: 4xx — чините запрос, 5xx — повторяйте позже. Коды storage_unavailable и try_later всегда означают «проверить не удалось», а не «нельзя»; правильная реакция на них — повтор с экспоненциальной задержкой.

Полная таблица кодов — §19.


4. getMe

GET /bot/v1/getMe

Возвращает карточку самого бота. Единственный способ убедиться, что токен рабочий.

curl -sS -H "Authorization: Bot $TOKEN" https://api.gambu.ru/bot/v1/getMe
{
  "bot_id": "bot_aZ12Qw34Er56",
  "username": "weather_bot",
  "display_name": "Погода",
  "about": "Прогноз по городу"
}

Ошибки — только авторизационные (§2).


5. Апдейты

Входящие события лежат в персональной очереди бота. Забрать их можно двумя способами: опросом (getUpdates, §6) или доставкой на ваш URL (вебхук, §7). Вебхук очередь не отменяет — если доставка не удалась, апдейт остаётся в очереди и достаётся опросом.

Формат апдейта

{
  "update_id": 1758300000123456789,
  "type": "message",
  "chat_id": "c1b2...",
  "from_user_id": "u9f8...",
  "message_id": "9c4e...",
  "msg_type": "image",
  "text": "Смотри, что получилось",
  "media_key": "media/9f1c....jpg",
  "thumbnail_url": "thumbs/2ab4....jpg",
  "file_url": "https://storage.yandexcloud.net/...",
  "thumbnail_file_url": "https://storage.yandexcloud.net/...",
  "topic_id": "t_7k2...",
  "created_at": 1758300000
}
ПолеЧто это
update_idМонотонно растущий идентификатор. Им подтверждают обработку (offset) и по нему удобно дедуплицировать
typemessage, edited_message, callback_query, web_app_data
chat_idЧат, из которого пришло событие. Сюда же обычно и отвечают
from_user_idКто отправил (человек, не бот)
message_idСообщение, о котором апдейт: у message — само пришедшее сообщение (его можно процитировать, §8), у edited_message — исправленное, у callback_query — сообщение с кнопкой
callback_query_idТолько у callback_query. Передаётся в answerCallbackQuery (§13)
msg_typeТип исходного сообщения: text, image, video, audio, video_note, file, sticker, gif, location, poll
textТекст сообщения; у медиа — подпись, если она есть, иначе короткая метка ([image], [file], [poll], [call]). Служебные данные карточки (имя и размер документа, звонок, опрос, геопозиция) в text не попадают — тип смотрите в msg_type. У callback_query — callback_data нажатой кнопки. У web_app_data — строка от мини-приложения
media_key, thumbnail_urlКлючи объектов в хранилище. Напрямую по ним ничего не открывается, но они стабильны и годятся как идентификатор вложения
file_url, thumbnail_file_urlГотовые ссылки на скачивание. Подписываются в момент выдачи и живут 1 час. Сохраняйте не ссылку, а ключ, и перевыпускайте ссылку через getFile
topic_idТема группы, в которой написано сообщение. Отсутствует в личном чате, в группе без тем и в общей ленте. Есть только у type: "message"
created_atUnix-секунды

Что стоит учесть:

Типы апдейтов

typeКогда приходит
messageЧеловек написал боту в личный чат или отправил команду в группе, где бот состоит
edited_messageЧеловек исправил своё сообщение в личном чате с ботом. message_id — какое сообщение, text — новый текст по тому же правилу, что у message (у документа с заменённым файлом — [file])
callback_queryНажата inline-кнопка с callback_data под сообщением бота
web_app_dataМини-приложение бота отправило данные боту (§17)

Что приходит в группах

В группе и канале боту пересылаются только команды, а не весь поток сообщений. Это правило постоянное и не отключается.

Командой считается сообщение, начинающееся с / и непробельного символа: /status, /help@weather_bot. Строки / привет и // — обычный текст.

Подпись к фото, начинающаяся с /cmd, тоже доходит как команда.

В личном чате пересылается всё: текст, медиа и правки.

Режим «только владелец»

Личный чат с ботом может завести любой, кто нашёл бота поиском, и по умолчанию его сообщения попадают в ту же очередь, что сообщения владельца.

Переключатель «только владелец» живёт в приложении, в карточке бота. Когда он включён, в очередь попадают только сообщения владельца; остальные остаются в чате, бот их просто не получает. Фильтр стоит на входе в очередь, поэтому фильтровать входящие на своей стороне не требуется.

Перевыпуск ссылки на файл: getFile

GET /bot/v1/getFile?chat_id=<id>&message_id=<id>&file_id=<ключ>

Ссылка file_url из апдейта живёт час. Если бот складывает апдейты в свою очередь, разбирает их пачкой или перезапустился и перечитал getUpdates со старого смещения, ссылка успеет истечь, и хранилище ответит 403. getFile подписывает ссылку заново по ключу, который в апдейте есть всегда.

curl -sSG "https://api.gambu.ru/bot/v1/getFile" \
  -H "Authorization: Bot $TOKEN" \
  --data-urlencode "chat_id=c1b2..." \
  --data-urlencode "message_id=9c4e..."
ПараметрОбязателенПравило
chat_idдаЧат, где бот состоит. Берётся из апдейта
message_idдаСообщение с вложением. Берётся из апдейта
file_idнетКлюч вложения (media_key или thumbnail_url из апдейта). Без него берётся media_key сообщения

Ответ:

{
  "ok": true,
  "file_id": "media/9f1c....jpg",
  "file_url": "https://storage.yandexcloud.net/...",
  "expires_in": 3600,
  "msg_type": "image"
}
КодerrorКогда
400missing_paramsПустой chat_id или message_id
400file_not_foundУ сообщения нет такого вложения либо оно удалено
403not_a_memberБот не состоит в этом чате
404message_not_foundТакого сообщения в этом чате нет
503storage_unavailableПрочитать не удалось. Повторите с задержкой
503media_storage_not_configuredХранилище недоступно

Ссылка адресуется парой «чат + сообщение», а не одним ключом: подписывается только то вложение, которое действительно лежит в указанном сообщении указанного чата, и только если бот в этом чате состоит.

Срок выданной ссылки — тот же час, он приходит явным полем expires_in. Сохранять ссылку по-прежнему не нужно, сохраняйте file_id.


6. getUpdates

GET /bot/v1/getUpdates?offset=<int64>&timeout=<секунды>

Длинный опрос очереди. Отдаёт апдейты с update_id строго больше offset.

ПараметрЗначение
offsetПодтверждение: «всё до этого номера включительно обработано». Апдейты с update_id <= offset удаляются из очереди. По умолчанию 0
timeoutСколько секунд ждать, если очередь пуста. Максимум 30, по умолчанию 0 (ответить сразу)

Размер выдачи фиксирован — 100 апдейтов за вызов; параметра limit нет. Если апдейтов больше, заберите остаток следующим вызовом.

curl -sS -H "Authorization: Bot $TOKEN" \
  "https://api.gambu.ru/bot/v1/getUpdates?offset=$OFFSET&timeout=25"
{"updates": [{"update_id": 1758300000123456789, "type": "message", "...": "..."}]}
КодerrorКогда
503storage_unavailableОчередь не прочитана. Это не «апдейтов нет» — повторите запрос

Пустой список и отказ чтения — намеренно разные ответы: пустой ответ бот принимает за спокойный чат и следующую попытку делает нескоро.

Апдейты старше 7 суток из очереди удаляются. Основной способ держать очередь короткой — подтверждать обработанное параметром offset; у бота, который не подтверждает ничего, очередь будет возвращать одно и то же.


7. Вебхук

Альтернатива опросу: сервис сам делает POST на ваш URL с телом-апдейтом.

setWebhook

POST /bot/v1/setWebhook

{"url": "https://example.com/gambu/hook", "secret": "необязательно"}
ПолеОбязателенПравило
urlдаhttp или https, порт 80 или 443, публичный адрес: внутренние и служебные адреса (localhost, частные сети, link-local) отклоняются, в том числе когда к ним ведёт DNS-имя или редирект. Редиректы не выполняются. Пустая строка эквивалентна deleteWebhook
secretнетДо 256 символов. Если не передать, сгенерируется секрет из 32 символов и вернётся в ответе один раз — он нужен для проверки подписи

Ответ: {"ok": true, "url": "...", "secret": "..."}.

Отказы: 400 invalid_json, 400 invalid_url, 400 secret_too_long, 503 try_later.

deleteWebhook

POST /bot/v1/deleteWebhook — без тела. Снимает вебхук: апдейты снова забираются только опросом.

Ответ {"ok": true}, отказ — 503 try_later.

getWebhookInfo

GET /bot/v1/getWebhookInfo

{"url": "https://example.com/gambu/hook", "has_secret": true}

Сам секрет не отдаётся — он показывается один раз при setWebhook. Отказ — 503 storage_unavailable.

Что приходит на ваш URL

POST, Content-Type: application/json, тело — один апдейт в том же формате, что в getUpdates (включая file_url). Плюс три заголовка:

ЗаголовокЗначение
X-Gambu-Update-Idupdate_id апдейта
X-Gambu-Webhook-TimestampUnix-секунды момента отправки; у каждой попытки своя
X-Gambu-Bot-Signaturev1=<hex(HMAC_SHA256(secret, "<update_id>.<timestamp>.<тело>"))>

Доставка считается успешной при любом ответе 2xx. Попыток три, с линейной задержкой (1 с, 2 с), таймаут одной попытки — 8 секунд. Подпись и метка времени пересчитываются на каждую попытку.

Если доставка не удалась, апдейт остаётся в очереди и его можно забрать через getUpdates. Автоматически вебхук при этом не отключается — доставка будет пробоваться и для следующих апдейтов.

Проверка подписи на вашей стороне

Подписываются идентификатор, метка времени и тело — не только тело. Это закрывает и повтор перехваченного запроса, и подмену update_id, по которому вы дедуплицируете. Метка времени защищает, только если вы её проверяете: отбивайте запросы старше 5 минут.

Порядок обязателен: сначала подпись, потом свежесть. До сверки подписи ни метка времени, ни update_id доверия не заслуживают.

import hmac, hashlib, time

def verify(secret: str, sig: str, update_id: str, ts: str, body: bytes) -> int:
    if not sig.startswith("v1="):
        raise ValueError("неизвестная версия подписи")
    want = hmac.new(secret.encode(),
                    f"{int(update_id)}.{int(ts)}.".encode() + body,
                    hashlib.sha256).hexdigest()
    if not hmac.compare_digest(want, sig[3:].lower()):
        raise ValueError("подпись не сходится")
    if abs(time.time() - int(ts)) > 300:      # допуск 5 минут в обе стороны
        raise ValueError("запрос слишком старый")
    return int(update_id)

8. sendMessage

POST /bot/v1/sendMessage

{
  "chat_id": "c1b2...",
  "text": "Готово: **отчёт** [по ссылке](https://gambu.ru)",
  "parse_mode": "markdown",
  "topic_id": "t_7k2...",
  "reply_to_message_id": "9c4e...",
  "reply_markup": {"inline_keyboard": [[{"text": "Ещё", "callback_data": "more"}]]}
}
ПолеОбязателенПравило
chat_idдаЧат, где бот состоит участником
textдаНепустой; обрезается до 4000 символов без ошибки
parse_modeнет"markdown" — разобрать разметку (ниже). Без поля текст уходит как есть
topic_idнетТема группы (§14)
reply_to_message_idнетОтвет цитатой на сообщение этого же чата (message_id из апдейта). Приложения рисуют его обычной цитатой
reply_markupнетКлавиатура (§13)

Ответ:

{"ok": true, "message_id": "9c4e...", "created_at": 1758300000, "topic_id": "t_7k2..."}

Поле topic_id в ответе: null, если это личный чат или у чата выключены темы; "general", если темы включены, а сообщение ушло в общую ленту.

Разметка текста: parse_mode

Без поля parse_mode текст уходит как есть. С "parse_mode": "markdown" сервер разбирает подмножество Markdown:

ПишетеПолучается
**жирный**жирный
_курсив_курсив
текст между обратными кавычкамимоноширинный, содержимое буквальное
[текст](https://...)текст-ссылка

Вложенность работает (**жирный с _курсивом_**, [**жирная ссылка**](https://...)), а внутри моноширинного фрагмента — нет: там всё буквально. Чтобы отправить сам символ разметки, экранируйте его обратным слешем: \*, \_, \[, \\, а также обратный слеш перед обратной кавычкой.

Что важно знать заранее:

Разметка работает одинаково у sendMessage, у подписей медиа (§10) и у editMessageText (§11) — одним и тем же полем и по тем же правилам.

Отказы

КодerrorКогда
400invalid_jsonТело не разбирается
400missing_chat_idПустой chat_id
400empty_messageПустой text
400bad_markupУказан parse_mode: "markdown", а разметка не разбирается
400bad_parse_modeНеизвестная схема разбора (html, markdownv2, ...)
400invalid_reply_markupКлавиатура нарушает ограничения §13
400topic_not_foundТемы нет, она удалена, принадлежит другому чату, либо режим тем у чата выключен
400reply_not_foundЦитируемого сообщения нет в этом чате
403topic_closedТема закрыта для новых сообщений
403not_a_memberБот не участник этого чата
403not_allowed_to_post, not_allowed_media, not_allowed_links, not_allowed_pollsПрава роли в группе запрещают (§15)
429rate_limitedПревышен лимит 30 отправок в секунду
500internal_errorСообщение не записалось
503storage_unavailable, try_laterНе удалось проверить членство, права, тему или цитируемое сообщение. Повторите

9. getUploadURL

POST /bot/v1/getUploadURL

Первый шаг двухшаговой отправки медиа. Ключей к хранилищу у бота нет, поэтому файл он заливает сам по подписанной ссылке, а серверу передаёт только ключ.

{"mime_type": "image/jpeg", "filename": "shot.jpg"}
ПолеОбязателенПравило
mime_typeдаMIME-тип файла
filenameнетНужен только ради расширения; без него расширение выводится из MIME-типа

Ответ:

{
  "key": "media/6f2a....jpg",
  "upload_url": "https://storage.yandexcloud.net/...",
  "thumbnail_key": "thumbs/1c8d....jpg",
  "thumbnail_upload_url": "https://storage.yandexcloud.net/..."
}

Поля thumbnail_* приходят только для image/* и video/*. Превью необязательно, но с ним сообщение выглядит так же, как у человека.

Ссылка на загрузку живёт 15 минут. Файл заливается обычным PUT с телом файла — и только после успешного PUT вызывается метод отправки (§10).

Отказы: 400 invalid_json, 400 missing_mime_type, 503 media_storage_not_configured.


10. sendPhoto, sendDocument, sendVideo, sendAudio, sendVoice

Второй шаг отправки медиа: ключ из getUploadURL плюс необязательные подпись и длительность. Все пять методов принимают одно и то же тело.

МетодТип сообщенияКак выглядит у получателя
POST /bot/v1/sendPhotoimageКартинка
POST /bot/v1/sendDocumentfileПлашка файла
POST /bot/v1/sendVideovideoВидео с кадром-превью и плеером
POST /bot/v1/sendAudioaudioГолосовое сообщение с плеером
POST /bot/v1/sendVoiceaudioТо же самое

Отдельного музыкального типа сообщения в Gambu нет: audio — это голосовое, приложения рисуют его плеером с длительностью и складывают во вкладку «Голосовые». Поэтому sendAudio и sendVoice дают одинаковый результат, и выбор между ними ни на что не влияет.

{
  "chat_id": "c1b2...",
  "media_key": "media/6f2a....mp4",
  "thumbnail_key": "thumbs/1c8d....jpg",
  "duration_ms": 12500,
  "caption": "**Отчёт** за сутки",
  "parse_mode": "markdown",
  "topic_id": "t_7k2...",
  "reply_to_message_id": "9c4e...",
  "reply_markup": {"inline_keyboard": [[{"text": "Открыть", "url": "https://example.com"}]]}
}
ПолеОбязателенПравило
chat_idдаЧат, где бот состоит
media_keyдаКлюч из getUploadURL; допустимы только префиксы media/ и thumbs/, без пробелов
thumbnail_keyнетКлюч превью; негодный даёт 400 bad_media
duration_msнетДлительность в миллисекундах, от 1 до 86 400 000 (сутки). Только для sendVideo, sendAudio и sendVoice
captionнетПодпись, обрезается до 1024 символов
parse_modeнет"markdown" — разобрать разметку подписи (§8)
topic_idнетТема группы (§14)
reply_to_message_idнетОтвет цитатой на сообщение этого же чата (§8)
reply_markupнетКлавиатура (§13)

Ответ — такой же, как у sendMessage.

Отказы — все коды sendMessage, плюс:

КодerrorКогда
400invalid_media_keyКлюч не из загрузочного префикса или содержит пробелы
400bad_mediaНегодный thumbnail_key; отрицательная duration_ms или больше суток; duration_ms у sendPhoto и sendDocument, где длительности не бывает
400media_not_uploadedОбъекта по этому ключу в хранилище нет: файл не залит или PUT не прошёл
503try_laterПроверить наличие объекта не удалось

Порядок обязателен: сначала PUT файла по upload_url, затем метод отправки. Проверяйте код ответа PUT у себя: сообщение, отправленное с ключом, по которому файл так и не появился, окажется в чате без содержимого.

Про видео стоит знать две вещи:


11. editMessageText

POST /bot/v1/editMessageText

Меняет текст и/или клавиатуру своего ранее отправленного текстового сообщения. Открытые приложения обновляют его в реальном времени.

{"chat_id": "c1b2...", "message_id": "9c4e...", "text": "Готово", "reply_markup": null}
ПолеОбязателенПравило
chat_idдаЧат сообщения
message_idдаИдентификатор из ответа sendMessage
textдаНепустой, обрезается до 4000 символов
parse_modeнет"markdown" — разобрать разметку (§8)
reply_markupнетНовая клавиатура; отсутствие поля снимает прежнюю
topic_idнетТолько как подтверждение текущей темы сообщения (§14)

Ответ:

{"ok": true, "message_id": "9c4e...", "edited_at": 1758300123, "topic_id": null}
КодerrorКогда
400invalid_jsonТело не разбирается
400missing_paramsПустой chat_id или message_id
400empty_messageПустой text
400bad_markupУказан parse_mode: "markdown", а разметка не разбирается (§8)
400bad_parse_modeНеизвестная схема разбора
400invalid_reply_markupКлавиатура нарушает ограничения §13
400not_editableСообщение не текстовое: медиа править нельзя
400topic_immutableПередан topic_id, отличный от текущей темы сообщения
403not_authorСообщение отправлено не этим ботом
404message_not_foundТакого сообщения нет
500internal_errorПравка не записалась
503storage_unavailableСообщение не прочитано. Повторите

Два практических замечания:

Лимита частоты у правки нет — он стоит только на отправке.


12. deleteMessage

POST /bot/v1/deleteMessage

Убирает своё ранее отправленное сообщение из чата. Служебные реплики («ищу...», одноразовый код, промежуточный результат) незачем оставлять в ленте навсегда, а editMessageText их не убирает — он только меняет текст.

{"chat_id": "c1b2...", "message_id": "9c4e..."}
ПолеОбязателенПравило
chat_idдаЧат сообщения
message_idдаИдентификатор из ответа метода отправки

Ответ:

{"ok": true, "message_id": "9c4e..."}

Повторный вызов на уже удалённом сообщении ошибкой не считается: ответ тот же, плюс "already_deleted": true. Это позволяет просто повторить запрос, если ответ потерялся в сети.

Удалить можно только сообщение самого бота. Роль admin в группе этого не меняет: чужие сообщения удаляются в приложении, а не токеном бота.

Удаление доходит до открытых приложений сразу — и в ленту чата, и в строку списка чатов.

КодerrorКогда
400invalid_jsonТело не разбирается
400missing_paramsПустой chat_id или message_id
403not_own_messageСообщение отправлено не этим ботом
403not_a_memberБот не участник этого чата
404message_not_foundТакого сообщения в этом чате нет
503storage_unavailable, try_laterНе удалось прочитать строку или записать пометку. Повторите

Лимита частоты у удаления нет — он стоит только на отправке.


13. Кнопки и answerCallbackQuery

Клавиатуры

Поле reply_markup принимает ровно один из трёх режимов; смешивать их нельзя — иначе 400 invalid_reply_markup.

{"inline_keyboard": [[{"text": "Да", "callback_data": "yes"},
                      {"text": "Сайт", "url": "https://example.com"}]]}
{"keyboard": [[{"text": "Погода"}, {"text": "Курс"}]]}
{"remove_keyboard": true}
РежимЧто делает
inline_keyboardКнопки под сообщением. У каждой кнопки ровно одно из полей callback_data или url
keyboardЗаменяет системную клавиатуру человека; нажатие отправляет подпись кнопки обычным сообщением
remove_keyboardУбирает ранее показанную keyboard

Ограничения — нарушение даёт 400 invalid_reply_markup, а не обрезку:

ЧтоПредел
Рядов в клавиатуре100
Кнопок в ряду1–8, пустой ряд запрещён
Длина подписи кнопки64 символа, непустая
Длина callback_data64 символа
url кнопкиhttp или https с непустым хостом

Значение callback_data другим участникам чата не показывается — это приватная нагрузка кнопки.

answerCallbackQuery

POST /bot/v1/answerCallbackQuery

Закрывает нажатие inline-кнопки: показывает нажавшему всплывающее уведомление.

{"callback_query_id": "3f9c...", "text": "Принято", "show_alert": false}
ПолеОбязателенПравило
callback_query_idдаИз апдейта callback_query
textнетОбрезается до 200 символов
show_alertнетtrue — модальное окно вместо всплывающей подсказки

Ответ — всегда {"ok": true}. Истёкший, чужой или уже отвеченный идентификатор тоже даёт ok: метод идемпотентен, повторный вызов безопасен.

Отвечать нужно в течение 5 минут — столько живёт незакрытое нажатие. Незакрытые нажатия не переживают обновление сервиса, поэтому ответ на них безопасно повторять: метод идемпотентен.

Отказы: 400 invalid_json, 400 missing_callback_query_id.


14. Темы в группах

Группа с включённым режимом тем — это набор отдельных лент, и каждое сообщение принадлежит одной из них.

Чтобы отвечать туда, откуда пришёл вопрос, просто верните topic_id из апдейта в метод отправки.

КодerrorКогда
400topic_not_foundТемы нет, она удалена, принадлежит другому чату — или режим тем у чата выключен, а topic_id передан
403topic_closedТема закрыта для новых сообщений
400topic_immutableeditMessageText с темой, отличной от текущей
503try_laterПроверить тему не удалось

Последняя строка — не формальность: «тему проверить не удалось» не равно «темы нет». В этом случае сообщение не отправляется вовсе, потому что молчаливая отправка в общую ленту увела бы ответ мимо адреса, а перенести его потом нечем.


15. Бот в группе: роль и права

Бот состоит в группе обычным участником с ролью, и права роли действуют на него так же, как на человека. Если участникам запрещено отправлять медиа или ссылки, бот с ролью member получит 403 not_allowed_media или 403 not_allowed_links. Иначе запрет обходился бы за минуту: достаточно было бы завести бота и отправлять через него.

Для законного случая есть роль admin: владелец чата выдаёт её боту-публикатору (лента, дайджест, оповещения), и ограничения роли member перестают действовать.

Отключение звука и медленный режим к ботам не применяются — их роль играет собственный лимит частоты (§18).

Добавляет бота в группу человек — из приложения, как обычного участника. Методов «вступить в чат» и «выйти из чата» у Bot API нет.

Правило «в группах приходят только команды» (§5) эту дверь не закрывает: оно про то, что бот получает, а не про то, что он отправляет.


16. Команды: setMyCommands и getMyCommands

Команды — это метаданные для меню в приложении. Сервер их не интерпретирует: /start и всё остальное доходит до бота обычным апдейтом, логика остаётся на вашей стороне.

setMyCommands

POST /bot/v1/setMyCommands

{"commands": [{"command": "start", "description": "Начать"},
              {"command": "help", "description": "Что я умею"}]}

Метод полностью заменяет список. Пустой массив очищает меню.

ЧтоПравило
Имя команды1–32 символа a-z, 0-9, _. Ведущий / снимается, регистр приводится к нижнему
Описание1–256 символов, непустое
Количестводо 100
Дубли по имениотбрасываются, остаётся первый

Ответ: {"ok": true, "commands": [...]} — нормализованный список.

Отказы: 400 invalid_json, 400 invalid_commands (негодное имя, пустое или слишком длинное описание, больше 100 команд), 500 internal_error.

getMyCommands

GET /bot/v1/getMyCommands → {"commands": [...]}.

Отказ — 503 storage_unavailable.


17. Мини-приложения

Мини-приложение — это бот с веб-страницей: приложение Gambu открывает ваш URL во встроенном браузере и передаёт туда подписанную строку initData с личностью пользователя. Формат — стандартная строка запроса с парами «ключ-значение» и полем hash.

Как включить. Владелец задаёт URL своему боту в приложении; по API это POST /v1/bots/:botId/webapp под токеном владельца, тело {"web_app_url": "https://...", "published": true}. URL обязан быть https. Значение published: true выводит мини-приложение в общий каталог.

Что получает ваш фронтенд. Приложение вызывает POST /v1/apps/:username/launch и получает:

{
  "url": "https://example.com/app",
  "init_data": "auth_date=1758300000&hash=...&query_id=...&user=%7B...%7D",
  "expires_in": 86400,
  "bot": {"bot_id": "bot_aZ12...", "username": "shop_bot", "display_name": "Магазин"}
}

Значение init_data — URL-кодированная строка запроса с парами auth_date, query_id, user (компактный JSON: id, first_name, опционально username и photo_url) и hash.

Как проверить initData на своём сервере. Секрет подписи выводится из токена бота:

secret_key    = HMAC_SHA256(key="WebAppData", data=<токен бота>)      # hex
data_check    = пары кроме hash, отсортированные по ключу, "k=v" через "\n"
hash          = hex(HMAC_SHA256(key=<байты secret_key>, data=data_check))

Срок годности initData — 24 часа (auth_date входит в подпись, запас на расхождение часов — 5 минут в будущее). Проверять возраст обязаны вы: подпись без проверки срока — это вечный пропуск, а перехваченная строка открывает мини-приложение от чужого имени. Истёк — приложение запросит запуск заново.

Перевыпуск токена бота меняет и секрет подписи: выданные ранее строки initData перестают сходиться сразу.

Если запуск отвечает 400 webapp_unavailable, у бота ещё нет секрета подписи — так бывает у ботов, созданных до появления мини-приложений. Лечится перевыпуском токена.

Обратный канал. Мини-приложение отправляет боту произвольную строку — она приходит апдейтом type: "web_app_data", где text — сама строка (до 4096 символов), а chat_id — личный чат человека с ботом.


18. Лимиты

ЧтоПределЧто при превышении
Отправок в секунду на бота30 (все методы send*)429 rate_limited
Длина текста сообщения4000 символовМолча обрезается
Длина подписи к медиа1024 символаМолча обрезается
Длина текста answerCallbackQuery200 символовМолча обрезается
Длина строки от мини-приложения4096 символовМолча обрезается
callback_data64 символа400 invalid_reply_markup
Подпись кнопки64 символа400 invalid_reply_markup
Рядов и кнопок в клавиатуре100 рядов по 8 кнопок400 invalid_reply_markup
Команд в меню100400 invalid_commands
Ботов на одного владельца20Отказ при создании
Жизнь апдейта в очереди7 сутокУдаляется
Жизнь file_url в апдейте1 часСсылка перестаёт работать; перевыпустите её через getFile (§5)
Жизнь ссылки из getFile1 час, поле expires_inТо же: вызовите getFile заново
duration_ms у медиа1 сутки400 bad_media
Жизнь upload_url15 минутPUT перестаёт приниматься
Жизнь callback_query_id5 минутanswerCallbackQuery молча отвечает ok
Срок годности initData24 часаЗапросить запуск заново
timeout у getUpdates30 секундЗначение обрезается до 30
Размер выдачи getUpdates100 апдейтовЗаберите остаток следующим вызовом

Лимит частоты считается отдельно для каждого бота.


19. Сводная таблица кодов ошибок

КодerrorГдеЧто делать
400invalid_jsonвездеПочинить тело запроса
400missing_chat_idотправкаЗаполнить chat_id
400missing_paramseditMessageText, deleteMessage, getFileЗаполнить chat_id и message_id
400empty_messageотправка, правкаПрислать непустой text
400bad_markupотправка, правкаРазметка не разбирается — см. §8
400bad_parse_modeотправка, правкаИз схем разбора доступна только markdown
400invalid_reply_markupотправка, правкаПривести клавиатуру к ограничениям §13
400invalid_media_keyмедиаВзять ключ из getUploadURL
400media_not_uploadedмедиаСначала PUT файла, потом отправка
400bad_mediaмедиаНегодный thumbnail_key либо duration_ms вне правил §10
400file_not_foundgetFileУ этого сообщения нет такого вложения либо оно удалено
400missing_mime_typegetUploadURLУказать mime_type
400not_editableeditMessageTextПравить можно только текстовые сообщения
400topic_not_foundотправкаПроверить, что тема жива и режим тем у чата включён
400topic_immutableeditMessageTextНе менять тему правкой
400reply_not_foundотправкаЦитировать можно только живое сообщение этого же чата
400invalid_urlsetWebhookhttp/https, порт 80/443, публичный адрес
400secret_too_longsetWebhookСекрет до 256 символов
400invalid_commandssetMyCommandsПривести список к правилам §16
400missing_callback_query_idanswerCallbackQueryВзять идентификатор из апдейта
401missing_tokenвездеДобавить заголовок Authorization: Bot <token>; схема Bearer не принимается
401invalid_tokenвездеТокен не подошёл, бот удалён или выключен. Повторять запрос бессмысленно
403feature_disabledвездеПлатформа ботов недоступна владельцу бота
503feature_unwiredвездеПроверить доступность платформы не удалось. Повторить
403account_restrictedпишущиеОграничение модерацией; обжалование — через поддержку в приложении
403not_a_memberотправка, deleteMessage, getFileДобавить бота в чат
403not_authoreditMessageTextПравить можно только свои сообщения
403not_own_messagedeleteMessageУдалять можно только свои сообщения
403not_allowed_to_post, not_allowed_media, not_allowed_links, not_allowed_pollsотправкаПрава роли в группе; поможет роль admin (§15)
403topic_closedотправкаТема закрыта
404message_not_foundeditMessageText, deleteMessage, getFileТакого сообщения нет
429rate_limitedотправкаПодождать и повторить
500internal_errorзаписьПовторить; если повторяется — написать на support@gambu.ru
503storage_unavailableвездеПрочитать не удалось. Повторить с нарастающей задержкой
503try_laterпишущиеПроверить не удалось. Повторить с нарастающей задержкой
503media_storage_not_configuredgetUploadURL, getFileХранилище недоступно; повторить позже

20. Примеры

curl: отправить сообщение

TOKEN='bot_aZ12Qw34Er56:xK9...'

curl -sS -X POST https://api.gambu.ru/bot/v1/sendMessage \
  -H "Authorization: Bot $TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"chat_id":"c1b2...","text":"Привет из curl"}'

curl: отправить картинку

# 1. взять ссылку на загрузку
RESP=$(curl -sS -X POST https://api.gambu.ru/bot/v1/getUploadURL \
  -H "Authorization: Bot $TOKEN" -H 'Content-Type: application/json' \
  -d '{"mime_type":"image/jpeg","filename":"shot.jpg"}')

KEY=$(printf '%s' "$RESP" | python3 -c 'import json,sys;print(json.load(sys.stdin)["key"])')
URL=$(printf '%s' "$RESP" | python3 -c 'import json,sys;print(json.load(sys.stdin)["upload_url"])')

# 2. залить файл прямо в хранилище
curl -sS -X PUT --upload-file shot.jpg "$URL"

# 3. отправить сообщение с этим ключом
curl -sS -X POST https://api.gambu.ru/bot/v1/sendPhoto \
  -H "Authorization: Bot $TOKEN" -H 'Content-Type: application/json' \
  -d "{\"chat_id\":\"c1b2...\",\"media_key\":\"$KEY\",\"caption\":\"Отчёт\"}"

Python: клиент на стандартной библиотеке

import json, urllib.request, urllib.error

BASE = "https://api.gambu.ru/bot/v1"

class Gambu:
    def __init__(self, token: str):
        self.token = token

    def call(self, method: str, payload: dict | None = None, query: str = ""):
        url = f"{BASE}/{method}{query}"
        data = json.dumps(payload).encode() if payload is not None else None
        req = urllib.request.Request(url, data=data, method="POST" if data else "GET")
        req.add_header("Authorization", f"Bot {self.token}")
        if data:
            req.add_header("Content-Type", "application/json")
        try:
            with urllib.request.urlopen(req, timeout=40) as r:
                return json.load(r)
        except urllib.error.HTTPError as e:
            body = json.loads(e.read() or b"{}")
            raise RuntimeError(f"{e.code} {body.get('error', '?')}") from None

Python: эхо-бот

import time

bot = Gambu("bot_aZ12Qw34Er56:xK9...")
offset = 0

while True:
    try:
        res = bot.call("getUpdates", query=f"?offset={offset}&timeout=25")
    except RuntimeError as e:
        print("не удалось забрать апдейты:", e)   # 503 — повторяем, не сдаёмся
        time.sleep(5)
        continue

    for u in res["updates"]:
        offset = u["update_id"]                   # подтверждаем всё до этого номера
        if u["type"] != "message" or not u.get("text"):
            continue
        bot.call("sendMessage", {
            "chat_id": u["chat_id"],
            "text": "Вы написали: " + u["text"],
            # отвечаем в ту же тему, если сообщение пришло из темы
            **({"topic_id": u["topic_id"]} if u.get("topic_id") else {}),
        })

Два неочевидных места в этом цикле:

  1. offset двигается на update_id обработанного апдейта, а не на +1 — выдача идёт строго «больше offset».
  2. 503 — не повод остановиться. Отказ чтения очереди и пустая очередь приходят разными ответами именно затем, чтобы сбой не был принят за тишину.

21. Чего в API нет

Чтобы не искать: перечисленного ниже в Gambu Bot API нет, и ничем из описанного выше это не заменяется.

Вопросы по Bot API — support@gambu.ru.