В Bot API MAX (российский мессенджер от VK Tech) есть два способа получать апдейты: long polling и webhook. Выбор между ними определяет инфраструктуру, способ отладки и поведение под нагрузкой. На старте кажется, что разницы нет, но к проду она становится принципиальной — от модели доставки зависит, влезает ли бот в serverless, можно ли запускать несколько инстансов и сколько денег уйдёт на хостинг. Разберём оба механизма с примерами кода под MAX, сравнительной таблицей и сценариями миграции без потери сообщений.
Как устроен long polling
Long polling — pull-модель: бот сам периодически дёргает API MAX методом getUpdates, удерживая HTTP-соединение открытым на длительный таймаут (обычно 25–30 секунд). MAX держит запрос «висящим», и как только в очереди появляется хотя бы одно событие — отдаёт массив Update и закрывает соединение. Бот сразу шлёт следующий getUpdates — и так в цикле.
Поток выглядит примерно так:
бот → GET https://api.max.ru/bot<TOKEN>/getUpdates?offset=N&timeout=30
[соединение висит до 30 с]
← 200 [{ update_id: N+1, ... }, { update_id: N+2, ... }]
бот → GET .../getUpdates?offset=N+3&timeout=30
...
Параметр offset — «подтверждение» обработанных апдейтов: передавая offset=last_update_id+1, бот говорит MAX «эти я забрал, можешь удалить из очереди». Если бот упал, не подтвердив, — MAX отдаст те же апдейты следующему getUpdates.
Вызов getUpdates curl-ом для отладки:
curl "https://api.max.ru/bot$BOT_TOKEN/getUpdates?timeout=30&allowed_updates=[\"message\",\"callback_query\"]"
Никакого публичного домена, SSL и открытых портов не нужно — нужен только исходящий HTTPS до api.max.ru. Инициатор соединения всегда бот, поэтому работает за NAT, в корпоративной сети, на ноутбуке разработчика.
Как устроен webhook
Webhook — push-модель: бот один раз говорит MAX «шли все апдейты POST-ом сюда», после чего MAX сам стучится на ваш URL при каждом новом событии.
Регистрация:
curl -X POST "https://api.max.ru/bot$BOT_TOKEN/setWebhook" \
-H "Content-Type: application/json" \
-d '{
"url": "https://bot.example.ru/max/webhook",
"secret_token": "k7Hg2pQwR9zX5mN8vL3jY6tF1aB4cD0e",
"allowed_updates": ["message", "callback_query", "inline_query"],
"max_connections": 40,
"drop_pending_updates": false
}'
После этого MAX сам шлёт POST с JSON-телом Update на указанный URL, ожидая HTTP 200 в течение примерно 60 секунд. Если ответа нет или код не 2xx — апдейт уйдёт в очередь повторов с экспоненциальной задержкой.
Требования MAX к webhook URL:
- HTTPS с валидным сертификатом — Let's Encrypt подходит, доверенным CA доверяют по умолчанию. Self-signed нужно явно прокидывать в
setWebhookчерез параметрcertificate. - Один из разрешённых портов: 443, 80, 88 или 8443. Произвольный порт API не примет.
- Публичный IP. Если MAX публикует CIDR-подсети для исходящих POST-ов, их можно добавить в IP-allowlist на firewall, чтобы закрыть webhook от посторонних сканеров.
- Доменное имя в URL (IP-адрес в URL не принимается).
Сравнительная таблица
| Параметр | Long polling | Webhook |
|---|---|---|
| Модель | Pull, бот тянет | Push, MAX шлёт |
| Latency доставки | 200–1500 мс | 30–150 мс |
| Throughput на инстанс | До нескольких сообщений в секунду | Сотни сообщений в секунду |
| Публичный IP | Не нужен | Обязателен |
| HTTPS-сертификат | Не нужен | Обязателен (Let's Encrypt OK) |
| Порты | Только исходящий 443 | Входящий 443 / 80 / 88 / 8443 |
| Несколько инстансов | Невозможно с одним токеном | Через load balancer |
| Serverless | Не работает | Работает естественно |
| Отладка локально | Запустил — работает | Нужен ngrok / cloudflared |
| Стоимость инфраструктуры | Минимальная | Домен + SSL + публичный хост |
| Перезапуск без потери | Через offset | Через очередь повторов |
| Защита эндпоинта | Не требуется | secret_token + IP-allowlist |
Когда выбирать long polling
Long polling — правильный выбор, когда:
- MVP и прототип. Запустить бот за 10 минут на ноутбуке, без VPS, домена и SSL.
- Локальная разработка. Проще отлаживать — не нужен ngrok или Cloudflare Tunnel, дебагер останавливает процесс без таймаутов от MAX.
- Нет публичного IP. Корпоративная сеть, NAT, домашний интернет, бот на офисном сервере без проброса портов.
- Малый трафик. До нескольких сообщений в секунду long polling справляется без напряга.
- Однопроцессный бот. Не нужен балансировщик, shared-storage, синхронизация состояния между инстансами.
- Простой деплой. Один контейнер, без nginx, без Let's Encrypt, без cron на обновление сертификата.
Главный архитектурный минус — невозможность запустить два инстанса с одним токеном. MAX отдаст апдейт первому подключившемуся getUpdates, и второй инстанс начнёт «воровать» сообщения у первого. На практике вы получите рандомное распределение апдейтов и сломанные FSM-состояния.
Когда выбирать webhook
Webhook — правильный выбор, когда:
- Высокий трафик. Сотни сообщений в секунду, нужно горизонтальное масштабирование.
- Серверлесс. Yandex Cloud Functions, AWS Lambda, Cloudflare Workers — long polling там не работает в принципе (нет долгоживущего процесса).
- Минимальная задержка. Webhook доставляет апдейт за десятки миллисекунд против секунды у polling — критично для AI-ботов и интерактивных сценариев.
- Несколько инстансов за балансировщиком. MAX сам распределит POST-запросы по живым нодам через ваш балансер.
- Production-готовность. Webhook ровно интегрируется в обычный веб-стек: nginx → app server → handler, как любой другой HTTP API.
- Экономия исходящего трафика. Не нужно держать сотни RPS впустую — MAX сам стучится только при наличии события.
secret_token и защита эндпоинта
Webhook URL рано или поздно попадёт в логи, в чужие сертификатные журналы (Certificate Transparency), в DNS-историю. Чтобы посторонний с угаданным URL не мог слать поддельные апдейты, в setWebhook передаётся secret_token — произвольная строка от 1 до 256 символов из алфавита A-Z a-z 0-9 _ -.
MAX при каждом POST добавляет заголовок X-Max-Bot-Api-Secret-Token со значением этого секрета. Сервер обязан проверять заголовок и отбрасывать запросы без него:
from fastapi import FastAPI, Request, Header, HTTPException
import os, hmac
app = FastAPI()
SECRET = os.environ["MAX_WEBHOOK_SECRET"]
@app.post("/max/webhook")
async def webhook(
request: Request,
x_max_bot_api_secret_token: str | None = Header(default=None),
):
if not x_max_bot_api_secret_token or not hmac.compare_digest(
x_max_bot_api_secret_token, SECRET
):
raise HTTPException(status_code=403, detail="forbidden")
update = await request.json()
# быстро поставить в очередь и ответить 200
await enqueue(update)
return {"ok": True}
Сравнение через hmac.compare_digest (а не ==) защищает от timing-атак. Дополнительный слой защиты — IP-allowlist на nginx или firewall, если MAX публикует CIDR исходящих подсетей.
allowed_updates: оптимизация трафика
По умолчанию MAX шлёт боту все типы апдейтов. Это значит — даже если ваш бот не реагирует на edited_message или poll_answer, вы всё равно получаете их POST-ом и тратите CPU на парсинг.
Параметр allowed_updates в setWebhook (и getUpdates) отсекает ненужные типы на стороне MAX:
curl -X POST "https://api.max.ru/bot$BOT_TOKEN/setWebhook" \
-d "url=https://bot.example.ru/max/webhook" \
-d 'allowed_updates=["message","callback_query"]'
Для типичного бота с командами и инлайн-кнопками достаточно message и callback_query. Если используете inline-режим — добавьте inline_query. Если есть платежи — pre_checkout_query и successful_payment (через message).
Пример: webhook на FastAPI + nginx
Минимальный продакшен-стек: nginx терминирует TLS и проксирует на FastAPI/Uvicorn, FastAPI быстро складывает апдейт в очередь (Redis/RabbitMQ/asyncio.Queue) и отвечает 200. Тяжёлая работа — в фоновом воркере.
nginx-конфиг:
server {
listen 443 ssl http2;
server_name bot.example.ru;
ssl_certificate /etc/letsencrypt/live/bot.example.ru/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/bot.example.ru/privkey.pem;
# MAX subnets (если опубликованы — заменить на актуальные CIDR)
# allow 95.142.192.0/21;
# deny all;
location /max/webhook {
proxy_pass http://127.0.0.1:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_read_timeout 60s;
client_max_body_size 1m;
}
}
FastAPI-handler из примера выше принимает POST, проверяет secret_token, кладёт апдейт в очередь и отвечает 200 за миллисекунды. Воркер в отдельном процессе разбирает очередь и вызывает обработчики. Аналогичный шаблон в aiohttp:
from aiohttp import web
async def handle_webhook(request: web.Request) -> web.Response:
secret = request.headers.get("X-Max-Bot-Api-Secret-Token")
if secret != WEBHOOK_SECRET:
return web.Response(status=403, text="forbidden")
update = await request.json()
request.app["queue"].put_nowait(update)
return web.json_response({"ok": True})
app = web.Application()
app["queue"] = asyncio.Queue()
app.router.add_post("/max/webhook", handle_webhook)
web.run_app(app, host="127.0.0.1", port=8000)
Пример: long polling на Python
Минимальный polling-цикл без фреймворка — для понимания, что происходит под капотом:
import os, time, requests
TOKEN = os.environ["BOT_TOKEN"]
API = f"https://api.max.ru/bot{TOKEN}"
offset = 0
while True:
try:
r = requests.get(
f"{API}/getUpdates",
params={"offset": offset, "timeout": 30,
"allowed_updates": '["message","callback_query"]'},
timeout=35,
)
for upd in r.json().get("result", []):
offset = upd["update_id"] + 1
handle(upd) # ваша обработка
except requests.RequestException:
time.sleep(2) # экспоненциальный бэкофф в проде
timeout HTTP-клиента должен быть больше timeout long polling, иначе клиент будет рвать соединение раньше, чем MAX успеет ответить. На фреймворках всё это уже обёрнуто: aiogram-аналоги для MAX, max-bot-api-python и т. п.
Local-tunnel для разработки webhook
Если архитектурно вы уже на webhook, но хочется отладить обработчик локально с дебагером — нужен туннель, выставляющий ваш localhost:8000 наружу с HTTPS-URL.
Популярные варианты:
- ngrok:
ngrok http 8000→ выдаёт URL видаhttps://abcd-1-2-3-4.ngrok-free.app. Бесплатный план меняет URL при каждом старте. - cloudflared:
cloudflared tunnel --url http://localhost:8000→ стабильный URL*.trycloudflare.com. Бесплатно, без лимитов на трафик. - localtunnel:
lt --port 8000→ URL*.loca.lt. Простой, но нестабильный. - bore / frp: self-hosted решения, если есть свой VPS с доменом.
После запуска туннеля — setWebhook с полученным URL и тем же secret_token, что в локальном .env.
Миграция с polling на webhook без потери сообщений
Самый частый сценарий: бот рос на polling, теперь нужно мигрировать на webhook без даунтайма и потери апдейтов. Алгоритм:
- Подготовить webhook-инстанс, не запуская его. Развернуть HTTPS-эндпоинт, проверить, что отвечает 200 на тестовый POST.
- Не трогать polling-инстанс пока. Он продолжает работать.
- Остановить polling-процесс командой деплоя. Последний
getUpdatesвернёт пустой массив или висит до таймаута. - Дренировать очередь: один раз вызвать
getUpdates?offset=last+1&timeout=0чтобы подтвердить все обработанные апдейты. - Вызвать
setWebhookс новым URL,secret_tokenиdrop_pending_updates=false— чтобы накопившиеся за минуту переключения апдейты пришли webhook-у. - Запустить webhook-инстанс, проверить через
getWebhookInfo, чтоpending_update_countуменьшается до нуля.
Окно простоя — секунды. Если использовать drop_pending_updates=true, потеряете апдейты, накопившиеся между шагами 3 и 5.
Обратная миграция (webhook → polling) такая же: deleteWebhook → старт polling-цикла. getUpdates сразу подхватит pending-очередь, если её не сбросили.
Несколько инстансов: polling нельзя, webhook можно
Long polling с одним токеном на нескольких инстансах работать не будет — MAX отдаёт каждый апдейт ровно одному getUpdates-запросу, и это будет случайный инстанс. FSM-состояния между ними не синхронизируются, бот ведёт себя непредсказуемо.
Webhook масштабируется естественно: ставим N инстансов за nginx или HAProxy, балансер раздаёт POST-ы. Что важно учесть:
- Идемпотентность по
update_id. При сетевом сбое MAX может ретраить тот жеupdate_id— храните обработанные ID (Redis SET с TTL 24 часа) и пропускайте дубли. - Shared FSM-storage. Состояния пользователей — в Redis или Postgres, не в памяти процесса.
- Sticky sessions не нужны. Любой инстанс должен уметь обработать любой
update_id. - Graceful shutdown. При раскатке нового релиза старый инстанс должен дообработать текущие POST-ы, а не убиваться SIGKILL.
Webhook в serverless
Serverless-платформы (Yandex Cloud Functions, AWS Lambda, Cloudflare Workers) подходят для webhook идеально: нет долгоживущего процесса, оплата за инвокацию, автоскейлинг из коробки. Но есть особенности:
- Cold start. Первый POST после простоя может занять 200–2000 мс на инициализацию рантайма. MAX это переживёт, но пользователь почувствует задержку. У Cloudflare Workers и Yandex Cloud Functions cold start менее 100 мс — там терпимо.
- Таймаут платформы. AWS Lambda — до 15 минут, Cloudflare Workers — 30 секунд CPU-time, Yandex Cloud Functions — 10 минут. Тяжёлые операции всё равно выносим в отдельную очередь (Yandex Message Queue, SQS, Cloudflare Queues).
- Stateless. FSM и сессии — обязательно во внешнем хранилище. KV-store (Yandex YDB, Cloudflare KV) идеально для этого.
- Логирование. У serverless-платформ свой стек логов; интеграция с Sentry или externals — отдельная настройка.
- Лимиты на размер тела. MAX присылает апдейты до 1 МБ (фото-сообщения с длинной подписью). Большинство платформ это переваривают, но проверьте лимит.
Мониторинг webhook
Главная команда для диагностики webhook — getWebhookInfo:
curl "https://api.max.ru/bot$BOT_TOKEN/getWebhookInfo"
Ответ:
{
"ok": true,
"result": {
"url": "https://bot.example.ru/max/webhook",
"has_custom_certificate": false,
"pending_update_count": 0,
"max_connections": 40,
"ip_address": "203.0.113.10",
"last_error_date": 1709123456,
"last_error_message": "SSL error: Hostname mismatch",
"allowed_updates": ["message", "callback_query"]
}
}
На что смотреть:
pending_update_count— сколько апдейтов в очереди MAX. Растёт — значит webhook не отвечает 200 или не успевает.last_error_date/last_error_message— последняя ошибка доставки. Типичные:Wrong response from the webhook: 502,Connection timed out,SSL error.ip_address— IP, на который MAX сейчас резолвит ваш домен. После переезда DNS должен обновиться в течение минут.max_connections— лимит параллельных POST-ов от MAX (1–100, по умолчанию 40). Поднимаем при больших нагрузках.
В прод-мониторинг закладываем алерт на pending_update_count > 50 и любое непустое last_error_message.
Подводные камни webhook
Webhook кажется проще, но прячет неочевидные проблемы:
- Время ответа на POST. MAX считает webhook упавшим, если ответ не пришёл за ~60 секунд, и повторит апдейт. Тяжёлые операции выносим в фон, в HTTP-обработчике делаем только приём и постановку в очередь.
- Идемпотентность. MAX может прислать один апдейт повторно при сбоях — храните
update_idобработанных апдейтов. - Безопасность. Эндпоинт публичный —
secret_tokenобязателен, IP-allowlist желателен. - Сертификат. Self-signed нужно явно прокидывать в
setWebhook. Проще получать Let's Encrypt — MAX доверяет ему по умолчанию, но не забудьте про автообновление (certbot renew). - CORS не помогает. MAX шлёт server-to-server, никакие CORS-заголовки не защищают.
Подводные камни long polling
Long polling тоже не бесплатный:
- При обрыве сети
getUpdatesнужно корректно перезапускать с экспоненциальным бэкоффом, иначе бот молчит до перезапуска. Фреймворки делают это сами, самописный цикл — нет. - При ошибке в обработке апдейта надо аккуратно обрабатывать исключения — иначе теряются последующие сообщения.
- Несколько инстансов с одним токеном работать не будут.
- При большой нагрузке
getUpdatesможет вернуть до 100 апдейтов за раз — обработчик должен либо параллелить, либо не лагать на одном тяжёлом сообщении.
Гибрид и переключение
В жизненном цикле бота нормально переходить с long polling на webhook. Локальная разработка и dev-стенд — long polling, продакшен — webhook. Между ними переключаемся через переменную окружения и одну из двух веток запуска:
if os.getenv("BOT_MODE") == "webhook":
await bot.set_webhook(
url=os.environ["WEBHOOK_URL"],
secret_token=os.environ["WEBHOOK_SECRET"],
allowed_updates=["message", "callback_query"],
)
# запускаем aiohttp/FastAPI приложение
else:
await bot.delete_webhook()
await start_polling(bot)
Итого
Long polling — для прототипов, локальной разработки, ботов за NAT и малых сценариев с одним инстансом. Webhook — для продакшена, серверлесса, высоких нагрузок и горизонтального масштабирования. Большинство современных ботов в проде используют webhook, но переход на него имеет смысл, когда инфраструктура уже есть — для MVP старт с long polling нормален, миграция на webhook занимает несколько часов и проводится без потери апдейтов через корректный deleteWebhook → дренаж → setWebhook.
Частые вопросы
Что выбрать для бота MAX — webhook или long polling?
Long polling — для прототипа, MVP, ботов с небольшой аудиторией (десятки-сотни активных в час), внутренних корпоративных сценариев без публичного HTTPS. Webhook — для прода с тысячами событий в день, для задач с минимальной задержкой (платежи, real-time уведомления), при готовой инфраструктуре с балансировщиком и планах горизонтального масштабирования. Если есть сомнения, выбирайте webhook сразу — переезжать в проде дороже.
Чем webhook отличается от long polling в Bot API MAX?
Long polling — бот сам периодически опрашивает API «есть новые апдейты?», MAX держит соединение открытым до 25–30 секунд или появления событий. Webhook — бот регистрирует HTTPS-URL, MAX сам делает POST на этот URL при появлении события. Разница: webhook даёт минимальную задержку (десятки миллисекунд против секунд), естественно масштабируется через балансер, не создаёт холостых запросов. Long polling проще запустить локально — не нужен публичный HTTPS.
Что нужно для подключения webhook в боте MAX?
Публичный HTTPS-эндпоинт с валидным TLS-сертификатом (Let's Encrypt через Certbot закрывает 90% случаев, самоподписанный не подойдёт), быстрый ответ сервером (несколько секунд максимум — длинная обработка уходит в очередь, бот сразу отвечает 200), защита эндпоинта секретным токеном через заголовок X-Max-Bot-Api-Secret-Token и опционально IP-allowlist подсетей MAX, реализация идемпотентности через хранение update_id в Redis с коротким TTL. Регистрация — POST на api.max.ru/bot<TOKEN>/setWebhook.
Зачем нужен secret_token в setWebhook?
Webhook URL рано или поздно попадёт в логи, в Certificate Transparency, в DNS-историю. Чтобы посторонний с угаданным URL не мог слать поддельные апдейты, в setWebhook передаётся secret_token — произвольная строка от 1 до 256 символов. MAX при каждом POST добавляет заголовок X-Max-Bot-Api-Secret-Token со значением этого секрета. Сервер обязан проверять заголовок и отбрасывать запросы без него или с неверным значением — через constant-time сравнение (hmac.compare_digest), а не через обычное равенство, чтобы защититься от timing-атак.
Как мигрировать с polling на webhook без потери сообщений?
Алгоритм. Подготовить webhook-инстанс не запуская его — развернуть HTTPS-эндпоинт, проверить что отвечает 200. Не трогать polling-инстанс пока, он продолжает работать. Остановить polling-процесс командой деплоя. Дренировать очередь — один раз вызвать getUpdates с offset=last+1 и timeout=0, чтобы подтвердить все обработанные апдейты. Вызвать setWebhook с новым URL, secret_token и drop_pending_updates=false — чтобы накопившиеся за минуту переключения апдейты пришли webhook-у. Запустить webhook-инстанс, проверить через getWebhookInfo, что pending_update_count уменьшается до нуля. Окно простоя — секунды.
Можно ли разрабатывать бота с webhook локально?
Да, через туннели — Cloudflare Tunnel, frp, ngrok. Они проксируют локальный порт через публичный домен с валидным HTTPS, и MAX отправляет webhook туда же. Это даёт те же условия, что в проде, и не требует ломать архитектуру при деплое. Альтернатива — long polling для разработки и webhook для прода, но тогда есть риск ловить разное поведение в двух режимах. Лучше сразу делать webhook через туннель, прописав в локальном .env тот же secret_token, что в setWebhook.
Как мониторить здоровье webhook в MAX?
Главная команда — getWebhookInfo. Возвращает url, has_custom_certificate, pending_update_count (сколько апдейтов в очереди MAX, растёт — значит webhook не отвечает 200 или не успевает), last_error_date и last_error_message (последняя ошибка доставки: Wrong response from the webhook: 502, Connection timed out, SSL error), ip_address (IP куда MAX сейчас резолвит домен), max_connections (лимит параллельных POST-ов, 1–100, по умолчанию 40), allowed_updates. В прод-мониторинг закладываем алерт на pending_update_count > 50 и любое непустое last_error_message.