Бот MAX на сайте: форма обратной связи вместо Telegram

Подключаем заявки с формы к боту MAX: верификация ИП, chat_id, лимит 4000 символов и главная засада — сертификат НУЦ Минцифры, из-за которого запрос падает до HTTP.

Дмитрий Мещеряков
Дмитрий Мещеряков
📅 1 сентября 2026 г.📖 12 мин чтения

Форма обратной связи, которая шлёт заявки в Telegram, — решение по умолчанию последних лет: один HTTPS-запрос без состояния, мгновенное уведомление, никакой возни с SMTP, SPF и DKIM. Ровно до того момента, пока кто-нибудь не прочитает, что именно уезжает в этот запрос.

А уезжает туда полный комплект персональных данных: имя, email, текст обращения, IP-адрес, идентификатор посетителя в Метрике. Серверы Telegram находятся за пределами РФ — значит, каждая отправка формы является трансграничной передачей по ст. 12 152-ФЗ.

Ниже — полный разбор переезда на бота MAX: от верификации профиля до кода обработчика. Отдельный большой раздел посвящён сертификату НУЦ Минцифры, потому что именно на нём спотыкается интеграция, и ни в одном туториале по MAX Bot API об этом не пишут.

Почему Telegram здесь — юридическая мина

Трансграничная передача — не формальность, которую закрывают галочкой в политике. Ч. 3–4 ст. 12 152-ФЗ требует подать отдельное уведомление в Роскомнадзор, причём до первой передачи, а не по факту. РКН вправе передачу ограничить или запретить.

Дополнительный неприятный момент: почти в каждой политике конфиденциальности, сгенерированной по шаблону, есть строка «трансграничная передача персональных данных не осуществляется». Если при этом форма шлёт заявки в Telegram, документ противоречит поведению сайта — и это расхождение обнаруживается ровно в тот момент, когда им кто-то заинтересуется.

💡 Совет

Важное различие. Ссылка на Telegram как на личный контакт — в подвале, на странице контактов, в тексте ошибки формы — трансграничной передачей не является. Посетитель сам решает открыть мессенджер, оператор ему туда ничего не отправляет. Убирать такие ссылки не требуется; вопрос только к серверному запросу, который делает ваш бэкенд.

MAX принадлежит VK и обрабатывает данные на территории России. Основание для уведомления исчезает, а формулировка в политике становится верной без правок.

MAX Bot API — что это и чем отличается

Bot API MAX — это HTTP-интерфейс для программного управления ботом: отправка сообщений, получение событий, работа с чатами и вложениями. Идея та же, что в Telegram Bot API, но совпадений в деталях меньше, чем кажется, и переписать интеграцию «поиском и заменой домена» не выйдет.

Telegram Bot APIMAX Bot API
Хостapi.telegram.orgplatform-api2.max.ru (до 19.07.2026 — platform-api)
Токенв пути: /bot<TOKEN>/sendMessageзаголовок Authorization: <токен>, без Bearer
ОтправкаPOST /bot<TOKEN>/sendMessagePOST /messages?chat_id=<id>
Получательполе chat_id в телеquery-параметр chat_id или user_id
Разметкаparse_mode: "HTML"format: "html" в теле
HTML-тегиширокий набортолько <b>, <i>, <del>, <ins>, <code>, <a>
Превью ссылокlink_preview_options в телеdisable_link_preview в query
Лимит текста40964000
Частота~30 msg/s30 rps на бота, 2 msg/s на чат
Создание бота@BotFather, анонимно, минутаверифицированный профиль + модерация
TLSкоммерческий УЦНУЦ Минцифры — нужен свой корень

Две последние строки — это не мелкий шрифт, а те самые пункты, из-за которых интеграция занимает не полчаса. Разберём обе.

Шаг 1. Верификация и модерация: закладывайте дни, а не минуты

В Telegram бот создаётся за минуту и анонимно. В MAX так нельзя: бота может завести только владелец верифицированного профиля организации, ИП или самозанятого.

  1. Открыть business.max.ru/self или мини-приложение «MAX для бизнеса» внутри мессенджера.
  2. Пройти верификацию — понадобятся ИНН и ОГРН/ОГРНИП. Количество доступных ботов зависит от типа профиля.
  3. Создать бота: имя, username, описание, аватар.
  4. Отправить на модерацию. До её прохождения бот недоступен пользователям.

