# Сайт, витрина и формы

Source: <https://telegafirst.com/docs/sites-storefront-and-forms>
Locale: ru
releaseGitSha: 0bb11ef116e17ec64c802d5476eaff442e53d252
sourceContentDigest: 9ec7bf9c1d7591d9d6c92550ab00a2ac068706d46c96e90aa255bdea1cb54041
Version: 4

Опубликуйте сайт своего бизнеса, настройте форму обращения и переход к покупке. В TelegaFirst сайт помогает начать разговор с AI-фронт-офисом в Telegram, а команда продолжает работу в Telegram-супергруппах. Заявка с сайта сохраняется сразу: переход посетителя в мессенджер помогает установить его личность, но не является условием сохранения обращения.

## Подготовка и адреса

Сначала пройдите [вход и настройку бизнеса](https://telegafirst.com/docs/getting-started), проверьте активный slug и доступный `tools/list`. Для сайта нужны полномочия владельца и scopes конкретной операции. MCP использует предусмотренный credential владельца или интеграции; перечисленные REST-маршруты принимают `X-Api-Key` либо `Authorization: Bearer tgf_*`, а не surface-session JWT. Полномочия проверяются при каждом вызове.

| Действие | Scope |
| - | - |
| Статус своего домена | `domain:read` |
| Запрос подключения, проверка DNS, отключение | `domain:write` |
| Загрузка/публикация сайта, формы, выдача checkout key | `site:write` |
| Manifest, чтение файла через MCP, список имён секретов | `site:read` |
| Настройки витрины | `storefront:read` / `storefront:write` |
| Анонимный checkout relay | `orders` |
| Чтение ответов форм через MCP | `pull:form_submissions` |
| Отправка и отметка обработки через Hub/MCP | `push:form_submissions` |

`Client.slug` — единственный читаемый tenant-адрес публичной поверхности. Товар, форма, событие и публичная заявка используют opaque `UniversalLink.code`; публичную ссылку нельзя построить из их номера. Для управления формой используется отдельный числовой `seqNum` своего tenant, для управления ответом — его собственный `seq_num`. Внутренний PK не передаётся ни в одну из этих поверхностей. [Идентификаторы](https://telegafirst.com/docs/identifiers-and-pagination).

Две зоны `.ru` и `.com` обязательны на запуске. Это отдельные юридические и платёжные зоны; русский — язык по умолчанию, а язык оператора определяется настройкой бизнеса. Домены и примеры ниже — контрактные иллюстрации: `acme.ru`, UUID и ссылки `*.test` не подтверждают зарегистрированный домен или опубликованный сайт. Хосты сервисов берите из актуального ответа и настройки окружения.

## Прямая ссылка, открывающая страницу как мини-приложение

`site_list_pages` / `GET /api/v1/tools/site/pages` отдают у каждой страницы `mini_app_links`, а на верхнем уровне — `mini_app_home`: по ссылке на Telegram, MAX и VK. Такая ссылка открывает опубликованную страницу внутри мини-приложения бота (`mini_app_home` — главную мини-приложения: `?startapp` без значения, в VK — без `#`), её можно вставить в текст рассылки. Ссылка несёт только opaque-код страницы, без номера. Если канал не откроет мини-приложение, `url` равен `null`, а `reason` говорит почему: `channel_not_connected` — мессенджер не подключён; `channel_surface_not_native` — мессенджер не открывает мини-приложения по ссылке; `mini_app_not_opened` — адрес мини-приложения ещё не зарегистрирован: он записывается при первом открытии мини-приложения бота, адрес которого задаётся в настройках бота (в Telegram — Mini App URL в BotFather); `page_not_published` — страница не показывается посетителям; `page_not_addressed` — у страницы ещё нет кода, опубликуйте её снова. По такой ссылке не считаются переходы `/r/l/`, а имя бота зашито в саму ссылку.

## Один адрес на зону

У бизнеса ровно один публичный адрес в каждой зоне. Пока свой домен этой зоны не активен, это технический адрес платформы вида `{slug}.<зона сайтов>`: возьмите его из `site_list_pages` → `hosts` (`kind: "platform"`), не собирайте руками. Когда свой домен зоны становится `ACTIVE`, он занимает это место, а технический адрес отвечает постоянным редиректом 308 на тот же путь своего домена; ссылки, уже выданные на технический адрес, продолжают работать. Поддомена `shop.` нет: сайт, вход, витрина, оформление заказа и кабинет покупателя живут на одном адресе.

На этом адресе пути делятся на три класса (без учёта регистра, путь `/x` включает `/x/...`):

| Класс | Пути | Кто отвечает |
| - | - | - |
| Платформенные | `/auth`, `/checkout`, `/cart`, `/account`, `/refund`, `/l`, `/f`, `/product`, `/events`, `/booking`, `/widget`, `/miniapp`, `/api`, `/__telegafirst`, `/.well-known/acme-challenge` | всегда платформа; bundle с файлом под таким путём отклоняется целиком с 422 `SITE_PATH_RESERVED` |
| Заменяемые | `/`, `/shop`, `/privacy`, `/terms`, `/refunds`, `/bio`, `/robots.txt`, `/sitemap.xml`, `/llms.txt`, `/.well-known` | ваш файл, если он есть в bundle; иначе автоматическая страница платформы |
| Сайт | всё остальное | ваш bundle |

Каталог всегда открывается по `/shop`. Корень `/` показывает ваш `index.html`. Без него на оплаченном тарифе там тот же каталог с `rel="canonical"` на `/shop`, поэтому `index.html` можно снять отдельно через `site_delete_page`; на бесплатном уровне корень без `index.html` ведёт себя как прежде. Карточка товара — `/product/{code}` с opaque кодом; ссылка «купить сейчас» — `/product/{code}?buy=1`: страница сама проводит вход и создаёт заказ, сам переход по ссылке ничего не записывает. В MCP товар каталога несёт `public_url` и `buy_url`; берите адреса оттуда. Скрипты service worker (`sw.js`, `service-worker.js`, `*-sw.js`) не принимаются ни под каким путём: тот же отказ `SITE_PATH_RESERVED`.

## Подключение своего домена

1. Прочитайте `domain_status` или `GET /api/v1/tools/domain`. Параметр `zone=ru` / `zone=com` выбирает свою зону; без него возвращается массив текущих подключений.
2. Вызовите `domain_add_request` или `POST /api/v1/tools/domain` с непустым HTTP `Idempotency-Key`. Передавайте bare hostname без схемы, пути и порта. Пример тела:

```json
{
  "domain": "acme.ru",
  "zone": "ru"
}
```

REST 201 не означает, что DNS уже проверен. Например, при anti-phishing admission ответ содержит `ownerStatus:"screening"`, `created:false`, `domain.status:"PENDING_DNS"` и `failedReason:"domain_screening_pending"`. Следуйте `instructions`: пока проверка безопасности не завершена, не публикуйте ни записи направления трафика, ни verification TXT. Повторный `domain_add_request` сообщает текущий admission status; не заменяйте его угадыванием DNS token.

3. После допуска опубликуйте точные DNS-записи из возвращённой инструкции, затем вызовите `domain_verify` / `POST /api/v1/tools/domain/verify` с `{"zone":"ru"}` и HTTP idempotency key. Проверка принимает только свою зону, не чужой hostname. Пример ещё незавершённого результата REST 201:

```json
{
  "domain": {
    "domain": "acme.ru",
    "zone": "ru",
    "status": "PENDING_DNS",
    "dnsVerifiedAt": null,
    "certIssuedAt": null,
    "failedReason": "domain_screening_pending"
  },
  "verified": false,
  "pendingReason": "screening_pending"
}
```

`verified:false` — ожидаемый результат незавершённой проверки. После screening DNS может сообщать `nxdomain`, `mismatch`, `ambiguous`, `timeout` или `resolver_error`; сверяйте возвращённые `pendingReason` и записи. Распространение DNS зависит от вашего TTL. Успешная проверка переводит свой домен в `VERIFIED`; сертификат выпускается при первом HTTPS-обращении, а не доказывается одним ответом 201.

4. Чтобы отключить домен через MCP, сначала вызовите `domain_prepare_unlink`. Покажите человеку замороженные домен/зону из `payload` и срок действия `expires_at` возвращённой квитанции. Только после финального подтверждения человеком вызовите `domain_execute_unlink`: его JSON-тело содержит ровно `preparation_id` (UUID) и `payload_hash` (64 строчных hex-символа), скопированные без изменений из одной и той же квитанции подготовки. Подготовка не разрешает автоматическое отключение. Исполнение прекращает routing и обслуживание сертификата; новое подключение снова требует DNS-проверки. Существующий REST `DELETE /api/v1/tools/domain/{zone}` — отдельная прямая операция владельца. У неё нет выдуманных REST prepare/execute twins; человек должен заранее понимать эффект.

## Загрузка файлов и публикация

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

Публикуйте на каноническом UGC-хосте бизнеса в `*.telegafirstsite.ru` или `*.telegafirstsite.com`, либо на его подключённом и проверенном custom-домене. Tenant UGC на основном домене платформы запрещён. Возьмите допустимый хост из настройки своего бизнеса; пример ниже иллюстративный и не подтверждает существующий сайт. Допустимый хост и подходящий тариф — предпосылки публикации.

`site_request_upload` / `POST /api/v1/tools/site/request-upload` принимает `host`, необязательный `mode` и непустой массив файлов. По умолчанию `mode` равен `merge`: объявите новые или изменённые файлы; omitted bio, бинарные assets, gate-конфигурация и references сохранённых файлов остаются на месте. Переданные файлы заменяют одноимённые пути вместе с их declarations. Явный `mode: "replace"` означает полный snapshot: перечислите весь целевой сайт; отсутствующие файлы и правила доступа намеренно удаляются. Этот режим выбирается при выдаче ticket и не меняется при publish.

Укажите путь относительно bundle, фактический размер UTF-8/бинарных байтов и MIME. `references`, когда нужны, — пути зависимостей внутри того же bundle; сервер проверяет их после объединения с сохранёнными файлами, и отсутствующая или недопустимая зависимость отклоняется. Квота проверяется по всему результирующему сайту, включая сохранённые файлы, а не только по patch. Пример добавления одной страницы с default merge:

```json
{
  "host": "demo.telegafirstsite.ru",
  "files": [
    {
      "path": "p/tripwire/index.html",
      "sizeBytes": 15,
      "contentType": "text/html"
    }
  ]
}
```

REST 201 возвращает `uploadId` UUID, канонический `host`, `mode`, `baseManifestDigest` (строчный SHA-256 или `null`, если исходного manifest нет) и `uploads` с `path`, staging `key`, presigned `putUrl`, `expiresAt`. Base определяется сервером и закрепляется ticket; caller `baseManifestDigest`, `baseManifest` и `clientId` запрещены. Ссылки действуют 300 секунд. Не сочиняйте UUID или staging key: возьмите их из своего ответа. Загрузите каждый файл HTTP PUT непосредственно по его `putUrl`, сохранив заявленные content type и byte length. Неправильный размер не превращает ticket в разрешение загрузить дополнительные байты.

Только после успешных PUT вызовите `site_publish` / `POST /api/v1/tools/site/publish`, снова с HTTP idempotency key:

```json
{
  "host": "demo.telegafirstsite.ru",
  "uploadId": "11111111-1111-4111-8111-111111111111"
}
```

UUID иллюстративный: нужен ticket того же tenant и закреплённого host. Перепривязать ticket к другому host нельзя; publish не принимает новый mode или base. Если исходный manifest или правила доступа изменились, ответ — 409 `SITE_BUNDLE_STALE`: получите новый ticket, выполните его PUT и затем publish. Не повторяйте stale ticket как разрешение перезаписать чужое изменение.

Пример результата REST 201:

```json
{
  "host": "demo.telegafirstsite.ru",
  "uploaded": ["p/tripwire/index.html"],
  "deleted": [],
  "unchanged": ["bio/index.html", "assets/app.js"],
  "manifestDigest": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
  "attributionSnippet": "<script src=\"https://gateway.example.test/pixel/telega-pixel.js\" async></script>",
  "pageUrls": [
    {
      "path": "p/tripwire/index.html",
      "url": "https://demo.telegafirstsite.ru/p/tripwire/index.html"
    }
  ]
}
```

`uploaded` — записанные пути, `deleted` — удалённые, `unchanged` — сохранённые без записи. Delta Sync вычисляет изменения по фактическим staged bytes; `index.html` записывается последним. `pageUrls` содержит только HTML-пути этого ticket, которые действительно завершили публикацию. Сохранённая bio-страница не выдаётся как новая landing. Открывайте точный URL из результата; не выводите homepage, extensionless URL или live-ссылку из одного успешного upload ticket.

После успеха staged файлы потреблены. Для нового deploy получите новые upload URLs; старый ticket нельзя использовать как постоянный источник контента. Размер всего сайта, размер одного видеофайла и суточный бюджет загрузок ограничиваются тарифом; число файлов сайта ограничено одинаково на всех тарифах. Суточный бюджет считает тикеты — один на запрос `site_request_upload`, сколько бы файлов он ни объявлял, — и байты объявленных файлов. Сервер проверяет заявленные размеры до выдачи URLs и реальные размеры перед публикацией. Конкретные числа своего тарифа берите из `details` отказа или из описания тарифа: документация их не повторяет.

### Сборка сайта из HTML-прототипа

HTML, сохранённый из браузера или собранный генератором, часто несёт изображения, видео и шрифты прямо в странице как `data:` URL. Такой файл не публикуется: один HTML-тег читается только до фиксированного размера, и тег с крупным встроенным ресурсом отклоняется кодом `SITE_HTML_TAG_TOO_LARGE`. Встроенные `<style>` и `<script>` сами по себе разрешены; причина отказа — размер одного тега, а не всего файла. Собственное хранилище или ключ S3 не нужны: файлы загружаются в хранилище платформы по URL из `site_request_upload`.

1. Соберите bundle: HTML-страницы и все файлы, которые они используют, — изображения, видео, шрифты, при желании CSS и JS. Каждый ресурс — отдельный файл bundle.
2. Каждый крупный `data:` URL декодируйте в файл **с теми же байтами**: тип возьмите из префикса `data:<тип>;base64,`, содержимое — base64-декодированием остатка. У URL без `;base64` (например `data:image/svg+xml,%3Csvg%20…`) содержимое — остаток после запятой, раскодированный из percent-encoding: `%3C` становится `<`, `%20` — пробелом. Дайте файлу имя по содержимому, например `assets/hero.3f2a9c.png` (первые символы SHA-256 байтов): такой файл кэшируется браузером надолго, а изменённый получает новое имя.
3. Замените `data:` URL в атрибуте относительной ссылкой на файл (путь считается от папки страницы: для `p/tripwire/index.html` и файла `p/tripwire/assets/hero.3f2a9c.png` это `src="assets/hero.3f2a9c.png"`, для `p/tripwire/quiz/index.html` — `src="../assets/hero.3f2a9c.png"`). Проверьте, что в страницах не осталось локальных путей вида `file://` или `C:\` и что соседние страницы ссылаются на те же файлы.
4. Объявите **все** файлы в одном `site_request_upload` с фактическими размером и MIME; ресурсы страницы перечислите в её `references`. Видео (`.mp4`, `.webm`) принимается в пределах размера одного видеофайла по тарифу и проверяется раньше размера всего сайта: больше — `SITE_VIDEO_TOO_LARGE`; сожмите ролик или встройте его с YouTube, VK Видео или Rutube.
5. Выполните PUT каждого файла, затем `site_publish`. По умолчанию `merge` сохраняет bio-страницу и уже опубликованные страницы; пути выбираете вы, префикс `p/` из примера обязателен только на платформенном apex.

Пример объявления лендинга и квиза с общими ресурсами:

```json
{
  "host": "demo.telegafirstsite.ru",
  "files": [
    { "path": "p/tripwire/index.html", "sizeBytes": 48211, "contentType": "text/html", "references": ["p/tripwire/assets/hero.3f2a9c.png", "p/tripwire/assets/promo.81d0e4.mp4"] },
    { "path": "p/tripwire/quiz/index.html", "sizeBytes": 31777, "contentType": "text/html", "references": ["p/tripwire/assets/hero.3f2a9c.png"] },
    { "path": "p/tripwire/assets/hero.3f2a9c.png", "sizeBytes": 182131, "contentType": "image/png" },
    { "path": "p/tripwire/assets/promo.81d0e4.mp4", "sizeBytes": 726493, "contentType": "video/mp4" }
  ]
}
```

Любой отказ загрузки или публикации оставляет живой сайт без изменений. Если причина в самих файлах, исправьте их и получите новый ticket: исправленные байты имеют другой размер и по старым URL не загрузятся. Ответ `pageUrls` перечисляет ровно опубликованные страницы — откройте их и проверьте, что изображения и видео загрузились.

Если страницы ссылаются на вашего бота, вставьте точный `attributionSnippet` из ответа в шаблон HTML. В приведённом ответе URL `gateway.example.test` — адрес стенда; его не следует копировать в живой сайт. Переход к боту может работать и без скрипта, но источник страницы/кампании не будет корректно атрибутирован.

Для правки существующего bundle через MCP сначала `site_get_manifest` с `{"host":"acme.ru"}`, затем `site_fetch_file` с `{"host":"acme.ru","path":"index.html"}`. Manifest несёт checksum/size и `manifestDigest`; fetch возвращает UTF-8 текст файла, а не секреты или конфигурацию access policy. После правки загрузите изменённые файлы через default merge; полный bundle нужен при явном replace. Эти чтения не вводят новых REST routes.

`site_pages_set_visibility` управляет существующими страницами по их tenant-номерам; корневые и системные страницы защищены, показ не отменяет тариф или модерацию. `site_page_preview` возвращает короткоживущий opaque URL на отдельном origin и не публикует страницу. Для удаления сайта используйте `site_prepare_delete`, покажите человеку frozen manifest digest, после подтверждения — `site_execute_delete`. Подготовка сама не удаляет байты. Точные схемы этих MCP операций запрашивайте через актуальное discovery.

## Секреты сайта и подтверждение владельца

Секрет сервера не должен находиться в HTML, JavaScript bundle или literal `destinations`. Для server-side отправки используйте сохранённое имя, например `API_TOKEN`. Site secret хранится с шифрованием AES-256-GCM и привязкой к tenant/полю. Список возвращает только `name`, `createdAt`, `lastRotatedAt`; прочитать значение назад через API нельзя.

`secret_put` с `site:write` либо `POST /api/v1/tools/site/secrets` начинает отдельное owner approval. Пример с фиктивным значением, которое не является credential:

```json
{
  "name": "API_TOKEN",
  "value": "secret-fixture-value"
}
```

Первый ответ означает только ожидание подтверждения, а не сохранение:

```json
{
  "status": "pending_approval",
  "actionId": "11111111-1111-4111-8111-111111111111"
}
```

Владелец подтверждает действие в Telegram. Затем повторите соответствующий вызов с теми же именем/значением и возвращённым UUID в поле `action_id`. Для REST leg2 используйте новый HTTP idempotency key: повтор leg1 с прежним ключом воспроизведёт pending answer или отклонит изменённое тело. Подтверждение проверяет реального владельца, tenant и действие; одной строки scope без необходимого provenance недостаточно. Не создавайте фиктивный ExternalApp для OAuth/M2M credential.

`secret_rotate` / `PUT /api/v1/tools/site/secrets/{name}` меняет значение с такой же двухшаговой проверкой; REST body содержит `value`, затем `action_id`. `secret_delete` / `DELETE /api/v1/tools/site/secrets/{name}` также требует подтверждения; во второй REST-вызов `action_id` передаётся query-параметром. Новый секрет под занятым live именем —409 `CONFLICT`: используйте rotate. Для чтения имён — `secret_list` с `{}` / `GET /api/v1/tools/site/secrets` и `site:read`. Маршрута `GET /secrets/{name}` нет.

Обычная публикация и декларация формы не требуют этой secret approval. Их право — проверенный `site:write`; это не разрешение изменить чужой домен или обойти другие ограничения.

## Декларация формы до создания HTML

Сначала вызовите `site_form_declare` / `POST /api/v1/tools/site/forms`. Форма получает адрес до первой публикации, поэтому HTML может сразу использовать возвращённый code. Пример создания с разрешённым origin:

```json
{
  "zone": "ru",
  "title": "Обращение",
  "description": "Ответим в течение часа",
  "submitLabel": "Отправить заявку",
  "afterSubmit": {
    "mode": "auto",
    "title": "Спасибо!",
    "text": "Продолжим в мессенджере.",
    "buttonLabel": "Забрать гайд"
  },
  "allowedOrigins": [
    "https://acme.ru"
  ],
  "captchaRequired": false,
  "funnelMode": "none",
  "enabled": true
}
```

REST отвечает 201 с `seqNum`, `clientSlug`, `code`, `zone`, `title`, `description`, `submitLabel`, `afterSubmit`, `enabled`, `created`, `updatedAt`. `seqNum` нужен управлению за tenant guard, `code` — публичной ссылке. Для обновления передайте свой числовой `seqNum`; публичные slug/code сохраняются. Без `seqNum` или с `null` создаётся новая форма с новым адресом. Если timeout не позволил узнать созданный номер, не повторяйте MCP create вслепую; это не idempotent create. Для REST receipt повторите исходный запрос с тем же HTTP key.

`description` (до 1000 символов) и `submitLabel` (до 40) показывает автостраница формы `/f/{code}`; `null` или пропуск — текст зоны. `afterSubmit` задаёт, что посетитель увидит после отправки, на любой поверхности: автостранице, странице с `forms.v1.js` или вашей собственной.

* `mode: "auto"` (по умолчанию) — экран результата с вашими `title` (до 120) и `text` (до 1000) и кнопками действий, которые выбирает платформа. `buttonLabel` (до 32 символов, пробелы по краям отбрасываются) — текст кнопки, ведущей в бота: и кнопки в мини-приложении, и ссылок на каналы в браузере (при нескольких каналах к нему добавляется имя канала). Пропущенный текст берётся из языка зоны формы; для кнопки это «Перейти в бота» в мини-приложении (и опознанному посетителю, и анонимному) и «Продолжить в {канал}» в браузере.
* `mode: "redirect"` — посетитель уходит на ваш `redirectUrl`. Он обязателен в этом режиме и запрещён в `auto`; `buttonLabel` в этом режиме запрещён — кнопки нет. Адрес только `https://` и только на хост этого бизнеса: его адрес сайта, подключённый custom-домен или origin из `allowedOrigins` той же декларации. Чужой хост — 422 `FORM_REDIRECT_HOST_NOT_ALLOWED`, форма при этом не записывается.

`afterSubmit` — часть полной декларации, а не патч: пропуск при redeclare возвращает `auto` с текстами зоны.

Декларация задаёт envelope: destinations, allowed origins, captcha и funnel. По умолчанию captcha включена; её отключение в примере задано явно. Рисуйте inputs в HTML самостоятельно. Необязательный `submissionSchema` валидирует совпадающие с HTML имена и значения; он не строит UI. Всегда давайте каждому полю человеческий `label` — полный текст вопроса, например `{"name": "q1", "type": "text", "label": "Как вас зовут?"}`: оператор видит его над каждым ответом в карточке заявки и в чате, а без него — только техническое имя поля (`q1`). До 20origins и 20destinations; до 50variants и 100 элементов submissionSchema. `funnelMode` — `none`, `deterministic`, `ai_agent`. Для лестницы отправляйте полный список `variants`: пропуск при redeclare удаляет лестницу. В deterministic используется `when`, в ai\_agent — `hint`; подготовленные шаги адресуются `stepSeqNum`, а не PK. Для checkout outcome нужен свой `catalogSeqNum`.

Destinations имеют существующие формы: Telegram topic, HTTPS webhook или external API с `secretName` вместо значения credential. Разрешайте точные origin своего сайта; пустой allowlist не допускает отправку. Остановите приём redeclare с `enabled:false`, сохранив необходимую конфигурацию. Отдельного удаления формы нет: исторические ответы должны сохранять смысл.

## Отправка и немедленная анонимная заявка

Посетитель отправляет JSON в публичный адрес `https://fn.telegafirst.ru/f/{clientSlug}/{code}`; для COM-зоны — `fn.telegafirst.com`. Оба значения возьмите из своей декларации. Пример тела:

```json
{
  "payload": {
    "question": "Записаться"
  }
}
```

Browser передаёт свой `Origin`; при включённой captcha нужен действительный `captcha_token`. Если страница открыта как мини-приложение и форму отправляете вы сами, без `forms.v1.js`, добавьте `"mini_app_channel"`: `"telegram"`, `"max"` или `"vk"` — мессенджер, в чьём мини-приложении открыта страница. Тогда ответ предложит `close_mini_app` или `open_bot`, в том числе анонимному посетителю. Это только подсказка для кнопок результата: посетителя она не опознаёт. Это публичная поверхность P1: посетитель не подставляет tenant, form number, submission number или внутренний ID. После проверки origin, captcha, размера, схемы и media сервер сохраняет новую заявку сразу и выделяет её собственный tenant-номер, даже если посетитель остаётся анонимным.

Публичный HTTP 202 одинаков для всех поверхностей:

```json
{
  "result": {
    "title": "Спасибо!",
    "text": "Продолжим в мессенджере.",
    "actions": [
      { "kind": "open_channel", "channel": "telegram", "url": "https://…/r/l/…/telegram?z=ru" },
      { "kind": "open_channel", "channel": "max", "url": "https://…/r/l/…/max?z=ru" }
    ],
    "button_label": "Забрать гайд"
  },
  "continue_url": "https://acme.telegafirstsite.ru/l/…"
}
```

`result` — то, что показать посетителю. Виды действий в `actions`:

* `close_mini_app` — посетитель опознан и отправил форму из мини-приложения: закройте окно, разговор продолжится в чате.
* `open_bot` — посетитель не опознан, но отправил форму из мини-приложения (`telegram`, `max`, `vk`): откройте `url` — бота этого мессенджера со ссылкой на заявку — средствами самого мессенджера (в Telegram `Telegram.WebApp.openTelegramLink`), затем закройте мини-приложение. Старт бота по этой ссылке связывает заявку с посетителем. Если мессенджер не умеет открыть ссылку внутри себя, перейдите по `url`.
* `open_channel` — кнопка перехода в канал (`telegram`, `max`, `vk`). Опознанному посетителю предлагается только его канал, анонимному — все включённые каналы бизнеса.
* `redirect` — переход на `url` при `afterSubmit.mode: "redirect"`; тот же адрес приходит в `redirect_url`.

`button_label` — ваш `afterSubmit.buttonLabel` или `null`, если показывать текст платформы.

`continue_url` — страница продолжения `/l/{code}` с текстами формы и кнопками каналов, на которую можно вернуться позже. Все адреса — opaque P1: ответ не несёт `seq_num`, отдельный `submission_code`, PK или данные посетителя. Переходите по выданным `url`, не собирайте ссылки на мессенджер самостоятельно: ссылку на бота платформа выдаёт сама в `open_bot`. Открытие мессенджера связывает личность с уже существующей заявкой. Бизнес может увидеть анонимную заявку раньше перехода и отличает её от `linked`.

При redirect анонимного посетителя платформа добавляет к вашему `redirectUrl` параметр `tgf_continue` с адресом страницы продолжения (до `#fragment`, если он есть). Покажите на своей странице кнопку «Продолжить в мессенджере», ведущую на значение `tgf_continue`: иначе анонимная заявка останется без перехода в чат. Опознанный посетитель получает `redirectUrl` без изменений.

## Своя HTML-форма через forms.v1.js

Чтобы не писать отправку, валидацию ответа и экран результата самому, подключите помощник одной строкой и пометьте свою форму code из декларации:

```html
<script src="https://acme.telegafirstsite.ru/widget/forms.v1.js" async></script>
<form data-tgf-form="CODE">
  <input name="question" required>
  <button type="submit">Отправить</button>
</form>
```

Скрипт берите с адреса сайта своего бизнеса (`site_list_pages` → `hosts`). Поля рисуете вы: помощник не создаёт ни одного поля. Он перехватывает submit, собирает значения по `name`, отправляет их в публичный ingest и показывает результат из ответа 202 рядом с формой, скрыв её. На время отправки форма несёт `data-tgf-state="sending"`, затем `sent` или `failed`. Токен captcha положите в атрибут `data-tgf-captcha-token` формы.

После успешной отправки на форме возникает отменяемое событие `tgf:submitted`, в `event.detail` — тот же ответ 202. Чтобы нарисовать результат самостоятельно, вызовите `event.preventDefault()`:

```js
document.addEventListener('tgf:submitted', (event) => {
  event.preventDefault();
  renderMyResult(event.detail.result);
});
```

При ошибке возникает отменяемое `tgf:failed` с `detail.status` (HTTP-статус или `0` при сетевой ошибке); без отмены помощник выводит текст ошибки в элемент `[data-tgf-error]` формы или создаёт его. Origin страницы должен быть в `allowedOrigins` формы.

Этот публичный ingest имеет собственные ошибки:400 malformed body/media,403 отсутствие допуска/неверный origin/captcha,413 превышение размера,422 `violations` объявленной схемы,429 ограничение частоты. Не приписывайте ему Hub Problem Details envelope или Hub scope: это другой HTTP boundary.

Для серверной отправки от интеграции есть отдельный действующий P3 route: `POST /api/v1/form-submissions`, scope `push:form_submissions`, HTTP key обязателен. Пример полного тела и результата 202 из принятой проверки:

```json
{
  "form_seq_num": 12,
  "payload": {
    "email": "ada@example.test"
  }
}
```

```json
{
  "outcome": "accepted",
  "seq_num": 1042,
  "submission_code": "opaque-form-answer",
  "status": "anonymous"
}
```

Здесь 12 — номер формы,1042 — собственный номер нового ответа; `submission_code` — отдельный публичный code. Числа передавайте JSON numbers, положительные int4 до 2147483647. MCP `submit_form_submission` использует тот же контракт, но каждый вызов создаёт новый ответ: HTTP replay не распространяется на него по умолчанию.

Прочитайте `pull_form_submissions` с `{"form_seq_num":12,"status":"anonymous","limit":50}`. Scope `pull:form_submissions`; действуют права и paid capability. Значение limit по умолчанию 50, диапазон 1..200. Cursor зашифрован, привязан к tenant/resource/эффективным фильтрам и живёт 15 минут: передавайте выданную строку как есть, не расшифровывайте PK, не меняйте фильтры между страницами. Просроченный/чужой cursor —400 `INVALID_CURSOR`; начните заново.

Для detail используйте `get_form_submission` с `{"seq_num":1042}`. Ответ показывает `seq_num`, отдельный `submission_code`, `form_seq_num`, title, created\_at и статус; linked ответ дополнительно несёт пользовательский tenant-номер и channel. Сам `payload` опущен, пока человек явно не попросил прочитать ответы. Тогда используйте единственное значение `include:"full_payload"`; disclosure записывается в аудит. Оно не требуется для подсчёта заявок или проверки работы формы. Статус заявки «Новая ↔ Обработана» переключается в обе стороны: MCP `set_form_submission_handled` с `{"seq_num":1042,"handled":true}` или REST `PATCH /api/v1/form-submissions/1042` с телом `{"handled":true}`; `handled:false` возвращает заявку в новые. Scope `push:form_submissions`. Ответ `outcome`: `changed` — статус изменили вы; `unchanged` — он уже был таким, повтор безопасен; `not_found` — такой заявки у бизнеса нет (REST отвечает 404 `NOT_FOUND`). Карточка в Telegram-топике операторов переключается и показывает, что статус сменила ваша интеграция.

## Витрина и checkout

`get_storefront` / `GET /api/v1/tools/storefront` читает singleton с `storefront:read`. `update_storefront` / `PATCH` меняет разрешённые поля с `storefront:write`. Частичный patch сохраняет пропущенные поля, `null` очищает subtitle/title. Текущий slug допускается как no-op, другой —409 `SLUG_IMMUTABLE`. Пример настроек из проверки:

```json
{
  "enabled": false,
  "slug": "sample",
  "title": "After",
  "subtitle": null
}
```

Ответ 200:

```json
{
  "updated_at": "2026-09-30T00:00:00.000Z",
  "enabled": false,
  "slug": "sample",
  "title": "After",
  "subtitle": null
}
```

Публичный checkout key выпускает `site_checkout_key` с `{}` / bodyless `POST /api/v1/tools/site/checkout-key` при `site:write`. Ключ `tgf_pub_*` показывается один раз, имеет только `orders` и допускается только на отмеченном relay. При ротации прежний ключ остаётся допустимым 24 часа; разверните новый на своём сайте. Такой ключ не читает каталог, заказы или настройки. Origin разрешается в форме, не при выдаче ключа.

Для `POST /api/v1/storefront/checkout` нужны свой public key, допустимый `Origin` и непустой `Idempotency-Key`. Форма должна быть включённой, иметь checkout outcome и свой продукт. Body содержит только opaque code:

```json
{
  "code": "aB3dE6gH9jK2mN5p"
}
```

Пример ответа 201; `pay.example` — искусственный адрес fixture:

```json
{
  "order_id": "qR7tY2uI8oP4aS6d",
  "payment_urls": {
    "stripe": "https://pay.example/fixture"
  }
}
```

`order_id` здесь — публичный opaque code, не PK и не buyer number. Price, product/user ID, redirect URL и external order reference из браузера не принимаются. Сервер сам разрешает форму в tenant и выбирает продукт/условия; origin/form/product admission повторяется перед cached replay. В личном кабинете покупателя `/checkout/{seq_num}` — P2 с отдельной customer session и проверкой владения: покупатель видит тот же номер заказа, что в диалоге. Публичный relay не заменяет этот guard.

Checkout receipt сохраняется 24 часа отдельно для tenant/credential/relay: тот же key/request воспроизводит 201 и тело, иной запрос с тем же key —409 `IDEMPOTENCY_CONFLICT`. Для settings PATCH HTTP key необязателен; иной body под переданным ключом —422 `IDEMPOTENCY_KEY_MISMATCH`. Внутренние AI-агенты не меняют деньги; агент клиента использует [денежные сценарии](https://telegafirst.com/docs/payments) через подготовку, review и финальное подтверждение человека.

## Ошибки Hub и дальнейшие действия

Для REST POST с непустым body нужен HTTP key; для bodyless checkout-key он необязателен. Generic HTTP replay хранится 24 часа по tenant/credential/handler/key, сохраняет исходный status; mismatch 422 `IDEMPOTENCY_KEY_MISMATCH`, processing 409 `CONFLICT`. Ошибка не является доказательством готовой публикации. У этих site/domain/settings ответов нет cursor pagination и ETag/If-Match; form pull cursor описан отдельно выше. У media ingress собственный контракт: site upload UUID и bundle paths нельзя подставлять в `/api/v1/media/uploads`.

Для показанного выше запроса подключения `POST /api/v1/tools/domain` с телом `domain:"acme.ru"`, `zone:"ru"` аутентифицированный credential в активном бизнесе, у которого нет `domain:write`, получает следующий отказ Hub P3. Это составленный контрактный пример: основные поля подтверждены принятой boundary fixture, а `hint` добавлен текущим каноническим фильтром ошибок. Он не является записью запроса к живому хосту и не подтверждает подключение домена.

```http
HTTP/1.1 403 Forbidden
Content-Type: application/problem+json
```

Полное тело ответа:

```json
{
  "type": "https://telegafirst.ru/docs/errors#INSUFFICIENT_SCOPE",
  "title": "Forbidden",
  "status": 403,
  "instance": "/api/v1/tools/domain",
  "code": "INSUFFICIENT_SCOPE",
  "traceId": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
  "detail": "Missing required scope(s): domain:write",
  "hint": {
    "reason": "The credential lacks a scope this operation requires.",
    "recovery": "Compare missing_scopes at GET /api/v1/me with this operation and use a key that includes them."
  }
}
```

Сравните отсутствующий `domain:write` с описанием доступа и используйте credential с этим scope для нужного бизнеса. Полномочия владельца, допуск домена и DNS-проверка сохраняют силу. Этот Problem Details относится к управлению доменом через Hub; у анонимной отправки формы P1 остаётся собственный протокол ошибок, описанный выше.

| Status / код Hub REST | Что делать |
| - | - |
| 400 `VALIDATION_ERROR`, `IDEMPOTENCY_KEY_REQUIRED` | Исправьте strict schema/UUID/zone/тип номера или обязательный HTTP key |
| 401 `INVALID_API_KEY` | Проверьте допустимый credential и отзыв доступа |
| 403 `INSUFFICIENT_SCOPE`, `FORBIDDEN` | Проверьте live права, owner provenance/approval, Origin и допуск формы |
| 404 `NOT_FOUND` | Хост не является своим live подключённым хостом либо secret отсутствует; не перебирайте чужие хосты |
| 409 `CONFLICT` | Разберите занятый хост, неверный bundle/dependency, чужой form number, занятое secret name или текущую публикацию |
| 409 `SLUG_IMMUTABLE`, `IDEMPOTENCY_CONFLICT` | Сохраните выбранный slug; для другого checkout используйте новый key |
| 422 `SITE_PATH_OUTSIDE_PUBLISHABLE_PREFIX` | На платформенном apex перенесите свои пути под `p/` |
| 422 `SITE_PATH_RESERVED` | Перенесите файлы с платформенных путей и уберите скрипты service worker; список путей — в ответе |
| 422 `SITE_HTML_TAG_TOO_LARGE` | Вынесите встроенный `data:` ресурс из тега `details.tag`, атрибут `details.attr`, страницы `details.path` в отдельный файл bundle |
| 422 `SITE_VIDEO_TOO_LARGE` | Сожмите видео `details.path` ниже `details.limitBytes` или встройте его с видеохостинга |
| 422 `SITE_BUNDLE_TOO_LARGE` | Уменьшите сайт до `details.limitBytes` или перейдите на тариф с большим объёмом сайта |
| 422 `SITE_TOO_MANY_FILES` | Объедините или уберите файлы до `details.limitFiles`; предел одинаков на всех тарифах |
| 429 `MINT_QUOTA_EXCEEDED` | Суточный бюджет загрузок исчерпан. `details.remainingTickets` = 0 — повтор только после `Retry-After` (полночь UTC); тикеты остались — объявите файлы в сумме не больше `details.remainingBytes` |
| 503 `MINT_QUOTA_UNAVAILABLE` | Бюджет не удалось проверить, ticket не выдан; повторите тот же запрос после `Retry-After` |
| 422 `IDEMPOTENCY_KEY_MISMATCH` | Не меняйте body/path/query под уже использованным generic key |
| 413 `PAYLOAD_TOO_LARGE`, `ITEM_COUNT_EXCEEDED` | Уменьшите запрос согласно реальному бюджету операции |
| 429 `RATE_LIMIT_EXCEEDED`, `RATE_LIMIT_UNAVAILABLE` | Соблюдайте `Retry-After`, если выдан; отказ не обходят |
| 500 `INTERNAL_ERROR` | Сохраните `traceId`; не объявляйте deploy или secret write успешным |

Успешные Hub REST-ответы несут context headers `X-Client-Slug` и `X-Bot-Username`, если доступны, а также три `X-RateLimit-*` headers. Точные DTO, все операции и ошибки — в [доменах](https://telegafirst.com/docs/api-domain), [сайтах](https://telegafirst.com/docs/api-site), [витрине](https://telegafirst.com/docs/api-storefront), [формах](https://telegafirst.com/docs/api-form-submissions) и [общих ошибках](https://telegafirst.com/docs/errors). Результат настройки — доступный по своему разрешённому адресу сайт и форма, чьи ответы сразу видны вашему бизнесу; подтверждайте это реальным состоянием своей публикации, а не примером из документации.
