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

URL: https://dmeshcheryakov.ru/blog/max-bot-website-integration/
Раздел: Интеграции
Теги: MAX, API, NextJS, Безопасность, 152-ФЗ
Опубликовано: 2026-09-01
Автор: Дмитрий Мещеряков (https://dmeshcheryakov.ru)

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

Форма обратной связи, которая шлёт заявки в 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 так нельзя: бота может завести только владелец **верифицированного профиля организации, ИП или самозанятого**.

1. Открыть [business.max.ru/self](https://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-тегов |
| `429` | 2 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.