Менять потом можно всё, кроме username, но каждая правка отправляет бота на повторную модерацию.

⚠️ Важно

Планируйте переезд с этого шага, а не с деплоя. Верификация и модерация занимают дни. Если вы уже выкатили код, который умеет только MAX, а токена ещё нет, — форма всё это время отдаёт ошибку. Держите на проде прежнюю сборку до получения токена.

Шаг 2. Токен и chat_id

Токен генерируется при создании бота и лежит в его настройках. Это прямой доступ к боту: кто угодно с этой строкой пишет от его имени. В репозиторий он не коммитится, префикс NEXT_PUBLIC_ для него недопустим — иначе токен уедет в клиентский бандл. Скомпрометированный токен перевыпускается в расширенных настройках.

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

bash
curl -s --cacert russian_trusted_root_ca.crt \
  -H "Authorization: <ТОКЕН>" \
  "https://platform-api2.max.ru/updates?types=message_created&timeout=0" | jq
json
{
  "updates": [
    {
      "update_type": "message_created",
      "message": {
        "sender":    { "user_id": 1234567890, "name": "Дмитрий" },
        "recipient": { "chat_id": 9876543210, "chat_type": "dialog" },
        "body":      { "text": "/start" }
      }
    }
  ],
  "marker": 1700000000
}

Нужное число — recipient.chat_id.

💡 Совет

Метод GET /chats, которым раньше перечисляли диалоги, с июня 2026 не поддерживается. Идентификаторы теперь берутся только из событий — через GET /updates или подписку на вебхуки POST /subscriptions. Если бот обслуживает больше одного чата, chat_id придётся сохранять у себя при первом событии bot_started.

Обратите внимание на --cacert в команде выше. Вот мы и добрались до главного.

Шаг 3. Сертификат НУЦ Минцифры — то, на чём всё ломается

Первый же запрос к API из Node падает так:

text
[contact] не удалось отправить заявку в MAX: TypeError: fetch failed
  at async sendToMax (src/app/api/contact/route.ts:228:20)
  [cause]: Error: unable to get local issuer certificate
    code: 'UNABLE_TO_GET_ISSUER_CERT_LOCALLY'

Первая реакция — искать ошибку в токене, в chat_id, в формате тела. Искать там нечего: это падение произошло на TLS-рукопожатии, до отправки единственного HTTP-байта. Токен здесь ни при чём.

Смотрим, что предъявляет сервер:

bash
openssl s_client -connect platform-api2.max.ru:443 \
  -servername platform-api2.max.ru 2>&1 | grep -E "depth|verify error|issuer="
text
depth=1 C=RU, O=The Ministry of Digital Development and Communications,
        CN=Russian Trusted Sub CA
verify error:num=20:unable to get local issuer certificate
issuer=C=RU, O=The Ministry of Digital Development and Communications,
       CN=Russian Trusted Sub CA

Сертификат *.max.ru выпущен НУЦ Минцифры России. Этого удостоверяющего центра нет:

  • в CA-бандле Node.js — там вшит список Mozilla, и российского корня в нём никогда не было;
  • в системном хранилище macOS и большинства Linux-дистрибутивов;
  • в наборах сертификатов большинства Docker-образов, на которых крутится ваш CI.

Ирония в том, что это прямое следствие ухода на отечественный сервис: у Telegram сертификат обычного коммерческого УЦ, и вопрос не возникал ни разу.

Три способа починить и почему два из них плохие

Способ 1: NODE_EXTRA_CA_CERTS. Переменная окружения, добавляющая корень к списку доверенных. Работает, документирована, гуглится первой.

Способ 2: установить корень в системное хранилище. Тоже работает, инструкции есть на Госуслугах.

У обоих одна и та же проблема, и она не техническая. Оба делают государственный удостоверяющий центр доверенным для всех соединений процесса или всей машины. Это значит, что выпущенный им сертификат ваше приложение примет на любой домен — на платёжный шлюз, на почтовый сервис, на API банка. Вы решали задачу «достучаться до одного хоста», а расширили модель доверия целиком.

Плюс практическое неудобство: NODE_EXTRA_CA_CERTS читается при старте процесса, то есть изнутри приложения её выставить нельзя. На shared-хостинге с Phusion Passenger это отдельная возня с директивами .htaccess, часть которых на конкретном сервере может быть просто запрещена.

