Форма обратной связи, которая шлёт заявки в 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 API | MAX Bot API | |
|---|---|---|
| Хост | api.telegram.org | platform-api2.max.ru (до 19.07.2026 — platform-api) |
| Токен | в пути: /bot<TOKEN>/sendMessage | заголовок Authorization: <токен>, без Bearer |
| Отправка | POST /bot<TOKEN>/sendMessage | POST /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 |
| Лимит текста | 4096 | 4000 |
| Частота | ~30 msg/s | 30 rps на бота, 2 msg/s на чат |
| Создание бота | @BotFather, анонимно, минута | верифицированный профиль + модерация |
| TLS | коммерческий УЦ | НУЦ Минцифры — нужен свой корень |
Две последние строки — это не мелкий шрифт, а те самые пункты, из-за которых интеграция занимает не полчаса. Разберём обе.
Шаг 1. Верификация и модерация: закладывайте дни, а не минуты
В Telegram бот создаётся за минуту и анонимно. В MAX так нельзя: бота может завести только владелец верифицированного профиля организации, ИП или самозанятого.
- Открыть business.max.ru/self или мини-приложение «MAX для бизнеса» внутри мессенджера.
- Пройти верификацию — понадобятся ИНН и ОГРН/ОГРНИП. Количество доступных ботов зависит от типа профиля.
- Создать бота: имя, username, описание, аватар.
- Отправить на модерацию. До её прохождения бот недоступен пользователям.
Менять потом можно всё, кроме username, но каждая правка отправляет бота на повторную модерацию.
Планируйте переезд с этого шага, а не с деплоя. Верификация и модерация занимают дни. Если вы уже выкатили код, который умеет только MAX, а токена ещё нет, — форма всё это время отдаёт ошибку. Держите на проде прежнюю сборку до получения токена.
Шаг 2. Токен и chat_id
Токен генерируется при создании бота и лежит в его настройках. Это прямой доступ к боту: кто угодно с этой строкой пишет от его имени. В репозиторий он не коммитится, префикс NEXT_PUBLIC_ для него недопустим — иначе токен уедет в клиентский бандл. Скомпрометированный токен перевыпускается в расширенных настройках.
С chat_id есть нюанс, на котором теряют время. Бот не может написать первым в несуществующий диалог, поэтому сначала напишите боту любое сообщение сами, и только потом запрашивайте события:
curl -s --cacert russian_trusted_root_ca.crt \
-H "Authorization: <ТОКЕН>" \
"https://platform-api2.max.ru/updates?types=message_created&timeout=0" | jq{
"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 падает так:
[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-байта. Токен здесь ни при чём.
Смотрим, что предъявляет сервер:
openssl s_client -connect platform-api2.max.ru:443 \
-servername platform-api2.max.ru 2>&1 | grep -E "depth|verify error|issuer="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 — правильный: передать корень точечно в тот единственный запрос, которому он нужен.
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 — это не архаика, а единственный встроенный способ получить точечное доверие.
Где взять сертификат и как его проверить
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 -fingerprintsubject=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Сверяйте отпечаток. Вы добавляете в доверие корневой сертификат — это ровно та операция, где «скачал и вставил» неуместно. Проверьте, что скачанный корень действительно валидирует живую цепочку:
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 с вычисляемым путём она затягивает не всегда. Выяснится это уже в проде. Строка в модуле попадает в бандл гарантированно.
// 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-----`;Проверьте после сборки, что он уехал на сервер и не уехал в браузер:
grep -rl "BEGIN CERTIFICATE" .next/standalone # должен найтись
grep -rl "BEGIN CERTIFICATE" .next/static # должно быть пустоШаг 4. Обработчик
Собираем всё вместе — route handler на Next.js:
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, страница отправки, реферер, часовой пояс, идентификатор Метрики, время и подпись. На реальных данных «конверт» съедает до полутора тысяч символов.
Правильная схема — считать конверт первым, а телу отдавать остаток бюджета:
// Пустая строка на месте тела сохраняет переводы строк, поэтому длина
// конверта совпадает с итоговой минус длина тела.
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-тегов |
429 | 2 msg/s на чат или 30 rps на бота | для формы недостижимо — искать цикл |
Полезный приём для проверки TLS отдельно от всего остального: дёрните GET /me с заведомо неверным токеном.
без своего 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, версия документа о согласии, — которую терять нельзя.