Короткий ответ: webhook Telegram Bot API 10.3 (24 августа 2026) — это HTTPS POST на ваш URL. Telegram не ходит на IPv6, не следует редиректам и принимает только порты 443, 80, 88, 8443. На VPS бот слушает 127.0.0.1, nginx терминирует TLS 1.2+ и проксирует на процесс. Один webhook на бота. Секрет — параметр secret_token, заголовок X-Telegram-Bot-Api-Secret-Token. getUpdates и webhook взаимоисключающие: два приёма апдейтов сразу нельзя. Self-signed допустим только если загрузить сертификат как файл в setWebhook.
Нужны: Ubuntu 24.04 по первой настройке, домен с A-записью IPv4 на сервер, nginx и сертификат по Certbot + nginx. Рантайм — любой: системный Node/Python или версии через mise. Если берёте Node — установка Node.js на VPS; если Python — Python, venv, uv, mise. Конкретную библиотеку бота этот гайд не навязывает: важен HTTPS-вход, секрет и systemd.
Ограничения Bot API 10.3, которые ломают «просто POST»
Источник — setWebhook и гайд по webhooks. На 13–14 сентября 2026 актуальная версия Bot API — 10.3 от 24 августа 2026. Для доставки апдейтов это значит не «любой URL», а фиксированный набор условий.
| Правило | Как соблюдать на Ubuntu 24.04 |
|---|---|
| Только HTTPS | Сертификат Let’s Encrypt на nginx, не HTTP plaintext |
| Порты 443, 80, 88, 8443 | Обычный путь — 443 за nginx |
| IPv6 для webhook не поддерживается | A-запись IPv4; не отдавайте Telegram AAAA как единственный адрес |
| Редиректы не поддерживаются | URL webhook должен сразу отвечать 2xx, не 301/302 |
| TLS 1.2 и новее | ssl_protocols TLSv1.2 TLSv1.3; |
| Один webhook на бота | Повторный setWebhook заменяет предыдущий URL |
| getUpdates XOR webhook | Перед webhook — deleteWebhook, остановить long polling |
Подсети, с которых Telegram стучится (если ограничиваете firewall): 149.154.160.0/20 и 91.108.4.0/22. Диапазон в документации могут расширить — при необъяснимых таймаутах сверяйтесь с текущим текстом гайда webhooks, а не с копией в этой статье.
Процесс бота на 127.0.0.1, не на 0.0.0.0:443
Публичный TLS держит nginx. Сам бот не должен слушать 443 и не должен работать от root. Каталог и системный пользователь:
sudo useradd --system --home-dir /opt/bot --shell /usr/sbin/nologin bot
sudo mkdir -p /opt/bot
sudo chown bot:bot /opt/bot
Токен BotFather храните в файле с правами 600, не в unit-файле с world-readable. Сгенерируйте секрет webhook сами (1–256 символов: латиница, цифры, _ и -) — это не токен бота.
sudo -u bot sh -c 'umask 077; openssl rand -hex 32 > /opt/bot/webhook.secret'
sudo -u bot sh -c 'umask 077; nano /opt/bot/bot.env'
Пример /opt/bot/bot.env (значения свои):
BOT_TOKEN=123456:замените_на_токен
WEBHOOK_SECRET=вставьте_из_webhook.secret
LISTEN=127.0.0.1
PORT=3000
Минимальный приёмник на стандартной библиотеке Python 3, без фреймворков. Он проверяет заголовок секрета и отвечает 200. Обработку апдейта вы замените на свою логику.
sudo -u bot tee /opt/bot/webhook.py >/dev/null <<'PY'
#!/usr/bin/env python3
import json, os
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
SECRET = os.environ["WEBHOOK_SECRET"]
HOST = os.environ.get("LISTEN", "127.0.0.1")
PORT = int(os.environ.get("PORT", "3000"))
class H(BaseHTTPRequestHandler):
def do_POST(self):
token = self.headers.get("X-Telegram-Bot-Api-Secret-Token", "")
n = int(self.headers.get("Content-Length", "0"))
raw = self.rfile.read(n)
if token != SECRET:
self.send_response(403)
self.end_headers()
return
try:
json.loads(raw.decode("utf-8"))
except Exception:
self.send_response(400)
self.end_headers()
return
self.send_response(200)
self.send_header("Content-Type", "text/plain")
self.end_headers()
self.wfile.write(b"ok")
def log_message(self, fmt, *args):
return
if __name__ == "__main__":
httpd = ThreadingHTTPServer((HOST, PORT), H)
httpd.serve_forever()
PY
sudo chmod 755 /opt/bot/webhook.py
Если бот на Node, тот же контракт: слушать только 127.0.0.1, сравнивать заголовок, отвечать 200 быстро. Тяжёлую работу уводите из запроса — Telegram повторит POST, если статус не 2xx, и сдастся после разумного числа попыток.
Абсолютный путь рантайма обязателен в systemd. Системный Python:
command -v python3
# /usr/bin/python3
Если интерпретатор из mise — не пишите node из PATH unit-файла: у сервиса нет вашего интерактивного shell. Возьмите реальный бинарник:
sudo -u bot -H bash -lc 'mise which python || mise which node'
В ExecStart вставляете то, что напечатал mise which, плюс скрипт. Не /root/.local/... от другого пользователя.
systemd: не root, Restart=always, абсолютный ExecStart
Unit /etc/systemd/system/bot.service:
sudo tee /etc/systemd/system/bot.service >/dev/null <<'EOF'
[Unit]
Description=Telegram bot webhook
After=network.target
[Service]
Type=simple
User=bot
Group=bot
WorkingDirectory=/opt/bot
EnvironmentFile=/opt/bot/bot.env
ExecStart=/usr/bin/python3 /opt/bot/webhook.py
Restart=always
RestartSec=3
NoNewPrivileges=true
[Install]
WantedBy=multi-user.target
EOF
sudo systemctl daemon-reload
sudo systemctl enable --now bot
systemctl is-active bot
ss -lntp | grep 3000
curl -sS -o /dev/null -w '%{http_code}\n' -X POST http://127.0.0.1:3000/ \
-H "X-Telegram-Bot-Api-Secret-Token: $(sudo cat /opt/bot/webhook.secret)" \
-H 'Content-Type: application/json' \
--data '{"update_id":1}'
Ожидаете: сервис active, сокет 127.0.0.1:3000 (не 0.0.0.0), локальный curl с верным секретом — 200, с неверным — 403. Логи: journalctl -u bot -n 50 --no-pager. Если User=root — переделайте до открытия порта наружу.
nginx: TLS 1.2+ и прямой 2xx без редиректа
Webhook URL должен быть тем адресом, который вы передадите в setWebhook, например https://bot.example.com/webhook. HTTP→HTTPS редирект для браузера допустим на других путях, но сам URL webhook Telegram вызывает уже по HTTPS; дополнительный 301 с https://bot.example.com/webhook на другой путь сломает доставку. Не вешайте webhook на «красивый» URL, который только редиректит.
Фрагмент server-блока на 443 (сертификаты — как в гайде Certbot; пути Let’s Encrypt стандартные):
server {
listen 443 ssl;
listen [::]:443 ssl;
server_name bot.example.com;
ssl_certificate /etc/letsencrypt/live/bot.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/bot.example.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
location /webhook {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host;
proxy_set_header X-Telegram-Bot-Api-Secret-Token $http_x_telegram_bot_api_secret_token;
proxy_redirect off;
}
}
После правки: sudo nginx -t && sudo systemctl reload nginx. Проверка TLS и отсутствия редиректа:
curl -sI --tlsv1.2 https://bot.example.com/webhook
curl -sS -o /dev/null -w '%{http_code} redirect=%{redirect_url}\n' \
-X POST https://bot.example.com/webhook \
-H "X-Telegram-Bot-Api-Secret-Token: $(sudo cat /opt/bot/webhook.secret)" \
-H 'Content-Type: application/json' \
--data '{"update_id":1}'
Нужен код 200 и пустой redirect=. Если 301 — чините location, не «так и задумано». IPv6: Telegram webhooks его не используют. Иметь AAAA для сайта можно, но webhook обязан резолвиться в IPv4. Проверьте: getent ahostsv4 bot.example.com.
setWebhook, getWebhookInfo и конфликт с getUpdates
Форма запроса — из официального примера curl. Токен в истории shell лучше не светить: читайте его из файла.
TOKEN=$(sudo sed -n 's/^BOT_TOKEN=//p' /opt/bot/bot.env)
SECRET=$(sudo cat /opt/bot/webhook.secret)
curl -fsS -X POST "https://api.telegram.org/bot${TOKEN}/deleteWebhook" \
-d 'drop_pending_updates=true'
curl -fsS -F "url=https://bot.example.com/webhook" \
-F "secret_token=${SECRET}" \
"https://api.telegram.org/bot${TOKEN}/setWebhook"
curl -fsS "https://api.telegram.org/bot${TOKEN}/getWebhookInfo"
Успешный setWebhook возвращает "ok": true. В getWebhookInfo смотрите url (ровно ваш HTTPS), pending_update_count, при сбоях — last_error_message и last_error_date. Поле has_custom_certificate будет true только если вы загружали файл сертификата.
Self-signed Telegram примет, только если публичный PEM уходит параметром certificate как InputFile, не строкой. Let’s Encrypt для этого не нужен. Пример из документации:
curl -F "url=https://bot.example.com/webhook" \
-F "certificate=@/path/to/YOURPUBLIC.pem" \
-F "secret_token=${SECRET}" \
"https://api.telegram.org/bot${TOKEN}/setWebhook"
Два способа получать апдейты одновременно нельзя. Пока webhook задан, getUpdates не работает. Если локально крутится long polling (скрипт, контейнер, «тест с ноутбука»), Telegram не сможет нормально пушить в webhook: канал приёма уже занят. Перед продакшеном остановите все процессы с getUpdates, вызовите deleteWebhook при необходимости, затем снова setWebhook. Обратно на polling: curl -fsS -X POST "https://api.telegram.org/bot${TOKEN}/deleteWebhook" и только после этого запускайте getUpdates.
Проверка с живого чата: напишите боту /start. На сервере не должно расти last_error_message. Локально можно имитировать POST, как в гайде Telegram, на HTTPS URL с заголовком секрета. Не открывайте порт приложения в ufw — наружу только 443 nginx.
Частые вопросы
Почему webhook «висит», хотя curl с сервера проходит?
Частые причины из документации: редирект (www/non-www, слэш в конце, HTTP→другой HTTPS URL), IPv6-only адрес без A-записи, порт не из списка 443/80/88/8443, TLS ниже 1.2, процесс слушает только localhost, а nginx проксирует не туда. Смотрите getWebhookInfo.last_error_message, не угадывайте. Проверьте, что URL в setWebhook буквально совпадает с location, который отдаёт 200.
Нужен ли self-signed, если уже есть Let’s Encrypt?
Нет. Параметр certificate — для самоподписанных (или нестандартного корня). На обычном VPS с Certbot загружать PEM не нужно: достаточно url и secret_token. Если всё же self-signed — только certificate=@file.pem как файл, не путь строкой.
Можно ли оставить getUpdates «на всякий случай» вместе с webhook?
Нет. Это два взаимоисключающих способа. Документация Bot API прямо говорит: getUpdates не работает, пока исходящий webhook задан. На практике параллельный long polling мешает доставке. Один бот — один канал приёма. Переключение только через deleteWebhook / setWebhook, с остановкой лишнего процесса.
Зачем secret_token, если URL секретный?
URL угадывают и сканируют. secret_token заставляет Telegram добавить заголовок X-Telegram-Bot-Api-Secret-Token. Обработчик должен отвергать запросы без этого значения. Это не замена токену бота и не замена TLS. Допустимые символы секрета: A–Z a–z 0–9 _ -, длина 1–256.
Почему бот в systemd падает сразу после reboot?
Почти всегда относительный ExecStart (node server.js) или PATH от mise, которого у User=bot нет. Укажите абсолютный путь: command -v python3 / mise which node. User не root, Restart=always, EnvironmentFile указывает на существующий файл. После правки: systemctl daemon-reload && systemctl restart bot.