Способ 3 — правильный: передать корень точечно в тот единственный запрос, которому он нужен.

ts
import { request as httpsRequest } from "node:https";
import { rootCertificates } from "node:tls";

import { RUSSIAN_TRUSTED_ROOT_CA } from "@/lib/russian-ca";

// Список именно расширяется, а не подменяется: если MAX однажды переедет
// на коммерческий УЦ, отправка продолжит работать без правок кода.
const MAX_CA = [...rootCertificates, RUSSIAN_TRUSTED_ROOT_CA];

Здесь доверие к государственному УЦ ограничено одним вызовом. Все остальные соединения приложения — в моём случае это DaData для определения города по IP — продолжают проверяться штатным списком. Кстати, показательная деталь: DaData работает через GlobalSign, то есть российский сервис вовсе не обязан использовать российский УЦ, и грести всех под одну гребёнку не нужно.

Почему не fetch

Глобальному fetch в Node нельзя передать список доверенных корней на конкретный запрос: опции ca у него нет, а кастомный dispatcher требует пакета undici в зависимостях. Поэтому запрос идёт через node:https — это не архаика, а единственный встроенный способ получить точечное доверие.

Где взять сертификат и как его проверить

bash
curl -sS -o russian_trusted_root_ca.crt \
  https://gu-st.ru/content/lending/russian_trusted_root_ca_pem.crt

openssl x509 -in russian_trusted_root_ca.crt -noout -subject -dates -sha256 -fingerprint
text
subject=C=RU, O=The Ministry of Digital Development and Communications,
        CN=Russian Trusted Root CA
notBefore=Mar  1 21:04:15 2022 GMT
notAfter=Feb 27 21:04:15 2032 GMT
sha256 Fingerprint=D2:6D:2D:02:31:B7:C3:9F:92:CC:73:85:12:BA:54:10:
                   35:19:E4:40:5D:68:B5:BD:70:3E:97:88:CA:8E:CF:31

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

bash
openssl s_client -connect platform-api2.max.ru:443 \
  -servername platform-api2.max.ru \
  -CAfile russian_trusted_root_ca.crt 2>&1 | grep "Verify return code"
# Verify return code: 0 (ok)
⚠️ Важно

Не пиньте промежуточный сертификат. Под этим корнем несколько Sub CA, и MAX предъявляет не тот, что лежит в публичной раздаче на gu-st.ru: отпечатки различаются. Я это проверил — сервер отдаёт свой промежуточный сам, в составе цепочки, поэтому в доверенные достаточно положить только корень. Захардкодив «тот самый» Sub CA из раздачи, вы получите неработающую интеграцию и очень неочевидную отладку.

Куда положить сертификат в проекте

Не файлом рядом с кодом, а константой в модуле. Причина прозаическая: Next.js с output: "standalone" собирает бандл трассировкой зависимостей, и readFileSync с вычисляемым путём она затягивает не всегда. Выяснится это уже в проде. Строка в модуле попадает в бандл гарантированно.

ts
// src/lib/russian-ca.ts
/**
 * Корневой сертификат НУЦ Минцифры России (Russian Trusted Root CA).
 * Отпечаток SHA-256: D2:6D:2D:02:...:CA:8E:CF:31, действует до 27.02.2032.
 * Источник: https://gu-st.ru/content/lending/russian_trusted_root_ca_pem.crt
 */
export const RUSSIAN_TRUSTED_ROOT_CA = `-----BEGIN CERTIFICATE-----
MIIFwjCCA6qgAwIBAgICEAAwDQYJKoZIhvcNAQELBQAwcDELMAkGA1UEBhMCUlUx
...
-----END CERTIFICATE-----`;

Проверьте после сборки, что он уехал на сервер и не уехал в браузер:

bash
grep -rl "BEGIN CERTIFICATE" .next/standalone   # должен найтись
grep -rl "BEGIN CERTIFICATE" .next/static       # должно быть пусто

Шаг 4. Обработчик

Собираем всё вместе — route handler на Next.js:

ts
const MAX_TIMEOUT_MS = 10_000;
const MAX_CA = [...rootCertificates, RUSSIAN_TRUSTED_ROOT_CA];

function postToMax(url: string, token: string, payload: string) {
  const { hostname, port, pathname, search } = new URL(url);

  return new Promise<{ status: number; body: string }>((resolve, reject) => {
    const req = httpsRequest(
      {
        hostname,
        port: port || 443,
        path: `${pathname}${search}`,
        method: "POST",
        headers: {
          // Именно голый токен: схемы Bearer у MAX нет, с ней приходит 401.
          Authorization: token,
          "Content-Type": "application/json",
          "Content-Length": Buffer.byteLength(payload),
        },
        ca: MAX_CA,
        timeout: MAX_TIMEOUT_MS,
      },
      (res) => {
        let data = "";
        res.setEncoding("utf8");
        res.on("data", (chunk) => { data += chunk; });
        res.on("end", () => resolve({ status: res.statusCode ?? 0, body: data }));
      }
    );

    // `timeout` только сигнализирует о простое сокета — рвать соединение
    // и отклонять промис нужно руками, иначе запрос повиснет навсегда.
    req.on("timeout", () => {
      req.destroy(new Error(`MAX не ответил за ${MAX_TIMEOUT_MS} мс`));
    });
    req.on("error", reject);
    req.end(payload);
  });
}

async function sendToMax(text: string): Promise<void> {
  const token = process.env.MAX_BOT_TOKEN;
  const chatId = process.env.MAX_CHAT_ID;
  if (!token || !chatId) throw new Error("MAX_BOT_TOKEN или MAX_CHAT_ID не заданы");

  const { status, body } = await postToMax(
    `https://platform-api2.max.ru/messages?chat_id=${encodeURIComponent(chatId)}` +
      `&disable_link_preview=true`,
    token,
    JSON.stringify({ text, format: "html", notify: true })
  );

  if (status < 200 || status >= 300) {
    throw new Error(`MAX ответил ${status}: ${body.slice(0, 300)}`);
  }
}

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

Лимит 4000 символов — это не мелочь

У MAX жёсткие 4000 символов на текст сообщения, и превышение отклоняет запрос целиком. Не обрезает хвост — теряет всю заявку.

Ошибка, которую легко допустить: ограничить длину поля в форме и считать вопрос закрытым. Но в сообщение кроме текста обращения попадают имя, email, услуга, город по IP, страница отправки, реферер, часовой пояс, идентификатор Метрики, время и подпись. На реальных данных «конверт» съедает до полутора тысяч символов.

Правильная схема — считать конверт первым, а телу отдавать остаток бюджета:

ts
// Пустая строка на месте тела сохраняет переводы строк, поэтому длина
// конверта совпадает с итоговой минус длина тела.
const envelopeLength = [...head, "", "", ...tail].join("\n").length;
const body = clampBody(escapeHtml(lead.message), MAX_TEXT_LIMIT - envelopeLength);

И режьте именно тело обращения, а не сообщение с конца. В подписи лежит служебная информация — время, IP, версия документа о согласии. Отрезав хвост, вы потеряете именно её, а недостающий абзац текста всегда можно уточнить у клиента.

Не забудьте про экранирование: срез мог разрубить HTML-сущность пополам, поэтому огрызок вида &am с конца надо отбросить.

Диагностика

СимптомПричинаЧто делать
UNABLE_TO_GET_ISSUER_CERT_LOCALLYнет корня НУЦ Минцифрыпередать корень в ca (см. шаг 3)
CERT_HAS_EXPIREDистёк корень или сертификат хостаобновить PEM из раздачи gu-st.ru
401 {"code":"verify.token"}неверный или отозванный токенперевыпустить в кабинете
404неверный chat_id, диалог не созданнаписать боту, повторить GET /updates
400битая разметка или превышение 4000сверить текст, проверить набор HTML-тегов
4292 msg/s на чат или 30 rps на ботадля формы недостижимо — искать цикл

Полезный приём для проверки TLS отдельно от всего остального: дёрните GET /me с заведомо неверным токеном.

text
без своего CA : UNABLE_TO_GET_ISSUER_CERT_LOCALLY
с корнем УЦ   : HTTP 401 {"code":"verify.token","message":"Invalid access_token"}

Получили 401 — рукопожатие проходит, вы разговариваете с настоящим API, и дальше разбираетесь уже с токеном. Это разделение экономит много времени: TLS-проблема и проблема авторизации маскируют друг друга.

Чек-лист

  • Профиль ИП/юрлица верифицирован на business.max.ru, бот прошёл модерацию
  • Токен лежит в переменных окружения без префикса NEXT_PUBLIC_
  • chat_id получен из GET /updates после первого сообщения боту
  • Корень НУЦ Минцифры сверен по SHA-256 и передан точечно, а не через NODE_EXTRA_CA_CERTS
  • Промежуточный Sub CA не захардкожен
  • Сертификат есть в серверном бандле и отсутствует в клиентском
  • Длина сообщения ограничена с учётом «конверта», режется тело, а не подпись
  • При ошибке отправки полный текст заявки уходит в лог
  • Уведомление по ст. 22 152-ФЗ подано (оно требуется независимо от мессенджера)
  • Геолокация по IP, если она есть, тоже не уезжает за границу

Что в итоге

Техническая часть интеграции с MAX Bot API — это полдня работы, и большая её половина уходит на один непрозрачный момент: сертификат НУЦ Минцифры, из-за которого запрос падает раньше, чем начинается. Зная про него, вы экономите вечер отладки токена, который на самом деле в порядке.

Организационная часть длиннее технической: верификация профиля и модерация бота занимают дни. Это единственное, что стоит запустить заранее.

А главный вывод скорее не про код: проверьте, что написано в вашей политике конфиденциальности, и сходится ли это с тем, куда ваш бэкенд отправляет заявки. Расхождение между документом и поведением сайта — проблема куда дороже любой ошибки TLS.

Частые вопросы

Почему запрос к MAX Bot API падает с UNABLE_TO_GET_ISSUER_CERT_LOCALLY?

Потому что сертификат platform-api2.max.ru выпущен НУЦ Минцифры России (Russian Trusted Root CA), а Node.js доверяет вшитому списку Mozilla, куда этот удостоверяющий центр не входит. Системное хранилище тоже не помогает: в macOS и большинстве Linux-дистрибутивов корня Минцифры нет. Ошибка возникает на этапе TLS-рукопожатия, то есть до того, как уйдёт хоть один HTTP-байт, и от токена или chat_id никак не зависит.

Нужно ли ставить сертификат Минцифры в систему или задавать NODE_EXTRA_CA_CERTS?

Не нужно, и лучше этого не делать. Оба способа делают государственный удостоверяющий центр доверенным для всех соединений процесса или всей машины: он сможет выпустить принимаемый вами сертификат на любой домен, включая платёжный шлюз и почту. Корректнее передать корень точечно в тот единственный запрос, которому он нужен, — в Node это опция ca у node:https.

Можно ли использовать глобальный fetch для запросов к MAX?

Только если корень Минцифры уже добавлен в доверие процесса. Глобальному fetch в Node нельзя передать список доверенных корней на конкретный запрос: опции ca у него нет, а кастомный dispatcher требует пакета undici. Поэтому для точечного доверия используется node:https, где ca передаётся в опциях запроса.

Чем Bot API MAX отличается от Telegram Bot API?

Токен уходит заголовком Authorization без схемы Bearer, а не в пути URL. Получатель (chat_id или user_id) передаётся query-параметром, а не полем тела. Разметка задаётся полем format в теле вместо parse_mode, и из HTML поддерживаются только b, i, del, ins, code и a. Лимит текста — 4000 символов против 4096. Ограничения частоты: 30 запросов в секунду на бота и 2 сообщения в секунду на чат.

Как узнать chat_id в MAX?

Написать боту любое сообщение и запросить GET /updates с типом message_created — идентификатор лежит в recipient.chat_id. Метод GET /chats, которым раньше можно было перечислить диалоги, с июня 2026 не поддерживается, так что chat_id берётся только из событий. Бот не может написать первым в несуществующий диалог, поэтому шаг с сообщением обязателен.

Зачем переносить форму обратной связи с Telegram на MAX?

Заявка состоит из персональных данных: имя, email, текст обращения, IP, идентификатор Метрики. Серверы Telegram расположены за пределами РФ, поэтому каждая отправка формы — трансграничная передача по ст. 12 152-ФЗ, а она требует отдельного уведомления в Роскомнадзор до первой передачи, и РКН вправе её запретить. MAX обрабатывает данные в России, и это основание исчезает.

Что будет, если сообщение превысит 4000 символов?

MAX отклонит запрос целиком, то есть потеряется вся заявка, а не её хвост. Поэтому обрезать текст нужно на своей стороне, и обрезать именно тело обращения, а не сообщение с конца: в подписи обычно лежит служебная информация — время, IP, версия документа о согласии, — которую терять нельзя.