# Вручай — полная документация API

Собрано автоматически из docs/api. Базовый адрес: https://vruchay.ru/api. MCP: https://vruchay.ru/mcp.
Аутентификация: заголовок Authorization: Bearer vru_… (токен выдаётся в кабинете) или вход через OAuth.
Страницы по отдельности: https://vruchay.ru/docs/<страница>.md, карта — https://vruchay.ru/llms.txt.

---

<!-- README -->
# API сервиса «Вручай»

«Вручай» выпускает именные документы: сертификаты, грамоты, дипломы и благодарности. Вы приносите макет листа и людей — таблицей, по одному или через форму, — сервис возвращает готовые PDF: по одному на человека, с именем в нужном падеже, номером и ссылкой на проверку подлинности.

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

## Три пути

### ИИ-агент по MCP

Рекомендуемый путь для агентов. Сервер MCP — `https://vruchay.ru/mcp`: агент
видит готовые инструменты — создать документ, свёрстать лист, посмотреть превью,
загрузить получателей, выпустить, дать ссылку на архив — и вызывает их сам.

- **Claude.ai, Claude Desktop, ChatGPT** — подключаются кнопкой: добавьте адрес
  как свой коннектор, войдите в кабинет «Вручай» и разрешите доступ. Токен не нужен.
- **Claude Code, Cursor, VS Code, Codex, Windsurf, Gemini CLI** — адрес и токен
  в настройках клиента.

Настройка под каждое приложение — в [Подключении агента по MCP](mcp.md).

### REST API по токену

Для своих программ, CRM, учебных платформ и агентов без MCP. Адрес —
`https://vruchay.ru/api`, вход — заголовок `Authorization: Bearer vru_…`. Справочник —
по странице на каждый адрес, со схемами, кодами ошибок и примером `curl` — в папке
`endpoints/`; полный перечень — в [llms.txt](https://vruchay.ru/llms.txt).

### Без кода — формы

Если документ должен выдаваться сам, без программы: форма по ссылке на странице
«Вручай» (своего сайта не нужно) или форма на сайте организации — Тильда,
WordPress, Битрикс, любая страница. Человек вводит имя и почту и сразу получает
документ. Форму настраивают в кабинете (документ → «Выпуск») или агент — одним
вызовом. Подробнее — в [Способах выдачи](integrations.md).

## Базовый адрес

```
https://vruchay.ru/api
```

Все адреса REST приводятся вместе с префиксом `/api`. Вне префикса лежат три
вещи: сервер MCP `https://vruchay.ru/mcp`, описание входа через OAuth
(`/.well-known/oauth-protected-resource`, `/.well-known/oauth-authorization-server`)
и проверка живости `/health` — она токена не требует.

Косая черта в конце адреса ничего не меняет: `/api/documents` и `/api/documents/` — один
и тот же маршрут.

## Формат обмена

- Тело запроса и ответа — JSON в UTF-8, заголовок `Content-Type: application/json`.
  Исключения: загрузка файлов (`multipart/form-data`), выгрузки CSV, PDF и ZIP
  и превью листа — картинка JPEG.
- Идентификаторы объектов — UUID.
- Даты — строки ISO 8601 в UTC, например `2026-09-03T10:15:00.000Z`.
- Размеры и координаты на листе — в миллиметрах, кегль — в пунктах.
- Тексты сообщений об ошибках — русские: они написаны для человека, который будет читать
  их в интерфейсе или в журнале.
- Предел размера тела запроса — 1 МБ. Исключения: подтверждение импорта получателей
  (16 МБ) и запросы к `/mcp` (2 МБ).

## С чего начать

**Если работу делает агент:**

1. Подключите его. Проще всего — сказать агенту одну фразу: «Подключи себе
   сервис «Вручай» по инструкции https://vruchay.ru/docs/connect.md» ([connect.md](connect.md)):
   он добавит сервер сам, а вы войдёте в кабинет и нажмёте «Разрешить». Токен
   не нужен — вход через OAuth. Команды и кнопки для каждого клиента — в
   [инструкции по MCP](mcp.md), в карточке «ИИ-агенты» (**Настройки → Интеграции
   и API**) и в блоке «Подключить ИИ-агента» на главной этой базы знаний.
2. Скопируйте агенту готовый текст из [Готовых текстов для агента](agent-prompt.md)
   (короткий вариант — кнопка «Скопировать промпт» в кабинете) и впишите задачу
   в скобки. Дальше агент прочитает [AGENTS.md](AGENTS.md) и сделает работу сам,
   а перед выпуском и рассылкой покажет план.

**Если пишете программу:**

1. Выдайте токен там же и проверьте связь:

   ```bash
   curl -H "Authorization: Bearer $VRUCHAY_TOKEN" https://vruchay.ru/api/auth/me
   ```

2. Пройдите основной путь: документ → получатели → проверка → выпуск → ссылка
   на архив. Он расписан по шагам в [AGENTS.md](AGENTS.md), целиком на примерах —
   в [Сценариях](recipes.md).

## Разделы

| Страница | О чём |
| --- | --- |
| [AGENTS.md](AGENTS.md) | Главное для агента: сущности, сценарии через MCP и REST, подводные камни |
| [Готовые тексты для агента](agent-prompt.md) | Что скопировать своему агенту под типовые задачи |
| [Подключение по MCP](mcp.md) | Claude, ChatGPT, Claude Code, Cursor, VS Code, Codex и другие; список инструментов |
| [Вёрстка листа](layout-guide.md) | Как свёрстать сертификат: координаты, шрифты, проверка и превью |
| [Способы выдачи](integrations.md) | Таблица, форма на сайте, форма по ссылке, API на одного, письма |
| [Сценарии целиком](recipes.md) | Олимпиада, курс, конференция, новое мероприятие по старому шаблону, исправление ошибки |
| [Аутентификация](authentication.md) | Токены, OAuth, роли, что закрыто для программ |
| [Ограничения частоты](rate-limits.md) | Где стоят лимиты и как читать ответ 429 |
| [Коды ошибок](errors.md) | Формат ошибки и разбор каждого кода, ошибки OAuth и MCP |
| [Макет листа](reference/layout.md) | Все поля блоков и переменные подстановки |
| [Условия и остаток](endpoints/org/get-org-usage.md) | Проба, план, предел, срок и возможности организации |

Справочник эндпоинтов — по странице на каждый адрес. Группы, которые чаще всего
нужны агенту:

| Группа | С чего начать |
| --- | --- |
| Вёрстка для программ | [Справочник вёрстки](endpoints/layout/get-layout-catalog.md), [правка блоков](endpoints/layout/patch-documents-id-sheets-sheetId-elements.md), [проверка листа](endpoints/layout/post-documents-id-sheets-sheetId-check.md), [превью](endpoints/layout/post-documents-id-sheets-sheetId-preview.md), [поля документа](endpoints/layout/get-documents-id-fields.md) |
| Выпуск | [Выпуск по таблице](endpoints/generation/post-documents-id-generate.md), [ссылка на архив](endpoints/generation/post-jobs-jobId-archive-link.md), [один документ по API](endpoints/generation/post-documents-id-issue.md) |
| MCP и OAuth | [Сервер MCP](endpoints/mcp/post-mcp.md), [описание входа OAuth](endpoints/oauth/get-well-known-oauth.md), [регистрация клиента](endpoints/oauth/post-oauth-register.md) |
| Формы выдачи | [Создать форму](endpoints/integrations/post-integrations-tilda.md), [список с готовым кодом](endpoints/integrations/get-integrations-tilda.md) |
| Письма | [Шаблон письма](endpoints/mail/post-mail-templates-documentId.md), [рассылка документов](endpoints/mail/post-mail-send-documentId.md), [рассылка без документа](endpoints/mailing/post-mailing-text.md) |
| Реестр и цифры | [Реестр](endpoints/registry/get-registry.md), [перевыпуск](endpoints/registry/post-registry-reissue.md), [аналитика](endpoints/analytics/get-analytics.md) |

Карта всех страниц — [llms.txt](https://vruchay.ru/llms.txt); вся документация
одним файлом — [llms-full.txt](https://vruchay.ru/llms-full.txt). Любая страница
доступна и сырым markdown: `https://vruchay.ru/docs/<страница>.md`.

## Что API не делает

Честнее сказать сразу, чем заставлять искать:

- **Нет вебхуков о выпуске.** О завершении задания узнают опросом
  `GET /api/jobs/{jobId}`; инструмент MCP `get_job` умеет ждать сам до минуты,
  а выдача одного документа ждёт готовности прямо в запросе. Входящие адреса
  платёжного сервиса (`POST /api/v1/payments/yookassa`) и банка
  (`POST /api/v1/payments/tochka`) — наши служебные: они отмечают оплату, начатую
  человеком в кабинете, и вызывать их самим незачем.
- **Нет вечных ссылок на архив.** Архив по адресу задания отдаётся только
  с заголовком `Authorization`. Ссылку, которую можно открыть в браузере, выдаёт
  `POST /api/jobs/{jobId}/archive-link` — она живёт час. Получателю его документ
  доступен по своей ссылке 30 дней.
- **Нет пакетного эндпоинта.** Строки получателей добавляются по одной либо целиком
  через импорт таблицы. Отдельного «создать сто строк одним запросом» нет.
- **Нет постраничной навигации по курсору.** Списки отдаются целиком либо
  с ограничением, указанным на странице конкретного эндпоинта.
- **Нет действий за человека.** Приглашения, роли, пароли, токены, разрешение
  доступа агенту и оплата — только в кабинете. Такие запросы отвечают `403`.

---

<!-- AGENTS -->
# AGENTS.md — «Вручай» для ИИ-агента

Для агента, которому человек поручил работу с «Вручай»: как устроена предметная
область, в каком порядке действовать и обо что спотыкаются. Каждый шаг дан двумя
путями — инструментом MCP и запросом REST.

- Подключиться самому: [connect.md](connect.md). Все клиенты и протокол: [mcp.md](mcp.md).
- Вёрстка листа: [layout-guide.md](layout-guide.md).
- Способы выдачи: [integrations.md](integrations.md). Сценарии целиком: [recipes.md](recipes.md).
- Карта всех страниц: `https://vruchay.ru/llms.txt`; всё одним файлом: `https://vruchay.ru/llms-full.txt`.

## Что делает сервис

«Вручай» выпускает именные документы: сертификаты, грамоты, дипломы, благодарности.
На входе — макет листа и люди (таблицей, по одному через API или через форму),
на выходе — по PDF (или JPEG) на человека: с ФИО в нужном падеже, номером,
кодом и QR, ведущим на страницу проверки подлинности. Выданное попадает в реестр,
его можно разослать письмами, перевыпустить или отозвать.

Работа идёт от имени организации. Токен принадлежит ей, а не человеку, и видит
ровно её данные.

## Прежде чем писать свой код

Выдача уже решена готовыми способами — сначала подбери подходящий. Форма и API
сами принимают заявку, выпускают документ, списывают квоту, держат «один документ
в руки» и шлют письмо. Поэтому свой сервер, прокси между сайтом и «Вручай»,
выгрузка списков и хранение данных участников у себя не нужны.

| Задача | Готовое решение | MCP | REST |
| --- | --- | --- | --- |
| Участники получают документ на сайте: Тильда, WordPress, Битрикс, любая страница | Форма на сайте — кнопка «Получить документ», код вставки приходит готовым | `setup_form`, `kind: "site"` | `POST /api/integrations/tilda` |
| Закрытая страница с личным кабинетом (Tilda Members и другие) | Та же форма: имя и почту берёт из кабинета сама, ФИО человек может поправить; `requireAccount: true` — только вошедшим; без писем — `authMode: "none"`, `sendEmail: false` | `setup_form`, `kind: "site"` | то же |
| Своего сайта нет | Форма по ссылке на `vruchay.ru/f/…` или на своём домене | `setup_form`, `kind: "link"` | то же |
| Документ по событию в CRM, LMS или боте | Выдача одному | `issue_document` | `POST /api/documents/{id}/issue` |
| Список известен заранее | Таблица → выпуск → архив | `import_recipients` → `start_generation` | `POST …/recipients/import` → `POST …/generate` |
| Разослать выпущенное | Письма по шаблону документа | `set_mail_template` → `send_mail` | `POST /api/mailing/send` |
| Свёрстать лист, поставить ФИО и другие поля | Всё оформление делается инструментами, кабинет не нужен | `apply_preset`, `edit_layout`, `preview_sheet` | `PATCH …/sheets/{sheetId}/elements` |

Как настроить каждый путь — [integrations.md](integrations.md). Если ни один
не подходит, сначала скажи человеку, чего не хватает, и только потом пиши своё.

## Как подключиться

- **MCP** — удобнее для агента. Адрес `https://vruchay.ru/mcp`, вход через OAuth:
  человек один раз входит в кабинет и разрешает доступ, токен не нужен. Как
  подключить себя самому — по шагам в [connect.md](connect.md). Если инструменты
  `vruchay` у тебя уже есть — работай через них. Начни с `get_account`, инструкции —
  `get_guide` (`start`, `layout`, `integrations`, `recipes`). Готовые задания —
  подсказки MCP (`prompts`): `design_sheet`, `issue_from_table`, `site_form` и другие.
- **REST** — `https://vruchay.ru/api`, заголовок `Authorization: Bearer vru_…`
  (токен выдаёт владелец в кабинете). Проверка связи — `GET /api/auth/me`.

Права одинаковые: инструменты MCP вызывают тот же REST изнутри сервера. Чего нет
отдельным инструментом, делается через `api_request` с путём REST. Права токена —
«Выпускать документы» (`member`) или «Полный доступ» (`admin`); формы, удаление
и отзыв требуют второго.

Файлы инструменты MCP не передают: они ходят JSON-ом. Бланк, логотип или таблица
у тебя на диске и есть оболочка (Claude Code, Codex, Cursor) — возьми
`get_upload_link` и выполни `curl -F "file=@путь" "<ссылка>"` из ответа: ссылка
одноразовая, на 15 минут, токен в команду не попадает. Base64 в аргументах
не передавай. Без оболочки — `upload_image` по публичной ссылке или кабинет.

## Сущности

| Сущность | Что это | Ключевые поля |
| --- | --- | --- |
| **Организация** | Владелец всего остального: проба, план или оплаченный тариф старого образца | `plan` |
| **План** | Сколько документов, до какого числа, какие возможности | `limit`, `left`, `expired`, `features` |
| **Проект** | Мероприятие или сайт: свои документы, реестр, аналитика, письма. Отбор — `projectId` (UUID или `none`). Прежнее имя — папка (`folderId`) | `id`, `name` |
| **Документ** | Заготовка награждения: размер листа, листы, таблица получателей, мероприятие. В кабинете — «материал». Шаблон — документ с `isTemplate: true` | `id`, `title`, `projectId`, `sheets` |
| **Лист** (`sheet`) | Одна страница документа: фон и блоки. У листа есть `version` — растёт с каждой правкой | `id`, `version`, `backgroundFileId` |
| **Блок** (`element`) | Текст, картинка, QR, ссылка или фигура на листе. Координаты в мм от левого верхнего угла, кегль в pt | `id`, `type`, `x`, `y`, `w`, `h`, `props` |
| **Поле** | То, что подставляется в текст: `%name`, колонки таблицы, постоянные поля документа, служебные (`date`, `code`, `name_dat`…) | имя латиницей |
| **Колонка / строка** | Таблица получателей. `name` и `email` есть всегда. В выпуск идут отмеченные строки | `data`, `checked`, `source` |
| **Задание** (`job`) | Один запуск выпуска, асинхронный | `status`, `total`, `done`, `failed` |
| **Файл** (`file`) | Выпущенный документ в реестре | `fileId`, `code`, `state` |
| **Форма** | Выдача посторонним: на сайте организации (`site`) или по ссылке (`link`) | `kind`, `token`, `connect` |
| **Письмо** | Документ, отправленный получателю | `status` |

## Перед началом

Узнай, кто ты и сколько можно выпустить: `get_account` — или `GET /api/auth/me`
и `GET /api/org/usage`.

- `left` — сколько документов ещё можно выпустить (`null` — без предела);
- `expired: true` — срок плана вышел: выпуск, выдача, формы и рассылка не
  работают при любом `left`;
- `features` — что входит в план: `mailing` (письма), `tilda` (формы на сайте
  и по ссылке), `api` (токены), `awards` (правила наград); `null` — всё.

Ноль в `left` или `expired: true` — скажи человеку сразу, до вёрстки: сам собой
остаток не вырастет.

## Сценарий 1. Создать и свёрстать документ

| Шаг | MCP | REST |
| --- | --- | --- |
| Посмотреть шрифты, форматы, раскладки | `get_layout_catalog` | `GET /api/layout/catalog` |
| Завести документ со свёрстанным листом | `create_document` с `format`, `orientation`, `preset: {kind, style}` | `POST /api/documents` |
| …или взять существующий | `list_documents`, `get_document` | `GET /api/documents`, `GET /api/documents/{id}` |
| Фон или логотип по ссылке | `upload_image` (`target: background` или `element`) | `POST …/sheets/{sheetId}/background/from-url`, `POST /api/documents/{id}/assets/from-url` |
| Фон или логотип файлом с диска (агент с оболочкой) | `get_upload_link` → `curl -F "file=@…"` по ссылке из ответа | `POST /api/uploads` → [`POST /api/v1/uploads/{link}`](endpoints/public/post-v1-uploads-link.md) |
| Поправить блоки | `edit_layout` (`add`, `update`, `remove`) или `apply_preset` | `PATCH …/sheets/{sheetId}/elements`, `POST …/sheets/{sheetId}/preset` |
| Мероприятие, подписант, дата | `update_document` (`eventName`, `constants`, `issueDate`) | `PATCH /api/documents/{id}` |
| Проверить | `check_sheet` | `POST …/sheets/{sheetId}/check` |
| Посмотреть глазами | `preview_sheet` | `POST …/sheets/{sheetId}/preview` (JPEG) |

Порядок работы — цикл: правка → `check_sheet` → `preview_sheet` → правка, пока
замечаний нет и картинка выглядит как задумано. Замечания: блок за краем
(`off_page`), блоки наложились (`overlap`), текст не влез на самой длинной строке
таблицы (`text_overflow`), неизвестное поле или шрифт. Превью квоту не тратит.

- Виды раскладок (`kind`): `award` (за место), `winner`, `laureate`, `participant`,
  `attendee`, `course`, `graduate`, `school`, `thanks`, `letter`. Стили: `classic`,
  `solemn`, `academic`, `strict`, `modern`, `script`, `handwritten`.
- Бланк с напечатанным заголовком — `titlePrinted: true`, а сам бланк — фоном.
- Готовая раскладка ставит подпись из постоянных полей `signer_role` и `signer_name`
  с образцами «Руководитель» и «И. О. Фамилия» — замени их через `update_document`
  (`constants` — список целиком).
- `format` без `orientation` даёт альбомный лист.
- Поля документа и где они стоят: `get_document` → `fields` (REST — `GET /api/documents/{id}/fields`).

Как верстать вручную — координаты, кегли, шрифты, примеры блоков — в
[layout-guide.md](layout-guide.md); все поля блоков — в [справочнике макета](reference/layout.md).

## Сценарий 2. Таблица → выпуск → ссылка

| Шаг | MCP | REST |
| --- | --- | --- |
| Загрузить таблицу | `import_recipients` (`columns`, `titles`, `rows`, `mode`) | `POST /api/documents/{id}/recipients/import`; файлом — сначала `…/recipients/parse` |
| …или файлом `.xlsx`/`.csv` с диска | `get_upload_link` (`target: recipients`, `mode`) → `curl -F "file=@…"` | `POST /api/uploads` → `POST /api/v1/uploads/{link}` |
| Проверить данные | `validate_recipients` — только смотрит | `POST …/validation` |
| Принять однозначные исправления | `edit_recipients` с `applySuggestions: true` | `POST …/validation/fix` с `fixes` из `suggestion` замечаний; снять строки — `…/validation/exclude` |
| Отметить, кого выпускать | `edit_recipients` (`checkAll`, `update[].checked`) | `POST …/recipients/checked` |
| Посмотреть на самой длинной строке | `preview_sheet` с `rowId` | `POST …/preview` с `{"rowId": …}` |
| Показать человеку план и получить «да» | — | — |
| Выпустить | `start_generation` (`format: pdf` или `jpg`) | `POST /api/documents/{id}/generate`, тело минимум `{}` |
| Дождаться | `get_job` с `waitSeconds: 60`, повторять | `GET /api/jobs/{jobId}` раз в 2–3 секунды |
| Остановить ошибочный выпуск | `cancel_job` | `POST /api/jobs/{jobId}/cancel` |
| Доделать прерванный | `start_generation` с `resume: true` | `POST /api/jobs/{jobId}/resume` |
| Отдать человеку | `get_download_link` (`format: zip` или `pdf`) | `POST /api/jobs/{jobId}/archive-link` |

- `columns` — имена латиницей: `name` (ФИО), `email`, свои (`place`, `course`, `hours`…).
  Колонки создаются сами. До 30 колонок и 10 000 строк за раз.
- `mode: "replace"` стирает прежние строки (кроме выданных формой), `append` дописывает.
- Через MCP тело ограничено 2 МБ: большую таблицу шли частями с `append`.
- Задание кончается статусом `done`, `failed` или `canceled`. Причины сбоев
  `get_job` показывает сам; в REST — `GET /api/jobs/{jobId}/failures`. Забирать
  их нужно до продолжения: оно стирает прежние причины. Прошлые выпуски документа
  и их `jobId` — `get_document` → `jobs`.
  `stuck: true` при `queued` — задание висит больше 15 минут, его пора продолжить.
- Ссылка от `get_download_link` открывается в браузере без входа и живёт час.
  `name` — шаблон имён файлов в архиве, до 120 знаков: `"%name"`, `"%number %name"`.
  Архив по адресу задания (`GET /api/jobs/{jobId}/archive`) отдаётся только
  с заголовком `Authorization` и потоком — для программ, а не для человека.

## Сценарий 3. Один документ одному человеку

Когда документы выдаются не пачкой, а по одному — человек закончил курс, сдал
тест, записался, — таблица не нужна:

1. Документ с макетом уже есть (сценарий 1).
2. MCP: `issue_document` с `documentId`, `data: {"name": "Иванова Анна Сергеевна"}`,
   `email`, `send: true` и `idempotencyKey`.
   REST: `POST /api/documents/{id}/issue` с телом
   `{"data": {"name": "Иванова Анна Сергеевна"}, "email": "…", "send": true}`
   и заголовком `Idempotency-Key`.
3. Ключ — номер заказа или заявки, 8–100 знаков: латиница, цифры и `_ - : .`.
   В MCP он обязателен. В REST обязателен по смыслу, хоть и не по схеме: без него
   повтор после обрыва связи выпустит и спишет второй документ.
4. Ответ `201` (`status: "done"`) — документ готов: в `file` код проверки, номер,
   ссылка на страницу проверки и ссылка получателя на PDF (`recipientUrl`, 30 дней).
   `202` (`processing`) — опрашивай `statusUrl` раз в 1–2 секунды. `200` с
   `status: "failed"` — не вышел, причина в `error`; повтор с тем же ключом
   заведёт новое задание.

Поля `data` — колонки и поля листа этого документа (список — `get_document` → `fields.issue`,
в REST — `GET /api/documents/{id}/fields`, раздел `issue`); лишнее имя — `400` со списком допустимых. Квота та же, что
у выпуска; отказ по ней — сразу, `400`. Предел — 120 документов в минуту на
организацию. Письмо (`send: true`) требует `mailing` в плане и адреса: без
возможности — `403`, без адреса — `400`, и документ тогда не выпускается вовсе.
Если письмо не ушло уже после выпуска, документ остаётся выпущенным, а причина —
в `emailError`.

## Сценарий 4. Форма по ссылке или на сайте

Люди получают документ сами — заполняют форму. Создавать формы может только
токен с «Полным доступом», а в плане должна быть возможность `tilda`.

| Шаг | MCP | REST |
| --- | --- | --- |
| Форма по ссылке (своего сайта не нужно) | `setup_form` с `kind: "link"`, `name`, `documentIds: [id]`, `formFields: ["name"]` | `POST /api/integrations/tilda` |
| Форма на сайте организации | `setup_form` с `kind: "site"`, `name`, `documentIds`, `allowedDomains: ["example.ru"]` | `POST /api/integrations/tilda` |
| Изменить, выключить | `setup_form` с `integrationId` (`active: false`) | `PATCH /api/integrations/tilda/{id}` |
| Найти готовые | `list_forms` | `GET /api/integrations/tilda` |
| Заявки | `api_request` `GET /api/integrations/tilda/requests` | то же |

`name` — название формы для кабинета, при создании обязательно.

В ответе — `connect`: `connect.formUrl` — ссылка `https://vruchay.ru/f/{token}`
для формы по ссылке; `connect.embedHtml[documentId]` — готовый HTML для вставки
на сайт. Отдай человеку ровно это. Форма по ссылке выдаёт один документ, форма
на сайте — до 50; поддомены разрешённого домена тоже считаются своими. Форма
на закрытой странице личного кабинета подставляет имя и почту вошедшего сама —
см. «Закрытая страница и личный кабинет» в [integrations.md](integrations.md).

Каждая заявка выпускает документ и списывает квоту. Кончилась квота или вышел
срок плана — форма перестаёт выдавать, и человек видит просьбу написать организатору.
`authMode` — подтверждать ли почту кодом: по умолчанию `email_code` у формы
на сайте и `none` у формы по ссылке;
`singleFilePerEmail: true` (по умолчанию) — один документ в руки. Подробнее —
[integrations.md](integrations.md).

## Сценарий 5. Письма

| Шаг | MCP | REST |
| --- | --- | --- |
| Шаблон письма | `set_mail_template` (`subject`, `bodyHtml`, `attachGeneratedFile`) | `POST /api/mail/templates/{documentId}` |
| Пробное письмо себе | `api_request` `POST /api/mail/templates/{documentId}/test-send`, тело — как у шаблона | то же |
| Кому уйдёт и кому нет — показать человеку, получить «да» | `get_mail` → `audience` | `POST /api/mailing/audience` |
| Разослать | `send_mail` с `documentId` | `POST /api/mailing/send` с `{"documentIds": [id], "kind": "transactional", "source": "table"}` |
| Судьба писем | `get_mail` → `log` (`problemsOnly: true` — только недоставленные) | `GET /api/mailing/log?documentId=…` |
| Повторить недоставленные | `send_mail` с `documentId` и `onlyFailed: true` | `POST /api/mailing/resend` |

- В теме и тексте подстановки — `%name` и другие поля строки.
- Письма уходят отмеченным строкам, у которых документ уже выпущен; кому не
  поставлено — в `skipped` с причиной.
- Пробное письмо по токену приходит тому, кто выдал токен (или разрешил
  подключение OAuth), а не на адрес из запроса.
- С общего адреса сервиса — не больше 300 писем за раз и 500 за сутки на
  организацию; со своего подтверждённого домена предела нет.
- Повторный `send_mail` безопасен: кто уже получил письмо, второй раз его не
  получит — так можно дослать письма строкам, выпущенным позже.

## Сценарий 6. Исправить выданное

Опечатка в ФИО, письмо не дошло, документ выдан по ошибке:

| Шаг | MCP | REST |
| --- | --- | --- |
| Найти выданное | `search_registry` (`search` — имя, почта или код) | `GET /api/registry` |
| Карточка и файл одного документа | `search_registry` с `fileId` | `GET /api/registry/files/{fileId}`, ссылка — `GET /api/documents/files/{fileId}/url` |
| Поправить строку таблицы | `edit_recipients` (`update`) | `PATCH …/recipients/rows/{rowId}` |
| Перевыпустить по исправленной строке | `reissue_documents` с `fileIds` | `POST /api/registry/reissue` |
| Переслать письмо | `send_mail` с `fileIds` | `POST /api/registry/resend` |
| Отозвать | `revoke_documents`: без `expectedCount` — предпросмотр, затем с ним | `POST /api/registry/revoke/preview`, затем `POST /api/registry/revoke` |

- Перевыпуск тратит квоту; новый документ получает новый номер, старый
  помечается заменённым, и его страница проверки ведёт на новый.
- Отзыв виден сразу на странице проверки по QR. Вернуть проверку можно
  (`restore: true`), но человек с бумагой мог уже увидеть «отозван».
- Перевыпуск и отзыв — только с «Полным доступом».

## Что важно знать заранее

- **Асинхронный только выпуск.** Остальное отвечает результатом сразу. Вебхуков
  о готовности нет — состояние узнают опросом (`get_job` ждёт сам до 60 секунд).
- **Тело `POST /generate` обязательно.** Пустое тело — `400`; посылай `{}`.
- **Выпускаются только отмеченные строки.** Импорт отмечает строки сам; снятые
  проверкой и строки, выданные формой или по API, в пакет не попадают.
- **Один выпуск на документ.** Пока по документу идёт задание, второе не начнётся;
  у организации — не больше трёх заданий в очереди.
- **Чужое отвечает `404`, а не `403`.** Так не подтверждается существование
  чужих объектов.
- **Лист правят по очереди.** Редактор кабинета записывает лист с версией и твою
  правку молча не затрёт: человек с открытым листом увидит «Лист изменили в другом
  месте» и сам выберет, взять твою версию или записать свою поверх. Чтобы обойтись
  без выбора, попроси не трогать лист, пока ты верстаешь. Сам передавай
  `baseVersion` — версию листа из `get_document`: если лист успел измениться, ответ
  `409` с текущей `version`; перечитай лист и повтори.
- **Превью — образец.** Код и номер на нём ненастоящие; данные — первой строки
  таблицы или образца, `rowId` — конкретной строки.
- **Только человек:** сотрудники и роли, пароль и вход, токены и разрешение
  OAuth, оплата картой и счета, удаление учётной записи. Такие запросы отвечают
  `403`, `api_request` в эти разделы не пускает.
- **Подпись PDF** ставится, только когда у организации отметка `paid` и на сервере
  настроен сертификат подписи. JPEG подписи не носит.
- **Выданное остаётся действительным** и после истечения плана.

## Что подтверждать у человека

Перед этими действиями покажи, что и сколько будет сделано, и дождись согласия:

- выпуск (`start_generation`, `POST …/generate`) — тратит квоту: сколько документов,
  по какому документу, сколько останется;
- выдача одному (`issue_document`) — тратит квоту; в потоке из CRM согласие даётся
  один раз на всю интеграцию;
- рассылка (`send_mail`) — необратима: сколько писем, с какого адреса, текст;
- форма (`setup_form`) — открывает выдачу посторонним и тратит квоту на каждую заявку;
- отзыв (`revoke_documents`) — сначала предпросмотр, число документов показать
  человеку; перевыпуск (`reissue_documents`) — тратит квоту;
- удаление — листа (`delete_sheet`), документа в корзину (`delete_document`),
  формы; удалить насовсем может только владелец в кабинете;
- `import_recipients` и `get_upload_link` для таблицы с `mode: "replace"` — стирают прежнюю таблицу.

## Если что-то пошло не так

| Ответ | Что значит | Что делать |
| --- | --- | --- |
| `400` с `errors[]` (в MCP — «Поля: …») | Не прошла проверка полей | Исправить по `errors[].field` и `errors[].message` |
| `400` про остаток или план | Не хватает документов или вышел срок | Сказать человеку; состояние — `get_account` |
| `401` | Токена нет, он отозван или заменён | Проверить заголовок; для OAuth — переподключиться |
| `403` | Не та роль, действие только для человека, возможности нет в плане или план истёк | Не повторять, объяснить человеку |
| `404` | Объекта нет или он чужой | Проверить идентификатор |
| `409` у листа | Лист изменился после чтения | Перечитать (`get_document`), повторить с новой `baseVersion` |
| `409` у выдачи | `Idempotency-Key` уже использован для другого документа | Ключ — на каждого получателя, а не на программу |
| `422` у превью | Лист не отрисовался | `check_sheet`, проверить `fileId` картинок |
| `429` | Слишком часто | Подождать столько секунд, сколько названо в тексте |
| `job.status = failed` | Задание не доделано | Причины (`get_job` или `…/failures`), потом `start_generation` с `resume: true` (REST — `POST /api/jobs/{jobId}/resume`) |
| `503` при запуске выпуска | Очередь недоступна; задание заведено и помечено `failed` | Продолжить его (`start_generation` с `resume: true`), а не запускать второе |
| `202` у выдачи | Документ ещё печатается | Опрашивать `statusUrl`, а не повторять выдачу |

Разбор форматов — в [Кодах ошибок](errors.md).

## Границы приличия

- Токен отправляется только на `vruchay.ru` и никуда больше: ни в сторонние
  сервисы, ни в публичные чаты, ни в код, который уйдёт в репозиторий.
- Таблица участников и выпущенные документы — персональные данные. Не выкладывай
  их в сторонние сервисы и не показывай больше, чем нужно для задачи.
- Ссылка на архив открывается без входа: отдавай её только тому, кто поручил работу.
- Сомневаешься, тратит ли действие квоту или можно ли его отменить, — спроси.

---

<!-- agent-prompt -->
# Готовые тексты для агента

Скопируйте подходящий текст, вставьте своему агенту — Claude, ChatGPT, Claude Code,
Cursor или любому другому — и замените то, что в квадратных скобках. Остальное агент
прочитает сам: тексты отсылают его к инструкции [AGENTS.md](AGENTS.md) и к нужным
разделам.

Если агент к «Вручай» ещё не подключён, начните с первого текста — он подключится
сам, а вы только войдёте в кабинет и нажмёте «Разрешить». Токен для этого не нужен.

Эти же задания агент видит и как подсказки сервера MCP: в Claude Code — командами
`/mcp__vruchay__design_sheet`, `/mcp__vruchay__issue_from_table` и другими.
Промпт для подключения есть и в кабинете — в карточке «ИИ-агенты»
(**Настройки → Интеграции и API**) и в блоке «Подключить ИИ-агента» на главной
базы знаний.

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

## Подключись сам

Одна строка. Агент в Claude Code, Codex, Gemini CLI, VS Code или Cursor добавит
сервер сам и попросит вас войти; в Claude.ai и ChatGPT подскажет, где нажать.

```text
Подключи себе сервис «Вручай» по инструкции https://vruchay.ru/docs/connect.md и проверь подключение вызовом get_account.
```

## Подключись и сделай

Универсальный текст — когда задача своя и не похожа на остальные.

```text
Прочитай https://vruchay.ru/docs/AGENTS.md — это инструкция сервиса «Вручай» для ИИ-агента.
Сервис выпускает именные документы (сертификаты, грамоты, дипломы): по PDF на человека,
с ФИО в нужном падеже и проверкой подлинности по QR.

Если инструментов vruchay (MCP) у тебя ещё нет — подключись по
https://vruchay.ru/docs/connect.md. Начни с get_account.

Задача: [что нужно: какой документ, для какого мероприятия, кому выдать
и как — по таблице, формой на сайте, по ссылке или через API].

Правила:
- сначала проверь остаток и срок плана и скажи, если их не хватит;
- перед выпуском, выдачей, рассылкой, подключением формы, отзывом и удалением
  покажи план (что, сколько документов или писем) и дождись моего «да»;
- ссылки на скачивание и персональные данные участников отдавай только мне.
```

## Свёрстай сертификат

По описанию или по готовому бланку. Агент верстает, проверяет и смотрит на
картинку, пока результат не станет чистым.

```text
Прочитай https://vruchay.ru/docs/AGENTS.md и https://vruchay.ru/docs/layout-guide.md.
Подключение к «Вручай» — как в AGENTS.md (MCP https://vruchay.ru/mcp или REST).

Свёрстай документ:
- вид: [сертификат участника / грамота за место / диплом / благодарность];
- формат: [A4 альбомный];
- мероприятие: [название, дата, место, часы — если нужны];
- подпись: [должность и ФИО подписанта];
- бланк: [ссылка на картинку PNG/JPEG с бланком — или «без бланка, на белом листе»];
- логотип: [ссылка на PNG/JPEG — или «без логотипа»];
- стиль: [строгий / торжественный / как в описании: …].

Как работать:
1. Посмотри справочник вёрстки (get_layout_catalog) и начни с готовой раскладки
   (preset) подходящего вида и стиля. Если на бланке уже напечатан заголовок —
   titlePrinted: true.
2. Бланк загрузи фоном, логотип — картинкой (upload_image).
3. После каждой заметной правки — check_sheet и preview_sheet. Повторяй,
   пока замечаний нет и картинка выглядит аккуратно: ничего не налезает на рамку
   и рисунок бланка, длинное ФИО помещается.
4. Покажи мне последнее превью и список полей, которые подставятся из таблицы.
Документы не выпускай.
```

## Выпусти документы по моей таблице

```text
Прочитай https://vruchay.ru/docs/AGENTS.md. Подключение к «Вручай» — как там описано.

Выпусти документы по таблице [приложу файлом / вот ссылка: …].
Документ: [название существующего документа — или «сначала свёрстай сертификат
участника для мероприятия …»].

Порядок:
1. Проверь остаток (get_account). Если документов не хватит — остановись и скажи.
2. Загрузи таблицу (import_recipients): ФИО — в колонку name, почту — в email,
   остальное — в свои колонки латиницей. Если в документе уже есть строки — спроси,
   заменить их или дописать.
3. Проверь данные (validate_recipients) и покажи, что вызвало сомнения.
4. Посмотри превью на строке с самым длинным ФИО.
5. Покажи план: сколько документов выйдет и сколько останется. Жди моего «да».
6. Выпусти (start_generation), дождись конца (get_job), сообщи о сбоях.
   Если запустил не то — останови (cancel_job).
7. Дай ссылку на архив (get_download_link, zip; имена файлов — "%name").
   Ссылка живёт час.
Письма не рассылай без отдельного разрешения. Если разрешу — сначала покажи,
кому уйдёт и кому нет (get_mail), потом send_mail.
```

## Подключи форму получения сертификата на мой сайт

```text
Прочитай https://vruchay.ru/docs/AGENTS.md и https://vruchay.ru/docs/integrations.md.
Подключение к «Вручай» — как там описано; для форм нужны права «Полный доступ».

Хочу, чтобы участники сами получали документ [название документа] на сайте
[адрес страницы, например https://example.ru/event].
Сайт сделан на [Тильде / WordPress / Битриксе / обычном HTML].
Страница [открытая / закрытая, только для вошедших в личный кабинет].

Что сделать:
1. Убедись, что документ свёрстан, а приём заявок с сайта (tilda) входит в план.
2. Свой сервер не пиши: форма «Вручай» сама принимает заявку, выпускает документ,
   списывает квоту и шлёт письмо.
3. Покажи настройки формы и дождись «да»: домен, подтверждение почты кодом,
   один документ на адрес, отправка письмом.
4. Создай форму (setup_form, kind: site, allowedDomains — только домен).
   Для закрытой страницы: requireAccount: true; если документ нужен по кнопке
   без писем — authMode: none, sendEmail: false, showDownload: true. Имя и почту
   из личного кабинета Тильды форма подставит сама.
5. Дай мне код вставки из ответа (connect.embedHtml) и объясни по шагам,
   куда вставить его на моей площадке.
6. Напомни: каждая заявка тратит документ из остатка; кончится остаток — форма
   перестанет выдавать.
Если своего сайта нет — сделай форму по ссылке (kind: link) и дай ссылку connect.formUrl.
```

## Выдавай сертификат из моей CRM или LMS

Для разработчика или агента, который пишет интеграцию.

```text
Прочитай https://vruchay.ru/docs/AGENTS.md (раздел «Один документ одному человеку»)
и https://vruchay.ru/docs/integrations.md.

Нужна интеграция: когда ученик заканчивает курс в [название CRM/LMS], ему выдаётся
сертификат «Вручай» по документу [название или id] и уходит письмом.

Требования:
- запрос POST https://vruchay.ru/api/documents/{id}/issue с заголовком
  Authorization: Bearer из переменной окружения VRUCHAY_TOKEN — не в коде;
- заголовок Idempotency-Key — идентификатор записи о завершении курса
  (8–100 знаков, латиница, цифры и _ - : .), чтобы повтор не выпустил второй документ;
- data — поля документа (список возьми из GET /api/documents/{id}/fields, раздел issue):
  name — ФИО полностью, [hours, course — если есть на листе];
- email и send: true — отправить письмом;
- ответ 201 — сохрани file.code, file.verifyUrl и file.recipientUrl в карточке ученика;
  202 — опрашивай statusUrl раз в 1–2 секунды; 200 со status: failed — повтор с тем же ключом;
- 429 — подожди названное в тексте число секунд; 400 про остаток или план — уведоми админа.
Вебхуков у «Вручай» нет — готовность узнаётся из ответа или опросом.
Сначала покажи код и проверь его на одном тестовом ученике с моего разрешения.
```

## Перенеси оформление на новое мероприятие

```text
Прочитай https://vruchay.ru/docs/AGENTS.md и https://vruchay.ru/docs/recipes.md
(сценарий «Новое мероприятие по прошлому шаблону»).

Возьми оформление документа [название прошлого документа] и сделай документ
для нового мероприятия:
- название: [новое название];
- мероприятие: [название, дата, место, часы];
- проект: [название проекта — или «без проекта»];
- что поменять в тексте: [например, «участник» → «призёр»].

Порядок:
1. Найди исходный документ (list_documents) — или шаблон организации (templates: true).
2. Сделай копию: шаблон — create_document с templateId; обычный документ —
   create_document с copyOf. Получатели и письмо не копируются.
3. Обнови мероприятие и тексты (update_document, edit_layout).
4. check_sheet и preview_sheet — покажи мне превью.
5. Спроси, сохранить ли результат шаблоном на будущее (create_document с copyOf
   и asTemplate: true).
Ничего не выпускай.
```

## Исправь ошибку в выданном документе

Опечатка в ФИО, письмо не дошло, документ выдан не тому.

```text
Прочитай https://vruchay.ru/docs/AGENTS.md (сценарий «Исправить выданное»).
Подключение к «Вручай» — как там описано; для перевыпуска и отзыва нужны права «Полный доступ».

Что случилось: [например: «у Ивановой Анны в грамоте олимпиады ошибка в отчестве,
правильно — Сергеевна» / «Петрову не пришло письмо» / «документ выдан по ошибке»].

Порядок:
1. Найди документ в реестре (search_registry) и покажи мне, что нашёл.
2. Опечатка: исправь строку в таблице (edit_recipients), покажи превью этой строки
   (preview_sheet с rowId), после моего «да» перевыпусти (reissue_documents) —
   это тратит документ из остатка. Дождись выпуска (get_job) и спроси, переслать ли
   письмо с новым документом (send_mail с fileIds).
3. Письмо не дошло: посмотри причину (get_mail, problemsOnly: true), скажи мне,
   при необходимости поправь адрес и после моего «да» перешли (send_mail с fileIds).
4. Выдан по ошибке: покажи, что будет отозвано (revoke_documents без expectedCount),
   и только после моего «да» отзови с подтверждённым числом.
```

---

<!-- connect -->
# Подключись к «Вручай» сам

Инструкция для ИИ-агента, которому человек написал «подключи Вручай». Пройди шаги
по порядку и закончи проверкой. Человеку останется одно действие — войти
в кабинет «Вручай» в браузере и нажать «Разрешить». Токен для этого не нужен.

Сервер MCP: `https://vruchay.ru/mcp`, транспорт Streamable HTTP, вход через OAuth.

## 1. Может, ты уже подключён

Если среди твоих инструментов есть `get_account` от сервера `vruchay`
(в Claude Code — `mcp__vruchay__get_account`), вызови его. Ответил организацией
и остатком — подключение есть, переходи к задаче человека.

## 2. Узнай, где ты работаешь

Обычно ты знаешь это сам. Если нет и у тебя есть терминал — посмотри, какие
команды доступны: `command -v claude codex gemini code cursor`.

## 3. Добавь сервер

Выполни команду для своего приложения. Если сервер `vruchay` уже добавлен,
но не работает, — сначала убери его (`claude mcp remove vruchay`,
`codex mcp remove vruchay`, `gemini mcp remove vruchay`).

| Где ты | Команда | Как человек войдёт |
| --- | --- | --- |
| Claude Code | `claude mcp add --transport http --scope user vruchay https://vruchay.ru/mcp` | в Claude Code `/mcp` → `vruchay` → Authenticate, или в терминале `claude mcp login vruchay` |
| Codex CLI | `codex mcp add vruchay --url https://vruchay.ru/mcp` | `codex mcp login vruchay` |
| Gemini CLI | `gemini mcp add --transport http --scope user vruchay https://vruchay.ru/mcp` | в Gemini CLI `/mcp auth vruchay` |
| VS Code с Copilot | `code --add-mcp '{"name":"vruchay","type":"http","url":"https://vruchay.ru/mcp"}'` | VS Code сам предложит войти при первом запуске сервера |
| Cursor | дописать в `~/.cursor/mcp.json` (см. ниже) или кнопка «Добавить в Cursor» в кабинете | у сервера в настройках MCP появится кнопка входа |

Для Cursor файл `~/.cursor/mcp.json` надо **дополнить**, а не перезаписать:
прочитай его, добавь в объект `mcpServers` ключ `vruchay` и сохрани. Остальные
серверы оставь как были. Если файла нет — создай:

```json
{
  "mcpServers": {
    "vruchay": { "url": "https://vruchay.ru/mcp" }
  }
}
```

Windsurf, Devin Desktop, Zed и другие клиенты с удалённым MCP подключаются так
же: адрес `https://vruchay.ru/mcp`, без заголовков, вход через OAuth. Где у них
лежат настройки — в [mcp.md](mcp.md).

## 4. Скажи человеку одно действие

Например: «Я добавил сервер «Вручай». Выполните `/mcp`, выберите vruchay
и нажмите Authenticate. Откроется страница «Вручай»: войдите и нажмите
«Разрешить». Права „Выпускать документы“ хватит для вёрстки и выпуска,
„Полный доступ“ нужен для форм, отзыва и перевыпуска».

Ещё нужно знать:

- Разрешить подключение может только владелец или управляющий организации.
  Если откроется «Нужны права управляющего», попроси человека позвать их.
- Инструменты появятся после входа. Некоторым приложениям нужен перезапуск или
  новый чат. Скажи об этом, если инструменты не появились.

## 5. Проверь

Вызови `get_account`. В ответе — организация, остаток (`left`), срок (`expired`)
и проекты. Скажи человеку, что подключение работает и сколько документов можно
выпустить, и переходи к задаче.

## Если ты в чат-приложении

Claude.ai, Claude Desktop и ChatGPT не дают агенту добавить сервер самому:
это делает человек в настройках. Дай ему шаги:

- **Claude**: Customize → Connectors → Add custom connector. Название «Вручай»,
  адрес `https://vruchay.ru/mcp`. Если спросит про OAuth client — «Register
  automatically». Затем Connect → войти в «Вручай» → «Разрешить». В чате:
  «+» → Connectors → включить «Вручай».
- **ChatGPT**: нужен режим разработчика (Developer mode). Plugins → «+» →
  Add custom MCP server, адрес `https://vruchay.ru/mcp`, Authentication — OAuth.
  Затем войти в «Вручай» и разрешить. В чате — `@` → «Вручай».

Подробности и тарифы — в [mcp.md](mcp.md).

## Если без токена нельзя

Некоторые программы не умеют входить через OAuth. Тогда нужен токен из кабинета:
**Настройки → Интеграции и API → Токены API** (выдаёт владелец или управляющий).

- Никогда не вписывай токен в файлы, команды и сообщения. Попроси человека
  положить его в переменную окружения `VRUCHAY_TOKEN` и ссылайся на неё:
  Codex — `bearer_token_env_var = "VRUCHAY_TOKEN"`; Cursor —
  `"headers": {"Authorization": "Bearer ${env:VRUCHAY_TOKEN}"}`; Claude Code
  (`.mcp.json`) — `"Bearer ${VRUCHAY_TOKEN}"`.
- Если человек всё же прислал токен в чат, не повторяй его в ответах и посоветуй
  потом выдать новый, а этот отозвать.

## Если MCP нет совсем

Работай с REST API: `https://vruchay.ru/api`, заголовок
`Authorization: Bearer <токен>` (как получить токен — выше). Порядок работы
тот же. Сценарии с запросами REST — в [AGENTS.md](AGENTS.md).

## Если не получается

| Что видно | Что делать |
| --- | --- |
| Клиент пишет, что сервер не отвечает или нужна авторизация | Пройти вход (шаг 4). Не помогло — убрать сервер и добавить заново |
| «Ссылка для подключения не подходит» | Начать вход заново из приложения |
| «У организации уже десять подключённых агентов» | Человек отключает лишние в «Настройки → Интеграции и API → Токены API» |
| `401` после того, как всё работало | Подключение отозвали в кабинете — войти заново |
| claude.ai или chatgpt.com не открываются | Это ограничение самих сервисов в регионе, «Вручай» тут ни при чём. Подключи «Вручай» к другому клиенту, который у человека работает, или работай по REST |

---

<!-- mcp -->
# Подключение агента по MCP

На сайте «Вручай» работает сервер MCP (Model Context Protocol). Агенту не нужно
изучать REST и собирать запросы руками: он видит готовые инструменты — «создать
документ», «свёрстать лист», «посмотреть превью», «выпустить», «дать ссылку
на архив» — и вызывает их сам. Здесь собрано, как подключить сервер к Claude,
ChatGPT, Claude Code, Cursor, VS Code, Codex, Gemini CLI и другим клиентам, какие
инструменты он даёт и что делать, если подключение не заработало.

**Быстрее всего — поручить подключение самому агенту.** Скажите ему одну фразу:

```text
Подключи себе сервис «Вручай» по инструкции https://vruchay.ru/docs/connect.md и проверь подключение вызовом get_account.
```

Агент в Claude Code, Codex, Gemini CLI, VS Code или Cursor добавит сервер сам
и попросит вас один раз войти в кабинет и нажать «Разрешить». В Claude.ai
и ChatGPT он подскажет, где нажать. Инструкция для агента — [connect.md](connect.md).

## Адрес

```
https://vruchay.ru/mcp
```

- Транспорт — Streamable HTTP. Сервер отвечает на каждый `POST` сразу одним JSON,
  без потока событий и без сессий: заголовок `Mcp-Session-Id` не выдаётся и не нужен.
- `GET` и `DELETE` отвечают `405` — так и задумано: уведомлений сервер не шлёт,
  закрывать нечего.
- Версии протокола: новая `2026-07-28` (без рукопожатия, с `server/discover`)
  и прежние `2025-11-25`, `2025-06-18`, `2025-03-26` (с `initialize`) — на одном
  адресе, клиент ничего не настраивает.
- Сервер даёт инструменты (`tools`) и готовые задания (`prompts`). Ресурсов
  и пачек запросов нет.
- Права и данные — те же, что у REST по тому же токену: инструменты вызывают наш
  же API изнутри сервера, поэтому роли, возможности плана, проверка полей и лимиты
  работают одинаково.

Протокол по шагам — в справочнике: [`POST /mcp`](endpoints/mcp/post-mcp.md).

Готовые настройки показывает и кабинет: карточка «ИИ-агенты» в **Настройки →
Интеграции и API** и блок «Подключить ИИ-агента» на главной базы знаний
(`https://vruchay.ru/docs`) — команды для каждого клиента, кнопки установки
в Cursor и VS Code и промпт для агента.

## Два способа войти

### Вход через OAuth — основной

Так подключаются все клиенты из списка ниже — без токена:

1. Клиент обращается к `/mcp` без токена и получает `401` с заголовком
   `WWW-Authenticate: Bearer resource_metadata="https://vruchay.ru/.well-known/oauth-protected-resource/mcp"`.
2. По этому адресу он находит сервер авторизации, регистрируется сам
   (Dynamic Client Registration) и открывает в браузере `https://vruchay.ru/oauth/authorize`.
3. Человек входит в кабинет (если ещё не вошёл) и видит, какое приложение
   просит доступ, к какой организации и куда вернётся разрешение.
4. Выбирает права — «Выпускать документы» или «Полный доступ» — и нажимает «Разрешить».
5. Клиент получает токен и дальше работает с ним сам.

Разрешить может только владелец или управляющий, и только если доступ по API
входит в план (на бесплатной пробе он есть). Подключение появляется в «Токенах API»
строкой «Агент «Claude»» (название — то, которым представилось приложение);
отключить его — кнопка «Отозвать» там же. Подключение личное: если человека, который
его разрешил, убирают из организации или понижают до сотрудника, агент отключается
(токены, выданные руками, остаются — они принадлежат организации). Повторное подключение того же приложения
тем же человеком заменяет прежний токен, а не добавляет второй; разные приложения
на одном компьютере (Claude Code и Codex) друг друга не вытесняют. Подключений через
OAuth у организации — не больше десяти, отдельно от десяти обычных токенов.

Адреса этого входа описаны в справочнике:
[описание сервера авторизации](endpoints/oauth/get-well-known-oauth.md),
[регистрация клиента](endpoints/oauth/post-oauth-register.md); как он устроен
и чем ограничен — в [Аутентификации](authentication.md).

Клиент представляется автоматической регистрацией (Dynamic Client
Registration, DCR): Claude, ChatGPT и остальные делают это сами. Если приложение
спрашивает, как ему представиться, выберите DCR («Register automatically»).
Вход по адресу описания клиента (Client ID Metadata Document, CIMD) сервер
не объявляет: описания Claude
и ChatGPT лежат на их сайтах, а те закрыты для российских адресов.

### Токен API — для программ без OAuth

Нужен своим скриптам, интеграциям и клиентам, которые не умеют входить через
OAuth. Токен выдаёт владелец или управляющий в кабинете: **Настройки → Интеграции и API →
Токены API** → «Выдать токен». Права на выбор:

- «Выпускать документы» (`member`) — документы, вёрстка, получатели, выпуск, письма;
- «Полный доступ» (`admin`) — то же плюс формы выдачи, домены почты, удаление,
  отзыв и перевыпуск документов.

Полный токен показывается один раз. Агент передаёт его в каждом запросе:

```
Authorization: Bearer vru_ВАШ_ТОКЕН
```

Cookie кабинета на `/mcp` не принимаются: вход только заголовком. Подробнее
о токенах — в [Аутентификации](authentication.md).

## Настройка клиентов

Ниже — проверенные настройки на октябрь 2026 года. Интерфейсы приложений меняются;
если пункт меню называется иначе, ищите раздел про коннекторы или серверы MCP.
У каждого клиента сначала вход через OAuth — токен не нужен. Вариант с токеном —
для программ без OAuth: токен кладите в переменную окружения `VRUCHAY_TOKEN`,
а не в файл, который попадёт в репозиторий.

### Claude.ai и Claude Desktop

Вход через OAuth, токен не нужен. Коннектор привязан к учётной записи Claude:
добавленный на claude.ai, он появляется и в Claude Desktop.

1. Откройте **Customize → Connectors** (`https://claude.ai/customize/connectors`).
2. Нажмите **Add custom connector** (на тарифах Pro и Max — «+ Add» →
   «Add custom connector»).
3. Название — «Вручай», адрес — `https://vruchay.ru/mcp`.
4. **Authentication** — «Sign in now» или «Sign in when needed».
   **OAuth client** — «Register automatically». Вариант «Use Claude's published
   identity» у нас не сработает: описание клиента Claude лежит на claude.ai,
   а тот закрыт для российских адресов. Если диалог одним экраном, с полями
   Client ID и Client Secret в «Advanced settings», оставьте их пустыми.
5. Нажмите **Add**, затем **Connect**: откроется страница «Вручай» — войдите
   и разрешите доступ.
6. В чате: кнопка **+** → **Connectors** → включите «Вручай».

На бесплатном тарифе Claude можно добавить один свой коннектор. На тарифах
Team и Enterprise коннектор добавляет владелец рабочего пространства:
**Organization settings → Connectors** → «Add» → «Custom» → «Web», с тем же адресом;
каждый участник потом нажимает **Connect** у себя в Customize → Connectors
и входит в «Вручай» сам.

У некоторых организаций Claude есть раздел **Request headers** (бета). Через него
можно подключиться токеном вместо входа: Authentication — «No sign-in», заголовок
`authorization`, значение `Bearer vru_ВАШ_ТОКЕН` (со словом `Bearer` и пробелом).
Тогда все участники рабочего пространства Claude работают одним токеном.

Источник: [Add a connector that isn't in the directory](https://claude.com/docs/connectors/custom/add-unlisted).

### ChatGPT

Вход через OAuth. Свой сервер MCP в ChatGPT подключается в режиме разработчика,
и права на это зависят от тарифа:

- **Business** — режим разработчика включает себе сам владелец или администратор
  рабочего пространства: Workspace Settings → Permissions & Roles → Connected Data →
  «Developer mode / Create custom MCP connectors»;
- **Enterprise и Edu** — Settings → Apps → Advanced settings → Developer mode;
- **Plus и Pro** — режим разработчика есть, но полная поддержка MCP с изменяющими
  инструментами объявлена для Business, Enterprise и Edu; проверьте на своей
  учётной записи.

Без поддержки изменяющих инструментов агент сможет только смотреть: документы,
реестр, превью.

Подключение:

1. Откройте **Plugins** (`https://chatgpt.com/plugins`), нажмите **+** →
   **Add custom MCP server**. В прежнем интерфейсе — Settings → Apps → «Create app».
2. Название — «Вручай», описание — «Выпуск именных сертификатов и грамот».
3. **Connection** — Public endpoint, адрес `https://vruchay.ru/mcp`.
4. **Authentication** — OAuth. Если ChatGPT предлагает выбрать способ регистрации
   клиента, выберите динамическую регистрацию (DCR).
5. Подтвердите предупреждение о рисках («I understand and want to continue»)
   и создайте («Create as a plugin»). ChatGPT откроет страницу «Вручай» —
   войдите и разрешите доступ.
6. В новом чате наберите `@` и выберите «Вручай».

Изменяющие инструменты ChatGPT выполняет только после вашего подтверждения.
Инструменты, которые только читают, помечены у нас `readOnlyHint` и подтверждения
не просят. Если инструменты на сервере поменялись — Plugins → «Вручай» → **Refresh**.

Источники: [Add custom MCP server](https://developers.openai.com/api/docs/guides/custom-mcp-server),
[Connect from ChatGPT](https://developers.openai.com/apps-sdk/deploy/connect-chatgpt),
[Developer mode and MCP apps in ChatGPT](https://help.openai.com/en/articles/12584461-developer-mode-and-mcp-apps-in-chatgpt).

### Claude Code

Одна команда; Claude Code может выполнить её сам, если попросить:

```bash
claude mcp add --transport http --scope user vruchay https://vruchay.ru/mcp
```

Затем войдите: в Claude Code — `/mcp` → «vruchay» → Authenticate, или в терминале
`claude mcp login vruchay`. Откроется страница «Вручай» — войдите и разрешите доступ.
`--scope user` делает сервер видимым во всех проектах; без него — только в текущем.

Подсказки сервера видны командами: `/mcp__vruchay__design_sheet`,
`/mcp__vruchay__issue_from_table` и другие.

С токеном — для общего на команду `.mcp.json` в корне проекта (`--scope project`):
токен не вписывают, а ссылаются на переменную — каждый держит свою:

```json
{
  "mcpServers": {
    "vruchay": {
      "type": "http",
      "url": "https://vruchay.ru/mcp",
      "headers": { "Authorization": "Bearer ${VRUCHAY_TOKEN}" }
    }
  }
}
```

Если задан свой заголовок `Authorization`, Claude Code пользуется им и OAuth
не запускает.

Проверка: `/mcp` показывает «vruchay» подключённым, инструменты называются
`mcp__vruchay__get_account` и так далее.

Источник: [Connect Claude Code to tools via MCP](https://code.claude.com/docs/en/mcp).

### Cursor

Проще всего — кнопкой «Добавить в Cursor» в кабинете (карточка «ИИ-агенты»).
Вручную — `~/.cursor/mcp.json` (для всех проектов) или `.cursor/mcp.json`
(для одного):

```json
{
  "mcpServers": {
    "vruchay": { "url": "https://vruchay.ru/mcp" }
  }
}
```

Без заголовков Cursor предложит войти через OAuth: у сервера в настройках MCP
появится кнопка входа.

С токеном — `"headers": { "Authorization": "Bearer ${env:VRUCHAY_TOKEN}" }`.
Переменную задайте в профиле оболочки или в системе: `envFile` у удалённых
серверов Cursor не работает, а запущенный из Dock Cursor профиль оболочки не читает.

Источники: [Cursor — Model Context Protocol](https://cursor.com/docs/context/mcp),
[MCP Install Links](https://cursor.com/docs/context/mcp/install-links).

### VS Code и GitHub Copilot

Кнопкой «Добавить в VS Code» в кабинете или одной командой — сервер добавится
в профиль пользователя:

```bash
code --add-mcp '{"name":"vruchay","type":"http","url":"https://vruchay.ru/mcp"}'
```

При первом запуске сервера VS Code спросит, доверяете ли вы ему, и предложит
войти через OAuth. Инструменты доступны в чате Copilot в режиме агента.

С токеном — `.vscode/mcp.json` в проекте. Токен VS Code спросит при первом запуске сервера
и сохранит в защищённом хранилище — в файл он не попадёт:

```json
{
  "inputs": [
    {
      "type": "promptString",
      "id": "vruchay-token",
      "description": "Токен API «Вручай»",
      "password": true
    }
  ],
  "servers": {
    "vruchay": {
      "type": "http",
      "url": "https://vruchay.ru/mcp",
      "headers": { "Authorization": "Bearer ${input:vruchay-token}" }
    }
  }
}
```

Для всех проектов — палитра команд → «MCP: Open User Configuration», тот же
фрагмент.

Источник: [MCP configuration reference](https://code.visualstudio.com/docs/agents/reference/mcp-configuration).

### OpenAI Codex CLI

```bash
codex mcp add vruchay --url https://vruchay.ru/mcp
codex mcp login vruchay
```

Первая команда пишет сервер в `~/.codex/config.toml`, вторая открывает вход.

С токеном — в `~/.codex/config.toml` только имя переменной окружения, сам токен
задайте перед запуском: `export VRUCHAY_TOKEN=vru_ВАШ_ТОКЕН`.

```toml
[mcp_servers.vruchay]
url = "https://vruchay.ru/mcp"
bearer_token_env_var = "VRUCHAY_TOKEN"
```

Источник: [Codex — MCP](https://learn.chatgpt.com/docs/extend/mcp?surface=cli).

### Windsurf (Devin Desktop)

Windsurf со 2 июня 2026 года называется Devin Desktop. Файл настроек MCP —
`mcp_config.json`; где он лежит, зависит от версии (`~/.config/devin/mcp_config.json`
в новых, `~/.codeium/windsurf/mcp_config.json` в прежних), поэтому надёжнее открыть
его из настроек MCP в самом приложении:

```json
{
  "mcpServers": {
    "vruchay": {
      "serverUrl": "https://vruchay.ru/mcp",
      "headers": { "Authorization": "Bearer ${env:VRUCHAY_TOKEN}" }
    }
  }
}
```

Источники: [Devin Desktop — MCP](https://docs.devin.ai/desktop/cascade/mcp),
[Devin Desktop FAQ](https://docs.devin.ai/desktop/devin-desktop-faq).

### Gemini CLI

```bash
gemini mcp add --transport http --scope user vruchay https://vruchay.ru/mcp
```

Затем в Gemini CLI — `/mcp auth vruchay`: откроется вход в «Вручай».
Без `--scope user` сервер запишется в настройки текущего проекта.

С токеном — в `~/.gemini/settings.json` (или `.gemini/settings.json` в проекте);
для Streamable HTTP ключ — `httpUrl`, а не `url`; переменные окружения
подставляются в любые строки файла:

```json
{
  "mcpServers": {
    "vruchay": {
      "httpUrl": "https://vruchay.ru/mcp",
      "headers": { "Authorization": "Bearer $VRUCHAY_TOKEN" }
    }
  }
}
```

Для бесплатных учётных записей и Google One Gemini CLI с 18 июня 2026 года
заменён на Antigravity CLI — там настройка своя.

Источники: [Gemini CLI — MCP servers](https://geminicli.com/docs/tools/mcp-server/),
[Gemini CLI — configuration](https://geminicli.com/docs/reference/configuration/).

### Любой другой клиент

Подойдёт любой клиент MCP с транспортом Streamable HTTP: адрес
`https://vruchay.ru/mcp`. Клиенты с OAuth находят вход сами по
`/.well-known/oauth-protected-resource/mcp` и `/.well-known/oauth-authorization-server`;
остальным — заголовок `Authorization: Bearer vru_ВАШ_ТОКЕН`.

Если клиент умеет запускать только локальные серверы (stdio), поможет сторонний
мост [mcp-remote](https://github.com/geelen/mcp-remote) — он запускается через
`npx` и пересылает вызовы на удалённый сервер:

```json
{
  "mcpServers": {
    "vruchay": {
      "command": "npx",
      "args": [
        "-y",
        "mcp-remote",
        "https://vruchay.ru/mcp",
        "--header",
        "Authorization:${VRUCHAY_AUTH}"
      ],
      "env": { "VRUCHAY_AUTH": "Bearer vru_ВАШ_ТОКЕН" }
    }
  }
}
```

Пробелов вокруг двоеточия в `--header` нет намеренно: часть клиентов (Cursor,
Codex, Claude Desktop под Windows) ломает аргументы с пробелами, а в переменной
окружения пробел допустим.

Если MCP недоступен вовсе, агент работает с [REST API](README.md) по тому же
токену — сценарии в [AGENTS.md](AGENTS.md) расписаны для обоих путей.

## Проверить подключение

Без клиента, одним запросом — вызов инструмента `get_account`:

```bash
curl -s https://vruchay.ru/mcp \
  -H "Authorization: Bearer $VRUCHAY_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json, text/event-stream" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"get_account","arguments":{}}}'
```

В ответе — организация, условия (`left`, `expired`, `features`) и проекты.
Список инструментов со схемами — тот же запрос с `"method":"tools/list"`.

## Инструменты

Первым агент вызывает `get_account`, за инструкцией идёт в `get_guide`. Чего нет
отдельным инструментом — делает через `api_request` по [справочнику](https://vruchay.ru/llms.txt).

| Инструмент | Что делает | Меняет данные | Тратит квоту |
| --- | --- | --- | --- |
| `get_account` | Кто подключён, условия (`left`, `expired`, `features`) и проекты | нет | нет |
| `get_guide` | Инструкция: `start`, `layout`, `layout_reference`, `integrations`, `recipes`, `mcp`, `errors` | нет | нет |
| `get_layout_catalog` | Единицы, форматы листа, шрифты, готовые раскладки, служебные поля | нет | нет |
| `list_documents` | Документы, шаблоны или корзина организации, отбор по проекту и поиску | нет | нет |
| `get_document` | Карточка документа, листы, поля и последние выпуски; блоки сжаты, `full: true` — макет целиком | нет | нет |
| `create_document` | Новый документ: формат, готовая раскладка (`preset`), копия шаблона (`templateId`) или своего документа (`copyOf`, с `asTemplate` — шаблоном) | да | нет |
| `update_document` | Название, размер листа, мероприятие, дата выдачи, постоянные поля, срок, проект | да | нет |
| `delete_document` | В корзину или из корзины (`restore`) | да | нет |
| `create_project` | Новый проект — мероприятие или сайт | да | нет |
| `edit_layout` | Добавить, поменять и убрать блоки листа одним вызовом; в ответе — замечания | да | нет |
| `set_layout` | Записать лист целиком | да | нет |
| `apply_preset` | Свёрстать лист по готовой раскладке | да | нет |
| `add_sheet` | Ещё один лист (например, оборот) | да | нет |
| `delete_sheet` | Удалить лист | да, необратимо | нет |
| `upload_image` | Скачать PNG или JPEG по ссылке: картинка блока или фон листа | да | нет |
| `get_upload_link` | Одноразовая ссылка на 15 минут: агент с оболочкой загружает curl'ом файл с диска — фон, картинку блока или таблицу получателей | да | нет |
| `check_sheet` | Что не так с вёрсткой: край листа, не влезает текст, блоки наложились, неизвестное поле или шрифт, мелкий QR | нет | нет |
| `preview_sheet` | Картинка листа, как он напечатается | нет | нет |
| `get_recipients` | Колонки и строки таблицы получателей, страницами | нет | нет |
| `import_recipients` | Таблица: дописать (`append`) или заменить (`replace`); за вызов до 2 МБ, больше — частями | да | нет |
| `edit_recipients` | Точечно: добавить, поправить, удалить строки, отметить все; `applySuggestions` — принять исправления проверки | да | нет |
| `validate_recipients` | Пустые имена, кривая почта, дубли, не влезающий текст — с предложенными исправлениями | нет | нет |
| `start_generation` | Выпуск по отмеченным строкам, асинхронно; `resume` — доделать прерванный | да | **да** |
| `get_job` | Ход выпуска; `waitSeconds` — подождать до 60 с; причины сбоев | нет | нет |
| `cancel_job` | Остановить выпуск; недошедшие строки квоту не тратят | да | нет |
| `get_download_link` | Ссылка на архив ZIP или общий PDF, действует час; `name` — имена файлов, например `"%name"` | нет | нет |
| `issue_document` | Один документ одному человеку, по желанию — с письмом | да | **да** |
| `list_forms` | Формы выдачи с готовой ссылкой и кодом вставки | нет | нет |
| `setup_form` | Создать или изменить форму на сайте или по ссылке, дневной предел выдачи | да | каждая заявка с формы |
| `set_mail_template` | Шаблон письма с документом | да | нет |
| `get_mail` | Письмо документа: шаблон, кому уйдёт сейчас и кому нет, что с отправленными | нет | нет |
| `send_mail` | Разослать (уже получившим — не повторно), повторить недоставленные или переслать выданные | да, необратимо | нет |
| `search_registry` | Реестр выданного: поиск и отбор; `fileId` — карточка и ссылка на файл | нет | нет |
| `revoke_documents` | Отозвать проверку подлинности: сначала предпросмотр, затем с подтверждённым числом | да | нет |
| `reissue_documents` | Перевыпустить выданные по исправленным строкам | да | **да** |
| `api_request` | Любой другой запрос к API, кроме закрытых разделов | по запросу | по запросу |

`setup_form`, `revoke_documents`, `reissue_documents` и `delete_document` (в корзину) требуют
прав «Полный доступ», остальное — и возврат из корзины (`restore: true`) — доступно и с правами «Выпускать документы».

Клиент видит у каждого инструмента пометки риска (`annotations`): только читает,
может стереть или заменить, безопасен при повторе, выходит наружу (письма,
чужие ссылки). По ним Claude и ChatGPT решают, спрашивать ли вас перед вызовом.

### Файлы с диска

Бланк, логотип или таблица часто лежат у агента файлом, а не в интернете.
Передать их аргументом инструмента нельзя: MCP ходит JSON-ом, а мегабайт
base64 модель не напишет. Агенту с оболочкой — Claude Code, Codex, Cursor,
VS Code — сервер даёт ссылку на загрузку:

1. `get_upload_link` с `documentId` и целью: `background` (с `sheetId`) — фон
   листа, `element` — картинка для блока, `recipients` — таблица получателей
   (`mode`: `append` — дописать, `replace` — заменить; `headers` — как понимать
   первую строку).
2. В ответе — готовая команда. Агент подставляет путь и выполняет её:

   ```bash
   curl -sS -F "file=@бланк.png" "https://vruchay.ru/api/v1/uploads/u.…"
   ```

3. curl печатает итог JSON-ом. У картинок — `fileId`, `widthPx`, `heightPx`:
   фон уже стоит на листе, дальше `preview_sheet`; картинку блока ставят
   в `props.fileId` через `edit_layout`. У таблицы — сколько строк загружено
   и как названы колонки, дальше `validate_recipients`.

Ссылка действует 15 минут и принимает один файл: повтор отвечает `410`,
за следующим файлом — новая ссылка. Файл не того типа ссылку не тратит.
Типы и размеры — те же, что у загрузки в кабинете: PNG или JPEG до 20 МБ,
таблица `.xlsx` или `.csv` до 10 МБ. Токена в команде нет: права — того токена,
который выдал ссылку, и отозванный токен гасит её. В журнал действий загрузка
попадает с автором — токеном или подключённым агентом; имени файла и строк
таблицы там нет. Без оболочки (Claude.ai, ChatGPT) — `upload_image`
по публичной ссылке или кабинет. Справочник —
[`POST /api/uploads`](endpoints/documents/post-uploads.md) и
[`POST /api/v1/uploads/{link}`](endpoints/public/post-v1-uploads-link.md).

### Готовые задания (подсказки)

Сервер отдаёт клиенту подсказки (`prompts`) — те же задания, что на странице
[Готовые тексты для агента](agent-prompt.md): `do_task`, `design_sheet`,
`issue_from_table`, `site_form`, `crm_integration`, `new_event`, `fix_issued`. В Claude Code
это команды `/mcp__vruchay__design_sheet` и другие: выберите, допишите своими
словами, что нужно, — и агент получит задание целиком. Подробности о каждом сценарии — в [AGENTS.md](AGENTS.md),
о вёрстке — в [руководстве по вёрстке](layout-guide.md).

## Как выглядят ответы

- **Обычный ответ** — один текстовый блок с JSON. Ответы сжаты: у листа — id,
  место и начало текста каждого блока, а не всё дерево; полный макет —
  `get_document` с `full: true`.
- **Превью** — картинка JPEG (`type: "image"`) и короткая подпись. Код и номер
  на превью — образцы.
- **Ошибка** — ответ с `isError: true` и текстом по-русски:
  `Ошибка 400: …`, следом `Поля: [...]` с путём и причиной по каждому полю.
  У `edit_recipients` — ещё `Успело выполниться: {...}`, если часть правок прошла.
  По этому тексту агент исправляет запрос сам.
- **Ошибки протокола** — JSON-RPC `error`: неизвестный инструмент (`-32602`),
  неверный JSON-RPC (`-32600`), неизвестный метод (`-32601`), заголовки не совпали
  с телом (`-32020`), незнакомая версия (`-32022`). Подробно — в справочнике
  [`POST /mcp`](endpoints/mcp/post-mcp.md).
- **Длинный ответ** обрезается на 80 000 знаках с припиской «сузьте запрос».
- `api_request` возвращает только JSON. Файлы (архив, CSV, PDF) через MCP не
  передаются — для готовых документов есть `get_download_link`, а файл
  с диска приносит curl по ссылке из `get_upload_link`.
- Тело `api_request` — тоже только JSON. Адреса, которые ждут файл
  (`multipart/form-data`: картинка, фон листа, логотип, разбор таблицы
  из файла), через него не вызвать. Файл с диска — через `get_upload_link`,
  картинка из интернета — ссылкой в `upload_image`, таблица — строками
  в `import_recipients`. Страница в справочнике REST ещё не значит, что адрес
  доступен агенту: закрытые разделы перечислены ниже, а роли и возможности
  плана действуют как у токена.

## Ограничения

| Что | Предел |
| --- | --- |
| Запросы к `/mcp` (любые: вызовы, список, рукопожатие) | 300 в минуту на организацию |
| Тело запроса к `/mcp` | 2 МБ; таблица больше — частями (`mode: "append"`) или файлом через `get_upload_link` |
| `preview_sheet` | 30 в минуту на организацию; отрисовка — до 30 секунд |
| `upload_image` | 30 в минуту на организацию; PNG или JPEG до 20 МБ |
| Загрузка по ссылке из `get_upload_link` | ссылка живёт 15 минут и принимает один файл; 30 загрузок в минуту с одного адреса; PNG или JPEG до 20 МБ, таблица до 10 МБ |
| `issue_document` | 120 в минуту на организацию; ответ ждёт готовый документ до 25 секунд |
| `validate_recipients` | 40 проверок за 5 минут на организацию |
| `get_job` | ждёт завершения не дольше 60 секунд за вызов |
| `get_download_link` | ссылка живёт час; скачивание — 30 в минуту с одного адреса |

Сверх лимита запросов к `/mcp` — `429` с числом секунд в тексте и в заголовке `Retry-After`. Внутри инструментов действуют те же
пределы, что у REST: см. [Ограничения частоты](rate-limits.md). Сверх них инструмент отвечает ошибкой
«Ошибка 429: Слишком много попыток. Повторите через N с.» — секунды только в тексте, заголовка у инструмента нет.

## Чего агент не может

- Управлять людьми: приглашать и удалять сотрудников, менять роли и пароли.
- Выдавать и отзывать токены, разрешать подключения OAuth.
- Платить: оплата картой и счета — только человек в кабинете.
- Удалить документ насовсем — только владелец в кабинете.
- Ходить в служебные и публичные адреса: `api_request` не пускает в `/api/auth`
  (кроме `/api/auth/me`), `/api/tokens`, `/api/oauth`, `/api/team`, `/api/team-invite`, `/api/push`,
  `/api/billing/checkout`, `/api/invoices`, `/api/platform`, `/api/leads`,
  `/api/render` и `/api/v1/*`.
- Видеть чужие организации: чужой объект отвечает `404`.

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

## Если не работает

| Что видно | Почему | Что делать |
| --- | --- | --- |
| `401`, клиент просит войти снова | Токена нет, он отозван или заменён новым подключением того же приложения | Проверить заголовок: `Authorization: Bearer vru_…`, слово `Bearer` и пробел. Для OAuth — подключиться заново |
| «Couldn't reach the MCP server» (Claude), сервер «failed» или «needs auth» в списке MCP | Вход не пройден или клиент не дошёл до сервера | Пройти вход (`/mcp` в Claude Code, Connect в Claude.ai). Не помогло — удалить сервер и добавить заново. Проверка без клиента — `curl` из раздела «Проверить подключение» |
| Страница «Нужны права управляющего» | Подключать агентов может владелец или управляющий | Попросить их подключить или выдать права |
| «Ссылка для подключения не подходит» | Устаревшая или повреждённая ссылка разрешения | Начать подключение в приложении заново |
| «Неизвестное приложение: client_id не наш или повреждён» | Клиент назвал client_id, которого сервер не выдавал, или его описание по адресу недоступно, не https или не называет себя этим адресом | Подключиться заново; если не помогает — выбрать автоматическую регистрацию клиента |
| «У организации уже десять подключённых агентов» | Предел подключений через OAuth | Отозвать лишние в «Токенах API» |
| `403` у инструмента | Не хватает прав (`setup_form` — «Полный доступ»), возможности нет в плане или срок плана вышел | Объяснить человеку; повтор не поможет |
| `409` у правки листа | Лист изменился с тех пор, как агент его читал | Перечитать `get_document` и повторить правку с новой версией |
| `429` | Слишком часто | Подождать: у запроса к `/mcp` секунды в `Retry-After`, у инструмента — в тексте ошибки («Повторите через N с.») |
| `413` | Тело больше 2 МБ | Таблицу — частями с `mode: "append"` или файлом через `get_upload_link` |
| `410` в ответе curl | Ссылка на загрузку истекла (15 минут) или уже приняла файл | Взять новую `get_upload_link` |
| `405` на `GET /mcp` | Клиент ждёт старый транспорт HTTP+SSE (2024 года) | Выбрать в клиенте Streamable HTTP или обновить его |
| claude.ai или chatgpt.com не открываются | Сами сервисы закрыты для российских адресов, «Вручай» тут ни при чём | Подключить «Вручай» к тому агенту, который у вас работает (Claude Code, Codex, Cursor, VS Code…), — серверу всё равно, откуда клиент, — или работать по REST |
| Токен попал в чат, файл или репозиторий | — | Отозвать его в «Токенах API» и выдать новый; агентам лучше вход через OAuth |
| Инструменты не видны | Коннектор выключен в этом чате или клиент не обновил список | Claude — «+» → Connectors; ChatGPT — Refresh; Claude Code — `/mcp` |
| Не подключается из офисной сети | Прокси или фильтр не пускает на `vruchay.ru` | Разрешить исходящий HTTPS на `vruchay.ru:443`; при подмене сертификатов прокси программам на Node нужен корпоративный корневой сертификат (`NODE_EXTRA_CA_CERTS`) |

Проверить подключение изнутри клиента проще всего просьбой: «вызови get_account».

Claude.ai и ChatGPT обращаются к серверу из своего облака, а не с вашего
компьютера, поэтому офисная сеть на них не влияет. Ответы сервера — обычный JSON,
без потоков, так что буферизующие прокси их не ломают.

Коды и формат ошибок — в [Кодах ошибок](errors.md).

---

<!-- layout-guide -->
# Как свёрстать лист: руководство для агента

Это руководство для программы, которая верстает сертификат, грамоту или диплом сама, без человека у экрана: где стоит каждый блок, каким шрифтом и кеглем он набран, как подставляются ФИО и падежи и как до выпуска убедиться, что всё влезло.

Точный список полей — в [справочнике макета](reference/layout.md), здесь —
как ими пользоваться. Запросы идут на `https://vruchay.ru/api` с заголовком
`Authorization: Bearer $VRUCHAY_TOKEN`; рядом назван инструмент MCP, который
делает то же самое. `…` в адресе ниже означает `/api/documents/{id}`.

## Порядок работы

1. `GET /api/layout/catalog` — единицы, форматы, шрифты, раскладки, умолчания
   блоков (MCP: `get_layout_catalog`).
2. `POST /api/documents` с `format`, `orientation`, при желании `preset`
   (MCP: `create_document`). Запомнить `id` и `sheets[0].id`.
3. `PATCH /api/documents/{id}`: мероприятие (`eventName`, `eventDate`,
   `eventPlace`, `eventHours`) и постоянные поля `constants` (MCP: `update_document`).
4. Загрузить таблицу получателей: проверка меряет текст на её самых длинных
   значениях, а раскладка выбирает строки по её колонкам.
5. Фон и картинки (MCP: `upload_image` по ссылке, файл с диска — `get_upload_link`).
6. Свёрстать: `…/sheets/{sheetId}/preset` или `…/sheets/{sheetId}/elements`
   (MCP: `apply_preset`, `edit_layout`, `set_layout`).
7. `…/check` → исправить → `…/preview` → посмотреть → исправить
   (MCP: `check_sheet`, `preview_sheet`), пока замечаний нет.
8. `POST …/validation` — проверка каждой строки таблицы перед выпуском
   (MCP: `validate_recipients`).

Редактор в кабинете записывает лист с номером версии и правку агента молча
не затрёт: человек с открытым листом увидит «Лист изменили в другом месте»
и выберет, чья версия остаётся. Если в редакторе ничего не ждёт записи, свежий
лист подхватится сам, когда человек вернётся во вкладку. Чтобы обойтись без
выбора, просите не трогать лист, пока агент верстает.

## 1. Как устроен лист

У документа один или несколько **листов** (`sheets` по `position`): нулевой —
лицевая сторона, дальше — оборот и следующие страницы. Лист — это `id`,
`position`, `layout` (до 500 блоков), `backgroundFileId` и `version` (номер
правки). **Размер** один на документ: `pageWidthMm` × `pageHeightMm`,
50…600 мм, по умолчанию 297 × 210 (A4 альбомный).

**Координаты** — мм от левого верхнего угла: `x` вправо, `y` вниз; блок —
прямоугольник `x, y, w, h`. **Кегль** — в pt: 1 pt ≈ 0,3528 мм.

| pt | 8 | 10 | 12 | 14 | 18 | 24 | 36 | 48 | 72 |
|---|---|---|---|---|---|---|---|---|---|
| мм | 2,8 | 3,5 | 4,2 | 4,9 | 6,4 | 8,5 | 12,7 | 16,9 | 25,4 |

- `rotation` — градусы −360…360, **по часовой вокруг центра блока**.
- `z` — целое −10000…10000, по умолчанию 0; больший поверх. **При равных `z`
  поверх тот, кто позже в массиве.**
- `opacity` 0…1 — прозрачность блока целиком.
- `hidden: true` — не печатается, но остаётся в листе; проверка его пропускает,
  кроме повтора `id` (`duplicate_id`): id должен быть единственным среди всех блоков.
- `locked`, `groupId` — замок и группа для мыши в редакторе; на печать
  и API не влияют.
- `name` — подпись в панели слоёв («ФИО», «Рамка»): ставьте, человеку после
  агента так проще.
- `id` — 1–100 символов, уникален на листе. Говорящие id (`title`, `name`,
  `qr`) удобнее: по ним идёт правка и ими проверка называет блоки.

Отрисовка: белый лист → фон → видимые блоки по возрастанию `z`. Всё за краем
листа обрезается. **Текст, не влезший в блок, обрезается по краю блока** —
высоту считают, а не угадывают (раздел 4).

## 2. Формат и ориентация

| `format` | Книжный, мм | Альбомный, мм |
|---|---|---|
| `a3` | 297 × 420 | 420 × 297 |
| `a4` | 210 × 297 | 297 × 210 |
| `a5` | 148 × 210 | 210 × 148 |
| `a6` | 105 × 148 | 148 × 105 |
| `letter` (US Letter) | 216 × 279 | 279 × 216 |
| `legal` (US Legal) | 216 × 356 | 356 × 216 |
| `square` | 210 × 210 | 210 × 210 |
| `wide` (экран 16:9) | 167 × 297 | 297 × 167 |

`POST /api/documents` принимает `format` + `orientation` (`portrait` |
`landscape`) или `pageWidthMm` + `pageHeightMm`. **Формат сильнее миллиметров;
без `orientation` он альбомный.** Одна `orientation` без `format` поворачивает
миллиметры: `{"orientation": "portrait"}` даёт 210 × 297.

```bash
curl -X POST "https://vruchay.ru/api/documents" \
  -H "Authorization: Bearer $VRUCHAY_TOKEN" -H "Content-Type: application/json" \
  -d '{"title": "Сертификаты участников", "format": "a4", "orientation": "landscape"}'
```

Ответ — документ с одним пустым листом и колонками `name`, `email`.
`PATCH /api/documents/{id}` формата не знает, только миллиметры (раздел 13).

## 3. Поля печати

Типография срезает до 3 мм с края, офисный принтер не печатает ближе 5 мм.
Текст и QR держите в 10–15 мм от края, рамку — в 5–10 мм; фон, полосы
и подложки можно тянуть до края. PDF — ровно размер листа, без вылетов:
если типография просит вылеты по 3 мм, сделайте лист на 6 мм больше
(A4 альбомный — 303 × 216). Проверка сообщает `off_page` (блок целиком
за листом), `partly_off_page` (текст, QR или ссылка частью за краем)
и `outside_safe_area` (текст или QR ближе 5 мм к краю).

## 4. Арифметика раскладки

`W`, `H` — лист, `m` — поле, `w`, `h` — блок.

- **Центр:** `x = (W − w) / 2`. На всю ширину между полями: `x = m`,
  `w = W − 2m` (A4 альбомный, `m = 30`: `x = 30`, `w = 237`; A4 книжный,
  `m = 20`: `x = 20`, `w = 170`) и `align: "center"`.
- **Две колонки** с промежутком `g`: `c = (W − 2m − g) / 2`, `x₁ = m`,
  `x₂ = m + c + g` (A4 альбомный, `m = 30`, `g = 17`: `c = 110`, `x₂ = 157`);
  правую выравнивайте `align: "right"`.
- **Стопка:** `y₍ᵢ₊₁₎ = yᵢ + hᵢ + отбивка`; 1–3 мм внутри группы, 6–15 мм
  между группами (шапка, имя, текст, подвал). Стопку поднимайте на 2,5 %
  высоты листа над центром — так делают заготовки: точно по центру она
  кажется съехавшей вниз.
- **Строки на одной высоте** (должность слева, фамилия справа) — равные `y`,
  `h` и `verticalAlign: "bottom"`.

**Высота текстового блока.** Строка — `fontSize × lineHeight × 0,3528` мм.
Буквы занимают 1,3 кегля, и при `lineHeight` меньше 1,3 выносные последней
строки выступают — измеритель прибавляет выступ:

```
h ≥ n × fontSize × lineHeight × 0,3528 + max(0, 1,3 − lineHeight) × fontSize × 0,3528
    + strokeWidth + 2 × (padding + borderWidth)
```

`n` — строк; прибавьте 1–2 мм запаса. Пример 1 ниже (A4 альбомный) посчитан так:

| Блок | Кегль, межстрочный, строк | Расчёт, мм | `y` | `h` |
|---|---|---|---|---|
| Логотип | — | — | 16 | 23 |
| Заголовок | 34 pt, 1,2, 1 | 14,4 + 1,2 = 15,6 | 42 | 17 |
| Подводка | 13 pt, 1,3, 1 | 6,0 | 62 | 8 |
| ФИО | 44 pt, 1,15, 2 | 35,7 + 2,3 = 38,0 | 72 | 40 |
| «принял участие» | 17 pt, 1,3, 1 | 7,8 | 113 | 9 |
| Мероприятие | 20 pt, 1,25, 2 | 17,6 + 0,4 = 18,0 | 123 | 19 |
| Место и даты | 13 pt, 1,3, 1 | 6,0 | 143 | 7 |
| Подписи | 11 pt, 1,25, 2 | 9,7 + 0,2 = 9,9 | 163 | 11 |
| Дата и номер | 10 pt, 1,3, 1 | 4,6 | 181 | 6 |

**Ширина строки** — прикидка: знаков ≈ `w / (fontSize × 0,3528 × k)`, где `k` —
средняя ширина знака строчного русского текста в кеглях: ≈0,5 у PT Sans,
Alegreya, Tinos, Marck Script; 0,53–0,57 у PT Serif, Playfair Display, Lora,
Roboto, Arimo; 0,57–0,6 у Inter, Montserrat, Merriweather; 0,65–0,75
у Unbounded, Comfortaa; ≈0,4 у Oswald, Caveat. Прописные шире на 20–30 %,
`letterSpacing` прибавляется после каждого знака. Точно меряет проверка листа —
по метрикам самих шрифтов.

## 5. Кегли по ролям

Заготовки задают кегли долями от кегля имени и подбирают его под свободное
поле. На A4 альбомном:

| Роль | Пример | Доля | Заготовки | Диапазон, pt |
|---|---|---|---|---|
| Заголовок | «Сертификат», «Диплом» | 0,72 | 31–37 pt, полужирный, разрядка 10 % кегля | 28–40 |
| Подводка | «Награждается», «Настоящим удостоверяется, что» | 0,30 | 13–15 pt, прописные, разрядка 10 % | 12–16 |
| ФИО | `%name`, `%name_dat` | 1 | 43–51 pt, межстрочный 1,15, `autoFit` | 32–54 |
| Команда, город | `%team · %city` | 0,26 | 11–13 pt | 11–14 |
| Текст, мероприятие | «принял участие», `%event` | 0,42 | 18–21 pt, мероприятие полужирным | 16–22 |
| Доп. строки | место и даты, «в объёме 72 ч.» | 0,30 | 13 pt | 12–14 |
| Подписи, дата, номер | `%signer_role`, `%date` | 0,24 | 10–12 pt | 9–12 |
| Подпись к QR, сноски | «Проверить подлинность» | — | — | 7–9 |

На A4 книжном заготовки крупнее примерно в 1,4 раза, но лист уже: имя
крупнее 48 pt держится там только на `autoFit`, спокойнее 32–40 pt. A5 —
кегли × 0,7, A3 — × 1,4; мельче 7 pt не читается. Цвет: заголовок
и подводка — акцент, имя — тёмный тон акцента, текст — тёмно-серый или
тёмно-коричневый, не чистый чёрный (`#8B2020`, `#7A1E1E`, `#4A3628`).

## 6. Шрифты

Печатаются только гарнитуры набора (файлы, кириллица, метрики; тот же список —
`fonts` в каталоге):

| Группа (`id`) | Полужирный и курсив | Только полужирный | Только обычное |
|---|---|---|---|
| Без засечек (`sans`) | PT Sans, Inter, Montserrat, Roboto, Open Sans, Arimo, Alegreya Sans | Comfortaa, Oswald | — |
| С засечками (`serif`) | PT Serif, Lora, Playfair Display, Merriweather, Alegreya, Alegreya SC, Tinos | — | Alice |
| Крупные жирные (`display`) | — | Unbounded, Unbounded ExtraBold, Unbounded Black | Dela Gothic One, Rubik Mono One, Ruslan Display |
| Рукописные (`script`) | — | Caveat, Amatic SC | Marck Script, Bad Script, Lobster, Pacifico, Pangolin, Pattaya |

- Файлы есть только у начертаний 400 и 700; недостающий полужирный или
  курсив браузер нарисует сам, грубо. `fontWeight` в разметке кроме 400 и 700
  возьмёт ближайший файл.
- Arimo и Tinos — замена Arial и Times New Roman с теми же ширинами (самих их
  нет). Широкие — Montserrat, Merriweather, Unbounded, Comfortaa; узкие —
  Oswald, Caveat, Amatic SC; Alegreya SC — капитель.
- Иное имя схема пропустит, но печать подставит системный шрифт, а проверка
  ответит `unknown_font`. Пишите имя ровно как в таблице.

| Стиль заготовки | Заголовок, подводка | ФИО | Текст |
|---|---|---|---|
| `classic` | PT Serif | Playfair Display | PT Serif |
| `solemn` | Playfair Display | Playfair Display | Lora |
| `academic` | Lora | Lora | Lora |
| `strict` | PT Sans | PT Sans | PT Sans |
| `modern` | Montserrat | Montserrat | Inter |
| `script` | PT Serif | Marck Script | PT Serif |
| `handwritten` | PT Sans | Caveat | PT Sans |

Не больше двух-трёх гарнитур на лист; рукописные — только для имени, без
прописных и разрядки; `display` — только для короткого заголовка.

## 7. Блоки

Умолчания — в каталоге (`elements`); непереданное свойство сервер дописывает
и хранит лист полным.

### Текст (`type: "text"`)

```json
{"id": "name", "type": "text", "name": "ФИО", "x": 30, "y": 72, "w": 237, "h": 40, "z": 5,
 "props": {"text": "%name", "fontFamily": "Playfair Display", "fontSize": 44, "bold": true, "color": "#7A1E1E",
  "align": "center", "verticalAlign": "middle", "lineHeight": 1.15, "letterSpacing": 0, "autoFit": true}}
```

| Свойство | Умолчание | Что делает |
|---|---|---|
| `text` | — | Строка: `%поле` — подстановка, `\n` — новый абзац; сервер превращает её в `doc` |
| `doc` | — | Дерево с оформлением кусков (ниже). Нужно одно из `text`, `doc` |
| `fontFamily` | `PT Sans` | Раздел 6 |
| `fontSize` | 16 | pt, до 500 |
| `color` | `#000000` | `#RRGGBB` |
| `align` / `verticalAlign` | `center` / `middle` | `left`, `center`, `right`, `justify` / `top`, `middle`, `bottom` |
| `lineHeight` | 1.2 | **Множитель** кегля |
| `letterSpacing` | 0 | Разрядка **в pt** после каждого знака, может быть < 0 |
| `bold`, `italic`, `underline`, `uppercase` | `false` | Весь блок; прописные — оформлением, данные не меняются |
| `strokeWidth`, `strokeColor` | 0, `#ffffff` | Обводка букв, мм, 0…2 — текст на пёстром фоне |
| `autoFit` | `false` | Ужимать текст, если не влезает |
| `padding` | 0 | Отступ текста от краёв, мм, 0…50 |
| `background` | `null` | Заливка блока; `null` — прозрачный |
| `borderWidth`, `borderColor` | 0, `#000000` | Рамка блока, мм, 0…5 |
| `shadow` | `false` | Мягкая тень под буквами |

Тексту остаётся `w − 2 × (padding + borderWidth)` в ширину и столько же меньше
в высоту. **`autoFit`** только ужимает, ступенями: кегль до 65 %, межстрочный
до 1,0 (не больше чем на пятую часть), кегль до 50 %, разрядка до −3 % кегля.
Слова не рвутся: длинная фамилия ужимает кегль, пока не встанет в ширину
целиком. Не влезло и так — обрежется, проверка скажет `text_overflow`. Включайте его
у всего, что приходит из данных; кегль ставьте как для обычного имени, блок —
с запасом на две строки.

### Дерево текста (`doc`)

Для разного оформления в одном блоке. Формат — как у ProseMirror:

```json
{"type": "doc", "content": [
  {"type": "paragraph", "attrs": {"align": "center"}, "content": [
    {"type": "text", "text": "за "},
    {"type": "mergeField", "attrs": {"source": "place_word", "format": "upper"},
     "marks": [{"type": "bold"}, {"type": "textStyle", "attrs": {"color": "#8A6A22"}}]},
    {"type": "text", "text": " место"},
    {"type": "hardBreak"},
    {"type": "mergeField", "attrs": {"source": "team", "fallback": "в личном зачёте"}, "marks": [{"type": "italic"}]}]},
  {"type": "paragraph", "content": [
    {"type": "text", "text": "Проверить: vruchay.ru/c/"},
    {"type": "mergeField", "attrs": {"source": "code"},
     "marks": [{"type": "link", "attrs": {"href": "https://vruchay.ru/c/{{code}}", "underline": false}}]}]}]}
```

- **Абзацы** идут без отбивки; пустой (`"content": []`) — пустая строка.
  `attrs.align` перекрывает выравнивание блока, `attrs.indent` 0…8 — ступени
  по 6 мм. Внутри: `text` (непустой), `mergeField`, `hardBreak`.
- **Списки:** `bulletList` (`marker`: `disc`, `dash`, `circle`, `square`),
  `orderedList` (`numbering`: `decimal-dot`, `decimal-paren`, `upper-roman`,
  `lower-alpha`; `start`) из `listItem` с абзацами; `checked: true/false`
  рисует ☑/☐.
- **Марки** (до 8): `bold`, `italic`, `underline`, `strike`, `superscript`,
  `subscript`; `textStyle` — `color`, `background`, `fontFamily`, `fontSize`
  (pt), `fontWeight`, `letterSpacing`, `wordSpacing` (pt), `transform`
  (`uppercase`, `lowercase`, `smallcaps`); `link` — `href` (`http(s)://`,
  поля `{{code}}`/`%code` кодируются как часть адреса), `underline`, `color`.
- Марка сильнее блока: кусок со своим `textStyle.fontFamily` не сменит
  гарнитуру вслед за блоком. До 1000 узлов, списки до 6 уровней.

### Картинка (`type: "image"`)

`props.fileId` — **картинка блока**, загруженная в документ (MCP:
`upload_image`, `target: "element"`; файл с диска — `get_upload_link` с тем же
`target`, см. раздел 9 «Фон»):

```bash
curl -X POST "https://vruchay.ru/api/documents/$DOCUMENT_ID/assets" \
  -H "Authorization: Bearer $VRUCHAY_TOKEN" -F "file=@logo.png"
curl -X POST "https://vruchay.ru/api/documents/$DOCUMENT_ID/assets/from-url" \
  -H "Authorization: Bearer $VRUCHAY_TOKEN" -H "Content-Type: application/json" \
  -d '{"url": "https://example.ru/logo.png"}'
```

Ответ: `{"fileId": "…", "url": "…", "widthPx": 800, "heightPx": 320}`.

```json
{"id": "logo", "type": "image", "x": 30, "y": 18, "w": 40, "h": 16, "props": {"fileId": "9b8a7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d"}}
```

- **Фон сюда не годится:** его `fileId` отвергается с 404 «Картинка не найдена».
- Картинка **вписывается** без обрезки и искажения, лишнее место пустое.
  Держите пропорции: `h = w × heightPx / widthPx`.
- PNG или JPEG до 20 МБ; подпись и печать — PNG с прозрачностью. Для печати —
  `w / 25,4 × 300` пикселей по ширине. По ссылке — только публичный
  `http(s)` на портах 80/443, до 3 переадресаций, 15 с, 30 загрузок в минуту.
- Без файла лист не напечатается (`missing_image`).
- Картинки ложатся в PDF почти без сжатия: фон и картинки листа вместе держите
  до 4 МБ (`heavy_images`), иначе почта может не принять PDF вложением.

### QR-код (`type: "qr"`)

```json
{"id": "qr", "type": "qr", "x": 244, "y": 151, "w": 30, "h": 30, "props": {"template": "", "color": "#1A1D20",
 "background": "#ffffff", "dotStyle": "square", "cornerStyle": "square", "cornerColor": null, "margin": 0, "level": "M"}}
```

- Пустой `template` — адрес проверки экземпляра `https://vruchay.ru/c/K7M2-9QXR-4TVB`.
  При `verifyEnabled: false` такой QR **не печатается**.
- Свой `template` — до 5000 знаков, поля **только** `%поле`, значения без
  кодирования (в адрес — поля без пробелов и кириллицы:
  `https://example.ru/check?c=%code`). `http(s)`-текст в PDF ещё и кликается.
- **Квадрат от 15 мм**, лучше 20–30; рисуется по меньшей стороне.
- `background` `null` — прозрачная подложка, только на светлом однотонном;
  `dotStyle` `square`/`rounded`/`dots`; `cornerStyle` `square`/`rounded`/`circle`;
  `cornerColor` (`null` — как точки); `margin` 0…4 — белое поле в клетках
  внутри блока (код мельче); `level` `L`/`M`/`Q`/`H` — запас 7/15/25/30 %,
  выше — гуще узор. Точки тёмные на светлом; подпись — отдельным блоком под QR.

### Ссылка (`type: "link"`)

Невидимая кликабельная область в PDF: `props.url` — `http(s)://`, до 2000
знаков, поля `{{code}}`/`%code` кодируются; не вышло адреса — область без
ссылки. Поверх картинки — `z` больше.

```json
{"id": "logo-link", "type": "link", "x": 30, "y": 18, "w": 40, "h": 16, "z": 2, "props": {"url": "https://edu.example.ru"}}
```

### Фигура (`type: "shape"`)

```json
{"id": "frame", "type": "shape", "x": 8, "y": 8, "w": 281, "h": 194, "z": 0, "locked": true,
 "props": {"kind": "rect", "fill": null, "stroke": "#8B2020", "strokeWidth": 0.8, "radius": 2, "dash": 0}}
```

`kind`: `rect` (умолчание), `ellipse` (вписан в блок), `line`; `fill` — `null`
или цвет; `stroke` — `#000000`; `strokeWidth` — мм, 0…20, по умолчанию 0,5;
`radius` — скругление, мм; `dash` — штрих пунктира, мм (0 — сплошная).

- **Линия** горизонтальна и идёт через середину блока: от `(x, y + h/2)`
  до `(x + w, y + h/2)`; толщина — `strokeWidth`, `h` ставьте 1–2 мм.
  Вертикальная — `rotation: 90` (раздел 16).
- `strokeWidth` печатается ровно в миллиметрах: линия подписи — 0,3–0,5 мм,
  рамка грамоты — 0,5–1,5 мм. Тонкая линия может быть и залитым
  прямоугольником без обводки: `{"kind": "rect", "fill": "#33383F",
  "strokeWidth": 0}` при `h = 0.3`.
- Подложка под текстом — меньший `z` и `opacity` 0,8–0,9.

## 8. Поля подстановки

Поле — ключ латиницей. В `text` и шаблоне QR — `%ключ`, в дереве —
`mergeField` с `attrs.source`, в адресе ссылки — `{{ключ}}` или `%ключ`.
`GET …/fields` перечисляет колонки, постоянные, мероприятие, служебные,
поля каждого листа и `unknown` — те, что взять неоткуда.

**Сила:** служебные < постоянные поля документа < колонки строки.

**Колонки** — `name`, `email` (всегда) и заведённые при импорте (`place`,
`team`, `city`…). Из `surname`, `firstname`, `patronymic` считаются падежи,
сокращение, латиница и пол, но `%name` печатает только колонку `name` —
заполняйте её или ставьте на лист части.

**Постоянные поля** — одно значение на всех (подписант, номер приказа);
список целиком заменяет прежний. Ключ `^[a-z][a-z0-9_]{0,39}$`, до 30 полей,
значение до 1000 знаков, служебные ключи заняты.

```bash
curl -X PATCH "https://vruchay.ru/api/documents/$DOCUMENT_ID" \
  -H "Authorization: Bearer $VRUCHAY_TOKEN" -H "Content-Type: application/json" \
  -d '{"constants": [{"key": "signer_role", "title": "Должность подписанта", "value": "Директор"},
                    {"key": "signer_name", "title": "Подписант", "value": "А. А. Петров"}]}'
```

**Мероприятие** — поля документа: `eventName` → `%event`, `eventDate` →
`%event_date`, `eventPlace` → `%event_place`, `eventHours` → `%hours`;
даты — свободным текстом.

| Служебное поле | Пример |
|---|---|
| `date` / `date_long` / `date_iso` / `date_en` — дата выдачи | `12.10.2026` / `12 октября 2026 г.` / `2026-10-12` / `12 October 2026` |
| `year`, `number` (строка с 1), `total` | `2026`, `3`, `120` |
| `reg_number` — сквозной номер за год | `142/2026` |
| `code` — проверочный код (тот, что в QR) | `K7M2-9QXR-4TVB` |
| `org`, `valid_until` (конец срока, если задан) | `АНО «Пример»`, `12.10.2027` |
| `event`, `event_date`, `event_place`, `hours` | мероприятие |
| `name_dat` / `name_gen` / `name_short` | `Петровой Анне Сергеевне` / `Петровой Анны Сергеевны` / `Петрова А. С.` |
| `name_lat_gost` / `name_lat_icao` (как в загранпаспорте) | `Petrova Anna Sergeevna` |
| `place_word` — из колонки `place` | `второе` |
| `hours_word` — из `hours` | `семьдесят два часа` |

- Дата — `issueDate` документа, иначе день выпуска, по Москве.
- Падеж выбирает фраза: после «Награждается», «Объявляется» — `%name_dat`;
  в «подтверждает, что …» — `%name`.
- `place_word` знает места 1–10 («1», «1 место», «1-е»), иное — как в ячейке.
  `hours_word` — только из чистого числа: пишите `eventHours: "72"`.
- Своя колонка (`name_dat`, `place_word`) сильнее вычисленного.

**Род.** В тексте макета: `награждён(а)`, `занял(а)`, `участник(-ца)`,
`победитель(-ница)`, `прошёл(ла)` — скобка вплотную, 1–5 букв; или пара
`принял|приняла` — для любых слов. Пол — из колонки `пол`/`pol`/`gender`/`sex`
(`м`, `муж`, `male`, `ж`, `жен`, `female`…), иначе угадывается по ФИО.
Не угадался — скобка остаётся как есть, пара печатается «принял/приняла».
Незнакомое («студент(ка)») не трогается — пишите пару.

**Печать значения.** Подставленное значение проходит типографику: кавычки →
«ёлочки», « - » → « — », «17-19» → «17–19», неразрывные пробелы в инициалах,
после «г.», «ул.», «№», перед «%» и после однобуквенных предлогов; `code`
и `reg_number` — буква в букву. Текст макета не трогается — набирайте сразу
с «ёлочками» и тире. У `mergeField`: `format` `none`/`upper`/`lower`/`title`,
`fallback` — если значения нет. Пустое поле — пустота; абзац только из полей
и разделителей (`,` `;` `:` `·` `•` `–` `—` `-`) при всех пустых полях
исчезает, разделитель между двумя полями уходит, если одно пусто
(`%event_place, %event_date` без даты → «г. Москва»). Абзац со словами
остаётся.

## 9. Фон

Картинка бланка под всеми блоками, своя у каждого листа (MCP: `upload_image`
по ссылке или `get_upload_link` для файла с диска, `target: "background"`):

```bash
BG="https://vruchay.ru/api/documents/$DOCUMENT_ID/sheets/$SHEET_ID/background"
curl -X POST "$BG" -H "Authorization: Bearer $VRUCHAY_TOKEN" -F "file=@blank.jpg"
curl -X POST "$BG/from-url" -H "Authorization: Bearer $VRUCHAY_TOKEN" \
  -H "Content-Type: application/json" -d '{"url": "https://example.ru/blank.jpg"}'
curl -X DELETE "$BG" -H "Authorization: Bearer $VRUCHAY_TOKEN"   # убрать фон
```

**Бланк с диска через MCP.** Агенту с оболочкой (Claude Code, Codex, Cursor)
файл передать аргументом нельзя — инструменты ходят JSON-ом. Вместо этого:

1. `get_upload_link {"documentId": "…", "target": "background", "sheetId": "…"}` —
   в ответе готовая команда с одноразовой ссылкой на 15 минут;
2. выполнить её, подставив путь: `curl -sS -F "file=@бланк.png" "https://vruchay.ru/api/v1/uploads/u.…"`;
3. curl ответит `{"target": "background", "fileId": "…", "widthPx": 3508, "heightPx": 2480}` —
   фон уже на листе; дальше `preview_sheet`.

Токена в команде нет: пропуск — сама ссылка, она принимает один файл. Файл
не того типа ссылку не тратит — повторите с верным; за следующим файлом —
новая ссылка. Логотип, подпись и печать — так же, с `target: "element"`:
полученный `fileId` ставится в `props.fileId` блока `image`.

- PNG или JPEG до 20 МБ; ответ — `fileId`, `url`, `widthPx`, `heightPx`.
- **Заполняет лист с обрезкой**, а не растягивается: лишнее по краям
  срезается поровну. Пропорции картинки — как у листа.
- Пикселей = мм / 25,4 × dpi; для печати 300, ниже 150 мылит. A4 альбомный —
  3508 × 2480 (минимум 1754 × 1240), книжный — 2480 × 3508, A5 альбомный —
  2480 × 1748, A3 альбомный — 4961 × 3508.
- Фон — не блок: нет в `layout`, нет `z`, его `fileId` не годится для `image`.

## 10. Готовые раскладки

Вид (`kind`) + набор шрифтов и цветов (`style`, раздел 6):
`award` (грамота за место: `%name_dat`, «за %place_word место»), `winner`,
`laureate`, `participant`, `school`, `thanks`, `letter` (все с `%name_dat`),
`attendee` (`%name`, «принял|приняла участие»), `course`, `graduate`
(`%name`, «в объёме %hours»).

```bash
curl -X POST "https://vruchay.ru/api/documents/$DOCUMENT_ID/sheets/$SHEET_ID/preset" \
  -H "Authorization: Bearer $VRUCHAY_TOKEN" -H "Content-Type: application/json" \
  -d '{"kind": "attendee", "style": "modern", "titlePrinted": false,
       "palette": {"ink": "#2B2F33", "accent": "#2C6E63"}, "mode": "replace"}'
```

То же при создании: `POST /api/documents` с `"preset": {"kind": "award", "style": "classic"}`.

- `titlePrinted` (по умолчанию `false`) — заголовок уже на бланке: своего
  не будет, текст начнётся с 34 % высоты листа.
- `palette` — `ink`, `accent`, `nameInk` (иначе `accent`); в MCP — то же поле
  у `apply_preset` и у `preset` в `create_document`.
- `mode`: `replace` (умолчание) или `append` (совпавшим id — суффикс; `z`
  блоков заготовки идут с нуля, под блоками с большим `z`).
- Строки с полями, которых нет в колонках **на момент применения**
  (`%team`, `%city`, `%place_word` без `place`), выбрасываются; колонки
  `result`, `category`, `coach`, `event_place`, `number` добавляются
  «подпись: значение» (до двух). Сначала таблица, потом заготовка.
- Подвал: `%signer_role` слева, `%signer_name` справа, `%date · %org`
  по центру. Если постоянных `signer_role`, `signer_name` нет, заготовка
  заводит их с образцами «Руководитель» и «И. О. Фамилия» — **замените**.
- При включённой проверке подлинности заготовка ставит QR проверки
  (`preset-{kind}-qr`) в правый нижний угол поля, 20–30 мм (на A4 — 23):
  фамилия подписанта кончается левее него, строка даты сужается с обеих
  сторон, стопка поднимается. Если QR уже есть на другом листе (или на этом
  при `append`), второй не ставится. Убрали QR — проверка напомнит
  `verify_without_qr`.
- Ответ — `{sheet, problems}`; id — `preset-{kind}-0`, `-1`…, `-signer-role`,
  `-signer-name`, `-issued`; у всех `autoFit`.

## 11. Правка по частям и версии

`PATCH …/sheets/{sheetId}/elements` (MCP: `edit_layout`):

```json
{"baseVersion": 7, "remove": ["old-note"],
 "update": [{"id": "name", "h": 40, "props": {"fontSize": 40, "autoFit": true}},
            {"id": "event", "props": {"text": "за участие в %event"}}],
 "add": [{"id": "qr", "type": "qr", "x": 244, "y": 151, "w": 30, "h": 30, "props": {}},
         {"type": "text", "y": 190, "w": 120, "h": 6, "props": {"text": "Рег. № %reg_number", "fontSize": 9}}]}
```

- Порядок: `remove`, `update`, `add`; убрать и добавить тот же id — заменить
  блок (так меняют его вид).
- `add`: без `id` — UUID, без `z` — поверх всех, без `x`/`y` — по центру
  листа, без `w`/`h` — текст 120 × 20, картинка 50 × 50, QR 30 × 30, ссылка
  80 × 10, прямоугольник 60 × 40, овал 40 × 40, линия 80 × 2. Занятый id — 400.
- `update`: верхние поля заменяются, `props` сливаются на один уровень;
  новый `props.text` или `props.doc` заменяет весь текст; `type` и `id`
  не меняются.
- До 500 операций каждого вида. Ответ — `{sheet, problems}`; ошибка схемы
  называет блок по id (`name.props.fontSize: …`).

**Лист целиком** — `PATCH …/sheets/{sheetId}` с `{"layout": [...],
"baseVersion": 7}` (MCP: `set_layout`, он же сразу проверяет).

**Версии.** `version` растёт с каждым сохранением. Если лист изменился после
`baseVersion`, ответ — 409 `{"message": "Лист изменился (версия 9, а правка
сделана к версии 7)…", "version": 9}`: перечитайте (`GET /api/documents/{id}`,
MCP: `get_document` с `full: true`) и повторите. Без `baseVersion` правка
по частям сама ложится на свежий лист (до 4 попыток), а лист целиком
пишется поверх. Новые картинки должны быть картинками организации — иначе
404 «Картинка не найдена».

## 12. Проверка и превью

`POST …/sheets/{sheetId}/check` (MCP: `check_sheet`): без тела — сохранённый
лист, `{"layout": [...]}` — черновик. Ответ — `{version, problems}`
(`code`, `severity`, `elementId`, `message`); скрытые блоки пропускаются —
кроме повтора `id`, он ищется среди всех.
Текст примеряется к образцу «Иванов Иван» и к самым длинным значениям каждой
колонки; код — «WWWW-WWWW-WWWW», номер — «1000/2026», мероприятие и постоянные —
из документа.

| Код | | Что значит → что делать |
|---|---|---|
| `duplicate_id` | error | Два блока с одним id → развести, сохранив лист целиком |
| `off_page` | error | Блок целиком за листом → поправить `x`, `y` |
| `partly_off_page` | warning | Текст, QR или ссылка частью за краем → внутрь |
| `outside_safe_area` | warning | Текст или QR ближе 5 мм к краю → 10–15 мм |
| `text_overflow` | error | Не влезает → больше `h`/`w`, меньше `fontSize`, `autoFit` |
| `unknown_field` | error | Поля нет у документа → исправить имя или завести поле |
| `unknown_font` | warning | Гарнитуры нет в наборе → раздел 6 |
| `missing_image` | error | Нет картинки с таким `fileId` → загрузить в `…/assets` |
| `small_qr` | warning | QR меньше 15 мм → 20–30 мм |
| `qr_not_square` | warning | `w` и `h` QR различаются больше 0,5 мм → квадрат |
| `qr_hidden_without_verify` | warning | Пустой QR при выключенной проверке → `verifyEnabled` или `template` |
| `verify_without_qr` | warning | Проверка включена, а QR проверки нет ни на одном листе → добавить QR с пустым `template` или выключить `verifyEnabled` |
| `empty_text` | warning | Ни текста, ни полей → заполнить или убрать |
| `overlap` | warning | QR на тексте или QR; текст на тексте больше чем на 1/5 меньшего → развести |
| `heavy_images` | warning | Фон и картинки вместе тяжелее 4 МБ — PDF не уйдёт вложением → JPEG, до 3500 px по длинной стороне |

Наложения фигур, картинок и ссылок — не ошибка: рамка под текстом обычна.

**Превью** — `POST …/sheets/{sheetId}/preview` (MCP: `preview_sheet`), тело
`{}` или `{"rowId": "…"}`: JPEG **сохранённого** листа таким, каким его
напечатает выпуск, 96 dpi (A4 альбомный — 1123 × 794). Данные — первая
строка таблицы или образец «Иванов Иван» (прочие колонки — заголовок
в скобках); код — `PRVW-0000-0000`, номер — `0/2026`. Квоту не тратит,
30 в минуту на организацию; 503 — повторить, 422 — лист не печатается
(не загрузилась картинка или шрифт). Посмотрите и строку с самым длинным ФИО.

**Вся таблица** — `POST …/validation` (MCP: `validate_recipients`) меряет
каждый блок с полями на **каждой** строке; `overflow` там — текст,
обрезанный у конкретного человека. Коды — в
[справочнике проверки](reference/validation-problem-codes.md).

## 13. Смена размера листа

`PATCH /api/documents/{id}` с `pageWidthMm`, `pageHeightMm` и `resize`
(MCP: `update_document`) — для всех листов:

- `keep` (умолчание) — блоки не трогаются и могут оказаться за краем;
- `scale` — всё пропорционально с `min(W₂/W₁, H₂/H₁)`, по центру: координаты,
  размеры (от 5 мм), кегли (от 4 pt), разрядка, отступы, рамки, обводки,
  кегли в разметке;
- `reposition` — размеры и кегли прежние, центр блока на той же доле листа.

Фон ляжет с обрезкой; под другие пропорции загрузите новый. Потом — проверка
и превью.

## 14. Несколько листов

`POST …/sheets` — пустой лист в конец (MCP: `add_sheet`); `DELETE
…/sheets/{sheetId}` — убрать (MCP: `delete_sheet`), единственный нельзя
(404 «Нельзя удалить единственный лист документа»). Лист — следующая
страница PDF; в JPEG листы идут один под другим. Размер общий, фон и блоки
свои.

## 15. Полные примеры

Проходят схему и проверку без замечаний при заполненном мероприятии
и постоянных `signer_role`, `signer_name`. `fileId` — заглушки, подставьте
свои из `…/assets`. Отправляются как `{"layout": [...]}` (`PATCH
…/sheets/{sheetId}`, MCP: `set_layout`).

### Пример 1. Сертификат участника, A4 альбомный

Поля 30 мм, проверка подлинности включена, высоты — из таблицы раздела 4.
Логотип по центру `(297 − 23) / 2 = 137`; подпись к QR под ним по центру
(`236 + 46/2 = 244 + 30/2`); линия подписи на высоте `172 + 2/2 = 173` —
у низа строк подписи, прижатых книзу.

```json
[
 {"id": "frame", "type": "shape", "name": "Рамка", "x": 8, "y": 8, "w": 281, "h": 194, "z": 0, "locked": true,
  "props": {"kind": "rect", "stroke": "#8B2020", "strokeWidth": 0.8, "radius": 2}},
 {"id": "frame-inner", "type": "shape", "name": "Рамка тонкая", "x": 11, "y": 11, "w": 275, "h": 188, "z": 1, "locked": true,
  "props": {"kind": "rect", "stroke": "#8B2020", "strokeWidth": 0.3, "radius": 1}},
 {"id": "logo", "type": "image", "name": "Логотип", "x": 137, "y": 16, "w": 23, "h": 23, "z": 2,
  "props": {"fileId": "3f2a9c1e-5b7d-4e8a-9c0f-1a2b3c4d5e6f"}},
 {"id": "title", "type": "text", "name": "Заголовок", "x": 30, "y": 42, "w": 237, "h": 17, "z": 3,
  "props": {"text": "Сертификат", "fontFamily": "PT Serif", "fontSize": 34, "bold": true, "uppercase": true,
   "letterSpacing": 3.4, "lineHeight": 1.2, "color": "#8B2020"}},
 {"id": "lead", "type": "text", "name": "Подводка", "x": 30, "y": 62, "w": 237, "h": 8, "z": 4,
  "props": {"text": "Настоящим удостоверяется, что", "fontFamily": "PT Serif", "fontSize": 13, "uppercase": true,
   "letterSpacing": 1.3, "lineHeight": 1.3, "color": "#4A3628"}},
 {"id": "name", "type": "text", "name": "ФИО", "x": 30, "y": 72, "w": 237, "h": 40, "z": 5,
  "props": {"text": "%name", "fontFamily": "Playfair Display", "fontSize": 44, "bold": true,
   "lineHeight": 1.15, "autoFit": true, "color": "#7A1E1E"}},
 {"id": "participation", "type": "text", "name": "Участие", "x": 30, "y": 113, "w": 237, "h": 9, "z": 6,
  "props": {"text": "принял|приняла участие в мероприятии", "fontFamily": "PT Serif", "fontSize": 17,
   "lineHeight": 1.3, "color": "#4A3628"}},
 {"id": "event", "type": "text", "name": "Мероприятие", "x": 30, "y": 123, "w": 237, "h": 19, "z": 7,
  "props": {"text": "%event", "fontFamily": "PT Serif", "fontSize": 20, "bold": true, "lineHeight": 1.25,
   "autoFit": true, "color": "#4A3628"}},
 {"id": "event-meta", "type": "text", "name": "Место и даты", "x": 30, "y": 143, "w": 237, "h": 7, "z": 8,
  "props": {"text": "%event_place, %event_date", "fontFamily": "PT Serif", "fontSize": 13, "lineHeight": 1.3,
   "color": "#4A3628"}},
 {"id": "signer-role", "type": "text", "name": "Должность", "x": 30, "y": 163, "w": 75, "h": 11, "z": 9,
  "props": {"text": "%signer_role", "fontFamily": "PT Serif", "fontSize": 11, "align": "left",
   "verticalAlign": "bottom", "lineHeight": 1.25, "autoFit": true, "color": "#4A3628"}},
 {"id": "sign-line", "type": "shape", "name": "Линия подписи", "x": 110, "y": 172, "w": 45, "h": 2, "z": 10,
  "props": {"kind": "line", "stroke": "#4A3628", "strokeWidth": 0.3}},
 {"id": "signer-name", "type": "text", "name": "Подписант", "x": 160, "y": 163, "w": 60, "h": 11, "z": 11,
  "props": {"text": "%signer_name", "fontFamily": "PT Serif", "fontSize": 11, "align": "left",
   "verticalAlign": "bottom", "lineHeight": 1.25, "color": "#4A3628"}},
 {"id": "issued", "type": "text", "name": "Дата и номер", "x": 30, "y": 181, "w": 190, "h": 6, "z": 12,
  "props": {"text": "Выдан %date · рег. № %reg_number", "fontFamily": "PT Serif", "fontSize": 10, "align": "left",
   "lineHeight": 1.3, "color": "#6B5B4E"}},
 {"id": "qr", "type": "qr", "name": "QR проверки", "x": 244, "y": 151, "w": 30, "h": 30, "z": 13,
  "props": {"template": "", "color": "#4A3628"}},
 {"id": "qr-caption", "type": "text", "name": "Подпись к QR", "x": 236, "y": 182, "w": 46, "h": 6, "z": 14,
  "props": {"text": "Проверить подлинность", "fontFamily": "PT Sans", "fontSize": 8, "lineHeight": 1.3,
   "color": "#6B5B4E"}}
]
```

«Константинопольская Александра Владимировна» встаёт при 44 pt в две строки;
длиннее — ужмёт `autoFit`.

### Пример 2. Диплом за место, A4 книжный

Рамка и герб напечатаны на фоне-бланке; поля 20 мм, в таблице есть `place`.
Имя в дательном, место словом прописными (`format: "upper"`), линия подписи —
тонкий залитый прямоугольник, номер и код по бокам от QR. Подписи слоёв
(`name`) в примерах 2 и 3 опущены для краткости.

```json
[
 {"id": "title", "type": "text", "x": 20, "y": 52, "w": 170, "h": 24, "z": 2,
  "props": {"text": "Диплом", "fontFamily": "Lora", "fontSize": 48, "bold": true, "uppercase": true,
   "letterSpacing": 4.8, "lineHeight": 1.2, "color": "#2F4A6B"}},
 {"id": "lead", "type": "text", "x": 20, "y": 84, "w": 170, "h": 9, "z": 3,
  "props": {"text": "Награждается", "fontFamily": "Lora", "fontSize": 15, "uppercase": true,
   "letterSpacing": 1.5, "lineHeight": 1.3, "color": "#2F4A6B"}},
 {"id": "name", "type": "text", "x": 20, "y": 96, "w": 170, "h": 34, "z": 4,
  "props": {"text": "%name_dat", "fontFamily": "Lora", "fontSize": 36, "bold": true, "lineHeight": 1.15,
   "autoFit": true, "color": "#1F2A38"}},
 {"id": "place", "type": "text", "x": 20, "y": 134, "w": 170, "h": 14, "z": 5,
  "props": {"fontFamily": "Lora", "fontSize": 26, "bold": true, "lineHeight": 1.2, "color": "#2F4A6B",
   "doc": {"type": "doc", "content": [{"type": "paragraph", "content": [
    {"type": "text", "text": "за "},
    {"type": "mergeField", "attrs": {"source": "place_word", "format": "upper"},
     "marks": [{"type": "textStyle", "attrs": {"color": "#8A6A22", "letterSpacing": 2}}]},
    {"type": "text", "text": " место"}]}]}}},
 {"id": "event", "type": "text", "x": 20, "y": 152, "w": 170, "h": 26, "z": 6,
  "props": {"text": "%event", "fontFamily": "Lora", "fontSize": 18, "lineHeight": 1.3, "autoFit": true, "color": "#33383F"}},
 {"id": "event-meta", "type": "text", "x": 20, "y": 180, "w": 170, "h": 7, "z": 7,
  "props": {"text": "%event_place, %event_date", "fontFamily": "Lora", "fontSize": 12, "italic": true,
   "lineHeight": 1.3, "color": "#33383F"}},
 {"id": "signer-role", "type": "text", "x": 20, "y": 234, "w": 70, "h": 11, "z": 8,
  "props": {"text": "%signer_role", "fontFamily": "Lora", "fontSize": 11, "align": "left",
   "verticalAlign": "bottom", "lineHeight": 1.25, "autoFit": true, "color": "#33383F"}},
 {"id": "sign-line", "type": "shape", "x": 93, "y": 244.7, "w": 34, "h": 0.3, "z": 9,
  "props": {"kind": "rect", "fill": "#33383F", "strokeWidth": 0}},
 {"id": "signer-name", "type": "text", "x": 130, "y": 234, "w": 60, "h": 11, "z": 10,
  "props": {"text": "%signer_name", "fontFamily": "Lora", "fontSize": 11, "align": "right",
   "verticalAlign": "bottom", "lineHeight": 1.25, "color": "#33383F"}},
 {"id": "reg", "type": "text", "x": 20, "y": 262, "w": 66, "h": 10, "z": 11,
  "props": {"text": "Рег. № %reg_number\nот %date_long", "fontFamily": "Lora", "fontSize": 9, "align": "left",
   "lineHeight": 1.3, "color": "#33383F"}},
 {"id": "qr", "type": "qr", "x": 92, "y": 252, "w": 26, "h": 26, "z": 12, "props": {"template": "", "color": "#1F2A38"}},
 {"id": "code", "type": "text", "x": 124, "y": 262, "w": 66, "h": 10, "z": 13,
  "props": {"text": "Код проверки\n%code", "fontFamily": "Lora", "fontSize": 9, "align": "right",
   "lineHeight": 1.3, "color": "#33383F"}}
]
```

Второе место печатается «за ВТОРОЕ место»; без `place` останется «за  место» —
перед выпуском проверьте, что колонка заполнена у всех.

### Пример 3. Сертификат об обучении, A4 альбомный

Montserrat и Inter, выключка влево, полоса у левого края (фигуре до края
можно), ссылка поверх логотипа, часы цифрами и прописью при
`eventHours: "72"` — «в объёме 72 ч. (семьдесят два часа)». При пустом
`hours` останется «в объёме ч. ()»: абзац со словами не схлопывается.

```json
[
 {"id": "band", "type": "shape", "x": 0, "y": 0, "w": 14, "h": 210, "z": 0, "locked": true,
  "props": {"kind": "rect", "fill": "#2C6E63", "strokeWidth": 0}},
 {"id": "logo", "type": "image", "x": 30, "y": 18, "w": 40, "h": 16, "z": 1,
  "props": {"fileId": "9b8a7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d"}},
 {"id": "logo-link", "type": "link", "x": 30, "y": 18, "w": 40, "h": 16, "z": 2, "props": {"url": "https://edu.example.ru"}},
 {"id": "title", "type": "text", "x": 30, "y": 44, "w": 200, "h": 18, "z": 3,
  "props": {"text": "Сертификат", "fontFamily": "Montserrat", "fontSize": 36, "bold": true, "align": "left",
   "lineHeight": 1.2, "color": "#1A1D20"}},
 {"id": "subtitle", "type": "text", "x": 30, "y": 62, "w": 200, "h": 9, "z": 4,
  "props": {"text": "о повышении квалификации", "fontFamily": "Montserrat", "fontSize": 16, "align": "left",
   "lineHeight": 1.2, "color": "#2C6E63"}},
 {"id": "lead", "type": "text", "x": 30, "y": 80, "w": 237, "h": 7, "z": 5,
  "props": {"text": "Настоящий сертификат подтверждает, что", "fontFamily": "Inter", "fontSize": 12,
   "align": "left", "lineHeight": 1.3, "color": "#2B2F33"}},
 {"id": "name", "type": "text", "x": 30, "y": 89, "w": 237, "h": 28, "z": 6,
  "props": {"text": "%name", "fontFamily": "Montserrat", "fontSize": 30, "bold": true, "align": "left",
   "lineHeight": 1.15, "autoFit": true, "color": "#1A1D20"}},
 {"id": "body", "type": "text", "x": 30, "y": 121, "w": 237, "h": 30, "z": 7,
  "props": {"fontFamily": "Inter", "fontSize": 14, "align": "left", "verticalAlign": "top", "lineHeight": 1.35,
   "autoFit": true, "color": "#2B2F33",
   "doc": {"type": "doc", "content": [
    {"type": "paragraph", "content": [
     {"type": "text", "text": "прошёл|прошла обучение по дополнительной профессиональной программе"}]},
    {"type": "paragraph", "content": [{"type": "mergeField", "attrs": {"source": "event"}, "marks": [{"type": "bold"}]}]},
    {"type": "paragraph", "content": [
     {"type": "text", "text": "в объёме "}, {"type": "mergeField", "attrs": {"source": "hours"}},
     {"type": "text", "text": " ч. ("}, {"type": "mergeField", "attrs": {"source": "hours_word"}},
     {"type": "text", "text": ")"}]}]}}},
 {"id": "issued", "type": "text", "x": 30, "y": 160, "w": 120, "h": 7, "z": 8,
  "props": {"text": "Дата выдачи: %date_long", "fontFamily": "Inter", "fontSize": 11, "align": "left",
   "lineHeight": 1.3, "color": "#2B2F33"}},
 {"id": "signer-role", "type": "text", "x": 30, "y": 175, "w": 90, "h": 11, "z": 9,
  "props": {"text": "%signer_role", "fontFamily": "Inter", "fontSize": 10, "align": "left",
   "verticalAlign": "bottom", "lineHeight": 1.25, "autoFit": true, "color": "#2B2F33"}},
 {"id": "sign-line", "type": "shape", "x": 125, "y": 185.7, "w": 40, "h": 0.3, "z": 10,
  "props": {"kind": "rect", "fill": "#2B2F33", "strokeWidth": 0}},
 {"id": "signer-name", "type": "text", "x": 170, "y": 175, "w": 60, "h": 11, "z": 11,
  "props": {"text": "%signer_name", "fontFamily": "Inter", "fontSize": 10, "align": "left",
   "verticalAlign": "bottom", "lineHeight": 1.25, "color": "#2B2F33"}},
 {"id": "reg", "type": "text", "x": 30, "y": 190, "w": 120, "h": 6, "z": 12,
  "props": {"text": "Рег. № %reg_number", "fontFamily": "Inter", "fontSize": 9, "align": "left",
   "lineHeight": 1.3, "color": "#6B7075"}},
 {"id": "qr", "type": "qr", "x": 247, "y": 158, "w": 30, "h": 30, "z": 13,
  "props": {"template": "", "color": "#1A1D20", "dotStyle": "rounded", "cornerStyle": "rounded", "cornerColor": "#2C6E63"}},
 {"id": "code", "type": "text", "x": 240, "y": 189, "w": 44, "h": 6, "z": 14,
  "props": {"text": "%code", "fontFamily": "Inter", "fontSize": 8, "letterSpacing": 0.5, "lineHeight": 1.3,
   "color": "#6B7075"}}
]
```

## 16. Рецепты правок

Тела `PATCH …/sheets/{sheetId}/elements` (MCP: `edit_layout`) для листа
из примера 1.

**Сдвинуть всё на 10 мм вниз** — пакетного сдвига нет: прибавьте 10 к `y`
всех блоков, кроме рамок, одним запросом. Сдвигайте всё содержимое, иначе
сдвинутое наедет на оставшееся.

```json
{"update": [{"id": "logo", "y": 26}, {"id": "title", "y": 52}, {"id": "lead", "y": 72}, {"id": "name", "y": 82},
 {"id": "participation", "y": 123}, {"id": "event", "y": 133}, {"id": "event-meta", "y": 153},
 {"id": "signer-role", "y": 173}, {"id": "sign-line", "y": 182}, {"id": "signer-name", "y": 173},
 {"id": "issued", "y": 191}, {"id": "qr", "y": 161}, {"id": "qr-caption", "y": 192}]}
```

**Сменить шрифт везде** — `{"id": "title", "props": {"fontFamily": "Lora"}}`
для каждого текстового блока. Куски со своим `textStyle.fontFamily` это
не меняет: перепишите их `doc` или замените текст строкой `props.text`.

**Длинные ФИО** — блок на всю ширину, высота на две строки, плотный
межстрочный, автоподбор. Имя в дательном — `"text": "%name_dat"`
и подводка «Награждается».

```json
{"update": [{"id": "name", "x": 30, "w": 237, "h": 40, "props": {"fontSize": 44, "lineHeight": 1.15, "autoFit": true}}]}
```

**Текст на пёстром фоне** — подложка с меньшим `z`, либо `background`
и `padding` у блока (отступ съедает высоту — блок выше), либо белая обводка
букв `strokeWidth` 0,2–0,4 мм.

```json
{"add": [{"id": "name-plate", "type": "shape", "x": 26, "y": 70, "w": 245, "h": 44, "z": 4, "opacity": 0.85,
  "props": {"kind": "rect", "fill": "#FFFFFF", "strokeWidth": 0, "radius": 3}}],
 "update": [{"id": "event-meta", "y": 142, "h": 9, "props": {"background": "#FFF8EE", "padding": 1}}]}
```

**Два блока в ряд** по колонкам раздела 4: равные `y`, `h`,
`verticalAlign: "bottom"`, левый влево, правый вправо.

```json
{"add": [
 {"id": "org-note", "type": "text", "x": 30, "y": 192, "w": 110, "h": 6,
  "props": {"text": "%org", "fontFamily": "PT Serif", "fontSize": 9, "align": "left", "verticalAlign": "bottom"}},
 {"id": "date-note", "type": "text", "x": 157, "y": 192, "w": 110, "h": 6,
  "props": {"text": "%event_date", "fontFamily": "PT Serif", "fontSize": 9, "align": "right", "verticalAlign": "bottom"}}]}
```

**Подпись картинкой** — линию заменить залитым прямоугольником (убрать
и добавить тот же id), сверху PNG подписи с прозрачностью:

```json
{"remove": ["sign-line"], "add": [
 {"id": "sign-line", "type": "shape", "x": 110, "y": 173.7, "w": 45, "h": 0.3, "z": 10,
  "props": {"kind": "rect", "fill": "#4A3628", "strokeWidth": 0}},
 {"id": "signature", "type": "image", "x": 115, "y": 158, "w": 35, "h": 17.5, "z": 15,
  "props": {"fileId": "5d6e7f80-91a2-4b3c-8d4e-5f6a7b8c9d0e"}}]}
```

**Вертикальная линия** 30 мм с центром в (232; 166): линия `w = 30`, `h = 2`,
повёрнутая на 90° вокруг центра, — `x = 232 − 15`, `y = 166 − 1`. И ссылка
на страницу проверки поверх подписи к QR:

```json
{"add": [
 {"id": "divider", "type": "shape", "x": 217, "y": 165, "w": 30, "h": 2, "rotation": 90,
  "props": {"kind": "line", "stroke": "#8B2020", "strokeWidth": 0.3}},
 {"id": "qr-link", "type": "link", "x": 236, "y": 182, "w": 46, "h": 6, "z": 20, "props": {"url": "https://vruchay.ru/c/{{code}}"}}]}
```

**Мелочи:** скрыть, не удаляя, — `{"update": [{"id": "logo", "hidden": true}]}`;
поверх всех — `{"update": [{"id": "qr", "z": 100}]}`; начать заново —
`…/preset` с `"mode": "replace"`.

**Оборот** — `POST …/sheets` (MCP: `add_sheet`), затем блоки на новый лист:

```json
{"add": [
 {"id": "back-title", "type": "text", "x": 30, "y": 30, "w": 237, "h": 12,
  "props": {"text": "Программа мероприятия", "fontFamily": "PT Serif", "fontSize": 22, "bold": true, "color": "#8B2020"}},
 {"id": "back-list", "type": "text", "x": 40, "y": 50, "w": 217, "h": 60,
  "props": {"fontFamily": "PT Serif", "fontSize": 13, "align": "left", "verticalAlign": "top", "lineHeight": 1.5,
   "doc": {"type": "doc", "content": [{"type": "orderedList", "content": [
    {"type": "listItem", "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Пленарное заседание"}]}]},
    {"type": "listItem", "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Работа секций"}]}]}]}]}}}]}
```

## 17. Особенности MCP

Инструменты повторяют REST (названы выше), с отличиями: `get_document`
с `full: true` отдаёт макет в `sheets[].elements`; `upload_image` берёт
картинку по ссылке, а файл с диска приносит curl по ссылке из `get_upload_link`; `set_layout` сразу возвращает замечания проверки;
поля документа — в `get_document` (`fields`); проверка черновика и удаление
фона — через `api_request`;
это руководство и справочник — `get_guide` с темами `layout`
и `layout_reference`.

---

<!-- integrations -->
# Способы выдачи

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

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

## Какой способ выбрать

| Способ | Когда подходит | Кто запускает | Что получает человек | Квота |
| --- | --- | --- | --- | --- |
| **По списку** | Список известен заранее: олимпиада, выпуск курса, конференция | Организатор или агент, один раз на весь список | Файл из архива от организатора или письмо с PDF | Списывается при выпуске, по числу отмеченных строк |
| **Форма по ссылке** | Своего сайта нет; ссылку раздают в чат, в письмо или QR-кодом на экране | Сам участник | Страницу «Вручай», где вводит имя и почту и сразу скачивает документ; копию — письмом | Документ на каждую заявку |
| **Форма на сайте** | У мероприятия или школы есть свой сайт — Тильда, WordPress, Битрикс, любая страница, в том числе закрытая страница личного кабинета | Сам участник | Кнопку «Получить документ» на странице сайта, дальше — как у формы по ссылке | Документ на каждую заявку |
| **API на одного** | Документ выдаёт программа по событию: CRM, учебная платформа, бот | Ваша программа | Письмо с PDF или ссылку, которую покажет ваша программа | Документ на каждый запрос |

Пути совмещаются: одна грамота может уйти пакетом по списку, а опоздавшие получат
её через форму по ссылке. Всё выданное любым путём попадает в один реестр
с кодом проверки и QR.

## По списку

| Шаг | MCP | REST |
| --- | --- | --- |
| Загрузить таблицу | `import_recipients` | [`POST /api/documents/{id}/recipients/import`](endpoints/recipients/post-documents-id-recipients-import.md) |
| Проверить | `validate_recipients` | [`POST /api/documents/{id}/validation`](endpoints/validation/post-documents-id-validation.md) |
| Выпустить | `start_generation` | [`POST /api/documents/{id}/generate`](endpoints/generation/post-documents-id-generate.md) |
| Дождаться | `get_job` | [`GET /api/jobs/{jobId}`](endpoints/generation/get-jobs-jobId.md) |
| Забрать | `get_download_link` | [`POST /api/jobs/{jobId}/archive-link`](endpoints/generation/post-jobs-jobId-archive-link.md) или [`GET /api/jobs/{jobId}/archive`](endpoints/generation/get-jobs-jobId-archive.md) |

В выпуск попадают отмеченные строки. Ссылка на архив открывается в браузере
без входа и живёт час; `format: "pdf"` склеивает все документы в один файл
для печати, `name` задаёт имена файлов в архиве (`"%name"`, `"%number %name"`). Дальше документы раздают сами или рассылают письмами (ниже).

Проверка остатка — до постановки задания: к выпущенному прибавляется бронь
незаконченных заданий. Не хватает документов или вышел срок плана — выпуск
не начнётся, ответ `400`.

## Форма по ссылке

Страница на адресе «Вручай» для тех, у кого нет своего сайта или нет времени
его трогать. Нужны права «Полный доступ» и возможность `tilda` в плане.

MCP:

```json
{
  "kind": "link",
  "name": "Конференция, сертификат участника",
  "documentIds": ["9e8d7c6b-5a4f-4e3d-8c2b-1a0f9e8d7c6b"],
  "formFields": ["name"],
  "singleFilePerEmail": true
}
```

— аргументы `setup_form`. REST — тот же объект в
[`POST /api/integrations/tilda`](endpoints/integrations/post-integrations-tilda.md).

В ответе `connect.formUrl` — ссылка вида `https://vruchay.ru/f/{token}`, а если
у организации подключён и подтверждён свой домен для форм
([`PATCH /api/org/domain`](endpoints/org/patch-org-domain.md)) — на нём:
`https://forms.school.ru/f/{token}`. Её и отдают людям.

- Документ у формы по ссылке ровно один.
- `formFields` — что спросить, кроме почты: имена полей документа, по умолчанию
  `["name"]`. Служебные и постоянные поля спросить нельзя — участник не должен
  напечатать на листе своё.
- `authMode` у формы по ссылке по умолчанию `none`: человек открывает ссылку
  и сразу получает документ, от перебора её держат `dailyLimit`
  и `singleFilePerEmail`. `email_code` — почту подтверждают шестизначным кодом
  из письма.
- Документ отдаётся сразу двумя путями — кнопкой скачивания и письмом:
  `showDownload` и `sendEmail` у формы по ссылке всегда `true`.
- `singleFilePerEmail: true` (по умолчанию) — повторная заявка с того же адреса
  получает уже выданный документ, второй не выпускается. Открывается он только
  после кода, который приходит на адрес выдачи, — даже при `authMode: "none"`.
- `dailyLimit` — предел заявок за последние 24 часа, по умолчанию 500.

Что видит участник: страницу с названием организации и документа, поля из `formFields`, почту
и согласие на обработку данных; затем код из письма (если он нужен) и документ —
кнопкой скачивания и письмом. В таблице получателей появляется неотмеченная
строка с `source: "link"`, документ — в реестре.

## Форма на сайте

Кнопка «Получить документ» на странице организации. Нужны права «Полный доступ»
и возможность `tilda` в плане.

MCP — `setup_form`:

```json
{
  "kind": "site",
  "name": "Сайт школы",
  "documentIds": ["9e8d7c6b-5a4f-4e3d-8c2b-1a0f9e8d7c6b"],
  "allowedDomains": ["example.ru"]
}
```

REST — тот же объект в `POST /api/integrations/tilda`.

- `allowedDomains` — домены страниц с формой, до 20. Адреса приводятся к домену
  сами (`https://www.example.ru/events` → `example.ru`), поддомены разрешённого
  домена тоже считаются своими. С других адресов форма заявки не примет — поэтому
  открытая с диска страница или чужой тестовый стенд работать не будут.
- `documentIds` — до 50 документов; на странице может стоять кнопка любого из них.

В ответе `connect.embedHtml` — готовый HTML для каждого документа: блок
с кнопкой и подключение стилей и скрипта формы. Куда вставлять:

- **Тильда** — блок «HTML-код» (T123) на нужной странице;
- **WordPress** — блок «Произвольный HTML» в редакторе страницы;
- **Битрикс** — визуальный редактор в режиме HTML или включаемая область;
- **обычная страница** — в любое место внутри `<body>`.

Скрипт защищён от повторной загрузки: две кнопки на одной странице подключают
его один раз. Готовые варианты для Тильды, WordPress и Битрикса показывает
кабинет: документ → «Выпуск» → «Форма на сайте».

Что видит участник: кнопку на странице, по нажатию — окно с полями, почтой,
согласием и кодом из письма; затем документ кнопкой скачивания и письмом.

### Закрытая страница и личный кабинет

Форма работает и внутри личного кабинета сайта — например, на закрытой странице
Tilda Members, куда пускают только участников мероприятия. Свой сервер для этого
не нужен. Кто может открыть страницу, решает сайт: группы и права Тильды «Вручай»
не читает.

- **Имя и почта подставляются сами.** Скрипт формы ищет их по порядку: атрибуты
  блока `data-name` и `data-email` (их заполняет своим шаблоном сайт с собственным
  кабинетом — WordPress, Битрикс), поля личного кабинета Тильды `ma_name`
  и `ma_email`, объект `window.tildamembers`. На Тильде ничего дописывать не нужно.
  Не нашёл — человек введёт сам.
- **ФИО можно поправить.** По кнопке открывается окно «Проверьте данные»: имя
  и почта уже вписаны, человек исправляет опечатку, отмечает согласие и получает
  документ. `allowEdit: false` делает поля окна только для чтения, а
  `prefillFromAccount: false` выключает подстановку. Запрет правки — удобство,
  не защита: заявку шлёт браузер посетителя.
- `showVerifyLink: true` — ссылка на проверку подлинности в окне успеха
  и кнопки «поделиться» ею (`showShare`, по умолчанию включены, но без ссылки
  не видны).
- `requireAccount: true` — заявка принимается, только если страница передала
  почту учётной записи, то есть человек вошёл в кабинет. Копия кнопки на открытой
  странице документ не выдаст.
- `checkList: true` — выдавать только тем, чья почта есть в таблице
  получателей документа.
- **Документ по кнопке, без писем** — `authMode: "none"`, `sendEmail: false`,
  `showDownload: true`. Без `authMode: "none"` форма на сайте сначала пришлёт код
  на почту: по умолчанию у неё `email_code`. Текст после выдачи без письма сам
  становится «Спасибо! Документ готов»; свой — в `successMessage`. Почта в окне
  всё равно обязательна, но из кабинета она подставится сама; повторная заявка
  того же человека получит уже выданный документ (`singleFilePerEmail`) — после
  кода на адрес, куда его выдали.

```json
{
  "kind": "site",
  "name": "Конференция, закрытая страница",
  "documentIds": ["9e8d7c6b-5a4f-4e3d-8c2b-1a0f9e8d7c6b"],
  "allowedDomains": ["academy.example.ru"],
  "requireAccount": true,
  "authMode": "none",
  "sendEmail": false,
  "showDownload": true
}
```

— аргументы `setup_form`; REST — тот же объект в `POST /api/integrations/tilda`.
Код вставки из `connect.embedHtml` — в блок «HTML-код» (T123) закрытой страницы.

### Форма Тильды без нашего скрипта

У Тильды есть второй путь — без кода на странице: обычная форма Тильды отправляет
данные прямо на адрес `https://vruchay.ru/api/v1/tilda-create` («Свой скрипт для
приёма данных»), со скрытыми полями `secure` (токен формы) и `doc_id` (id документа),
полями `mask_name`, `mask_email` и галочкой `consent`. Пошаговая инструкция —
в кабинете там же.

### Управление формами

| Действие | MCP | REST |
| --- | --- | --- |
| Список с готовыми ссылками и кодом | `list_forms` | [`GET /api/integrations/tilda`](endpoints/integrations/get-integrations-tilda.md) |
| Изменить или выключить (`active: false`) | `setup_form` с `integrationId` | [`PATCH /api/integrations/tilda/{id}`](endpoints/integrations/patch-integrations-tilda-id.md) |
| Заявки и причины отказов | `api_request` | [`GET /api/integrations/tilda/requests`](endpoints/integrations/get-integrations-tilda-requests.md) |
| Удалить | `api_request` | [`DELETE /api/integrations/tilda/{id}`](endpoints/integrations/delete-integrations-tilda-id.md) |

Вид формы (`kind`) после создания не меняется. Выключенная форма перестаёт
принимать заявки сразу, код на странице править не нужно.

## Один документ по API

Ваша программа присылает имя — в ответе готовый документ.

- MCP: `issue_document` с `documentId`, `data`, `email`, `send` и `idempotencyKey`.
- REST: [`POST /api/documents/{id}/issue`](endpoints/generation/post-documents-id-issue.md)
  с заголовком `Idempotency-Key`; состояние —
  [`GET /api/documents/{id}/issue/{jobId}`](endpoints/generation/get-documents-id-issue-jobId.md).

```bash
curl -X POST "https://vruchay.ru/api/documents/$DOCUMENT_ID/issue" \
  -H "Authorization: Bearer $VRUCHAY_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: lms-completion-48213" \
  -d '{"data":{"name":"Иванова Анна Сергеевна","hours":"72"},"email":"ivanova@example.com","send":true}'
```

- `Idempotency-Key` — ваш идентификатор события, 8–100 знаков. Повтор с тем же
  ключом отдаёт первый документ, а не выпускает второй.
- `201` — документ готов: код проверки, номер, ссылка на страницу проверки
  и ссылка получателя на PDF (`recipientUrl`, 30 дней). `202` — опрашивайте
  `statusUrl` раз в 1–2 секунды.
- Поля `data` — колонки и поля листа документа; их список отдаёт
  `GET /api/documents/{id}/fields` (раздел `issue`). `name` обязательно.
- Письмо (`send: true`) уходит по шаблону документа и требует возможности `mailing`.
- Предел — 120 документов в минуту на организацию.

Что видит получатель: письмо с документом (если `send: true`) или то, что покажет
ваша программа, — ссылку `recipientUrl` или код проверки.

## Письма

Рассылка уже выпущенных документов по строкам таблицы. Нужна возможность `mailing`
в плане.

| Шаг | MCP | REST |
| --- | --- | --- |
| Шаблон письма | `set_mail_template` | [`POST /api/mail/templates/{documentId}`](endpoints/mail/post-mail-templates-documentId.md) |
| Пробное письмо | `api_request` | [`POST /api/mail/templates/{documentId}/test-send`](endpoints/mail/post-mail-templates-documentId-test-send.md) |
| Кому уйдёт и кому нет | `get_mail` | [`POST /api/mailing/audience`](endpoints/mailing/post-mailing-audience.md) |
| Разослать, дослать новым | `send_mail` | [`POST /api/mailing/send`](endpoints/mailing/post-mailing-send.md) — уже получившие пропускаются |
| Повторить недоставленные | `send_mail` с `onlyFailed` | [`POST /api/mailing/resend`](endpoints/mailing/post-mailing-resend.md) |
| Судьба каждого письма | `get_mail` | [`GET /api/mailing/log`](endpoints/mailing/get-mailing-log.md) |

- В теме и тексте — подстановки `%name` и другие поля строки. `attachGeneratedFile`
  (по умолчанию `true`) прикладывает PDF.
- PDF тяжелее, чем принимает почта, письмо не останавливает: оно уходит без файла,
  с пометкой и кнопкой «Открыть документ», в журнале — `sent`. Чтобы файл
  прикладывался, держите фон и картинки листа лёгкими — `check_sheet` предупредит
  (`heavy_images`).
- Письма ставятся отмеченным строкам с выпущенным документом; кому не поставлено —
  в `skipped` с причиной.
- Без своего отправителя письма идут с общего адреса сервиса: не больше 300 за раз
  и 500 за сутки на организацию. Со своего подтверждённого домена предела нет —
  домен подключает управляющий в кабинете.
- Пробное письмо по токену приходит тому, кто выдал токен, а не на адрес из запроса.
- Рассылка необратима: перед `send_mail` покажите человеку, кому и сколько писем уйдёт.

Форма и API шлют письмо сами (`sendEmail` у формы, `send` у API) по тому же
шаблону документа.

## Какой домен где

Слово «домен» в API встречается в четырёх разных настройках, и они друг
от друга не зависят:

| Настройка | Зачем | Где |
| --- | --- | --- |
| Домены страниц формы на сайте | С каких сайтов форма принимает заявки; ничего подключать не нужно | `allowedDomains` у [`POST /api/integrations/tilda`](endpoints/integrations/post-integrations-tilda.md) |
| Свой домен для форм по ссылке | Ссылка `https://forms.school.ru/f/{token}` вместо `vruchay.ru`; работает после проверки записей DNS | [`PATCH /api/org/domain`](endpoints/org/patch-org-domain.md), [`POST /api/org/domain/check`](endpoints/org/post-org-domain-check.md) |
| Домен отправки писем | Письма с адреса организации и без предела общего адреса; записи SPF и DKIM в DNS | [`POST /api/mail/domains`](endpoints/mail/post-mail-domains.md), затем отправитель — [`POST /api/mail/senders`](endpoints/mail/post-mail-senders.md) |
| Домен страницы проверки | Пока только хранится: ссылки и QR в документах ведут на `vruchay.ru` | [`PATCH /api/org/verify-domain`](endpoints/org/patch-org-verify-domain.md) |

Один и тот же поддомен под формы и под письма подключать не нужно: у форм
запись указывает на «Вручай», у писем — на почтовый сервис.

## Когда выдача останавливается

| Причина | По списку | Форма | API |
| --- | --- | --- | --- |
| Остаток кончился (`left: 0`) | `400` до постановки задания | Участник видит «Выдача документов по этой форме сейчас недоступна — напишите организатору»; заявка — `rejected` | `400` сразу |
| Срок плана вышел (`expired: true`) | То же | То же | То же |
| Суточный предел формы (`dailyLimit`) | — | «Сегодня выдача документов по этой форме уже недоступна. Попробуйте завтра» | — |
| Форма выключена (`active: false`) | — | Заявки не принимаются | — |
| Нет `mailing` в плане | Рассылка — `403` | Документ выдаётся кнопкой скачивания, письмо не уходит (и копия организатору тоже); причина — в `error` заявки | `403` при `send: true` |

Квота у формы бронируется в момент заявки (при подтверждении кодом — в момент
ввода кода), поэтому участник узнаёт об отказе сразу. Отказы видны организатору
в журнале заявок и в журнале действий (`issue.refused`). Уже выданные документы
при любом из этих условий остаются действительными и проверяются по QR.

Остаток и срок заранее показывает `get_account` или
[`GET /api/org/usage`](endpoints/org/get-org-usage.md).

---

<!-- recipes -->
# Сценарии целиком

Шесть типовых задач от начала до конца: какие инструменты MCP вызывать, в каком
порядке и с какими аргументами, и то же самое запросами REST. Порядок и подводные
камни объяснены в [AGENTS.md](AGENTS.md), вёрстка — в [руководстве по вёрстке](layout-guide.md).

Как читать:

- строка MCP — `инструмент {аргументы}`; строка REST — `МЕТОД /путь {тело}`,
  всегда с заголовком `Authorization: Bearer vru_…`;
- `<documentId>`, `<sheetId>`, `<jobId>` и подобные берутся из ответов предыдущих шагов;
- шаг «показать человеку» — не вызов, а пауза: дождитесь согласия, прежде чем
  тратить квоту или слать письма.

## Школьная олимпиада: грамоты за места

Задача: призёрам олимпиады — грамоты «за первое (второе, третье) место», ФИО
в дательном падеже, школа под именем.

Порядок важен: сначала таблица, потом раскладка. Готовая раскладка верстает
строку «за %place_word место», только если у документа уже есть колонка `place`,
а `%place_word` («первое») считается из неё сама.

```text
get_account {}
create_project {"name": "Олимпиада по математике 2026"}
create_document {"title": "Грамота призёра — олимпиада по математике", "projectId": "<projectId>", "format": "a4", "orientation": "portrait"}
import_recipients {"documentId": "<documentId>", "columns": ["name", "email", "place", "team"], "titles": ["ФИО", "Почта", "Место", "Школа"], "rows": [["Иванова Мария Сергеевна", "ivanova@example.com", "1", "Школа № 5"], ["Петров Олег Иванович", "", "2", "Лицей № 2"]], "mode": "replace"}
apply_preset {"documentId": "<documentId>", "sheetId": "<sheetId>", "kind": "award", "style": "classic", "titlePrinted": false}
update_document {"documentId": "<documentId>", "eventName": "Городская олимпиада по математике", "eventDate": "14 ноября 2026", "constants": [{"key": "signer_role", "title": "Должность подписанта", "value": "Директор"}, {"key": "signer_name", "title": "Подписант", "value": "А. В. Смирнова"}]}
check_sheet {"documentId": "<documentId>", "sheetId": "<sheetId>"}
preview_sheet {"documentId": "<documentId>", "sheetId": "<sheetId>"}
validate_recipients {"documentId": "<documentId>"}
edit_recipients {"documentId": "<documentId>", "applySuggestions": true}   — однозначные исправления, если они есть
— показать человеку: сколько грамот выйдет и сколько останется —
start_generation {"documentId": "<documentId>", "format": "pdf"}
get_job {"jobId": "<jobId>", "waitSeconds": 60}
get_download_link {"jobId": "<jobId>", "format": "zip", "name": "%place место %name"}
```

```text
GET   /api/auth/me
GET   /api/org/usage
POST  /api/projects {"name": "Олимпиада по математике 2026"}
POST  /api/documents {"title": "…", "projectId": "<projectId>", "format": "a4", "orientation": "portrait"}
POST  /api/documents/<documentId>/recipients/import {"columns": […], "titles": […], "rows": […], "mode": "replace"}
POST  /api/documents/<documentId>/sheets/<sheetId>/preset {"kind": "award", "style": "classic", "titlePrinted": false}
PATCH /api/documents/<documentId> {"eventName": "…", "eventDate": "…", "constants": […]}
POST  /api/documents/<documentId>/sheets/<sheetId>/check {}
POST  /api/documents/<documentId>/sheets/<sheetId>/preview {}
POST  /api/documents/<documentId>/validation {}
POST  /api/documents/<documentId>/validation/fix {"fixes": [{"rowId": "…", "column": "name", "value": "…"}]}   — suggestion из замечаний
POST  /api/documents/<documentId>/generate {"format": "pdf"}
GET   /api/jobs/<jobId>                       — раз в 2–3 секунды до done
POST  /api/jobs/<jobId>/archive-link {"format": "zip", "name": "%place место %name"}
```

Если грамоты печатаются на готовых бланках с напечатанным словом «Грамота» —
`titlePrinted: true`, а бланк загрузите фоном:
`upload_image {"documentId": "<documentId>", "sheetId": "<sheetId>", "target": "background", "url": "https://example.ru/blank.jpg"}`.
Участникам без места подойдёт отдельный документ с раскладкой `participant`.

Протокол и бланк лежат файлами на диске, а у агента есть оболочка (Claude Code,
Codex, Cursor) — вместо `import_recipients` и `upload_image`:

```text
get_upload_link {"documentId": "<documentId>", "target": "recipients", "mode": "replace"}
curl -sS -F "file=@протокол.xlsx" "<ссылка из ответа>"    — в оболочке; ответ: imported, columns
get_upload_link {"documentId": "<documentId>", "target": "background", "sheetId": "<sheetId>"}
curl -sS -F "file=@бланк.jpg" "<ссылка из ответа>"        — фон сразу на листе
preview_sheet {"documentId": "<documentId>", "sheetId": "<sheetId>"}
```

## Курс с часами: сертификат и выдача из учебной платформы

Задача: каждому, кто закончил курс, учебная платформа сама выдаёт сертификат
с объёмом часов и отправляет его письмом.

Подготовка (один раз, вместе с человеком):

```text
get_account {}
create_document {"title": "Сертификат — курс «Основы SQL»", "format": "a4", "preset": {"kind": "course", "style": "modern", "titlePrinted": false}}
update_document {"documentId": "<documentId>", "eventName": "«Основы SQL»", "eventHours": "72 академических часа", "constants": [{"key": "signer_role", "title": "Должность подписанта", "value": "Руководитель программы"}, {"key": "signer_name", "title": "Подписант", "value": "И. П. Орлов"}]}
check_sheet {"documentId": "<documentId>", "sheetId": "<sheetId>"}
preview_sheet {"documentId": "<documentId>", "sheetId": "<sheetId>"}
set_mail_template {"documentId": "<documentId>", "subject": "%name, ваш сертификат о прохождении курса", "bodyHtml": "<p>Здравствуйте, %name!</p><p>Поздравляем с окончанием курса. Сертификат — во вложении.</p>", "attachGeneratedFile": true}
get_document {"documentId": "<documentId>"}
```

Последний вызов показывает в `fields.issue`, какие поля принимает выдача.
Раскладка `course` печатает «прошёл» или «прошла» по ФИО и строку «в объёме %hours»
из `eventHours`.

Выдача — запрос платформы на каждого выпускника:

```bash
curl -X POST "https://vruchay.ru/api/documents/$DOCUMENT_ID/issue" \
  -H "Authorization: Bearer $VRUCHAY_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: sql-course-enrollment-48213" \
  -d '{"data":{"name":"Иванова Анна Сергеевна"},"email":"ivanova@example.com","send":true}'
```

То же через MCP — для проверки на одном человеке:
`issue_document {"documentId": "<documentId>", "data": {"name": "Иванова Анна Сергеевна"}, "email": "ivanova@example.com", "send": true, "idempotencyKey": "sql-course-enrollment-48213"}`.

- Ключ — идентификатор записи о завершении курса: повтор после обрыва связи
  вернёт тот же сертификат, а не выпустит второй.
- `201` — сохраните в карточке ученика `file.code`, `file.verifyUrl`
  и `file.recipientUrl`. `202` — опрашивайте `statusUrl` раз в 1–2 секунды.
- Вебхуков нет: готовность узнаётся из ответа или опросом.
- Предел — 120 выдач в минуту на организацию; `429` — подождать и повторить.

## Конференция: сертификат участника, форма по ссылке и письма

Задача: зарегистрированным заранее — сертификаты письмом после конференции;
тем, кто пришёл без регистрации, — ссылка или QR на экране, по которым сертификат
забирают сами.

```text
create_project {"name": "Конференция «Цифровая школа» 2026"}
create_document {"title": "Сертификат участника", "projectId": "<projectId>", "format": "a4", "preset": {"kind": "attendee", "style": "strict", "titlePrinted": false}}
update_document {"documentId": "<documentId>", "eventName": "Конференция «Цифровая школа»", "eventDate": "20–21 ноября 2026", "eventPlace": "Казань"}
check_sheet {"documentId": "<documentId>", "sheetId": "<sheetId>"}
preview_sheet {"documentId": "<documentId>", "sheetId": "<sheetId>"}
set_mail_template {"documentId": "<documentId>", "subject": "Сертификат участника конференции", "bodyHtml": "<p>%name, спасибо, что были с нами! Сертификат — во вложении.</p>"}
import_recipients {"documentId": "<documentId>", "columns": ["name", "email"], "rows": [["Соколова Елена Павловна", "sokolova@example.com"]], "mode": "replace"}
validate_recipients {"documentId": "<documentId>"}
edit_recipients {"documentId": "<documentId>", "applySuggestions": true}   — однозначные исправления, если они есть
— показать человеку: сколько сертификатов выйдет —
start_generation {"documentId": "<documentId>"}
get_job {"jobId": "<jobId>", "waitSeconds": 60}
get_mail {"documentId": "<documentId>"}
— показать человеку: сколько писем уйдёт (audience.willSend), кому нет и с какого адреса —
send_mail {"documentId": "<documentId>"}
— показать человеку настройки формы —
setup_form {"kind": "link", "name": "Конференция — без регистрации", "documentIds": ["<documentId>"], "formFields": ["name"], "singleFilePerEmail": true}
```

```text
POST  /api/projects {"name": "…"}
POST  /api/documents {"title": "Сертификат участника", "projectId": "<projectId>", "format": "a4", "preset": {"kind": "attendee", "style": "strict", "titlePrinted": false}}
PATCH /api/documents/<documentId> {"eventName": "…", "eventDate": "…", "eventPlace": "…"}
POST  /api/mail/templates/<documentId> {"subject": "…", "bodyHtml": "…"}
POST  /api/documents/<documentId>/recipients/import {"columns": ["name", "email"], "rows": […], "mode": "replace"}
POST  /api/documents/<documentId>/generate {}
GET   /api/jobs/<jobId>
POST  /api/mailing/send {"documentIds": ["<documentId>"], "kind": "transactional", "source": "table"}
POST  /api/integrations/tilda {"kind": "link", "name": "…", "documentIds": ["<documentId>"], "formFields": ["name"]}
```

Ответ формы несёт `connect.formUrl` — ссылку `https://vruchay.ru/f/…`: её
показывают на экране или превращают в QR. «Один документ в руки» считается
по заявкам самой формы: повторная заявка с того же адреса получает уже выданный
формой сертификат — после кода на этот адрес. Тому, кому сертификат ушёл письмом по списку, форма выпустит
второй, — поэтому ссылку показывают тем, кто пришёл без регистрации. Каждая новая
заявка тратит документ из остатка. Для формы нужны права «Полный доступ».

## Новое мероприятие по прошлому шаблону

Задача: оформление прошлогодней грамоты — новому турниру, без старых получателей.

Из шаблона организации (`isTemplate: true`):

```text
list_documents {"templates": true}
create_document {"title": "Грамота — турнир 2027", "templateId": "<templateId>", "projectId": "<projectId>"}
```

Из обычного документа — копией «под новое мероприятие»: копируются размер листа,
листы с макетами и фоном, колонки, проект и настройки проверки; получатели,
мероприятие, даты и срок — нет.

```text
list_documents {"search": "турнир 2026"}
create_document {"copyOf": "<sourceId>", "title": "Грамота — турнир 2027"}
update_document {"documentId": "<newId>", "eventName": "Турнир по шахматам 2027", "eventDate": "март 2027"}
get_document {"documentId": "<newId>"}
edit_layout {"documentId": "<newId>", "sheetId": "<sheetId>", "baseVersion": 1, "update": [{"id": "<id текстового блока>", "props": {"text": "за победу в турнире"}}]}
check_sheet {"documentId": "<newId>", "sheetId": "<sheetId>"}
preview_sheet {"documentId": "<newId>", "sheetId": "<sheetId>"}
create_document {"copyOf": "<newId>", "asTemplate": true, "title": "Грамота турнира"}
```

REST: `GET /api/documents?templates=true`, `POST /api/documents {"templateId": …}`,
`POST /api/documents/{id}/duplicate`, `PATCH /api/documents/{id}`,
`PATCH /api/documents/{id}/sheets/{sheetId}/elements`, `POST /api/documents/{id}/template`.

- `baseVersion` — `version` листа из `get_document`. Если лист успел поменяться,
  придёт `409` — перечитайте и повторите.
- `props.text` заменяет текст блока целиком; поля пишутся как `%name`.
- Последний вызов сохраняет копию макета шаблоном на следующий год — спросите
  человека, нужно ли.

## Исправить ошибку в выданном документе

Задача: в грамоте опечатка в фамилии — выдать исправленную, а старую сделать
недействительной.

```text
search_registry {"search": "Иванова"}
get_recipients {"documentId": "<documentId>", "limit": 500}
edit_recipients {"documentId": "<documentId>", "update": [{"rowId": "<rowId>", "data": {"name": "Иванова Марина Сергеевна"}}]}
preview_sheet {"documentId": "<documentId>", "sheetId": "<sheetId>", "rowId": "<rowId>"}
— показать человеку: перевыпуск тратит документ из остатка —
reissue_documents {"fileIds": ["<fileId>"]}
get_job {"jobId": "<jobId>", "waitSeconds": 60}
search_registry {"search": "Иванова Марина"}
send_mail {"fileIds": ["<новый fileId>"]}
```

- В реестре у строки есть `fileId` и `documentId`; строку таблицы находят
  по имени или почте в `get_recipients` — перевыпуск печатает по ней.
- `jobId` для `get_job` — из ответа перевыпуска, `jobs[0].jobId`.
- Перевыпуск не отзывает старый документ, а заменяет: его страница проверки
  показывает, где взять новый (`state: "replaced"`, `replacedBy`).
- `send_mail` с `fileIds` отправляет письмо с уже выпущенным файлом на почту из строки таблицы и квоту не тратит.

Если документ выдан по ошибке вовсе — отзыв, без замены:

```text
revoke_documents {"fileIds": ["<fileId>"]}
— показать человеку: число документов и имена —
revoke_documents {"fileIds": ["<fileId>"], "expectedCount": 1, "reasonPublic": "Выдан по ошибке"}
```

REST — те же пути и тела: `GET /api/registry?search=…`, `PATCH /api/documents/{id}/recipients/rows/{rowId}`,
`POST /api/registry/reissue`, `POST /api/registry/resend`, `POST /api/registry/revoke/preview`,
`POST /api/registry/revoke`. Перевыпуск и отзыв требуют прав «Полный доступ».

## Проект на сайт: несколько документов за одной формой

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

```text
create_project {"name": "Сайт школы — олимпиада"}
create_document {"title": "Диплом участника", "projectId": "<projectId>", "format": "a4", "preset": {"kind": "participant", "style": "academic", "titlePrinted": false}}
create_document {"title": "Благодарность учителю", "projectId": "<projectId>", "format": "a4", "preset": {"kind": "thanks", "style": "academic", "titlePrinted": false}}
update_document {"documentId": "<id диплома>", "eventName": "Школьная олимпиада по физике"}
update_document {"documentId": "<id благодарности>", "eventName": "Школьная олимпиада по физике"}
check_sheet {…} и preview_sheet {…} — для каждого листа
— показать человеку настройки формы —
setup_form {"kind": "site", "name": "Сайт школы", "documentIds": ["<id диплома>", "<id благодарности>"], "allowedDomains": ["school5.example.ru"]}
```

В ответе `connect.embedHtml` — HTML для каждого документа отдельно: две кнопки
на одной странице или на разных. Куда вставлять на Тильде, WordPress и Битриксе —
в [Способах выдачи](integrations.md).

Дальше всё смотрят по проекту:

```text
search_registry {"projectId": "<projectId>"}
api_request {"method": "GET", "path": "/api/registry/analytics", "query": {"projectId": "<projectId>"}}
api_request {"method": "GET", "path": "/api/integrations/tilda/requests", "query": {"integrationId": "<integrationId>"}}
```

REST: `POST /api/projects`, `POST /api/documents` (с `projectId`),
`POST /api/integrations/tilda`, `GET /api/registry?projectId=…`,
`GET /api/registry/analytics?projectId=…`, `GET /api/integrations/tilda/requests`.
Для формы нужны права «Полный доступ» и возможность `tilda` в плане; каждая
заявка тратит документ из остатка.

---

<!-- authentication -->
# Аутентификация

## Токен

API опознаёт вызывающего по заголовку:

```
Authorization: Bearer vru_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
```

Токен выдаёт владелец или управляющий в кабинете: **Настройки → Интеграции и API →
Токены API** → «Выдать токен». Он состоит из приставки `vru_` и 32 случайных байт
в base64url. Приставка не украшение: по ней сервисы поиска утечек (GitHub, GitLab)
узнают секрет в чужом репозитории и предупреждают владельца.

Полный токен показывается **один раз**, в ответе на его создание. На сервере лежит только
хеш SHA-256, восстановить строку нельзя. Потеряли — отзовите и выдайте новый.
Держите токен в переменной окружения, а не в файлах и командах.

Получить токен можно и без кабинета — входом через OAuth: так подключаются
ИИ-агенты — Claude.ai, Claude Desktop, ChatGPT, Claude Code, Cursor, VS Code,
Codex, Gemini CLI (см. ниже). Это такой же токен `vru_`,
просто его выдача начинается в приложении агента.

## Сервер MCP

`https://vruchay.ru/mcp` принимает только заголовок `Authorization: Bearer vru_…` —
токен из кабинета или выданный через OAuth. Cookie кабинета там не действуют:
адрес вызывают программы, и открытая в браузере сессия не должна давать чужой
странице действовать от имени человека. Без токена `/mcp` отвечает `401`
с заголовком `WWW-Authenticate`, по которому клиенты находят вход через OAuth.
Права у инструментов MCP те же, что у REST по тому же токену. Подключение
клиентов — в [Подключении агента по MCP](mcp.md).

## Что даёт токен

Токен принадлежит организации, а не человеку. Он видит те же документы, получателей,
задания и реестр, что и сотрудник этой организации с той же ролью. Идентификатор
организации всегда берётся из токена и никогда из тела или адреса запроса: подставить
чужой `orgId` невозможно, а обращение к чужому объекту отвечает `404`, а не `403`, —
чтобы не подтверждать, что объект существует.

## Вход через OAuth

Для приложений, которые подключаются кнопкой, сервис — сервер авторизации
OAuth 2.1 с PKCE. Человек при этом токен не видит и никуда не копирует.

1. Клиент обращается к `/mcp` без токена, получает `401` и читает описание
   ресурса `/.well-known/oauth-protected-resource/mcp`, а из него — описание
   сервера авторизации
   [`/.well-known/oauth-authorization-server`](endpoints/oauth/get-well-known-oauth.md).
2. Регистрируется сам: [`POST /api/oauth/register`](endpoints/oauth/post-oauth-register.md) (Dynamic Client Registration,
   только публичные клиенты, `token_endpoint_auth_method: none`). Записи в базе
   при этом не появляется: `client_id` — это подписанные сервисом название
   приложения и его адреса возврата.
3. Открывает в браузере `https://vruchay.ru/oauth/authorize` с `code_challenge`
   (`S256`). Не вошедшего в кабинет сначала просят войти.
4. Страница согласия показывает, какое приложение просит доступ, к какой
   организации и на какой адрес вернётся разрешение. Человек выбирает права —
   «Выпускать документы» (`member`) или «Полный доступ» (`admin`) — и разрешает
   ([`POST /api/oauth/authorize`](endpoints/oauth/post-oauth-authorize.md))
   или отказывает.
5. Клиент меняет одноразовый код на токен:
   [`POST /api/oauth/token`](endpoints/oauth/post-oauth-token.md)
   (`grant_type=authorization_code`, `code`, `code_verifier`, `client_id`).
   Код живёт десять минут.

Что важно:

- **Разрешает только владелец или управляющий**, и только если доступ по API
  входит в план (возможность `api`; на пробе она есть). Сотрудник с ролью
  `member` увидит «Нужны права управляющего». Отказать может любой.
- **Результат — обычный токен `vru_`** с выбранной ролью. Токена обновления нет,
  срок жизни не ограничен: токен действует, пока его не отзовут и пока
  разрешивший остаётся в организации владельцем или управляющим.
- **Видно и отзывается** в «Токенах API»: строка «Агент «Claude»» (название —
  то, которым представилось приложение), с правами и временем последней работы.
  «Отозвать» — и агент сразу теряет доступ.
- **Повторное подключение того же приложения** тем же человеком заменяет прежний
  токен, а не копит новые. «Тем же» считается приложение с тем же названием
  и адресом возврата с того же сайта: Claude Code и Codex на одном компьютере
  живут рядом.
- **Не больше десяти подключений** через OAuth на организацию, отдельно от десяти
  обычных токенов. Одиннадцатое не пройдёт, пока не отозвать лишнее.
- **Адреса возврата** — `https`, `http` только на `localhost` и `127.0.0.1`
  (настольные клиенты) и собственные схемы приложений вроде `cursor://`.
- **Как клиент представляется.** Автоматической регистрацией
  (`POST /api/oauth/register`) — её сервер и объявляет. Клиент, который сам
  приходит с адресом своего описания (CIMD), тоже принимается: тогда
  `client_id` — это https-адрес JSON-документа с `client_id`, `client_name`
  и `redirect_uris`. Сервис скачивает его с защитой от походов во внутреннюю
  сеть, проверяет, что документ называет себя тем же адресом, и помнит его час.
  Описания на сайтах, закрытых для российских адресов (claude.ai, chatgpt.com),
  сервис не получит — таким клиентам нужна регистрация.
- Если за десять минут между разрешением и обменом кода человек потерял права
  управляющего, токен не выдастся.

Ошибки OAuth отвечают в формате RFC 6749, см. [Коды ошибок](errors.md).

## Роли

У токена своя роль, выбранная при выдаче: `member` («Выпускать документы») или
`admin` («Полный доступ»). У человека роль может быть ещё и `owner`. Часть действий
закрыта ролью:

| Роль | Что доступно |
| --- | --- |
| `member` | Документы, получатели, проверка, выпуск, чтение реестра, рассылка |
| `admin` | То же плюс удаление документов, отзыв выданного, домены и отправители, интеграции |
| `owner` | То же плюс безвозвратное удаление документа |

Роль `owner` токену выдать нельзя. Если действие требует роли выше вашей, ответ — `403`
с сообщением «Недостаточно прав для этого действия».

## Чего токен не может

Всё, что управляет людьми, закрыто для токенов наглухо и не открывается ролью:

- приглашение и удаление сотрудников, смена их ролей — `/api/team/*`;
- смена пароля;
- выдача и отзыв токенов — `/api/tokens/*`;
- разрешение доступа агенту на странице согласия OAuth.

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

Так же закрыто то, что человек делает от своего имени: своё имя и настройки кабинета
(`PATCH /api/org/me`, `GET` и `PATCH /api/org/preferences`), отзыв о сервисе
(`PATCH /api/reviews/mine`), обращения в поддержку (`POST /api/support/tickets`,
`POST /api/support/tickets/{id}/messages`). Своего имени у токена нет: по нему автором
и владельцем настроек оказался бы сотрудник, выдавший токен.
Читать организацию (`GET /api/org`), отзыв и обращения токену можно; `userName` и `email`
в `GET /api/org` у него — данные выдавшего, пока тот состоит в организации.

Такие запросы отвечают `403` с сообщением: «Это действие доступно только человеку, вошедшему
в кабинет. По токену API его выполнить нельзя.»

Отдельно закрыт раздел самого сервиса — выставление счетов, заявки, модерация отзывов,
назначение планов. Туда токен не пускают ни при каких правах. Оплату картой
(`POST /api/billing/checkout`) и счёт по тарифу (`POST /api/org/invoices`) токен тоже
начать не может: платит только человек.

## Возможности плана

Роль решает, кому из сотрудников действие доступно, а план организации — входит ли оно
в условия. План перечисляет возможности в поле `features` ответа `GET /api/org/usage`:

| Возможность | Что закрывает | Маршруты |
| --- | --- | --- |
| `mailing` | Рассылка писем участникам | `POST /api/mail/send/{documentId}`, `POST /api/mail/templates/{documentId}/test-send`, `POST /api/mailing/send`, `POST /api/mailing/test`, `POST /api/mailing/resend`, `POST /api/mailing/text/{id}/test`, `POST /api/mailing/text/{id}/send`; выдача по API с письмом (`POST /api/documents/{id}/issue` с `send: true`). Без неё не уходят и письма форм на сайте и по ссылке (документ выдаётся скачиванием), и повторная отправка из реестра (`POST /api/registry/resend` пропускает письма с причиной) |
| `tilda` | Приём заявок с сайта | `POST /api/integrations/tilda` — создание новой формы |
| `api` | Доступ по API-токену | `POST /api/tokens` — выдача нового токена; разрешение подключения агента через OAuth |
| `awards` | Автоматические правила награждения | `POST /api/award-rules`, `POST /api/award-rules/{id}/duplicate`, `POST /api/documents/{id}/awards/rule-set` |

Проверка стоит только на этих маршрутах. Чтение, правка и удаление уже созданного
ею не закрываются, а выданные раньше токены продолжают работать и без `api`.

На бесплатной пробе и на оплаченном тарифе старого образца (`features: null`) ограничений
нет. Если возможности в плане нет, ответ — `403`:

> «Рассылка писем участникам» не входит в ваш план. Напишите нам — добавим.

Если срок плана вышел (`expired: true`), те же маршруты отвечают `403` при любом составе
плана:

> Срок плана «Название» закончился, поэтому раздел «Рассылка писем участникам» сейчас
> недоступен. Уже выданные документы остаются действительными. Напишите нам — обсудим продление.

В кавычках стоит название возможности: «Рассылка писем участникам», «Приём заявок
с сайта», «Доступ по API-токену» или «Автоматические правила награждения». Проверка
плана идёт после проверки входа и роли, но до проверки тела запроса: запрос с ошибкой
в полях получит сперва `403`, а не `400`.

## Сессия человека

Кабинет работает не на токенах, а на httpOnly-cookie `gramota_session` со сроком жизни
семь суток. Оба способа разбирает один и тот же слой: сперва проверяется заголовок
`Authorization`, и только если токена нет — cookie. Для интеграции нужен именно токен:
вход по паролю программе недоступен, а формы входа защищены ограничением частоты.

Организацию заводит человек, регистрируясь в кабинете. Регистрация означает принятие
лицензионного договора-оферты и договора-поручения: сервис записывает, кто, когда,
с какого адреса и какую редакцию принял.

## Что ещё отличает токен от человека

- **Пробные письма** (`POST /api/mail/templates/{documentId}/test-send` и другие
  пробы) по токену уходят тому, кто токен выдал, — для подключения через OAuth тому,
  кто его разрешил. Адрес из запроса не берётся: иначе «проверить письмо» стало бы
  способом слать что угодно кому угодно. Если этого человека в организации уже нет,
  проба по токену невозможна — отправьте её из кабинета.
- **Ограничения частоты** для токена считаются на организацию, а не на IP-адрес:
  агенты Claude и ChatGPT ходят с общих облачных адресов за многих пользователей
  сразу. Подробнее — в [Ограничениях частоты](rate-limits.md).
- **Журнал действий** записывает действие от имени токена: «Токен «Название»».
- **Выдавший ушёл.** Токен, выданный в кабинете, принадлежит организации и продолжает
  работать, когда выдавшего его сотрудника убирают из организации. Связь с ним на этом
  обрывается: в журнале действие по-прежнему записано на токен и к ушедшему не привязано,
  `userName` и `email` в `GET /api/org` пустые, пробные письма по токену не уходят.
  Ненужные токены ушедшего отзовите в **Настройки → Интеграции и API → Токены API**.
  Подключение через OAuth — личное: это агент самого человека, поэтому оно отзывается
  вместе с его уходом, и журнал записывает отзыв каждого агента отдельной строкой.
  Агент получает `401`; подключить его снова может только тот, кто в организации
  состоит. То же при удалении учётной записи разрешившего.
- **Разрешивший понижен.** Подключать агентов могут только владелец и управляющий,
  и держать их — тоже. Когда разрешившего понижают до сотрудника (`member`), все его
  подключения через OAuth в этой организации отзываются, какие бы права ни были
  выбраны на странице согласия, и журнал записывает отзыв каждого агента. Агент
  получает `401`; если человека снова сделают управляющим, агента подключают заново.
  Токены, выданные им в кабинете, от его роли не зависят и продолжают работать
  с прежними правами.

## Гигиена

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

## Ошибки

| Код | Когда |
| --- | --- |
| `401` | Заголовка нет, токен неизвестен или отозван, либо заменён повторным подключением того же приложения |
| `403` | Роли не хватает, действие доступно только человеку, возможности нет в плане или срок плана вышел |
| `404` | Объект принадлежит другой организации либо не существует |

---

<!-- rate-limits -->
# Ограничения частоты

## Как это устроено

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

Счёт ведётся по маршруту и по тому, кто обращается. Лимит на вход не расходуется
обращениями к публичной форме и наоборот. Счётчики общие для всех процессов приложения,
лежат в Redis и живут ровно одно окно.

Кто обращается, определяется так:

- **запрос по токену** (из кабинета или выданному через OAuth) считается
  на организацию — с какого бы адреса он ни пришёл. Агенты Claude и ChatGPT ходят
  к нам с общих облачных адресов за многих пользователей сразу, и счёт по IP делил бы
  один предел между чужими организациями;
- **человек в кабинете и публичные адреса** — по IP-адресу.

Объём выпуска ограничивается условиями организации — пробой или планом, — а не
частотой запросов: см. `GET /api/org/usage`.

## Где лимиты есть

Из адресов, доступных по токену (по токену — на организацию):

| Маршрут | Лимит |
| --- | --- |
| `POST /mcp` — сервер MCP, любые запросы: вызовы инструментов, список, рукопожатие | 300 запросов в минуту на организацию |
| `POST /api/documents/{id}/sheets/{sheetId}/preview` — превью листа | 30 запросов в минуту на организацию |
| `POST /api/documents/{id}/sheets/{sheetId}/background/from-url` — фон по ссылке | 30 запросов в минуту |
| `POST /api/documents/{id}/assets/from-url` — картинка по ссылке | 30 запросов в минуту |
| `POST /api/documents/{id}/validation` | 40 запросов за 5 минут |
| `POST /api/documents/{id}/validation/fix` | 80 запросов за 5 минут |
| `POST /api/documents/{id}/validation/exclude` | 80 запросов за 5 минут |
| `POST /api/mail/templates/{documentId}/test-send` — пробное письмо по черновику шаблона | 5 запросов в минуту на организацию; отказ — `400`, а не `429` |
| `POST /api/mailing/test` и `POST /api/mailing/text/{id}/test` — пробные письма рассылок | 5 запросов в минуту на организацию, общий счётчик; отказ — `400`, а не `429` |
| `POST /api/analytics/digest/preview` — пробная месячная сводка на свою почту | 5 запросов в час |
| `POST /api/onboarding/events` — счётчики обучения в кабинете | 120 запросов в минуту |
| `POST /api/documents/{id}/issue` — один документ по API | 120 запросов в минуту на организацию |

Обращения в поддержку токену закрыты (`403`): их пишет человек в кабинете —
новое обращение не чаще 10 раз в час, ответ в нём — 30 раз в час.

Превью, MCP, пробные письма и выдача по API считаются на организацию всегда —
и по токену, и из кабинета. Отказ пробного письма по частоте приходит кодом `400`
с текстом «Слишком часто. Подождите N секунд и попробуйте снова». Инструменты MCP вызывают REST изнутри сервера, поэтому
каждый вызов инструмента расходует и лимит `/mcp`, и лимит маршрута, который он
вызывает: например, `validate_recipients` — ещё и лимит проверки.

Вход агентов через OAuth и ссылка на архив — по IP-адресу:

| Маршрут | Лимит |
| --- | --- |
| `POST /api/oauth/register` — регистрация клиента OAuth | 600 запросов в час |
| `POST /api/oauth/token` — обмен кода на токен | 300 запросов в минуту |
| `GET /api/v1/archive/{token}` — скачивание по ссылке на архив | 30 запросов в минуту |

Публичные адреса, доступные без токена:

| Маршрут | Лимит |
| --- | --- |
| `GET /api/v1/verify/{id}` | 30 запросов в минуту |
| `GET /api/v1/public/org/{slug}` | 60 запросов в минуту |
| `GET /api/v1/public/org/{slug}/search` | 20 запросов в минуту |
| `GET /api/referral-offer` | 20 запросов за 5 минут |
| `POST /api/v1/leads` — заявка на разговор об условиях | 5 запросов за 10 минут |
| `GET /api/v1/recipient/{token}` — страница документа по ссылке из письма | 60 запросов за 5 минут |
| `GET /api/v1/recipient/{token}/pdf` — файл документа по той же ссылке | 30 запросов за 10 минут |
| `GET /api/v1/t/o/{emailId}` — пиксель открытия письма | 60 запросов в минуту |
| `GET /api/v1/u/{emailId}` и `GET /api/v1/u/{emailId}/confirm` — отписка | по 30 запросов за 5 минут |
| `POST /api/v1/payments/yookassa` — уведомления платёжного сервиса | 120 запросов за 5 минут |
| `POST /api/v1/payments/tochka` — уведомления банка о поступлениях | 120 запросов за 5 минут |

Формы выдачи — на сайте организации (Тильда и наш скрипт формы) и по ссылке:

| Маршрут | Лимит |
| --- | --- |
| `GET /api/v1/forms/{token}` — страница формы по ссылке | 120 запросов за 5 минут |
| `POST /api/v1/tilda/submit` | 20 запросов за 5 минут на форму, не больше 100 с адреса |
| `POST /api/v1/tilda-create` — приём с серверов Тильды | 120 запросов за 5 минут |
| `POST /api/v1/tilda/confirm` | 15 запросов за 10 минут на заявку, не больше 100 с адреса |
| `GET /api/v1/tilda/confirm/{requestId}/{code}` | 15 запросов за 10 минут |
| `GET /api/v1/tilda/status/{requestId}` | 120 запросов за 5 минут на заявку, не больше 600 с адреса |
| `GET /api/v1/tilda/download/{requestId}` | 20 запросов за 10 минут на заявку, не больше 100 с адреса |
| `POST /api/v1/tilda/my` | 10 запросов за 5 минут |
| `POST /api/v1/tilda/my/confirm` | 15 запросов за 10 минут |
| `GET /api/v1/tilda/my/{listId}`, `GET /api/v1/tilda/my/{listId}/download/{requestId}` | по 60 запросов за 10 минут |

У `tilda-create` лимит по адресу общий на все сайты сразу: Тильда шлёт со своих
серверов. От накрутки там защищает суточный предел интеграции (`dailyLimit`).

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

Вход и учётная запись — это адреса человека, а не программы, но лимиты на них тоже есть:

| Маршрут | Лимит |
| --- | --- |
| `POST /api/auth/login` | 10 запросов за 5 минут |
| `POST /api/auth/login/totp` | 5 запросов за 5 минут |
| `POST /api/auth/register` | 5 запросов за 10 минут |
| `POST /api/auth/resend-verification` | 3 запроса за 10 минут |
| `POST /api/auth/verify` | 10 запросов за 10 минут |
| `POST /api/auth/forgot` | 3 запроса за 15 минут |
| `POST /api/auth/reset` | 10 запросов за 15 минут |
| `DELETE /api/auth/account` | 5 запросов за 15 минут |
| `POST /api/auth/totp/setup`, `/enable`, `/disable` | по 10 запросов за 10 минут |
| `POST /api/auth/totp/backup-codes` | 5 запросов за 15 минут |
| `POST /api/team-invite/accept` | 10 запросов за 15 минут |

Остальные маршруты кабинета — документы, получатели, выпуск, реестр, рассылка — частотой
не ограничены (через MCP — в пределах общих 300 вызовов в минуту). Это не приглашение
молотить их в тысячу потоков: сервис общий, и разумный темп в ваших же интересах.

## Ответ при превышении

```
HTTP/1.1 429 Too Many Requests
Content-Type: application/json
```

```json
{
  "message": "Слишком много попыток. Повторите через 43 с."
}
```

Код ошибки — в статусе HTTP, поля `statusCode` в теле нет. У превью, выдачи по API
и сервера MCP текст свой («Слишком много превью…», «Не больше 120 документов
по API в минуту…», «Слишком часто…»), но число секунд в нём есть всегда.

Число секунд в сообщении — время до конца текущего окна. Отдельного заголовка
`Retry-After` в ответе нет, значение придётся взять из текста.

Сервер MCP при превышении своего предела отвечает `429` с телом JSON-RPC:

```json
{
  "jsonrpc": "2.0",
  "id": null,
  "error": { "code": -32600, "message": "Слишком часто. Повторите через 12 с." }
}
```

Если же лимит сработал на маршруте внутри инструмента, сам запрос к `/mcp`
отвечает `200`, а инструмент возвращает ошибку с `isError: true` и текстом
«Ошибка 429: …».

## Как вести себя программе

1. Не гонитесь за скоростью там, где ответ не изменится: список получателей и состояние
   задания меняются медленно.
2. Задание выпуска опрашивайте не чаще раза в 2–3 секунды. Тысяча документов печатается
   минутами, а не миллисекундами. Через MCP удобнее `get_job` с `waitSeconds`: он ждёт
   на стороне сервера и тратит один вызов вместо двадцати.
3. Получив `429`, подождите указанное число секунд и повторите. Повтор сразу же
   продлевает ожидание, а не сокращает его.
4. При недоступности Redis сервис считает обращения в памяти процесса. Лимит при этом
   становится мягче, но не исчезает.

---

<!-- errors -->
# Ошибки

## Формат

Ошибка — это всегда JSON. Минимальный вид:

```json
{
  "statusCode": 404,
  "message": "Документ не найден",
  "error": "Not Found"
}
```

`message` написан по-русски и предназначен человеку: его можно показать в интерфейсе
как есть. Внутренних подробностей — путей, SQL, стека — в ответе нет никогда; они
остаются в журнале сервера.

Ошибка проверки тела запроса дополнительно перечисляет поля:

```json
{
  "message": "Назовите токен — потом не разберётесь",
  "errors": [
    { "field": "name", "message": "Назовите токен — потом не разберётесь" }
  ]
}
```

- `errors[].field` — путь к полю через точку, например `data.name` или `rows.3.1`.
- `errors[].message` — что именно не так.
- `message` — сводка: до трёх разных причин через точку с запятой, дальше
  «— и ещё N». Длинное перечисление никто не дочитывает.

Ответы с дополнительными полями — проверка полей, конфликт версии листа,
превышение частоты, сбой превью — приходят без `statusCode` и `error`: в теле
только `message` и свои поля. Поэтому код ошибки берите из статуса HTTP,
а не из тела.

## Коды

| Код | Что значит | Что делать |
| --- | --- | --- |
| `200` | Успех | — |
| `201` | Объект создан. Ответ на `POST`, если не указано иное | — |
| `400` | Тело или параметры не прошли проверку — либо состояние не позволяет действие: выпуск по материалу уже идёт, остатка документов не хватает, срок плана вышел | Исправить поля из `errors`; для остального прочитать `message` и текущее состояние (`GET /api/org/usage`, задание выпуска) |
| `401` | Нет токена, либо он неизвестен или отозван | Проверить заголовок `Authorization` |
| `403` | Не хватает роли, действие только для человека, возможности нет в плане или срок плана вышел | См. [Аутентификация](authentication.md) |
| `404` | Объекта нет — или он принадлежит другой организации | Проверить идентификатор |
| `409` | Конфликт: лист изменился с тех пор, как его прочитали (правка листа с `baseVersion`); проект с таким названием уже есть (`POST /api/projects`, `PATCH /api/projects/{id}` и их устаревшие синонимы `/api/folders`); `Idempotency-Key` уже использован для другого документа (`POST /api/documents/{id}/issue`) | Для листа — перечитать и повторить с новой версией (см. ниже); для проекта — другое название; для выдачи — новый ключ на каждого получателя |
| `413` | Тело больше допустимого | Разбить на части; предел — 1 МБ, у импорта 16 МБ, у `/mcp` 2 МБ |
| `422` | Превью листа не отрисовалось (`POST …/sheets/{sheetId}/preview`) | Проверить лист (`POST …/check`) и `fileId` картинок, повторить |
| `429` | Слишком часто: с одного адреса — человек и публичные маршруты; от организации — запросы по токену, MCP, превью и выдача по API | Подождать столько секунд, сколько названо в `message`, см. [Ограничения частоты](rate-limits.md) |
| `500` | Ошибка на нашей стороне | Повторить позже; текст ответа обобщённый |
| `503` | Внешняя служба недоступна: очередь выпуска или печати, почтовый сервис при подключении домена, оплата картой не подключена; превью не успело отрисоваться за 30 секунд | Повторить позже; при выпуске сперва найти уже заведённое задание, см. [AGENTS.md](AGENTS.md) |

## Конфликт версии листа

У листа есть номер версии `version` — он растёт с каждым сохранением. Правка
листа (`PATCH …/sheets/{sheetId}`, `PATCH …/sheets/{sheetId}/elements`, инструменты
MCP `edit_layout` и `set_layout`) принимает необязательный `baseVersion` — версию,
которую программа видела. Если лист за это время изменился, ответ `409`, и в теле
есть текущая версия:

```json
{
  "message": "Лист изменился (версия 8, а правка сделана к версии 7). Перечитайте лист и повторите правку.",
  "version": 8
}
```

Перечитайте лист (`GET /api/documents/{id}` или `get_document`), примените правку
к свежему листу и передайте `baseVersion: 8`. Без `baseVersion` последнее сохранение
выигрывает. Редактор в кабинете пишет с версией: чужую правку он не затирает,
а спрашивает человека, чья версия остаётся.

Если лист правят одновременно слишком часто и правка четыре раза подряд не застала
его неизменным, ответ тоже `409`, без `version`: «Лист правят одновременно слишком
часто — повторите запрос». Так бывает и у правок без `baseVersion`, например
у готовой раскладки.

## Проверка подлинности — своя форма ответа

Публичная проверка подлинности `GET /api/v1/verify/{id}` отвечает на ненайденный
документ телом другой формы — без `statusCode` и `error`, зато с полем `reason`:

```json
{ "message": "Такого документа нет", "reason": "checksum" }
```

`reason` принимает значения `malformed` (код не той длины или с посторонними
знаками), `checksum` (опечатка — контрольная часть кода не сошлась) и `unknown`
(код правильный, но такого документа нет). Различие нужно, чтобы сказать
проверяющему «проверьте, не ошиблись ли при наборе» вместо «документа не
существует».

## Ошибки входа через OAuth

Адреса OAuth (`POST /api/oauth/register`, `POST /api/oauth/token`) разбирают
клиенты OAuth, а не люди, поэтому отвечают в формате RFC 6749 — полями `error`
и `error_description`, без `statusCode`:

```json
{
  "error": "invalid_grant",
  "error_description": "Код неверный, просрочен или уже использован"
}
```

| `error` | Когда |
| --- | --- |
| `invalid_client_metadata` | При регистрации нет `redirect_uris` или клиент не публичный (`token_endpoint_auth_method` не `none`) |
| `invalid_redirect_uri` | Адрес возврата не годится: `http` не на `localhost`, фрагмент `#`, опасная схема |
| `unsupported_grant_type` | `grant_type` не `authorization_code` |
| `invalid_request` | Нет `code`, `code_verifier` или `client_id` |
| `invalid_grant` | Код неверный, просрочен (живёт 10 минут) или уже использован; выдан другому клиенту; не совпал `redirect_uri` или PKCE; разрешивший больше не управляющий; у организации уже десять подключений |

Страница согласия `https://vruchay.ru/oauth/authorize` показывает ошибки человеку
по-русски: «Неизвестное приложение: client_id не наш или повреждён», «Адрес
возврата не совпадает с зарегистрированным у приложения», «Нужны права управляющего».
Отказ человека возвращает приложению `error=access_denied` в адресе возврата.

Сервер MCP без токена или с неизвестным токеном отвечает `401` с телом
`{"error": "unauthorized", "message": "…"}` и заголовком
`WWW-Authenticate: Bearer resource_metadata="https://vruchay.ru/.well-known/oauth-protected-resource/mcp"`
(с неизвестным токеном — ещё `error="invalid_token"`): по нему клиент начинает вход.

## Ошибки в MCP

Ошибка инструмента — это не ошибка протокола. Запрос к `/mcp` отвечает `200`,
а в результате вызова стоит `isError: true` и текст по-русски — тот же, что
вернул бы REST:

```json
{
  "jsonrpc": "2.0",
  "id": 7,
  "result": {
    "isError": true,
    "content": [
      {
        "type": "text",
        "text": "Ошибка 400: Введите название\nПоля: [{\"field\":\"title\",\"message\":\"Введите название\"}]"
      }
    ]
  }
}
```

Так агент видит причину и может поправить запрос, а не обрывает работу.
Аргументы, не подошедшие схеме инструмента, — тоже `isError` с текстом
«Аргументы не подошли — …».

Ошибкой протокола JSON-RPC (`error` вместо `result`) отвечают только на неверное
сообщение: `-32600` — не JSON-RPC 2.0 или пачка сообщений (и превышение
300 запросов в минуту, вместе с HTTP `429` и `Retry-After`), `-32601` — неизвестный
метод, `-32602` — неизвестный инструмент или подсказка либо `arguments` не объект.
Клиенты протокола `2026-07-28` получают ещё `-32020` (заголовки `MCP-Protocol-Version`,
`Mcp-Method`, `Mcp-Name` не совпали с телом, HTTP `400`) и `-32022` (незнакомая
версия, HTTP `400`, в `data.supported` — версии сервера), а неизвестный метод
у них — HTTP `404`. Подробно — в [`POST /mcp`](endpoints/mcp/post-mcp.md).

## Почему `404`, а не `403`

Обращение к объекту чужой организации отвечает `404`. Ответ `403` подтвердил бы, что
объект с таким идентификатором существует, — а это уже сведения о чужих делах. Поэтому
«не ваш» и «не существует» для API неразличимы.

## Разбор ошибок в программе

Ориентируйтесь на статус HTTP и на `errors[].field`. Тексты сообщений живут в схемах
проверки и меняются вместе с ними: сопоставлять их построчно — способ однажды сломаться
на исправленной опечатке.

Ошибки с кодами `4xx` повторять бессмысленно, пока не изменился запрос. Повторять стоит
`429` (после паузы) и `500` (с нарастающей задержкой).

---

<!-- endpoints/analytics/get-analytics-summary -->
> method: GET | path: /api/analytics/summary | auth: token | roles: any | rate_limit: none

# GET /api/analytics/summary

Сводка за период одним запросом — экран «Аналитика» в кабинете: выпуск и проверки по QR по дням, сравнение с прошлым таким же периодом, письма, состояние выданных документов и разбивка по материалам. Сужается до проекта или одного материала.

Период отсчитывается от начала дня по Москве: `7d` — сегодня и шесть предыдущих дней, `30d`, `90d` и `365d` — так же. `all` — всё время; сравнения с прошлым периодом у него нет (`prev: null`), а графики по дням всё равно охватывают последние 365 дней. Пустые дни в графиках стоят нулями.

«Выданное» — то же, что показывает реестр (`GET /api/registry`): выпущенные документы, не удалённые, с сохранённым файлом. Проверки берутся из обезличенного счётчика по дням — кто проверял, там нет. Письма считаются только привязанные к документам, выданным за период; рассылки без документа сюда не входят — их цифры в `GET /api/mailing/stats`.

С `projectId` всё считается по материалам проекта; `none` — по материалам без проекта. Чужой проект — 404. `documentId` проверяется только на вид UUID: чужой или несуществующий материал даёт нули, а не ошибку.

## Запрос

Неизвестные параметры строки запроса игнорируются.

| Параметр | Тип | Обязателен | Описание |
|---|---|---|---|
| period | `7d` \| `30d` \| `90d` \| `365d` \| `all` | нет | Период; по умолчанию `30d` |
| documentId | string (UUID) | нет | Только один материал |
| projectId | string (UUID) \| `none` | нет | Только материалы проекта ([`GET /api/projects`](../projects/get-projects.md)); `none` — без проекта |

## Ответ

`200 OK`

| Поле | Тип | Описание |
|---|---|---|
| period | string | Период из запроса |
| range.from | string (ISO 8601) \| null | Начало периода — полночь по Москве; `null` у `all` |
| range.to | string (ISO 8601) | Момент запроса |
| range.prevFrom, range.prevTo | string (ISO 8601) \| null | Прошлый такой же период для сравнения; `null` у `all` |
| issued.total | number | Выдано за период |
| issued.prev | number \| null | Выдано за прошлый период |
| issued.byDay | array | `{day, n}` — выдано по дням, `day` в виде `ГГГГ-ММ-ДД` по Москве |
| checks.total | number | Проверок по QR за период |
| checks.prev | number \| null | Проверок за прошлый период |
| checks.uniques | number | Из них первых за сутки — повторное открытие той же страницы тем же посетителем в тот же день не считается |
| checks.files | number | Сколько документов проверяли за период (документ мог быть выдан и раньше) |
| checks.lastAt | string (ISO 8601) \| null | Последняя проверка за всё время |
| checks.byDay | array | `{day, n}` — проверки по дням |
| mail.sent | number | Писем принято шлюзом: `sent`, `delivered`, `opened`, `bounced` |
| mail.delivered | number | Доставлено: `delivered`, `opened` |
| mail.undelivered | number | Не дошло: `bounced`, `failed` |
| states.valid | number | Из выданных за период — действуют |
| states.revoked | number | Отозваны |
| states.replaced | number | Заменены новыми (и не отозваны) |
| states.expired | number | Срок действия истёк (не отозваны и не заменены) |
| materials | array | Разбивка по материалам: до 50 строк, больше выдано — выше |
| materials[].documentId | string (UUID) \| null | Материал |
| materials[].title | string | Название (у материала в корзине — тоже его название); «Материал удалён», если записи материала не нашлось; «Без материала» при `documentId: null` |
| materials[].eventName | string | Мероприятие материала |
| materials[].issued | number | Выдано за период |
| materials[].checks | number | Проверок за период по документам материала |
| materials[].checkedFiles | number | Сколько документов материала проверяли за период |
| materials[].sent | number | Писем принято шлюзом |
| materials[].delivered | number | Писем доставлено |
| materialsTotal | number | Сколько всего материалов с выдачей за период — разбивка показывает не больше 50 |

```json
{
  "period": "30d",
  "range": {
    "from": "2026-09-12T21:00:00.000Z",
    "to": "2026-10-12T09:15:00.000Z",
    "prevFrom": "2026-08-13T21:00:00.000Z",
    "prevTo": "2026-09-12T21:00:00.000Z"
  },
  "issued": {
    "total": 340,
    "prev": 120,
    "byDay": [
      { "day": "2026-09-13", "n": 0 },
      { "day": "2026-09-14", "n": 293 }
    ]
  },
  "checks": {
    "total": 152,
    "prev": 40,
    "uniques": 131,
    "files": 96,
    "lastAt": "2026-10-11T17:42:00.000Z",
    "byDay": [
      { "day": "2026-09-13", "n": 0 },
      { "day": "2026-09-14", "n": 12 }
    ]
  },
  "mail": { "sent": 318, "delivered": 301, "undelivered": 9 },
  "states": { "valid": 334, "revoked": 1, "replaced": 3, "expired": 2 },
  "materials": [
    {
      "documentId": "9e8d7c6b-5a4f-4e3d-2c1b-0a9f8e7d6c5b",
      "title": "Грамоты участникам",
      "eventName": "Конкурс «Мастер года»",
      "issued": 293,
      "checks": 140,
      "checkedFiles": 88,
      "sent": 271,
      "delivered": 258
    }
  ],
  "materialsTotal": 2
}
```

В примере `byDay` сокращены: на самом деле в них по строке на каждый день периода.

## Ошибки

| Код | Когда |
|---|---|
| 400 | `period` не из списка: «Invalid option: expected one of "7d"\|"30d"\|"90d"\|"365d"\|"all"» |
| 400 | `documentId` не UUID: «Некорректный идентификатор материала» |
| 400 | `projectId` не UUID и не `none`: «Некорректный идентификатор проекта» |
| 401 | Нет токена или сессии |
| 404 | Проекта нет или он чужой: «Проект не найден» |

## Пример

```bash
curl "https://vruchay.ru/api/analytics/summary?period=90d&projectId=$PROJECT_ID" \
  -H "Authorization: Bearer $VRUCHAY_TOKEN"
```

---

<!-- endpoints/analytics/get-analytics -->
> method: GET | path: /api/analytics | auth: token | roles: any | rate_limit: none

# GET /api/analytics

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

Параметров нет: организация берётся из токена или сессии, отбора по проекту и материалу здесь нет. Сводка за период с отбором по проекту и материалу — `GET /api/analytics/summary`.

Как считается:

- «Выданное» — то же, что показывает реестр (`GET /api/registry`): выпущенные документы, не удалённые, с сохранённым файлом. Отозванные и заменённые входят.
- «Разослано» — письма, принятые почтовым шлюзом: `sent`, `delivered`, `opened` и `bounced` (ушло и вернулось). Письма всех видов — по материалам и рассылки без документа.
- Проверки по QR — обезличенные счётчики на документах. Кто и откуда проверял, сервис не хранит.
- Пакеты — выпуски пакетом по таблице, включая перевыпуск из реестра. Выдача по одному документу — с формы или через `POST /api/documents/{id}/issue` — в них не входит. Отменённые пакеты не считаются ни удачными, ни неудачными.
- Месяцы — календарные, по Москве.

Всё считается агрегатами по своей организации — строки в память не выбираются, поэтому ответ одинаково быстрый у новой организации и у организации с сотней тысяч документов.

## Запрос

Параметров нет.

## Ответ

`200 OK`

| Поле | Тип | Описание |
|---|---|---|
| registeredAt | string (ISO 8601) | Когда организация зарегистрирована |
| activation.steps | array | Шаги работы с сервисом по порядку: `{key, label, done}` |
| timeToFirst.firstIssuedAt | string (ISO 8601) \| null | Когда выдан первый документ; `null` — ещё не выдавали |
| timeToFirst.minutes | number \| null | Полных минут от регистрации до первого выданного документа |
| timeToFirst.targetMinutes | number | Ориентир — 10 минут |
| issued | number | Выдано документов за всё время |
| mailed | number | Разослано писем за всё время |
| verifications.total | number | Проверок по QR за всё время |
| verifications.files | number | Сколько выданных документов проверяли хотя бы раз |
| packages.started | number | Пакетов выпуска поставлено в очередь, в любом состоянии |
| packages.finished | number | Закончились: выполненные и упавшие |
| packages.clean | number | Выполнены без единой ошибки |
| packages.cleanShare | number \| null | `clean / finished`, от 0 до 1; `null`, если законченных нет |
| reissues.count | number | Выданных документов, которые заменены или ждут замены |
| reissues.share | number \| null | `count / issued`, от 0 до 1; `null`, если выданных нет |
| thisMonth | object | Текущий месяц: `{title, issued, mailed, verifiedFiles}` |
| lastMonth | object | Прошлый месяц, те же поля |

Шаги `activation.steps[].key` по порядку: `registered` — «Зарегистрировались», `imported` — «Загрузили список», `checked` — «Прошли проверку списка», `issued` — «Выпустили первый документ», `mailed` — «Разослали первый пакет», `returned` — «Вернулись со вторым мероприятием». Воронка не растёт: пройденный поздний шаг отмечает и все предыдущие. `checked` засчитывается по правкам из проверки списка или по запущенному выпуску; `returned` — когда первые выпуски по разным материалам пришлись хотя бы на два разных дня.

В месячных итогах: `title` — «октябрь 2026»; `issued` — выданные в этом месяце; `mailed` — письма, принятые шлюзом в этом месяце; `verifiedFiles` — документы, последняя проверка которых пришлась на этот месяц (истории проверок по датам у документа нет, поэтому документ, проверенный и в сентябре, и в октябре, попадёт только в октябрь).

```json
{
  "registeredAt": "2026-08-19T14:00:00.000Z",
  "activation": {
    "steps": [
      { "key": "registered", "label": "Зарегистрировались", "done": true },
      { "key": "imported", "label": "Загрузили список", "done": true },
      { "key": "checked", "label": "Прошли проверку списка", "done": true },
      { "key": "issued", "label": "Выпустили первый документ", "done": true },
      { "key": "mailed", "label": "Разослали первый пакет", "done": true },
      { "key": "returned", "label": "Вернулись со вторым мероприятием", "done": false }
    ]
  },
  "timeToFirst": {
    "firstIssuedAt": "2026-08-19T14:08:30.000Z",
    "minutes": 8,
    "targetMinutes": 10
  },
  "issued": 340,
  "mailed": 318,
  "verifications": { "total": 152, "files": 96 },
  "packages": { "started": 6, "finished": 5, "clean": 4, "cleanShare": 0.8 },
  "reissues": { "count": 3, "share": 0.008823529411764706 },
  "thisMonth": { "title": "октябрь 2026", "issued": 47, "mailed": 47, "verifiedFiles": 12 },
  "lastMonth": { "title": "сентябрь 2026", "issued": 293, "mailed": 271, "verifiedFiles": 80 }
}
```

## Ошибки

| Код | Когда |
|---|---|
| 401 | Нет токена или сессии |

## Пример

```bash
curl "https://vruchay.ru/api/analytics" \
  -H "Authorization: Bearer $VRUCHAY_TOKEN"
```

---

<!-- endpoints/analytics/post-analytics-digest-preview -->
> method: POST | path: /api/analytics/digest/preview | auth: token | roles: owner, admin | rate_limit: «5 запросов в час»

# POST /api/analytics/digest/preview

Отправляет на свою почту месячную сводку — то самое письмо, что первого числа в 9:00 по Москве уходит владельцам организации, если за прошлый месяц у неё что-то происходило. Нужно, чтобы посмотреть письмо, не дожидаясь первого числа.

В письме — итоги прошлого календарного месяца по Москве: выпущено документов, разослано писем, сколько документов проверяли по QR, и общее число проверок за всё время. Тема — «Вручай: итоги за сентябрь 2026». Письмо служебное и уходит с адреса сервиса, а не с отправителя организации, мимо шаблонов и журнала писем. В отличие от настоящей сводки, проба в журнал действий не пишется и ежемесячную рассылку не отменяет.

Получатель не задаётся — иначе маршрут стал бы способом отправить письмо от имени сервиса на чужой ящик:

- вход по сессии — письмо уходит на почту вошедшего;
- вход по токену API — тому, кто выдал токен. Если этого человека больше нет в организации, запрос отклоняется — пробу надо отправить из кабинета.

Частота ограничена: 5 запросов в час — для входа по токену на организацию, для входа по сессии — с одного IP-адреса. Превышение возвращает `429`.

## Запрос

Тело не нужно и не читается.

## Ответ

`201 Created` — письмо ушло; в ответе те же цифры, что в нём.

| Поле | Тип | Описание |
|---|---|---|
| ok | boolean | Всегда `true` |
| title | string | Месяц сводки: «сентябрь 2026» |
| issued | number | Выдано документов за месяц |
| mailed | number | Писем принято почтовым шлюзом за месяц |
| verifiedFiles | number | Документы, последняя проверка которых по QR пришлась на этот месяц |

```json
{
  "ok": true,
  "title": "сентябрь 2026",
  "issued": 293,
  "mailed": 271,
  "verifiedFiles": 80
}
```

## Ошибки

| Код | Когда |
|---|---|
| 400 | Вход по токену, а выдавшего токен уже нет в организации: «Пробное письмо по токену уходит человеку, который выдал токен, а его в организации уже нет. Отправьте пробу из кабинета.» |
| 401 | Нет токена или сессии |
| 403 | Роль `member`: «Недостаточно прав для этого действия» |
| 429 | Больше пяти запросов за час: «Слишком много попыток. Повторите через N с.» |
| 500 | Почтовый шлюз не принял письмо |

## Пример

```bash
curl -X POST "https://vruchay.ru/api/analytics/digest/preview" \
  -H "Authorization: Bearer $VRUCHAY_TOKEN"
```

---

<!-- endpoints/audit/get-audit-facets -->
> method: GET | path: /api/audit/facets | auth: token | roles: owner, admin | rate_limit: none

# GET /api/audit/facets

Чем можно отобрать [журнал действий](get-audit.md): виды действий и авторы, которые в журнале организации встречаются. Пустых видов и авторов без записей здесь нет — отбор по ним дал бы пустой список. Счёт — по всему журналу, а не по какому-то отбору.

Автор — это почта сотрудника, а не его имя: если имя меняли, у человека всё равно один пункт, подпись — из последней записи. Токен отделён от выдавшего его сотрудника и узнаётся по названию; действия системы собраны в один пункт `system` с подписью «Сервис».

## Запрос

Без параметров.

## Ответ

`200 OK`

| Поле | Тип | Описание |
|---|---|---|
| `groups` | array | Виды действий, которые есть в журнале, в постоянном порядке; `other` — последним. |
| `groups[].group` | string | Значение для параметра `group`: `issue`, `mail`, `verify`, `table`, `delete`, `team`, `access`, `upload`, `billing`, `domain`, `platform`, `other`. |
| `groups[].count` | number | Сколько записей этого вида. |
| `actors` | array | Авторы записей, у кого записей больше — первыми. |
| `actors[].id` | string | Значение для параметра `actor`: `email:<почта>`, `name:Токен «…»` или `system`. |
| `actors[].label` | string | Имя сотрудника из последней его записи, название токена или «Сервис». |
| `actors[].count` | number | Сколько записей у автора. |

```json
{
  "groups": [
    { "group": "issue", "count": 42 },
    { "group": "mail", "count": 17 },
    { "group": "team", "count": 3 }
  ],
  "actors": [
    { "id": "email:maria@school.ru", "label": "Мария Иванова", "count": 51 },
    { "id": "name:Токен «выгрузка из CRM»", "label": "Токен «выгрузка из CRM»", "count": 9 },
    { "id": "system", "label": "Сервис", "count": 2 }
  ]
}
```

## Ошибки

| Код | Когда |
|---|---|
| 401 | Нет действующего токена и нет cookie-сессии. |
| 403 | Роль `member` (в том числе токен с ролью `member`): «Недостаточно прав для этого действия». |

## Пример

```bash
curl "https://vruchay.ru/api/audit/facets" \
  -H "Authorization: Bearer $VRUCHAY_TOKEN"
```

---

<!-- endpoints/audit/get-audit -->
> method: GET | path: /api/audit | auth: token | roles: owner, admin | rate_limit: none

# GET /api/audit

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

Смотреть могут владелец и управляющий (в том числе по токену с ролью `admin`). Подробности события (`meta`), адрес (`ip`) и идентификатор сотрудника в ответ не входят — только имя и почта, скопированные в момент действия, чтобы запись оставалась читаемой после удаления сотрудника. Действия системы (например, отметка об отправленной месячной сводке) идут с пустыми `actorName` и `actorEmail`.

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

Известные значения `action` (собраны по коду сервера) и вид, к которому каждое относится, — для параметра `group`:

| action | group | Что произошло |
|---|---|---|
| generation.start | issue | Запущен выпуск |
| generation.cancel | issue | Выпуск отменён |
| generation.resume | issue | Выпуск возобновлён |
| issue.api | issue | Документ выдаётся по API («Документ выдаётся по API», с письмом — «…с письмом») |
| issue.refused | issue | Форма на сайте не выдала документ: квота исчерпана, срок плана вышел, материал стал шаблоном или его макет пуст («Форма на сайте не выдала документ: …»); пишет система |
| registry.reissue | issue | Перевыпуск документов из реестра («Перевыпущено документов: N») |
| mail.send | mail | Разосланы письма участникам с документами («Разослано писем участникам: N») |
| mailing.send | mail | Запущена рассылка по списку («Рассылка (<вид>): писем N») |
| mailing.resend | mail | Повторная отправка недоставленных писем |
| registry.resend | mail | Повторная отправка писем из реестра («Переотправлено писем из реестра: N») |
| analytics.digest | mail | Владельцу организации отправлена месячная сводка («Отправлена сводка за …»); пишет система |
| verify.revoke | verify | Отозвана проверка выданного документа (из материала или из реестра) |
| verify.restore | verify | Проверка документа возвращена |
| validation.fix | table | Исправлены строки по результатам проверки («Исправлено строк: N») |
| validation.exclude | table | Снята отметка со строк по результатам проверки («Снята отметка со строк: N») |
| document.trash | delete | Материал отправлен в корзину |
| document.purge | delete | Материал удалён навсегда вместе с выданными файлами |
| team.invite | team | Приглашён сотрудник |
| team.role | team | Изменена роль сотрудника |
| team.remove | team | Сотрудник удалён из организации |
| token.create | access | Выдан токен API |
| token.revoke | access | Отозван токен API |
| oauth.grant | access | Подключён ИИ-агент входом («Подключён агент «…»») |
| upload.background | upload | Агент загрузил фон листа файлом по ссылке из `get_upload_link` («Фон листа «…» загружен файлом по ссылке агента»); автор — токен или подключённый агент |
| upload.element | upload | Агент загрузил картинку для блока файлом по ссылке |
| upload.recipients | upload | Агент загрузил таблицу получателей файлом по ссылке («Таблица получателей «…» дополнена (заменена) файлом по ссылке агента: строк N»); строк и имени файла в записи нет |
| billing.invoice.issue | billing | Организация получила счёт по тарифу в кабинете («Выставлен счёт № …») |
| platform.invoice.issue | billing | Владелец сервиса выставил организации счёт («Выставлен счёт № …») |
| domain.set | domain | Подключён свой домен для форм |
| domain.verified | domain | Свой домен для форм подтверждён |
| domain.removed | domain | Свой домен для форм отключён |
| platform.plan | platform | Владелец сервиса сменил отметку тарифа организации (`free`/`paid`) |
| platform.plan.assign | platform | Владелец сервиса назначил организации план («Назначен план «…» на N документов») |
| platform.verified | platform | Владелец сервиса выдал или снял значок верифицированного эмитента |

Код, которого нет в таблице (появится позже), относится к виду `other`.

## Запрос

Параметры строки запроса:

| Параметр | Тип | Обязателен | Описание |
|---|---|---|---|
| limit | number | нет | Сколько записей на странице: целое от 1 до 200. По умолчанию 50. |
| offset | number | нет | Сколько записей пропустить: целое, не меньше 0. По умолчанию 0. |
| action | string | нет | Показать только события с этим `action` (точное совпадение), до 60 знаков. |
| group | string | нет | Показать только события этого вида: `issue`, `mail`, `verify`, `table`, `delete`, `team`, `access`, `upload`, `billing`, `domain`, `platform` или `other` (см. таблицу выше). Какие виды есть в журнале — [`GET /api/audit/facets`](get-audit-facets.md). |
| actor | string | нет | Показать только действия одного автора: значение `actors[].id` из [`GET /api/audit/facets`](get-audit-facets.md) — `email:<почта>` для сотрудника, `name:Токен «…»` для токена, `system` для действий системы. До 330 знаков. |

Параметры складываются: `group=mail&actor=email:maria@school.ru` — письма, которые отправляла Мария.

## Ответ

`200 OK`

| Поле | Тип | Описание |
|---|---|---|
| total | number | Всего записей, подходящих под отбор. |
| limit | number | Применённый размер страницы. |
| offset | number | Применённое смещение. |
| items | array | Записи, новые сверху. |
| items[].id | string | Идентификатор записи (числовой, отдаётся строкой). |
| items[].action | string | Код действия, см. таблицу выше. |
| items[].summary | string | Человекочитаемое описание. |
| items[].actorName | string | Имя сотрудника на момент действия; по токену — `Токен «<название>»`; пустая строка — система. |
| items[].actorEmail | string | Почта сотрудника на момент действия; по токену и у системы — пустая строка. |
| items[].targetType | string \| null | Тип предмета действия (`token`, `document` и т. п.). |
| items[].targetId | string \| null | Идентификатор предмета действия. |
| items[].createdAt | string (ISO 8601) | Когда. |

```json
{
  "total": 128,
  "limit": 2,
  "offset": 0,
  "items": [
    {
      "id": "913",
      "action": "token.create",
      "summary": "Выдан токен доступа «выгрузка из CRM»",
      "actorName": "Мария Иванова",
      "actorEmail": "maria@vruchay.ru",
      "targetType": "token",
      "targetId": "5f1c2e6a-9d0b-4c3e-8a7f-1b2c3d4e5f60",
      "createdAt": "2026-09-02T18:03:10.000Z"
    },
    {
      "id": "912",
      "action": "generation.start",
      "summary": "Выпуск документов: 12",
      "actorName": "Токен «выгрузка из CRM»",
      "actorEmail": "",
      "targetType": "document",
      "targetId": "0c9b7a65-4d3e-4f21-8b0a-1e2f3a4b5c6d",
      "createdAt": "2026-09-02T14:21:00.000Z"
    }
  ]
}
```

## Ошибки

| Код | Когда |
|---|---|
| 400 | `limit` не целое от 1 до 200, `offset` отрицательный или не целое, `action` длиннее 60 знаков, `group` не из списка, `actor` не начинается с `email:`/`name:` и не `system` — стандартное сообщение Zod. |
| 401 | Нет действующего токена и нет cookie-сессии. `{"statusCode":401,"message":"Требуется вход в систему","error":"Unauthorized"}` |
| 403 | Роль `member` (в том числе токен с ролью `member`): «Недостаточно прав для этого действия». |

## Пример

```bash
curl "https://vruchay.ru/api/audit?limit=50&offset=0&group=issue" \
  -H "Authorization: Bearer $VRUCHAY_TOKEN"
```

---

<!-- endpoints/auth/get-auth-me -->
> method: GET | path: /api/auth/me | auth: token | roles: any | rate_limit: none

# GET /api/auth/me

Возвращает, от чьего имени выполняются запросы: имя, роль в организации и признак, что это организация самого сервиса. Удобен как первая проверка токена — по нему видно, что заголовок `Authorization` принят и какая роль у токена.

При входе по токену API «пользователь» — это сам токен: `email` пустой, `name` имеет вид `Токен «<название токена>»`, `role` — роль, заданная при выдаче токена (`member` или `admin`). При входе из кабинета (cookie-сессия) возвращаются имя и почта сотрудника и его роль в организации.

## Запрос

Параметров нет.

## Ответ

`200 OK`

| Поле | Тип | Описание |
|---|---|---|
| email | string | Почта сотрудника. По токену — пустая строка. |
| name | string | Имя сотрудника. По токену — `Токен «<название>»`. |
| role | string | Роль: `owner`, `admin` или `member`. У токена — только `member` или `admin`. |
| isPlatform | boolean | `true` только у организации, которой принадлежит сам сервис (`PLATFORM_ORG_ID`). Для клиентов всегда `false`. |

```json
{
  "email": "",
  "name": "Токен «выгрузка из CRM»",
  "role": "member",
  "isPlatform": false
}
```

## Ошибки

| Код | Когда |
|---|---|
| 401 | Нет заголовка `Authorization` с действующим токеном и нет cookie-сессии; токен отозван. `{"statusCode":401,"message":"Требуется вход в систему","error":"Unauthorized"}` |

## Пример

```bash
curl "https://vruchay.ru/api/auth/me" \
  -H "Authorization: Bearer $VRUCHAY_TOKEN"
```

---

<!-- endpoints/awards/delete-award-rules-id -->
> method: DELETE | path: /api/award-rules/{id} | auth: token | roles: any | rate_limit: none

# DELETE /api/award-rules/{id}

Убирает набор из списка. Удаление мягкое: запись остаётся в базе с отметкой времени удаления, а признак «по умолчанию» снимается. Так сделано ради реестра выданного — он должен уметь объяснить, по какому правилу выдан документ на прошлом мероприятии, даже когда набором больше не пользуются.

После удаления набор перестаёт отдаваться где бы то ни было: `GET /api/award-rules/{id}` вернёт 404, привязать его к документу уже нельзя. Привязка у документов, где набор был выбран, при этом не снимается сама — `POST /api/documents/{id}/awards/preview` без тела на таком документе ответит 404 «Набор правил не найден», пока не привязать другой набор.

Восстановления из корзины для наборов нет.

## Запрос

| Параметр | Тип | Обязателен | Описание |
|---|---|---|---|
| `id` | UUID (путь) | да | Идентификатор набора правил |

Тела нет.

## Ответ

`200 OK`

| Поле | Тип | Описание |
|---|---|---|
| `ok` | boolean | всегда `true` |

```json
{ "ok": true }
```

## Ошибки

| Код | Когда |
|---|---|
| 400 | `Некорректный идентификатор` — `id` не UUID |
| 401 | Нет токена или сессии |
| 404 | `Набор правил не найден` — нет такого, он уже удалён или принадлежит другой организации |

## Пример

```bash
curl -X DELETE "https://vruchay.ru/api/award-rules/$RULE_SET_ID" \
  -H "Authorization: Bearer $VRUCHAY_TOKEN"
```

---

<!-- endpoints/awards/get-award-rules-id -->
> method: GET | path: /api/award-rules/{id} | auth: token | roles: any | rate_limit: none

# GET /api/award-rules/{id}

Набор правил со всеми правилами, условиями и выходами — то, что показывает конструктор и применяет движок раскладки. Правила отдаются по возрастанию `position`: порядок в этом массиве и есть приоритет, выигрывает первое совпавшее правило.

Набор другой организации и удалённый набор отдаются как ненайденные (404), а не как запрещённые: 403 подтвердил бы, что такой идентификатор существует.

Записи, сохранённые прошлой версией формата, проверяются схемой на выходе, а не на раскладке: если `conditions` или `outputs` в базе не соответствуют текущему контракту, ответ будет ошибкой сервера, а не молча посчитанной раскладкой.

## Запрос

| Параметр | Тип | Обязателен | Описание |
|---|---|---|---|
| `id` | UUID (путь) | да | Идентификатор набора правил |

Тела нет.

## Ответ

`200 OK` — набор правил в общем контракте `AwardRuleSet`.

Поля `id`, `name`, `schemaVersion`, `groupColumn`, `statusColumn`, `rules[]` (с `position`, `enabled`, `label`, `match`, `conditions`, `action`, `outputs`) разобраны в [reference/award-rules.md](../../reference/award-rules.md) — там же полный пример ответа и таблицы условий и выходов.

Признака `isDefault` в этом ответе нет: он живёт только в списке `GET /api/award-rules`.

## Ошибки

| Код | Когда |
|---|---|
| 400 | `Некорректный идентификатор` — `id` не UUID |
| 401 | Нет токена или сессии |
| 404 | `Набор правил не найден` — нет такого, он удалён или принадлежит другой организации |

## Пример

```bash
curl "https://vruchay.ru/api/award-rules/$RULE_SET_ID" \
  -H "Authorization: Bearer $VRUCHAY_TOKEN"
```

---

<!-- endpoints/awards/get-award-rules-templates -->
> method: GET | path: /api/award-rules/templates | auth: token | roles: any | rate_limit: none

# GET /api/award-rules/templates

Список документов организации, из которых выбирают шаблон для выхода правила (`outputs[].templateDocumentId`). Отдельного признака «шаблон» у документа нет: шаблоном может быть любой живой документ организации — тот, чьи листы будут выпущены получателям, попавшим под правило.

Отдаются не больше 200 документов, свежие по времени правки сверху; документы из корзины не показываются. Пагинации и поиска нет. Ссылка на документ другой организации или на удалённый документ при сохранении набора отклоняется — см. `POST /api/award-rules`.

## Запрос

Параметров пути и строки запроса нет. Тела нет.

## Ответ

`200 OK` — массив документов.

| Поле | Тип | Описание |
|---|---|---|
| `id` | UUID | идентификатор документа-шаблона |
| `title` | string | название документа |

```json
[
  { "id": "3a9e0c4d-3333-4c7d-8e1f-2a3b4c5d6e7f", "title": "Диплом победителя" },
  { "id": "4b0f1d5e-5555-4d8e-9f21-4c5d6e7f8091", "title": "Грамота участника" }
]
```

## Ошибки

| Код | Когда |
|---|---|
| 401 | Нет токена или сессии |

## Пример

```bash
curl "https://vruchay.ru/api/award-rules/templates" \
  -H "Authorization: Bearer $VRUCHAY_TOKEN"
```

---

<!-- endpoints/awards/get-award-rules -->
> method: GET | path: /api/award-rules | auth: token | roles: any | rate_limit: none

# GET /api/award-rules

Наборы правил награждения организации. Набор описывает, кому какой документ полагается по данным таблицы получателей: «первое место — диплом победителя», «снятым не выдавать», «остальным грамота участника». Набор принадлежит организации, а не мероприятию, — организация настраивает награждение один раз на сезон и применяет к каждому следующему протоколу привязкой `POST /api/documents/{id}/awards/rule-set`.

Список короткий: сами правила здесь не отдаются, только их количество. Полный набор — `GET /api/award-rules/{id}`. Удалённые наборы (мягкое удаление) не показываются. Пагинации нет — отдаются все живые наборы организации.

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

## Запрос

Параметров пути и строки запроса нет. Тела нет.

## Ответ

`200 OK` — массив наборов.

| Поле | Тип | Описание |
|---|---|---|
| `id` | UUID | |
| `name` | string | название набора |
| `groupColumn` | string | колонка группировки, `""` — не выбрана |
| `statusColumn` | string | колонка статуса, `""` — не выбрана |
| `isDefault` | boolean | предлагать по умолчанию для нового мероприятия; такой набор у организации один |
| `schemaVersion` | integer | версия формата правил, сейчас 1 |
| `updatedAt` | string (ISO 8601) | время последней правки |
| `ruleCount` | integer | сколько правил в наборе |
| `documentCount` | integer | к скольким документам набор сейчас привязан |

```json
[
  {
    "id": "7c1f7d1e-2b5e-4c3a-9c1a-8d2f0a6b1e11",
    "name": "Награждение по протоколу",
    "groupColumn": "category",
    "statusColumn": "status",
    "isDefault": true,
    "schemaVersion": 1,
    "updatedAt": "2026-03-02T09:14:00.000Z",
    "ruleCount": 3,
    "documentCount": 7
  }
]
```

## Ошибки

| Код | Когда |
|---|---|
| 401 | Нет токена или сессии |

## Пример

```bash
curl "https://vruchay.ru/api/award-rules" \
  -H "Authorization: Bearer $VRUCHAY_TOKEN"
```

---

<!-- endpoints/awards/patch-award-rules-id -->
> method: PATCH | path: /api/award-rules/{id} | auth: token | roles: any | rate_limit: none

# PATCH /api/award-rules/{id}

Меняет набор правил. Поля необязательные: передавайте только то, что правите, но хотя бы одно поле — пустое тело отклоняется.

Правила заменяются целиком. Если в теле есть `rules`, все прежние правила набора удаляются и создаются заново из присланного списка, а `position` берётся из порядка в массиве. Точечной правки одного правила нет и не будет: порядок правил — это их приоритет, он меняется при перетаскивании любого правила, и присылать перенумерацию половины списка пришлось бы всё равно. Если `rules` в теле нет — правила не трогаются, меняются только названия и колонки.

При `isDefault: true` признак «по умолчанию» снимается со всех остальных наборов организации. Всё выполняется одной транзакцией.

## Запрос

| Параметр | Тип | Обязателен | Описание |
|---|---|---|---|
| `id` | UUID (путь) | да | Идентификатор набора правил |

Тело — те же поля, что у `POST /api/award-rules`, все необязательные, но не пустой объект:

| Поле | Тип | Обязательно | Описание |
|---|---|---|---|
| `name` | string | нет | 1–200 символов после обрезки пробелов |
| `groupColumn` | string | нет | имя колонки группировки или `""` |
| `statusColumn` | string | нет | имя колонки статуса или `""` |
| `isDefault` | boolean | нет | признак «по умолчанию» |
| `rules` | Rule[] | нет | до 50 правил; присланный список заменяет прежние целиком |

Структура правила и условий — в [reference/award-rules.md](../../reference/award-rules.md).

```json
{
  "name": "Награждение по протоколу — 2026",
  "rules": [
    {
      "id": "1c7b2e3f-2222-4b6c-9d0e-1f2a3b4c5d6e",
      "label": "Победители",
      "conditions": [{ "field": "place", "op": "placeEquals", "value": 1, "withinGroup": true }],
      "action": "issue",
      "outputs": [
        { "templateDocumentId": "3a9e0c4d-3333-4c7d-8e1f-2a3b4c5d6e7f", "label": "Диплом победителя" },
        { "templateDocumentId": "5c1e2f60-6666-4e9f-8a12-5d6e7f809123", "subjectColumn": "coach", "dedupeScope": "group", "label": "Благодарность наставнику" }
      ]
    }
  ]
}
```

## Ответ

`200 OK` — набор после правки, контракт `AwardRuleSet` (см. [reference/award-rules.md](../../reference/award-rules.md)).

## Ошибки

| Код | Когда |
|---|---|
| 400 | `Некорректный идентификатор` — `id` не UUID |
| 400 | `Нечего обновлять` — тело без единого поля |
| 400 | `Введите название набора`, `Выберите колонку`, `Некорректное имя колонки`, `Выберите шаблон`, `Добавьте хотя бы одно значение`, `Начало интервала больше конца` и прочие сообщения схемы — как у `POST /api/award-rules` |
| 400 | `Выбранный шаблон недоступен: он удалён или лежит в корзине` |
| 401 | Нет токена или сессии |
| 404 | `Набор правил не найден` |
| 413 | Тело больше 1 МБ |

## Пример

```bash
curl -X PATCH "https://vruchay.ru/api/award-rules/$RULE_SET_ID" \
  -H "Authorization: Bearer $VRUCHAY_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name":"Награждение по протоколу — 2026","isDefault":true}'
```

---

<!-- endpoints/awards/post-award-rules-id-duplicate -->
> method: POST | path: /api/award-rules/{id}/duplicate | auth: token | roles: any | rate_limit: none

# POST /api/award-rules/{id}/duplicate

Делает копию набора — «взять как на прошлом мероприятии и поправить». Тела у запроса нет.

Что происходит с копией: название — исходное с припиской ` — копия`, обрезанное до 200 символов; колонки группы и статуса, версия формата, а также все правила с их условиями, выходами и порядком переносятся как есть; признак «по умолчанию» у копии всегда `false`. Идентификаторы правил выдаются новые: у копии своя жизнь, и снимки прошлых раскладок должны ссылаться на исходные правила, а не на копии.

Повторный вызов сделает ещё одну копию — никакой проверки на существующую копию нет, названия могут совпасть.

## Запрос

| Параметр | Тип | Обязателен | Описание |
|---|---|---|---|
| `id` | UUID (путь) | да | Идентификатор копируемого набора |

Тела нет.

## Ответ

`201 Created` — новый набор в контракте `AwardRuleSet` (см. [reference/award-rules.md](../../reference/award-rules.md)); `id` набора и `id` каждого правила — новые.

```json
{
  "id": "9f2a4c6e-7777-4a1b-8c2d-3e4f5a6b7c8d",
  "name": "Награждение по протоколу — копия",
  "schemaVersion": 1,
  "groupColumn": "category",
  "statusColumn": "status",
  "rules": []
}
```

## Ошибки

| Код | Когда |
|---|---|
| 400 | `Некорректный идентификатор` — `id` не UUID |
| 401 | Нет токена или сессии |
| 403 | Возможности `awards` нет в плане: ««Автоматические правила награждения» не входит в ваш план. Напишите нам — добавим.» Срок плана вышел: «Срок плана «…» закончился, поэтому раздел «Автоматические правила награждения» сейчас недоступен. Уже выданные документы остаются действительными. Напишите нам — обсудим продление.» См. [Аутентификация](../../authentication.md) |
| 404 | `Набор правил не найден` — нет такого, он удалён или принадлежит другой организации |

## Пример

```bash
curl -X POST "https://vruchay.ru/api/award-rules/$RULE_SET_ID/duplicate" \
  -H "Authorization: Bearer $VRUCHAY_TOKEN"
```

---

<!-- endpoints/awards/post-award-rules -->
> method: POST | path: /api/award-rules | auth: token | roles: any | rate_limit: none

# POST /api/award-rules

Заводит набор правил награждения у организации. Набор — это упорядоченный список правил «при каком условии какой документ выдать»: выигрывает первое совпавшее правило сверху вниз, поэтому «снятых не награждаем» обязано стоять выше, чем «всем участникам». Правила приходят списком целиком, а приоритет берётся из порядка в массиве — отдельного поля с номером в запросе нет, сервер проставляет `position` сам.

Заготовку набора под конкретную таблицу удобно получить через `POST /api/documents/{id}/awards/suggest` и прислать сюда, дополнив шаблонами.

Все шаблоны из `outputs[].templateDocumentId` проверяются на принадлежность организации до записи: чужой или удалённый документ — 400 на весь запрос. При `isDefault: true` признак «по умолчанию» снимается со всех остальных наборов организации, всё в одной транзакции.

## Запрос

Тело:

| Поле | Тип | Обязательно | Описание |
|---|---|---|---|
| `name` | string | да | 1–200 символов после обрезки пробелов |
| `groupColumn` | string | нет | имя колонки группировки или `""` (по умолчанию) |
| `statusColumn` | string | нет | имя колонки статуса или `""` (по умолчанию) |
| `isDefault` | boolean | нет | предлагать по умолчанию, по умолчанию `false` |
| `rules` | Rule[] | нет | до 50 правил, порядок в массиве — приоритет; по умолчанию `[]` |

Структура правила (`id`, `enabled`, `label`, `match`, `conditions`, `action`, `outputs`), список операций условий и поля выхода — в [reference/award-rules.md](../../reference/award-rules.md). Идентификатор правила задаёт клиент (конструктор заводит правило до сохранения); сервер пересоздаёт правила набора целиком, поэтому идентификатор живёт только внутри своего набора.

```json
{
  "name": "Награждение по протоколу",
  "groupColumn": "category",
  "statusColumn": "status",
  "isDefault": true,
  "rules": [
    {
      "id": "0b6a1d2e-1111-4a5b-8c9d-0e1f2a3b4c5d",
      "label": "Снятые и не стартовавшие",
      "match": "any",
      "conditions": [{ "field": "status", "op": "statusIn", "value": ["dsq", "dns", "dnf", "dnq", "withdrawn"] }],
      "action": "skip",
      "outputs": []
    },
    {
      "id": "1c7b2e3f-2222-4b6c-9d0e-1f2a3b4c5d6e",
      "label": "Победители",
      "conditions": [{ "field": "place", "op": "placeEquals", "value": 1 }],
      "action": "issue",
      "outputs": [{ "templateDocumentId": "3a9e0c4d-3333-4c7d-8e1f-2a3b4c5d6e7f", "label": "Диплом победителя" }]
    },
    {
      "id": "2d8c3f40-4444-4c8e-9f20-3b4c5d6e7f80",
      "label": "Все остальные участники",
      "conditions": [],
      "action": "issue",
      "outputs": [{ "templateDocumentId": "4b0f1d5e-5555-4d8e-9f21-4c5d6e7f8091" }]
    }
  ]
}
```

## Ответ

`201 Created` — созданный набор в контракте `AwardRuleSet` (`id`, `name`, `schemaVersion`, `groupColumn`, `statusColumn`, `rules[]` с проставленными значениями по умолчанию и `position` по порядку). Форма и пример — в [reference/award-rules.md](../../reference/award-rules.md). Признака `isDefault` в ответе нет, он виден в `GET /api/award-rules`.

## Ошибки

| Код | Когда |
|---|---|
| 400 | `Введите название набора` — пустое `name` |
| 400 | `Выберите колонку` / `Некорректное имя колонки` — `groupColumn`, `statusColumn` или `conditions[].field` не по шаблону `^[a-zA-Z][a-zA-Z0-9_]*$` |
| 400 | `Выберите шаблон` — `outputs[].templateDocumentId` не UUID |
| 400 | `Добавьте хотя бы одно значение` — пустой массив у условия `oneOf` |
| 400 | `Начало интервала больше конца` — у `placeBetween` `from > to` |
| 400 | `rules` длиннее 50; `conditions` длиннее 20; `outputs` длиннее 10; `name` длиннее 200; `label` длиннее 120; место вне 1…300; `op`, `match`, `action`, `dedupeScope` или код статуса не из списка (сообщения Zod) |
| 400 | `Выбранный шаблон недоступен: он удалён или лежит в корзине` — шаблон другой организации или в корзине |
| 401 | Нет токена или сессии |
| 403 | Возможности `awards` нет в плане: ««Автоматические правила награждения» не входит в ваш план. Напишите нам — добавим.» Срок плана вышел: «Срок плана «…» закончился, поэтому раздел «Автоматические правила награждения» сейчас недоступен. Уже выданные документы остаются действительными. Напишите нам — обсудим продление.» См. [Аутентификация](../../authentication.md) |
| 413 | Тело больше 1 МБ |

## Пример

```bash
curl -X POST "https://vruchay.ru/api/award-rules" \
  -H "Authorization: Bearer $VRUCHAY_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name":"Награждение по протоколу","groupColumn":"category","statusColumn":"status","rules":[{"id":"1c7b2e3f-2222-4b6c-9d0e-1f2a3b4c5d6e","label":"Победители","conditions":[{"field":"place","op":"placeEquals","value":1}],"outputs":[{"templateDocumentId":"3a9e0c4d-3333-4c7d-8e1f-2a3b4c5d6e7f"}]}]}'
```

---

<!-- endpoints/awards/post-documents-id-awards-preview -->
> method: POST | path: /api/documents/{id}/awards/preview | auth: token | roles: any | rate_limit: none

# POST /api/documents/{id}/awards/preview

Прогоняет правила по таблице получателей документа и возвращает раскладку: кому какой шаблон полагается, сколько документов выйдет после дедупликации и какие строки остались без документа и почему. Ничего не сохраняет и ничего не выпускает.

Правила берутся из тела (`ruleSet`) или, если тела с набором нет, из набора, привязанного к документу через `POST …/awards/rule-set`. Метод POST именно ради первого случая: человек правит правила в конструкторе и хочет видеть результат, не сохраняя черновик в базу. Несохранённый набор проверяется теми же схемами, что и при сохранении, включая принадлежность шаблонов организации.

Рассматриваются только строки, отмеченные к выпуску (`checked: true`), по порядку в таблице, и не больше 5000 за раз. Если отмеченных больше, обрезка не замалчивается: в `problems` появляется строка «Отмечено строк: N, в раскладку взяты первые 5000…» — иначе тысяча человек осталась бы без документов до самого награждения.

Кем подписан участник в отчёте: берётся колонка `name`, а если её нет — первая колонка таблицы по порядку.

Расчёт идёт на сервере, хотя тот же движок есть и в браузере: решение, кому что полагается, принимает сервер. Выпуска по раскладке пока нет — `POST …/generate` правила не применяет и печатает отмеченные строки по листам своего документа; документы по шаблонам выпускают в самих шаблонах.

## Запрос

| Параметр | Тип | Обязателен | Описание |
|---|---|---|---|
| `id` | UUID (путь) | да | Идентификатор документа-мероприятия |

Тело:

| Поле | Тип | Обязательно | Описание |
|---|---|---|---|
| `ruleSet` | object | нет | Несохранённый набор правил в том же виде, что и тело `POST /api/award-rules` (`name`, `groupColumn`, `statusColumn`, `isDefault`, `rules`). Без него берётся набор, привязанный к документу |

Пустое тело `{}` — законный запрос: считать по привязанному набору.

```json
{
  "ruleSet": {
    "name": "Черновик",
    "groupColumn": "category",
    "statusColumn": "status",
    "rules": [
      {
        "id": "1c7b2e3f-2222-4b6c-9d0e-1f2a3b4c5d6e",
        "label": "Победители",
        "conditions": [{ "field": "place", "op": "placeEquals", "value": 1, "withinGroup": true }],
        "action": "issue",
        "outputs": [{ "templateDocumentId": "3a9e0c4d-3333-4c7d-8e1f-2a3b4c5d6e7f" }]
      }
    ]
  }
}
```

## Ответ

`201 Created`

| Поле | Тип | Описание |
|---|---|---|
| `plan` | AwardPlan | раскладка: `schemaVersion`, `items[]`, `summary[]`, `issues[]`, `totals` |
| `problems` | string[] | что не так с самим набором, а не со строками: правило «иначе» не в конце, правило без шаблона, дедупликация по группе без колонки группы, статус или место «внутри группы» без соответствующей колонки, а также предупреждение об обрезке протокола. Пустой массив — замечаний нет |

Поля `plan` (включая `items[]`, `summary[]`, `issues[]` с кодами `no-rule`, `excluded-by-rule`, `missing-group`, `unparsable-place`, `unknown-status`, `no-template`, `empty-subject`, `duplicate-subject` и все счётчики `totals`) разобраны в [reference/award-rules.md](../../reference/award-rules.md).

```json
{
  "plan": {
    "schemaVersion": 1,
    "items": [
      {
        "rowId": "6e5d4c3b-8888-4f01-9a23-6e7f80912345",
        "rowNumber": 1,
        "group": "Юноши 12-13",
        "subject": "Иванова Мария Сергеевна",
        "templateDocumentId": "3a9e0c4d-3333-4c7d-8e1f-2a3b4c5d6e7f",
        "templateTitle": "Диплом победителя",
        "ruleId": "1c7b2e3f-2222-4b6c-9d0e-1f2a3b4c5d6e",
        "ruleLabel": "Победители",
        "outputLabel": "",
        "mergedRowIds": []
      }
    ],
    "summary": [
      {
        "templateDocumentId": "3a9e0c4d-3333-4c7d-8e1f-2a3b4c5d6e7f",
        "templateTitle": "Диплом победителя",
        "ruleLabels": ["Победители"],
        "count": 1
      }
    ],
    "issues": [
      {
        "code": "no-rule",
        "severity": "error",
        "rowId": "7f6e5d4c-9999-4012-8b34-7f8091234567",
        "rowNumber": 2,
        "group": "Юноши 12-13",
        "subject": "Петров Олег Иванович",
        "message": "Не подошло ни одно правило — документ не выйдет. Добавьте правило или поставьте в конец списка правило без условий"
      }
    ],
    "totals": {
      "rows": 2,
      "issuingRows": 1,
      "excludedRows": 0,
      "unmatchedRows": 1,
      "blockedRows": 0,
      "documents": 1,
      "deduplicated": 0
    }
  },
  "problems": []
}
```

Тексты `message` у замечаний и порядок правил в сводке приходят готовыми к показу — их не нужно собирать на клиенте.

## Ошибки

| Код | Когда |
|---|---|
| 400 | `Некорректный идентификатор` — `id` не UUID |
| 400 | `К этому мероприятию не привязан набор правил — выберите его в разделе «Правила»` — тела с `ruleSet` нет и привязки у документа нет |
| 400 | `Выбранный шаблон недоступен: он удалён или лежит в корзине` — в черновике ссылка на чужой или удалённый шаблон |
| 400 | Сообщения схемы набора правил — как у `POST /api/award-rules` (`Введите название набора`, `Некорректное имя колонки`, `Выберите шаблон` и прочие) |
| 401 | Нет токена или сессии |
| 404 | `Документ не найден` |
| 404 | `Набор правил не найден` — привязанный к документу набор удалён |
| 413 | Тело больше 1 МБ |

## Пример

```bash
curl -X POST "https://vruchay.ru/api/documents/$DOCUMENT_ID/awards/preview" \
  -H "Authorization: Bearer $VRUCHAY_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{}'
```

---

<!-- endpoints/awards/post-documents-id-awards-rule-set -->
> method: POST | path: /api/documents/{id}/awards/rule-set | auth: token | roles: any | rate_limit: none

# POST /api/documents/{id}/awards/rule-set

Выбирает, по какому набору правил награждается это мероприятие. Сам набор принадлежит организации и живёт отдельно (`/api/award-rules`); здесь запоминается только ссылка на него у документа. Привязка ничего не считает и не выпускает — она нужна, чтобы `POST /api/documents/{id}/awards/preview` знал, какие правила брать, когда тело запроса пустое.

Привязка одна: повторный вызов заменяет прежний набор. `ruleSetId: null` отвязывает набор от документа.

И документ, и набор проверяются на принадлежность организации; чужие отдаются как ненайденные.

## Запрос

| Параметр | Тип | Обязателен | Описание |
|---|---|---|---|
| `id` | UUID (путь) | да | Идентификатор документа-мероприятия |

Тело:

| Поле | Тип | Обязательно | Описание |
|---|---|---|---|
| `ruleSetId` | UUID \| null | да | Набор правил; `null` — отвязать. Поле обязано присутствовать |

```json
{ "ruleSetId": "7c1f7d1e-2b5e-4c3a-9c1a-8d2f0a6b1e11" }
```

## Ответ

`201 Created`

| Поле | Тип | Описание |
|---|---|---|
| `ok` | boolean | всегда `true` |
| `ruleSetId` | UUID \| null | что теперь привязано |

```json
{ "ok": true, "ruleSetId": "7c1f7d1e-2b5e-4c3a-9c1a-8d2f0a6b1e11" }
```

## Ошибки

| Код | Когда |
|---|---|
| 400 | `Некорректный идентификатор` — `id` в пути не UUID |
| 400 | `ruleSetId` отсутствует или не UUID и не `null` (сообщение Zod) |
| 401 | Нет токена или сессии |
| 403 | Возможности `awards` нет в плане: ««Автоматические правила награждения» не входит в ваш план. Напишите нам — добавим.» Срок плана вышел: «Срок плана «…» закончился, поэтому раздел «Автоматические правила награждения» сейчас недоступен. Уже выданные документы остаются действительными. Напишите нам — обсудим продление.» См. [Аутентификация](../../authentication.md) |
| 404 | `Документ не найден` |
| 404 | `Набор правил не найден` — набора нет, он удалён или принадлежит другой организации |
| 413 | Тело больше 1 МБ |

## Пример

```bash
curl -X POST "https://vruchay.ru/api/documents/$DOCUMENT_ID/awards/rule-set" \
  -H "Authorization: Bearer $VRUCHAY_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"ruleSetId":"7c1f7d1e-2b5e-4c3a-9c1a-8d2f0a6b1e11"}'
```

---

<!-- endpoints/awards/post-documents-id-awards-suggest -->
> method: POST | path: /api/documents/{id}/awards/suggest | auth: token | roles: any | rate_limit: none

# POST /api/documents/{id}/awards/suggest

Собирает черновик набора правил по колонкам уже загруженной таблицы получателей. Ничего не сохраняет: конструктор показывает предложенное, человек дописывает шаблоны и сохраняет сам через `POST /api/award-rules`. Молча заведённый в базе набор пришлось бы потом искать и удалять тому, кто просто посмотрел.

Как узнаются колонки (берётся первая существующая из списка, иначе `""`):

| Что ищем | Кандидаты по порядку |
|---|---|
| колонка группы (`groupColumn`) | `category`, `group`, `age_group` |
| колонка статуса (`statusColumn`) | `status`, `result_status` |
| колонка места (в условиях правил) | `place`, `rank` |

Какие правила предлагаются: «Снятые и не стартовавшие» (`action: skip`, условие `statusIn` со всеми статусами без награды) — только если нашлась колонка статуса; «Победители» (`placeEquals` 1) и «Призёры» (`placeBetween` 2–3) — только если нашлась колонка места; «Все остальные участники» без условий последним правилом. Порядок здесь не косметика: выигрывает первое совпавшее, и стой правило про места выше правила про снятых, дисквалифицированный получил бы грамоту участника.

Шаблоны в заготовке не проставлены (`outputs: []`) — какие бланки у организации, сервер не знает. Правила без шаблона конструктор помечает как незаконченные, и `POST …/awards/preview` на таком наборе сообщит, что шаблон не выбран.

Различие с превью: `suggest` предлагает правила по колонкам и не смотрит на строки, `preview` применяет уже готовые правила к строкам и показывает результат.

## Запрос

| Параметр | Тип | Обязателен | Описание |
|---|---|---|---|
| `id` | UUID (путь) | да | Идентификатор документа-мероприятия |

Тело:

| Поле | Тип | Обязательно | Описание |
|---|---|---|---|
| `name` | string | нет | Название будущего набора, 1–200 символов после обрезки пробелов. Без него — `Награждение по протоколу` |

Пустое тело `{}` — законный запрос.

```json
{ "name": "Конкурс «Мастер года», весна" }
```

## Ответ

`201 Created` — черновик в контракте `AwardRuleSet` (см. [reference/award-rules.md](../../reference/award-rules.md)). В базе его нет: `id` набора и `id` правил выданы на лету и станут постоянными, только если прислать этот набор в `POST /api/award-rules`.

```json
{
  "id": "b3f1c8a2-aaaa-4d10-9e45-1a2b3c4d5e6f",
  "name": "Конкурс «Мастер года», весна",
  "schemaVersion": 1,
  "groupColumn": "category",
  "statusColumn": "status",
  "rules": [
    {
      "id": "c4a2d9b3-bbbb-4e21-8f56-2b3c4d5e6f70",
      "position": 0,
      "enabled": true,
      "label": "Снятые и не стартовавшие",
      "match": "any",
      "conditions": [{ "field": "status", "op": "statusIn", "value": ["dsq", "dns", "dnf", "dnq", "withdrawn"] }],
      "action": "skip",
      "outputs": []
    },
    {
      "id": "d5b3eac4-cccc-4f32-9a67-3c4d5e6f7081",
      "position": 1,
      "enabled": true,
      "label": "Победители",
      "match": "all",
      "conditions": [{ "field": "place", "op": "placeEquals", "value": 1, "withinGroup": false }],
      "action": "issue",
      "outputs": []
    },
    {
      "id": "e6c4fbd5-dddd-4043-8b78-4d5e6f708192",
      "position": 2,
      "enabled": true,
      "label": "Призёры",
      "match": "all",
      "conditions": [{ "field": "place", "op": "placeBetween", "value": { "from": 2, "to": 3 }, "withinGroup": false }],
      "action": "issue",
      "outputs": []
    },
    {
      "id": "f7d50ce6-eeee-4154-9c89-5e6f70819203",
      "position": 3,
      "enabled": true,
      "label": "Все остальные участники",
      "match": "all",
      "conditions": [],
      "action": "issue",
      "outputs": []
    }
  ]
}
```

## Ошибки

| Код | Когда |
|---|---|
| 400 | `Некорректный идентификатор` — `id` не UUID |
| 400 | `name` пустой или длиннее 200 символов (сообщение Zod) |
| 401 | Нет токена или сессии |
| 404 | `Документ не найден` |
| 413 | Тело больше 1 МБ |

## Пример

```bash
curl -X POST "https://vruchay.ru/api/documents/$DOCUMENT_ID/awards/suggest" \
  -H "Authorization: Bearer $VRUCHAY_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name":"Конкурс «Мастер года», весна"}'
```

---

<!-- endpoints/billing/get-billing-card-payments-id -->
> method: GET | path: /api/billing/card-payments/{id} | auth: token | roles: owner, admin | rate_limit: none

# GET /api/billing/card-payments/{id}

Состояние платежа, созданного `POST /api/billing/checkout`. Его спрашивает страница, на которую ЮKassa возвращает человека после оплаты.

Пока платёж ждёт (`pending`), запрос сам уточняет его состояние у ЮKassa: уведомление об оплате могло ещё не дойти, а человек уже вернулся. Если ЮKassa подтвердила оплату, объём начисляется здесь же — тем же путём, что по уведомлению, и тоже ровно один раз, — и ответ приходит уже со статусом `paid`.

Виден только платёж своей организации; чужой отвечает `404`.

## Запрос

| Параметр | Тип | Обязателен | Описание |
|---|---|---|---|
| id | string (UUID) | да | Идентификатор из ответа `POST /api/billing/checkout`. |

## Ответ

`200 OK`

| Поле | Тип | Описание |
|---|---|---|
| id | string (UUID) | Идентификатор платежа. |
| status | string | `pending` — ждёт оплаты, `paid` — оплачен и начислен, `canceled` — отменён в ЮKassa. |
| title | string | Что покупали: «Тариф «Год»: 6000 документов на год» или «120 документов поштучно». |
| documents | number | Сколько документов в покупке — без переноса остатка. |
| months | number \| null | Срок тарифа в месяцах; `null` — документы не сгорают. |
| paidAt | string (ISO 8601) \| null | Когда оплата подтверждена. |

```json
{
  "id": "4b1e2c3d-5f6a-4b7c-8d9e-0f1a2b3c4d5e",
  "status": "paid",
  "title": "Тариф «Год»: 6000 документов на год",
  "documents": 6000,
  "months": 12,
  "paidAt": "2026-09-28T10:42:17.000Z"
}
```

## Ошибки

| Код | Когда |
|---|---|
| 400 | `id` не UUID: «Некорректный идентификатор». |
| 401 | Нет действующего токена и нет cookie-сессии. |
| 403 | Роль `member`: «Недостаточно прав для этого действия». |
| 404 | Платежа нет или он другой организации: «Платёж не найден». |

## Пример

```bash
curl "https://vruchay.ru/api/billing/card-payments/$PAYMENT_ID" \
  -H "Authorization: Bearer $VRUCHAY_TOKEN"
```

---

<!-- endpoints/billing/get-billing-card -->
> method: GET | path: /api/billing/card | auth: token | roles: any | rate_limit: none

# GET /api/billing/card

Отвечает, можно ли сейчас оплатить документы картой. Оплата картой работает, только когда у сервиса подключён магазин ЮKassa; если он не подключён, `POST /api/billing/checkout` отвечает `503`, и показывать кнопку оплаты незачем.

Доступно любой роли. Сама оплата — только владельцу и управляющему, и только человеку в кабинете.

## Запрос

Параметров нет.

## Ответ

`200 OK`

| Поле | Тип | Описание |
|---|---|---|
| available | boolean | `true` — магазин подключён и оплата картой принимается. |

```json
{ "available": true }
```

## Ошибки

| Код | Когда |
|---|---|
| 401 | Нет действующего токена и нет cookie-сессии. `{"statusCode":401,"message":"Требуется вход в систему","error":"Unauthorized"}` |

## Пример

```bash
curl "https://vruchay.ru/api/billing/card" \
  -H "Authorization: Bearer $VRUCHAY_TOKEN"
```

---

<!-- endpoints/billing/get-org-billing -->
> method: GET | path: /api/org/billing | auth: token | roles: owner, admin | rate_limit: none

# GET /api/org/billing

Реквизиты, по которым организации выставляют счета и закрывающие документы. Читают владелец и управляющий, меняет только владелец (`PATCH /api/org/billing`). Без заполненных реквизитов счёт организации не выставить.

Поле `suggested` приходит всегда и подсказывает, чем заполнить пустую форму: реквизитами покупателя из последнего счёта, ушедшего на контактную почту организации или на почту владельца, а если такого счёта нет — названием и ИНН из публичного профиля. Подсказка ничего не сохраняет: реквизиты подтверждает человек.

## Запрос

Параметров нет.

## Ответ

`200 OK`

| Поле | Тип | Описание |
|---|---|---|
| kind | string \| null | Кто платит: `legal` — юридическое лицо, `ie` — предприниматель, `self_employed` — самозанятый, `individual` — физическое лицо. `null` — реквизиты не заполнялись; остальные поля тогда — пустые строки. |
| name | string | Название организации или ФИО. |
| inn | string | ИНН. |
| kpp | string | КПП, только у юридического лица. |
| ogrn | string | ОГРН или ОГРНИП. |
| address | string | Адрес. |
| email | string | Почта для счетов; пустая — счета уходят владельцу организации. |
| suggested | object | Подсказка для пустой формы. |
| suggested.from | string | `invoice` — из последнего выставленного счёта, `org` — из профиля организации. |
| suggested.name | string | Название. |
| suggested.inn | string | ИНН; пустая строка — неизвестен. |
| suggested.email | string | Почта. |
| suggested.at | string (ISO 8601) \| null | Когда выставлен счёт, из которого взята подсказка; `null` при `from: "org"`. |

```json
{
  "kind": "legal",
  "name": "ООО «Развитие»",
  "inn": "7712345671",
  "kpp": "770701001",
  "ogrn": "1027700000000",
  "address": "г. Москва, ул. Примерная, д. 1",
  "email": "buh@vruchay.ru",
  "suggested": {
    "from": "org",
    "name": "Учебный центр «Развитие»",
    "inn": "",
    "email": "info@vruchay.ru",
    "at": null
  }
}
```

## Ошибки

| Код | Когда |
|---|---|
| 401 | Нет действующего токена и нет cookie-сессии. |
| 403 | Роль `member`: «Недостаточно прав для этого действия». |

## Пример

```bash
curl "https://vruchay.ru/api/org/billing" \
  -H "Authorization: Bearer $VRUCHAY_TOKEN"
```

---

<!-- endpoints/billing/get-org-invoices-available -->
> method: GET | path: /api/org/invoices/available | auth: token | roles: owner, admin | rate_limit: none

# GET /api/org/invoices/available

Отвечает, выставляет ли сервис сейчас счета по тарифу — `POST /api/org/invoices`. Если нет, тот отвечает `503`, и показывать кнопку «Получить счёт» незачем; счёт на особые условия выставляется после разговора.

## Запрос

Параметров нет.

## Ответ

`200 OK`

| Поле | Тип | Описание |
|---|---|---|
| available | boolean | `true` — счёт по тарифу можно получить сразу. |

```json
{ "available": true }
```

## Ошибки

| Код | Когда |
|---|---|
| 401 | Нет действующего токена и нет cookie-сессии. |
| 403 | Роль `member`: «Недостаточно прав для этого действия». |

## Пример

```bash
curl "https://vruchay.ru/api/org/invoices/available" \
  -H "Authorization: Bearer $VRUCHAY_TOKEN"
```

---

<!-- endpoints/billing/get-org-invoices-id-pdf -->
> method: GET | path: /api/org/invoices/{id}/pdf | auth: token | roles: owner, admin | rate_limit: none

# GET /api/org/invoices/{id}/pdf

Отдаёт счёт своей организации в PDF — байтами прямо в ответе, а не ссылкой. Скачивать нужно с тем же заголовком `Authorization`.

Отдаётся только счёт своей организации. Чужой счёт отвечает `404`, а не `403`: иначе по ответу можно было бы проверять, существует ли счёт с таким идентификатором.

## Запрос

| Параметр | Тип | Обязателен | Описание |
|---|---|---|---|
| id | string (UUID) | да | Идентификатор счёта из `GET /api/org/invoices`. |

## Ответ

`200 OK`, `Content-Type: application/pdf`, `Content-Disposition: inline`, `Cache-Control: no-store`. Тело — файл счёта.

Подвал счёта называет основание платежа. Счёт по тарифу и счёт, выставленный без договора, — по лицензионному договору-оферте: в подвале её адрес, строка «Оплата счёта означает акцепт оферты» и дата, до которой счёт действителен (пять календарных дней со дня выставления). Если владелец сервиса указал для счёта подписанный договор, подвал называет его — «Счёт выставлен по лицензионному договору № … от …» — и просит оплатить счёт в срок, установленный договором; ни оферты, ни её акцепта, ни даты «действителен до» в таком счёте нет. Назначение платежа в обоих случаях одно: «Оплата по счёту № N от ДД.ММ.ГГГГ». Основание задано при выставлении и дальше не меняется: счёт, скачанный позже или отправленный повторно, говорит то же самое.

## Ошибки

| Код | Когда |
|---|---|
| 400 | `id` не UUID: «Некорректный идентификатор». |
| 401 | Нет действующего токена и нет cookie-сессии. |
| 403 | Роль `member`: «Недостаточно прав для этого действия». |
| 404 | Счёта нет или он другой организации: «Счёт не найден». |

## Пример

```bash
curl "https://vruchay.ru/api/org/invoices/$INVOICE_ID/pdf" \
  -H "Authorization: Bearer $VRUCHAY_TOKEN" \
  -o invoice.pdf
```

---

<!-- endpoints/billing/get-org-invoices -->
> method: GET | path: /api/org/invoices | auth: token | roles: owner, admin | rate_limit: none

# GET /api/org/invoices

Счета, выставленные своей организации, новые первыми, не больше 100. Это те же счета, что видны в кабинете в «Настройки → Тариф и оплата».

Счёт по тарифу из каталога организация получает сама — [`POST /api/org/invoices`](post-org-invoices.md). Счёт на особые условия выставляет владелец сервиса после разговора (`POST /api/invoices` — адрес раздела самого сервиса, для организаций и токенов закрыт). Реквизиты покупателя берутся из `GET /api/org/billing` и копируются в счёт на момент выставления: поправленные потом реквизиты выставленный счёт не меняют. Счёт сразу уходит письмом на адрес из реквизитов, а если его там нет — владельцу организации.

Основание платежа в счёте зависит от того, как он выставлен. По умолчанию это лицензионный договор-оферта: оплата счёта означает акцепт оферты, а сам счёт действителен пять календарных дней со дня выставления. Если с организацией подписан отдельный договор, владелец сервиса указывает при выставлении его номер и дату. Тогда позиция счёта, подвал PDF и письмо ссылаются на этот договор — «Счёт выставлен по лицензионному договору № … от …» — и не говорят ни об оферте, ни о её акцепте, ни о пяти днях: счёт нужно оплатить в срок, который установлен договором.

Когда деньги по счёту пришли, счёт отмечается оплаченным — это поле `paidAt`: сам, если банк сообщил о поступлении и оно сошлось со счётом по номеру, сумме и ИНН, или вручную владельцем сервиса. Отметка начисляет объём и срок, за которые выставлен счёт, один раз — они появятся в `GET /api/org/usage`.

## Запрос

Параметров нет.

## Ответ

`200 OK` — массив счетов.

| Поле | Тип | Описание |
|---|---|---|
| id | string (UUID) | Идентификатор счёта — для `GET /api/org/invoices/{id}/pdf`. |
| number | number | Номер счёта в году. |
| year | number | Год выставления. |
| tariff | string | Что оплачивается: «2500 документов, срок 12 месяцев» или «… документов, без ограничения срока». |
| amountKopecks | number | Сумма в копейках. |
| createdAt | string (ISO 8601) | Когда выставлен. |
| paidAt | string (ISO 8601) \| null | Когда оплачен; `null` — ещё не оплачен. |

```json
[
  {
    "id": "6a7b8c9d-0e1f-4a2b-8c3d-4e5f6a7b8c9d",
    "number": 14,
    "year": 2026,
    "tariff": "2500 документов, срок 12 месяцев",
    "amountKopecks": 1499000,
    "createdAt": "2026-09-20T08:15:00.000Z",
    "paidAt": null
  }
]
```

## Ошибки

| Код | Когда |
|---|---|
| 401 | Нет действующего токена и нет cookie-сессии. |
| 403 | Роль `member`: «Недостаточно прав для этого действия». |

## Пример

```bash
curl "https://vruchay.ru/api/org/invoices" \
  -H "Authorization: Bearer $VRUCHAY_TOKEN"
```

---

<!-- endpoints/billing/patch-org-billing -->
> method: PATCH | path: /api/org/billing | auth: token | roles: owner | rate_limit: none

# PATCH /api/org/billing

Сохраняет реквизиты, по которым организации выставляют счета. Меняет только владелец: по этим реквизитам придут документы, которые организация подпишет. Тело передаётся целиком, все поля перезаписываются.

Проверка зависит от того, кто платит (`kind`):

| `kind` | ИНН | КПП | ОГРН |
|---|---|---|---|
| `legal` — юридическое лицо | обязателен, 10 цифр | 9 цифр или пусто | 13 цифр (ОГРН) или пусто |
| `ie` — предприниматель | обязателен, 12 цифр | нельзя | 15 цифр (ОГРНИП) или пусто |
| `self_employed` — самозанятый | обязателен, 12 цифр | нельзя | нельзя |
| `individual` — физическое лицо | необязателен; если указан — 12 цифр | нельзя | нельзя |

ИНН проверяется и по длине, и по контрольной цифре: ИНН с опечаткой бухгалтерия покупателя не проведёт.

## Запрос

Тело (`application/json`), все поля обязательны; пустая строка — «не указано», где это допустимо:

| Поле | Тип | Обязательно | Описание |
|---|---|---|---|
| kind | string | да | `legal`, `ie`, `self_employed` или `individual`. |
| name | string | да | Название или ФИО, от 2 до 300 знаков. |
| inn | string | да | ИНН по правилам таблицы выше, до 12 знаков. |
| kpp | string | да | КПП — девять цифр, или пустая строка. |
| ogrn | string | да | ОГРН или ОГРНИП по правилам таблицы выше, до 15 знаков, или пустая строка. |
| address | string | да | Адрес, до 500 знаков. |
| email | string | да | Почта для счетов, до 254 знаков, или пустая строка — тогда счета уходят владельцу организации. Приводится к нижнему регистру. |

```json
{
  "kind": "legal",
  "name": "ООО «Развитие»",
  "inn": "7712345671",
  "kpp": "771201001",
  "ogrn": "",
  "address": "г. Москва, ул. Примерная, д. 1",
  "email": "buh@vruchay.ru"
}
```

## Ответ

`200 OK`

```json
{ "ok": true }
```

## Ошибки

| Код | Когда |
|---|---|
| 400 | `kind` не из списка; поле отсутствует или длиннее допустимого — сообщение Zod. |
| 400 | `name` короче двух знаков: «Укажите название или ФИО». |
| 400 | ИНН пуст там, где обязателен: «Без ИНН счёт не выставить»; не той длины: «ИНН организации состоит из 10 цифр» / «ИНН человека состоит из 12 цифр»; не сходится контрольная цифра: «ИНН не сходится по контрольной цифре — проверьте, нет ли опечатки». |
| 400 | КПП не из девяти цифр: «КПП состоит из девяти цифр»; КПП не у юридического лица: «КПП бывает только у организации». |
| 400 | ОГРН у самозанятого или физического лица: «ОГРН бывает у организации и предпринимателя»; не той длины: «ОГРН состоит из 13 цифр» / «ОГРНИП состоит из 15 цифр». |
| 400 | `email` не адрес: «Некорректный адрес». |
| 401 | Нет действующего токена и нет cookie-сессии. |
| 403 | Роль не `owner`, в том числе любой токен API (токену роль `owner` не выдаётся): «Недостаточно прав для этого действия». |
| 413 | Тело больше 1 МБ. |

## Пример

```bash
curl -X PATCH "https://vruchay.ru/api/org/billing" \
  -H "Cookie: gramota_session=…" \
  -H "Content-Type: application/json" \
  -d '{"kind":"ie","name":"Иванов Иван Иванович","inn":"771234567859","kpp":"","ogrn":"","address":"","email":""}'
```

---

<!-- endpoints/billing/post-billing-checkout -->
> method: POST | path: /api/billing/checkout | auth: humans-only | roles: owner, admin | rate_limit: none

# POST /api/billing/checkout

Создаёт платёж в ЮKassa и возвращает ссылку, по которой человек оплачивает покупку картой. Доступно владельцу и управляющему и только при входе в кабинет: токен API списывать деньги не может.

Запрос называет позицию каталога, а не сумму. Цену сервер берёт из того же каталога, что показывает страница цен, — сумму из запроса он не принимает. Позиций две:

- тариф по сроку — `{"tariffId": "month" | "quarter" | "half" | "year"}`;
- документы поштучно — `{"quantity": N}`, где N от 50 до 10 000.

| Позиция | Документов | Срок | Цена |
|---|---|---|---|
| `month` | 300 | 1 месяц | 900 ₽ (3 ₽ за документ) |
| `quarter` | 1 000 | 3 месяца | 3 000 ₽ (3 ₽ за документ) |
| `half` | 2 500 | 6 месяцев | 7 500 ₽ (3 ₽ за документ) |
| `year` | 6 000 | 12 месяцев | 18 000 ₽ (3 ₽ за документ) |
| `quantity` | N | без срока | 3 ₽ за документ |

Документ стоит 3 ₽ в любой позиции: тариф — готовый объём и срок, скидки за объём нет.

В любую позицию входят все возможности: рассылка, приём заявок с сайта, выдача токенов API и правила награждения.

Что происходит после оплаты:

1. ЮKassa присылает уведомление на служебный адрес `POST /api/v1/payments/yookassa`. Телу уведомления сервис не верит: он заново запрашивает платёж у ЮKassa по своему ключу и сверяет сумму и валюту с записью о платеже.
2. Подтверждённый платёж начисляется один раз, сколько бы уведомлений ни пришло. Организации назначается новый план, а отметка тарифа (`plan` в `GET /api/org`) становится `paid`.
3. Выпущенное по плану считается с даты его начала, поэтому неизрасходованный остаток прежнего действующего плана или бесплатной пробы переносится в новый: объём плана — купленное плюс остаток, а в названии плана появляется «+ остаток N». Остаток истёкшего плана не переносится, а у оплаченного тарифа старого образца предела нет и переносить нечего.
4. Срок тарифа отсчитывается от момента оплаты. Если у действующего плана срок дальше, новый план получает его срок. Документы поштучно не сгорают (`neverExpires: true`, `endsAt: null`).

Результат оплаты видно в `GET /api/billing/card-payments/{id}` и в `GET /api/org/usage`.

## Запрос

Тело — один из двух вариантов, лишние поля не допускаются:

| Поле | Тип | Обязательно | Описание |
|---|---|---|---|
| tariffId | `month` \| `quarter` \| `half` \| `year` | либо это поле | Тариф по сроку. |
| quantity | integer | либо это поле | Число документов поштучно, от 50 до 10 000. |

```json
{ "tariffId": "year" }
```

```json
{ "quantity": 120 }
```

## Ответ

`201 Created`

| Поле | Тип | Описание |
|---|---|---|
| id | string (UUID) | Идентификатор платежа у нас — для `GET /api/billing/card-payments/{id}`. |
| confirmationUrl | string | Страница оплаты ЮKassa. После оплаты ЮKassa возвращает человека в кабинет, в «Настройки → Тариф и оплата». |

```json
{
  "id": "4b1e2c3d-5f6a-4b7c-8d9e-0f1a2b3c4d5e",
  "confirmationUrl": "https://yoomoney.ru/checkout/payments/v2/contract?orderId=…"
}
```

## Ошибки

| Код | Когда |
|---|---|
| 400 | Тело не подходит ни под один вариант, лишнее поле, неизвестный `tariffId` — сообщение Zod. `quantity` не целое: «Число документов — целое»; меньше 20: «Поштучно — от 20 документов»; больше 5000: «Поштучно — до 5000 документов». |
| 401 | Нет cookie-сессии. |
| 403 | Запрос пришёл по токену API: «Это действие доступно только человеку, вошедшему в кабинет. По токену API его выполнить нельзя.» |
| 403 | Роль `member`: «Недостаточно прав для этого действия». |
| 500 | ЮKassa отказала в создании платежа или не вернула ссылку на оплату. |
| 503 | Магазин не подключён: «Оплата картой пока не подключена — напишите нам, выставим счёт». |

## Пример

Оплата картой начинается только из кабинета, по cookie-сессии человека:

```bash
curl -X POST "https://vruchay.ru/api/billing/checkout" \
  -H "Cookie: gramota_session=…" \
  -H "Content-Type: application/json" \
  -d '{"tariffId":"year"}'
```

---

<!-- endpoints/billing/post-org-invoices -->
> method: POST | path: /api/org/invoices | auth: humans-only | roles: owner, admin | rate_limit: none

# POST /api/org/invoices

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

Запрос называет позицию каталога, а не сумму — те же позиции и цены, что у оплаты картой, см. [Оплатить документы картой](post-billing-checkout.md):

- тариф по сроку — `{"tariffId": "month" | "quarter" | "half" | "year"}`;
- документы поштучно — `{"quantity": N}`, где N от 50 до 10 000.

Счёт выставляется по реквизитам из `GET /api/org/billing`: они копируются в счёт, поправленные потом реквизиты выставленный счёт не меняют. Счёт сразу уходит письмом на почту из реквизитов, а если её там нет — владельцу организации. Он же виден в `GET /api/org/invoices` и открывается в PDF через `GET /api/org/invoices/{id}/pdf`.

Основание такого счёта — лицензионный договор-оферта: оплата счёта означает акцепт оферты, а сам счёт действителен пять календарных дней со дня выставления. Если с организацией подписан отдельный договор, счёт по нему выставляет владелец сервиса — см. [`GET /api/org/invoices`](get-org-invoices.md).

Номера счетов сквозные, поэтому повторный запрос на ту же позицию, пока прежний счёт не оплачен и действует (5 дней), не выставляет новый: возвращается прежний с `reused: true`, письмо повторно не уходит. Неоплаченных счетов за последние 30 дней у организации может быть не больше пяти.

Что происходит после оплаты:

1. Деньги приходят на расчётный счёт продавца. В назначении платежа должен стоять номер счёта — он напечатан в счёте («Оплата по счёту № N от ДД.ММ.ГГГГ»).
2. Банк продавца сообщает о поступлении, и сервис ищет счёт: номер и год из назначения, сумма до копейки, ИНН плательщика равен ИНН из реквизитов. Если подошёл ровно один неоплаченный счёт, он отмечается оплаченным. Если нет — оплату сверяют и отмечают вручную.
3. Оплаченный счёт начисляет объём один раз: организации назначается план на объём и срок счёта, неизрасходованный остаток прежнего плана переносится — так же, как при оплате картой. Объём виден в `GET /api/org/usage`.

## Запрос

Тело — один из двух вариантов, лишние поля не допускаются:

| Поле | Тип | Обязательно | Описание |
|---|---|---|---|
| tariffId | `month` \| `quarter` \| `half` \| `year` | либо это поле | Тариф по сроку. |
| quantity | integer | либо это поле | Число документов поштучно, от 50 до 10 000. |

```json
{ "tariffId": "year" }
```

## Ответ

`201 Created`

| Поле | Тип | Описание |
|---|---|---|
| id | string (UUID) | Идентификатор счёта — для `GET /api/org/invoices/{id}/pdf`. |
| number | number | Номер счёта в году. Его указывают в назначении платежа. |
| tariff | string | Что оплачивается — например, «Тариф «Год»: 6000 документов на год». |
| amountKopecks | number | Сумма в копейках. |
| createdAt | string (ISO 8601) | Дата выставления. |
| reused | boolean | `true` — на эту позицию уже был выставлен действующий неоплаченный счёт, и вернулся он. |

```json
{
  "id": "4b1e2c3d-5f6a-4b7c-8d9e-0f1a2b3c4d5e",
  "number": 12,
  "tariff": "Тариф «Год»: 6000 документов на год",
  "amountKopecks": 900000,
  "createdAt": "2026-10-09T09:30:00.000Z",
  "reused": false
}
```

## Ошибки

| Код | Когда |
|---|---|
| 400 | Тело не подходит ни под один вариант, лишнее поле, неизвестный `tariffId` — сообщение Zod. |
| 400 | Реквизиты не заполнены: «Реквизиты покупателя не заполнены — «Настройки → Тариф и оплата»». Неверный ИНН: «В реквизитах покупателя неверный ИНН — счёт по нему бухгалтерия не проведёт». |
| 400 | Неоплаченных счетов уже пять: «Неоплаченных счетов уже пять. Оплатите один из них или напишите нам — разберёмся.» |
| 401 | Нет cookie-сессии. |
| 403 | Запрос пришёл по токену API или роль `member`. |
| 503 | Счета по тарифу у сервиса пока не выставляются: «Счёт по тарифу пока не выставить — нажмите «Обсудить условия»». Проверить заранее — `GET /api/org/invoices/available`. |

## Пример

```bash
curl -X POST "https://vruchay.ru/api/org/invoices" \
  -H "Cookie: gramota_session=…" \
  -H "Content-Type: application/json" \
  -d '{"tariffId":"year"}'
```

---

<!-- endpoints/documents/delete-documents-id-purge -->
> method: DELETE | path: /api/documents/{id}/purge | auth: humans-only | roles: owner | rate_limit: none

# DELETE /api/documents/{id}/purge

Окончательное удаление документа, который уже лежит в корзине. Вместе с ним
уничтожаются его файлы в хранилище и записи о них: фоны, картинки и **выданные
получателям документы** — ссылки проверки по ним перестают работать. Исключение —
фон и картинки, которые печатает другой документ (см. ниже). Отменить нечем,
поэтому только владелец организации. Действие пишется в журнал (`document.purge`);
то же правило о файлах действует и при удалении по сроку корзины.

**По токену API недоступно.** Маршрут требует роль `owner`, а у токена роль только
`member` или `admin` — токен получит `403` «Недостаточно прав для этого действия».
Удалить навсегда может только владелец, вошедший в кабинет. Без этого шага документ
из корзины всё равно удаляется сам — по сроку хранения корзины организации.

Копии документа и документы по шаблону, снятому с него, печатают фон и картинки
блоков исходника по ссылке на тот же файл, а не своей копией. Такие файлы
окончательное удаление не трогает: фон или картинка, которые стоят у другого
документа той же организации (фоном листа или блоком `image` в макете; документы
в корзине тоже считаются), переходят к этому документу и удалятся уже вместе с ним.
Копии продолжают печататься как прежде. Удаляются выданные документы и картинки,
которыми больше никто не пользуется.

## Запрос

| Параметр | Тип | Обязателен | Описание |
|---|---|---|---|
| `id` | UUID | да | идентификатор документа в корзине |

Тела нет.

## Ответ

`200 OK`

| Поле | Тип | Описание |
|---|---|---|
| `ok` | `true` | |
| `title` | string | название удалённого документа |

```json
{ "ok": true, "title": "Грамота за место" }
```

## Ошибки

| Код | Когда |
|---|---|
| 400 | «Некорректный идентификатор» — `id` не UUID |
| 401 | нет токена или сессии |
| 403 | «Недостаточно прав для этого действия» — роль `admin` или `member`, в том числе любой токен API |
| 404 | «Документ не найден» — нет такого, чужой или ещё не в корзине |

## Пример

Из кабинета, сессией владельца:

```bash
curl -X DELETE "https://vruchay.ru/api/documents/$DOCUMENT_ID/purge" \
  -H "Cookie: gramota_session=…"
```

---

<!-- endpoints/documents/delete-documents-id-sheets-sheetId-background -->
> method: DELETE | path: /api/documents/{id}/sheets/{sheetId}/background | auth: token | roles: any | rate_limit: none

# DELETE /api/documents/{id}/sheets/{sheetId}/background

Убирает фон листа: `backgroundFileId` становится `null`, лист печатается белым,
блоки остаются на месте. Файл фона удаляется из хранилища, только если он больше
нигде не стоит — копия документа и документ из шаблона держат фон исходника ссылкой
на тот же файл, и он им остаётся.

Лист без фона — не ошибка: ответ тот же, ничего не меняется. Версия листа
(`version`) от этого не растёт — фон в неё не входит.

## Запрос

| Параметр | Тип | Обязателен | Описание |
|---|---|---|---|
| `id` | UUID | да | идентификатор документа |
| `sheetId` | UUID | да | идентификатор листа |

Тела нет.

## Ответ

`200 OK`

| Поле | Тип | Описание |
|---|---|---|
| `ok` | `true` | |

```json
{ "ok": true }
```

## Ошибки

| Код | Когда |
|---|---|
| 400 | «Некорректный идентификатор» — параметр не UUID |
| 401 | нет токена или сессии |
| 404 | «Лист не найден» — лист не из этого документа, документ чужой или в корзине |

## Пример

```bash
curl -X DELETE "https://vruchay.ru/api/documents/$DOCUMENT_ID/sheets/$SHEET_ID/background" \
  -H "Authorization: Bearer $VRUCHAY_TOKEN"
```

---

<!-- endpoints/documents/delete-documents-id-sheets-sheetId -->
> method: DELETE | path: /api/documents/{id}/sheets/{sheetId} | auth: token | roles: any | rate_limit: none

# DELETE /api/documents/{id}/sheets/{sheetId}

Удаляет лист документа и перенумеровывает оставшиеся (`position` без дыр).
Единственный лист удалить нельзя.

## Запрос

| Параметр | Тип | Обязателен | Описание |
|---|---|---|---|
| `id` | UUID | да | идентификатор документа |
| `sheetId` | UUID | да | идентификатор листа |

Тела нет.

## Ответ

`200 OK`

```json
{ "ok": true }
```

## Ошибки

| Код | Когда |
|---|---|
| 400 | «Некорректный идентификатор» — параметр не UUID |
| 401 | нет токена или сессии |
| 404 | «Документ не найден» — нет такого, чужой или в корзине; «Нельзя удалить единственный лист документа» (проверяется до поиска листа); «Лист не найден» — лист не из этого документа |

## Пример

```bash
curl -X DELETE "https://vruchay.ru/api/documents/$DOCUMENT_ID/sheets/$SHEET_ID" \
  -H "Authorization: Bearer $VRUCHAY_TOKEN"
```

---

<!-- endpoints/documents/delete-documents-id -->
> method: DELETE | path: /api/documents/{id} | auth: token | roles: owner, admin | rate_limit: none

# DELETE /api/documents/{id}

Мягкое удаление: документ помечается `deletedAt` и пропадает из списка, но остаётся
в корзине (`GET /api/documents?trashed=true`). Выданные файлы и ссылки проверки
продолжают работать. Вернуть — `POST /api/documents/{id}/restore`; удалить насовсем —
`DELETE /api/documents/{id}/purge`. Документы, пролежавшие в корзине дольше срока,
чистятся по расписанию.

Действие пишется в журнал организации (`document.trash`).

## Запрос

| Параметр | Тип | Обязателен | Описание |
|---|---|---|---|
| `id` | UUID | да | идентификатор документа |

Тела нет.

## Ответ

`200 OK`

| Поле | Тип | Описание |
|---|---|---|
| `ok` | `true` | |
| `title` | string | название удалённого документа |

```json
{ "ok": true, "title": "Грамота за место" }
```

## Ошибки

| Код | Когда |
|---|---|
| 400 | «Некорректный идентификатор» — `id` не UUID |
| 401 | нет токена или сессии |
| 403 | «Недостаточно прав для этого действия» — роль `member` |
| 404 | «Документ не найден» — нет такого, чужой или уже в корзине |

## Пример

```bash
curl -X DELETE "https://vruchay.ru/api/documents/$DOCUMENT_ID" \
  -H "Authorization: Bearer $VRUCHAY_TOKEN"
```

---

<!-- endpoints/documents/get-documents-files-fileId-raw -->
> method: GET | path: /api/documents/files/{fileId}/raw | auth: token | roles: any | rate_limit: none

# GET /api/documents/files/{fileId}/raw

Постоянный адрес файла организации — для `<img src>` и всего, что хранит ссылку
дольше пятнадцати минут. Подписанная ссылка на хранилище живёт 15 минут, а макет
хранит только `fileId`; этот маршрут каждый раз отвечает переадресацией `302`
на свежую подписанную ссылку. Так показывают картинки блоков и фоны холст редактора,
превью и миниатюры в списке.

Ответ кешируется браузером на 5 минут (`Cache-Control: private, max-age=300`) —
меньше срока ссылки, чтобы браузер не пошёл по уже истёкшей. Файл ищется в
организации текущего пользователя, удалённые не отдаются. Нужна ссылка
в JSON, а не переадресация, — [`GET /api/documents/files/{fileId}/url`](get-documents-files-fileId-url.md).

Переадресация ведёт на хранилище: заголовок `Authorization` туда не нужен,
доступ даёт подпись в адресе.

## Запрос

| Параметр | Тип | Обязателен | Описание |
|---|---|---|---|
| `fileId` | UUID | да | идентификатор файла организации: картинка блока (`props.fileId`), фон листа (`backgroundFileId`); вид файла не проверяется |

Тела нет.

## Ответ

`302 Found`, тела нет.

- `Location` — подписанная ссылка GET на объект хранилища, действует 15 минут
- `Cache-Control: private, max-age=300`

```http
HTTP/1.1 302 Found
Location: https://storage.example/…/asset/e5f6a7b8-….png?X-Amz-Expires=900&X-Amz-Signature=…
Cache-Control: private, max-age=300
```

## Ошибки

| Код | Когда |
|---|---|
| 400 | «Некорректный идентификатор» — `fileId` не UUID |
| 401 | нет токена или сессии |
| 404 | «Файл не найден» — нет такого, чужой или удалён |

## Пример

```bash
curl -L "https://vruchay.ru/api/documents/files/$FILE_ID/raw" \
  -H "Authorization: Bearer $VRUCHAY_TOKEN" \
  -o logo.png
```

---

<!-- endpoints/documents/get-documents-files-fileId-url -->
> method: GET | path: /api/documents/files/{fileId}/url | auth: token | roles: any | rate_limit: none

# GET /api/documents/files/{fileId}/url

Возвращает временную подписанную ссылку на файл организации в хранилище — в первую
очередь на фон листа (`backgroundFileId`), чтобы показать его в превью. Ссылка действует
15 минут; за новой нужно обращаться снова. Файл ищется по организации текущего
пользователя, удалённые (`deletedAt`) не отдаются.

## Запрос

| Параметр | Тип | Обязателен | Описание |
|---|---|---|---|
| `fileId` | UUID | да | идентификатор файла |

Тела нет.

## Ответ

`200 OK`

| Поле | Тип | Описание |
|---|---|---|
| `url` | string | подписанная ссылка GET на объект хранилища |

```json
{ "url": "https://storage.vruchay.ru/…?X-Amz-Signature=…" }
```

## Ошибки

| Код | Когда |
|---|---|
| 400 | «Некорректный идентификатор» — `fileId` не UUID |
| 401 | нет токена или сессии |
| 404 | «Файл не найден» — нет такого, чужой или удалён |

## Пример

```bash
curl "https://vruchay.ru/api/documents/files/$FILE_ID/url" \
  -H "Authorization: Bearer $VRUCHAY_TOKEN"
```

---

<!-- endpoints/documents/get-documents-id-registry-csv -->
> method: GET | path: /api/documents/{id}/registry.csv | auth: token | roles: any | rate_limit: none

# GET /api/documents/{id}/registry.csv

Тот же реестр, что `GET /api/documents/{id}/registry`, но таблицей под русский Excel:
разделитель — точка с запятой, кодировка UTF-8 с меткой порядка байтов, строки
разделены `\r\n`, каждая ячейка в кавычках. Значения, начинающиеся с `=`, `+`, `-`, `@`,
табуляции, предваряются апострофом — защита от выполнения формул.

Колонки: `№`, `ФИО`, `Адрес почты`, затем все остальные колонки таблицы получателей
(по фактическим данным, кроме `name` и `email`), `Выдан` (дата и время по Москве,
`19.06.2026, 15:00`), `Проверочный код` (в этой колонке — `publicId`), `Письмо`
(«не отправлялось», «в очереди», «отправлено», «доставлено», «прочитано»,
«не доставлено», «ошибка»).

## Запрос

| Параметр | Тип | Обязателен | Описание |
|---|---|---|---|
| `id` | UUID | да | идентификатор документа |

Тела нет.

## Ответ

`200 OK`, файл.

- `Content-Type: text/csv; charset=utf-8`
- `Content-Disposition: attachment; filename="registry-<id>.csv"`

```
"№";"ФИО";"Адрес почты";"place";"Выдан";"Проверочный код";"Письмо"
"1";"Иванов Пётр Ильич";"ivanov@vruchay.ru";"1";"19.06.2026, 15:00";"f6a7b8c9-d0e1-4f2a-9b3c-4d5e6f7a8b9c";"доставлено"
```

## Ошибки

| Код | Когда |
|---|---|
| 400 | «Некорректный идентификатор» — `id` не UUID |
| 401 | нет токена или сессии |
| 404 | «Документ не найден» — нет такого, чужой или в корзине |

## Пример

```bash
curl "https://vruchay.ru/api/documents/$DOCUMENT_ID/registry.csv" \
  -H "Authorization: Bearer $VRUCHAY_TOKEN" \
  -o registry.csv
```

---

<!-- endpoints/documents/get-documents-id-registry -->
> method: GET | path: /api/documents/{id}/registry | auth: token | roles: any | rate_limit: none

# GET /api/documents/{id}/registry

Реестр выдач по документу: по одной строке на каждого получателя, у которого есть
выпущенный файл (`lastFileId`), в порядке строк таблицы. Строка знает свой последний
экземпляр (публичный идентификатор, код, время выпуска, отзыв, чем заменён при
перевыпуске) и состояние последнего письма. Пагинации нет — реестр отдаётся целиком.
Та же таблица в CSV — `GET /api/documents/{id}/registry.csv`.

## Запрос

| Параметр | Тип | Обязателен | Описание |
|---|---|---|---|
| `id` | UUID | да | идентификатор документа |

Тела нет.

## Ответ

`200 OK`

| Поле | Тип | Описание |
|---|---|---|
| `document` | `{ id, title }` | |
| `total` | integer | число строк реестра |
| `items[].rowId` | UUID | строка получателя |
| `items[].name` | string | значение колонки `name` (пусто, если нет) |
| `items[].email` | string | адрес последнего письма, иначе колонка `email`, иначе пусто |
| `items[].fields` | object | все данные строки как есть (имя колонки → значение) |
| `items[].fileId` | UUID | последний выпущенный файл |
| `items[].publicId` | UUID | публичный идентификатор экземпляра |
| `items[].code` | string | короткий проверочный код (`K7M2-9QXR-4TVB`); у старых экземпляров без кода — `publicId` |
| `items[].verifyPath` | string | путь страницы проверки: `/c/<code>` или `/verify/<publicId>` |
| `items[].issuedAt` | string | время выпуска файла |
| `items[].revoked` | boolean | проверка отозвана |
| `items[].replacedBy` | `{ publicId, code, verifyPath, issuedAt }` \| null | действующий двойник после перевыпуска |
| `items[].mailStatus` | string \| null | статус последнего письма: `queued`, `sent`, `delivered`, `opened`, `bounced`, `failed`; `null` — не отправлялось |
| `items[].mailSentAt` | string \| null | |
| `items[].mailError` | string \| null | |

```json
{
  "document": { "id": "6f1c3b2a-9d4e-4f5a-8b6c-7d8e9f0a1b2c", "title": "Грамота за место" },
  "total": 1,
  "items": [
    {
      "rowId": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
      "name": "Иванов Пётр Ильич",
      "email": "ivanov@vruchay.ru",
      "fields": { "name": "Иванов Пётр Ильич", "email": "ivanov@vruchay.ru", "place": "1" },
      "fileId": "e5f6a7b8-c9d0-4e1f-8a2b-3c4d5e6f7a8b",
      "publicId": "f6a7b8c9-d0e1-4f2a-9b3c-4d5e6f7a8b9c",
      "code": "K7M2-9QXR-4TVB",
      "verifyPath": "/c/K7M2-9QXR-4TVB",
      "issuedAt": "2026-06-19T12:00:00.000Z",
      "revoked": false,
      "replacedBy": null,
      "mailStatus": "delivered",
      "mailSentAt": "2026-06-19T12:01:10.000Z",
      "mailError": null
    }
  ]
}
```

## Ошибки

| Код | Когда |
|---|---|
| 400 | «Некорректный идентификатор» — `id` не UUID |
| 401 | нет токена или сессии |
| 404 | «Документ не найден» — нет такого, чужой или в корзине |

## Пример

```bash
curl "https://vruchay.ru/api/documents/$DOCUMENT_ID/registry" \
  -H "Authorization: Bearer $VRUCHAY_TOKEN"
```

---

<!-- endpoints/documents/get-documents-id -->
> method: GET | path: /api/documents/{id} | auth: token | roles: any | rate_limit: none

# GET /api/documents/{id}

Документ целиком с листами (и их макетами) в порядке `position`. Документы из корзины
этим маршрутом не отдаются — сначала `POST /api/documents/{id}/restore`. Реестр выдач
и получатели в карточку не входят — у них свои маршруты.

## Запрос

| Параметр | Тип | Обязателен | Описание |
|---|---|---|---|
| `id` | UUID | да | идентификатор документа |

Тела нет.

## Ответ

`200 OK`

| Поле | Тип | Описание |
|---|---|---|
| `id` | UUID | |
| `orgId` | UUID | организация |
| `title` | string | |
| `pageWidthMm`, `pageHeightMm` | number | размер листа, мм |
| `verifyEnabled` | boolean | страница проверки подлинности включена |
| `verifyFields` | string[] | имена полей получателя, которые раскрывает страница проверки |
| `eventName`, `eventDate`, `eventPlace`, `eventHours` | string | мероприятие (переменные `%event`, `%event_date`, `%event_place`, `%hours`) |
| `constants` | object[] | постоянные поля `{ key, title, value }` — одно значение на все документы, на листе `%key` |
| `issueDate` | string \| null | дата выдачи (день, в JSON — полночь UTC); `null` — день выпуска |
| `expiresIn` | string \| null | срок действия от выдачи, ISO 8601 (`P1Y`) |
| `expiresAt` | string \| null | срок действия фиксированной датой; если заданы оба, побеждает дата |
| `projectId` | string (UUID) \| null | проект; `null` — «Без проекта» |
| `issueVia` | string | способ выдачи: `list` (список), `site` (форма на сайте), `link` (форма по ссылке), `api` |
| `folderId` | string (UUID) \| null | устаревшее имя `projectId`, то же значение |
| `sourceDocumentId` | UUID \| null | исходник копии (сырое поле; для показа удобнее `source`) |
| `source` | `{ id, title }` \| null | исходный бланк, если он не в корзине |
| `isTemplate` | boolean | шаблон (`true`) или документ |
| `ruleSetId` | UUID \| null | привязанный набор правил награждения |
| `createdAt`, `updatedAt` | string | |
| `deletedAt` | null | у живого документа всегда `null` |
| `sheets` | object[] | листы: `id`, `documentId`, `position`, `backgroundFileId` (UUID \| null), `layout` (массив элементов), `schemaVersion`, `version` — номер правки листа, для `baseVersion` в правках макета |

```json
{
  "id": "6f1c3b2a-9d4e-4f5a-8b6c-7d8e9f0a1b2c",
  "orgId": "0a1b2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d",
  "title": "Грамота за место",
  "pageWidthMm": 297,
  "pageHeightMm": 210,
  "verifyEnabled": true,
  "verifyFields": ["name", "place"],
  "eventName": "Конкурс «Мастер года»",
  "eventDate": "17–19 июня 2026",
  "eventPlace": "г. Челябинск",
  "eventHours": "",
  "issueDate": "2026-06-19T00:00:00.000Z",
  "expiresIn": null,
  "expiresAt": null,
  "projectId": "9f1c0c8e-0f3c-4a1a-9c1f-1f7d2a3b4c5d",
  "folderId": "9f1c0c8e-0f3c-4a1a-9c1f-1f7d2a3b4c5d",
  "sourceDocumentId": null,
  "source": null,
  "isTemplate": false,
  "ruleSetId": "7c1f7d1e-2b5e-4c3a-9c1a-8d2f0a6b1e11",
  "createdAt": "2026-08-01T08:00:00.000Z",
  "updatedAt": "2026-08-30T10:12:44.000Z",
  "deletedAt": null,
  "sheets": [
    {
      "id": "b2c3d4e5-f6a7-4b8c-9d0e-1f2a3b4c5d6e",
      "documentId": "6f1c3b2a-9d4e-4f5a-8b6c-7d8e9f0a1b2c",
      "position": 0,
      "backgroundFileId": "d4e5f6a7-b8c9-4d0e-9f1a-2b3c4d5e6f70",
      "layout": [],
      "schemaVersion": 3,
      "version": 12
    }
  ]
}
```

`layout` хранится в том виде, в каком его сохранили: у листов, записанных до версии 2,
текстовые блоки могут содержать `props.text` вместо `props.doc`. Форма элементов —
в [справочнике макета](../../reference/layout.md).

## Ошибки

| Код | Когда |
|---|---|
| 400 | «Некорректный идентификатор» — `id` не UUID |
| 401 | нет токена или сессии |
| 404 | «Документ не найден» — нет такого, чужой или в корзине |

## Пример

```bash
curl "https://vruchay.ru/api/documents/$DOCUMENT_ID" \
  -H "Authorization: Bearer $VRUCHAY_TOKEN"
```

---

<!-- endpoints/documents/get-documents -->
> method: GET | path: /api/documents | auth: token | roles: any | rate_limit: none

# GET /api/documents

Библиотека материалов организации: страница списка с итоговым числом. По умолчанию
отдаются живые документы без шаблонов, отсортированные по времени правки. У каждого
элемента — превью первого листа (макет и подписанная ссылка на фон), число листов
и получателей.

Три списка в одном маршруте:

| Запрос | Что в списке | Фильтр по проекту |
|---|---|---|
| без `templates` и `trashed` | документы (не шаблоны), не в корзине | действует |
| `templates=true` | шаблоны организации, не в корзине | не действует: шаблоны общие на организацию, `projectId` не проверяется и не сужает список |
| `trashed=true` | корзина — и документы, и шаблоны вместе | действует |

`trashed=true` сильнее `templates`: удалённые шаблоны лежат в общей корзине, и
восстанавливаются они оттуда же (`POST /api/documents/{id}/restore`). Отличить
шаблон в корзине можно по `isTemplate`. Новый документ по шаблону —
`POST /api/documents` с `templateId`; сохранить документ шаблоном —
[`POST /api/documents/{id}/template`](post-documents-id-template.md).

## Запрос

Параметры строки запроса:

| Параметр | Тип | Обязателен | Описание |
|---|---|---|---|
| `limit` | integer | нет | 1…100, по умолчанию 50 |
| `offset` | integer | нет | ≥ 0, по умолчанию 0 |
| `search` | string | нет | до 200 символов; поиск по названию без учёта регистра (подстрока) |
| `projectId` | string (UUID) \| `none` | нет | проект из `GET /api/projects`; `none` — документы без проекта; без параметра — все. При `templates=true` (без `trashed`) не действует |
| `folderId` | string (UUID) | нет | устаревшее имя `projectId` (только UUID). Если указаны оба с разными значениями — 400 |
| `sort` | `updated` \| `created` \| `title` | нет | `updated` — по правке (новые сверху), `created` — по созданию, `title` — по названию. По умолчанию `updated` |
| `templates` | `true` \| `false` | нет | `true` — только шаблоны, `false` (по умолчанию) — только документы. При `trashed=true` не действует |
| `trashed` | `true` \| `false` | нет | `true` — только корзина (документы и шаблоны вместе), `false` (по умолчанию) — только живые |

Тела нет.

## Ответ

`200 OK`

| Поле | Тип | Описание |
|---|---|---|
| `items` | object[] | документы страницы |
| `items[].id` | UUID | |
| `items[].title` | string | |
| `items[].pageWidthMm`, `items[].pageHeightMm` | number | размер листа, мм |
| `items[].updatedAt`, `items[].createdAt` | string (ISO 8601) | |
| `items[].projectId` | string (UUID) \| null | проект; `null` — «Без проекта» |
| `items[].isTemplate` | boolean | шаблон или документ |
| `items[].folderId` | string (UUID) \| null | устаревшее имя `projectId`, то же значение |
| `items[].eventName`, `items[].eventDate` | string | мероприятие (пустые строки, если не заполнены) |
| `items[].deletedAt` | string \| null | когда отправлен в корзину; `null` у живых |
| `items[].sheetCount` | integer | число листов |
| `items[].recipientCount` | integer | число строк получателей |
| `items[].source` | `{ id, title }` \| null | исходный бланк, с которого снята копия; `null`, если копии нет или исходник в корзине |
| `items[].preview.layout` | array | макет первого листа (см. [макет листа](../../reference/layout.md)); `[]`, если листов нет |
| `items[].preview.backgroundUrl` | string \| null | подписанная ссылка на фон первого листа (действует 15 минут); `null`, если фона нет или подпись не удалась |
| `total` | integer | всего документов под фильтром |
| `limit`, `offset` | integer | как в запросе |

```json
{
  "items": [
    {
      "id": "6f1c3b2a-9d4e-4f5a-8b6c-7d8e9f0a1b2c",
      "title": "Грамота за место",
      "pageWidthMm": 297,
      "pageHeightMm": 210,
      "updatedAt": "2026-08-30T10:12:44.000Z",
      "createdAt": "2026-08-01T08:00:00.000Z",
      "projectId": "9f1c0c8e-0f3c-4a1a-9c1f-1f7d2a3b4c5d",
      "folderId": "9f1c0c8e-0f3c-4a1a-9c1f-1f7d2a3b4c5d",
      "isTemplate": false,
      "eventName": "Конкурс «Мастер года»",
      "eventDate": "17–19 июня 2026",
      "deletedAt": null,
      "sheetCount": 1,
      "recipientCount": 214,
      "source": null,
      "preview": {
        "layout": [],
        "backgroundUrl": "https://storage.vruchay.ru/…?X-Amz-Signature=…"
      }
    }
  ],
  "total": 1,
  "limit": 50,
  "offset": 0
}
```

## Ошибки

| Код | Когда |
|---|---|
| 400 | параметр не прошёл проверку: `limit` вне 1…100, `offset` < 0, `search` длиннее 200, `projectId` не UUID и не `none`, `folderId` не UUID, разные `projectId` и `folderId`, неизвестный `sort`, `templates` или `trashed` не `true`/`false` |
| 401 | нет токена или сессии |
| 404 | «Проект не найден» — проекта с таким `projectId` в вашей организации нет (чужой тоже) |

## Пример

```bash
curl "https://vruchay.ru/api/documents?limit=20&projectId=$PROJECT_ID&sort=title" \
  -H "Authorization: Bearer $VRUCHAY_TOKEN"
```

Шаблоны организации:

```bash
curl "https://vruchay.ru/api/documents?templates=true" \
  -H "Authorization: Bearer $VRUCHAY_TOKEN"
```

---

<!-- endpoints/documents/patch-documents-id-sheets-sheetId -->
> method: PATCH | path: /api/documents/{id}/sheets/{sheetId} | auth: token | roles: any | rate_limit: none

# PATCH /api/documents/{id}/sheets/{sheetId}

Заменяет макет листа целиком: в `layout` передаётся весь массив элементов, а не
разница. Макет проверяется общей Zod-схемой `sheetLayout`, нормализуется (подставляются
значения по умолчанию, строка `props.text` превращается в дерево `props.doc`) и
сохраняется уже в таком виде с `schemaVersion: 3`. Именно этот сохранённый макет потом
печатает воркер.

Структура элементов, дерево текста и переменные подстановки —
в [справочнике макета](../../reference/layout.md), как верстать —
в [руководстве по вёрстке](../../layout-guide.md).

## Версия листа и чужие правки

У листа есть номер правки `version`: он растёт с каждым сохранением — этим маршрутом,
правкой по частям, готовой раскладкой и перекладкой блоков при смене размера листа.

Редактор в кабинете сохраняет лист **целиком** через 1,5 секунды после последней
правки — с `baseVersion`. Вашу запись он не затрёт: на его следующее сохранение
сервер ответит `409`, и человек выберет, взять вашу версию или записать свою поверх.
Если в редакторе ничего не ждёт записи, свежий лист подхватится сам, когда человек
вернётся во вкладку. А ваша запись без `baseVersion` ложится поверх всего — в том
числе поверх правки человека, которой вы не видели.

С `baseVersion` запись идёт только поверх той версии, которую вы прочитали
(`GET /api/documents/{id}` → `sheets[].version`). Лист успели поменять — `409`
с текущей версией в теле: перечитайте лист, примените правку к свежему макету
и повторите. Для точечных правок удобнее
[`PATCH …/sheets/{sheetId}/elements`](../layout/patch-documents-id-sheets-sheetId-elements.md):
она меняет только названные блоки и сама переживает одновременную соседнюю правку.

## Картинки

`props.fileId` блока `image` — только картинка, загруженная для блоков этой
организации: [`POST /api/documents/{id}/assets`](post-documents-id-assets.md)
или [`…/assets/from-url`](post-documents-id-assets-from-url.md). Файл фона,
выданный документ и чужой файл не подходят — `404` «Картинка не найдена».
Проверяются только `fileId`, которых не было в сохранённом макете: прежние
картинки (в том числе у копии материала, чьи файлы лежат у исходника) сохранению
не мешают.

## Запрос

| Параметр | Тип | Обязателен | Описание |
|---|---|---|---|
| `id` | UUID | да | идентификатор документа |
| `sheetId` | UUID | да | идентификатор листа этого документа |

Тело:

| Поле | Тип | Обязательно | Описание |
|---|---|---|---|
| `layout` | Element[] | да | до 500 элементов типов `text`, `image`, `qr`, `link`, `shape` |
| `baseVersion` | integer ≥ 0 | нет | версия листа, к которой сделана правка. Не совпала с текущей — `409`. Без поля последнее сохранение выигрывает |

```json
{
  "baseVersion": 4,
  "layout": [
    {
      "id": "title",
      "type": "text",
      "x": 20, "y": 40, "w": 257, "h": 25,
      "props": {
        "fontSize": 40,
        "bold": true,
        "uppercase": true,
        "doc": {
          "type": "doc",
          "content": [
            { "type": "paragraph", "content": [ { "type": "text", "text": "Грамота" } ] }
          ]
        }
      }
    },
    {
      "id": "name",
      "type": "text",
      "x": 20, "y": 90, "w": 257, "h": 20,
      "props": {
        "fontSize": 28,
        "doc": {
          "type": "doc",
          "content": [
            {
              "type": "paragraph",
              "content": [
                { "type": "text", "text": "Награждается " },
                { "type": "mergeField", "attrs": { "source": "name" } },
                { "type": "text", "text": ", занявший " },
                { "type": "mergeField", "attrs": { "source": "place_word" } },
                { "type": "text", "text": " место" }
              ]
            }
          ]
        }
      }
    },
    {
      "id": "qr",
      "type": "qr",
      "x": 260, "y": 170, "w": 25, "h": 25,
      "props": { "template": "" }
    }
  ]
}
```

## Ответ

`200 OK` — лист с нормализованным макетом.

| Поле | Тип | Описание |
|---|---|---|
| `id` | UUID | |
| `documentId` | UUID | |
| `position` | integer | |
| `backgroundFileId` | UUID \| null | фон листа |
| `layout` | Element[] | макет после нормализации: у каждого элемента заполнены `rotation`, `z`, `opacity`, `locked`, `hidden`, `groupId`, `name`; у текста — `doc` и все стили |
| `schemaVersion` | `3` | |
| `version` | integer | номер правки листа после сохранения — на единицу больше прежнего |

```json
{
  "id": "b2c3d4e5-f6a7-4b8c-9d0e-1f2a3b4c5d6e",
  "documentId": "6f1c3b2a-9d4e-4f5a-8b6c-7d8e9f0a1b2c",
  "position": 0,
  "backgroundFileId": null,
  "layout": [
    {
      "id": "qr",
      "type": "qr",
      "x": 260, "y": 170, "w": 25, "h": 25,
      "rotation": 0, "z": 0, "opacity": 1, "locked": false, "hidden": false,
      "groupId": null, "name": null,
      "props": { "template": "", "color": "#000000" }
    }
  ],
  "schemaVersion": 3,
  "version": 5
}
```

## Ошибки

| Код | Когда |
|---|---|
| 400 | «Некорректный идентификатор» — `id` или `sheetId` не UUID; макет не прошёл схему: больше 500 элементов, неизвестный `type`, координаты вне −5000…5000, «У текстового блока нет ни текста, ни дерева», «Текст блока длиннее 5000 символов», «В блоке больше 1000 узлов», «Списки вложены глубже 6 уровней», «Недопустимое имя поля», «Ссылка должна начинаться с http:// или https://» и прочие сообщения Zod с путём до поля в `errors[].field` (например `layout.1.props.fontSize`) |
| 401 | нет токена или сессии |
| 400 | `baseVersion` не целое или меньше нуля |
| 404 | «Лист не найден» — лист не принадлежит этому документу, документ чужой или в корзине |
| 404 | «Картинка не найдена» — в новом блоке `image` стоит `fileId`, которого нет среди картинок организации (фон, выданный документ, чужой или удалённый файл) |
| 409 | «Лист изменился (версия N, а правка сделана к версии M). Перечитайте лист и повторите правку.» — `baseVersion` не совпал с текущей версией; в теле есть `version` с текущим номером: `{"message": "…", "version": 6}` |
| 413 | тело больше 1 МБ |

## Пример

```bash
curl -X PATCH "https://vruchay.ru/api/documents/$DOCUMENT_ID/sheets/$SHEET_ID" \
  -H "Authorization: Bearer $VRUCHAY_TOKEN" \
  -H "Content-Type: application/json" \
  -d @layout.json
```

---

<!-- endpoints/documents/patch-documents-id -->
> method: PATCH | path: /api/documents/{id} | auth: token | roles: any | rate_limit: none

# PATCH /api/documents/{id}

Частичное обновление карточки: название, размер листа, настройки проверки подлинности,
мероприятие, дата выдачи, срок действия, раздел. Передаются только меняемые поля;
пустое тело отклоняется. Макет листа этим маршрутом не меняется — для него
`PATCH /api/documents/{id}/sheets/{sheetId}`. Документ из корзины править нельзя (404).

Срок действия — правило на момент выпуска: изменение не трогает уже выданные файлы.

## Смена размера листа: `resize`

Новые `pageWidthMm`/`pageHeightMm` сами по себе блоки не двигают — как и раньше.
Поле `resize` перекладывает блоки всех листов документа тем же правилом, что
диалог «Размер листа» в редакторе:

- `scale` — всё пропорционально: место, размер, кегль, разрядка, отступы, рамки,
  обводки и кегли внутри текста умножаются на `min(новая ширина / старая, новая
  высота / старая)`; если пропорции листов разные, композиция встаёт по центру.
  Кегль не мельче 4 pt, блок не меньше 5 мм;
- `reposition` — кегли и размеры как были, центр каждого блока остаётся на той же
  доле листа и не выходит за его край;
- `keep` — не трогать (по умолчанию). Блоки, которые после уменьшения листа
  вылезли за край, покажет [`POST …/check`](../layout/post-documents-id-sheets-sheetId-check.md).

Перекладка идёт в одной транзакции с правкой документа; у каждого переложенного
листа растёт `version`, так что чужая правка с прежним `baseVersion` получит `409`.
Лист, макет которого не читается схемой, пропускается. Без смены размера `resize`
ни на что не влияет.

## Запрос

| Параметр | Тип | Обязателен | Описание |
|---|---|---|---|
| `id` | UUID | да | идентификатор документа |

Тело — любое непустое подмножество полей:

| Поле | Тип | Обязательно | Описание |
|---|---|---|---|
| `title` | string | нет | 1–200 символов. Ошибка: «Введите название» |
| `pageWidthMm` | number | нет | 50…600 |
| `pageHeightMm` | number | нет | 50…600 |
| `resize` | `scale` \| `reposition` \| `keep` | нет | что делать с блоками при смене размера, см. выше. По умолчанию `keep` |
| `verifyEnabled` | boolean | нет | показывать ли страницу проверки |
| `verifyFields` | string[] | нет | до 10 имён полей, каждое до 64 символов; эти значения получателя раскрываются на странице проверки |
| `eventName` | string | нет | до 300 |
| `eventDate` | string | нет | до 100, живым текстом («17–19 июня 2026») |
| `eventPlace` | string | нет | до 200 |
| `eventHours` | string | нет | до 50 |
| `constants` | object[] | нет | постоянные поля — одно значение на все документы материала, список целиком, до 30. Элемент: `key` — латиницей `^[a-z][a-z0-9_]{0,39}$`, не имя служебного поля (`date`, `event`, `name_dat`…), без повторов; `title` — 1–100 символов; `value` — до 1000. На листе подставляются как `%key`; колонка таблицы с тем же ключом сильнее. Ошибки: «Недопустимый ключ поля», «Это имя занято полем сервиса», «Два поля с одним ключом» |
| `issueDate` | string \| null | нет | `ГГГГ-ММ-ДД`; `null` — печатать день выпуска. Ошибки: «Дата в виде ГГГГ-ММ-ДД», «Такой даты нет» |
| `projectId` | string (UUID) \| null | нет | проект из `GET /api/projects`; `null` — убрать из проекта («Без проекта»). Вместе с документом в проект переезжает всё выданное по нему и письма |
| `folderId` | string (UUID) \| null | нет | устаревшее имя `projectId`; разные значения в обоих полях — 400 |
| `issueVia` | string | нет | способ выдачи: `list`, `site`, `link` или `api`. Кабинет меняет его, когда открывают вкладку шага «Выпуск» |
| `expiresIn` | string \| null | нет | длительность ISO 8601 от даты выдачи: `P1Y`, `P6M`, `P2W`, `P30D`, `P1Y6M`; до 20 символов, приводится к верхнему регистру; не больше 100 лет; `null` — снять. Ошибка: «Срок задаётся как P1Y, P6M, P2W или P30D» |
| `expiresAt` | string \| null | нет | фиксированная дата окончания (любая запись, которую разбирает `Date`); `null` — снять. Ошибка: «Не удалось разобрать дату окончания срока» |

```json
{
  "eventName": "Конкурс «Мастер года»",
  "eventDate": "17–19 июня 2026",
  "eventPlace": "г. Челябинск",
  "issueDate": "2026-06-19",
  "verifyFields": ["name", "place"]
}
```

## Ответ

`200 OK` — обновлённый документ с листами. Форма — как у `GET /api/documents/{id}`,
но **без** поля `source` (есть только сырой `sourceDocumentId`). После `resize`
в `sheets` уже переложенные макеты и новые `version`.

```json
{
  "id": "6f1c3b2a-9d4e-4f5a-8b6c-7d8e9f0a1b2c",
  "orgId": "0a1b2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d",
  "title": "Грамота за место",
  "pageWidthMm": 297,
  "pageHeightMm": 210,
  "verifyEnabled": true,
  "verifyFields": ["name", "place"],
  "eventName": "Конкурс «Мастер года»",
  "eventDate": "17–19 июня 2026",
  "eventPlace": "г. Челябинск",
  "eventHours": "",
  "constants": [],
  "issueDate": "2026-06-19T00:00:00.000Z",
  "expiresIn": null,
  "expiresAt": null,
  "projectId": "9f1c0c8e-0f3c-4a1a-9c1f-1f7d2a3b4c5d",
  "folderId": "9f1c0c8e-0f3c-4a1a-9c1f-1f7d2a3b4c5d",
  "sourceDocumentId": null,
  "isTemplate": false,
  "ruleSetId": null,
  "createdAt": "2026-08-01T08:00:00.000Z",
  "updatedAt": "2026-09-03T09:05:00.000Z",
  "deletedAt": null,
  "sheets": [
    { "id": "b2c3d4e5-…", "documentId": "6f1c3b2a-…", "position": 0, "backgroundFileId": null, "layout": [], "schemaVersion": 3, "version": 7 }
  ]
}
```

## Ошибки

| Код | Когда |
|---|---|
| 400 | «Нечего обновлять» — пустое тело; «Некорректный идентификатор»; «Введите название»; `resize` не `scale`/`reposition`/`keep`; «Дата в виде ГГГГ-ММ-ДД»; «Такой даты нет»; «Срок задаётся как P1Y, P6M, P2W или P30D»; «Не удалось разобрать дату окончания срока»; выход за пределы длины/диапазона; `projectId`/`folderId` не UUID; «В projectId и folderId указаны разные проекты — оставьте одно» |
| 401 | нет токена или сессии |
| 404 | Проекта с таким `projectId` в вашей организации нет: «Проект не найден». |
| 404 | «Документ не найден» — нет такого, чужой или в корзине |
| 413 | тело больше 1 МБ |

## Пример

```bash
curl -X PATCH "https://vruchay.ru/api/documents/$DOCUMENT_ID" \
  -H "Authorization: Bearer $VRUCHAY_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"eventName":"Конкурс «Мастер года»","issueDate":"2026-06-19","expiresIn":"P1Y"}'
```

Перейти на A5 альбомный и пропорционально уменьшить блоки:

```bash
curl -X PATCH "https://vruchay.ru/api/documents/$DOCUMENT_ID" \
  -H "Authorization: Bearer $VRUCHAY_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"pageWidthMm":210,"pageHeightMm":148,"resize":"scale"}'
```

---

<!-- endpoints/documents/post-documents-id-assets-from-url -->
> method: POST | path: /api/documents/{id}/assets/from-url | auth: token | roles: any | rate_limit: «30 запросов в минуту: токен — на организацию, вход в кабинет — с одного IP»

# POST /api/documents/{id}/assets/from-url

То же, что [загрузка картинки для блока файлом](post-documents-id-assets.md), но
картинку скачивает сервер по ссылке: логотип с сайта организации, подпись или
печать, выложенные в облако. Агент в чате передать файл не может, а ссылку — может.
Дальше всё как при загрузке: тип по сигнатуре (PNG или JPEG), до 20 МБ, `fileId`
из ответа ставится в `props.fileId` блока `type: "image"`, а `widthPx`/`heightPx`
подсказывают пропорции блока.

Ссылка проверяется так же, как у
[фона по ссылке](post-documents-id-sheets-sheetId-background-from-url.md) (раздел «Как сервер ходит по ссылке»):
только `http://` и `https://` на портах 80 и 443, без логина в адресе; если хоть
один адрес имени частный, локальный или служебный — отказ; не больше трёх
переадресаций, каждая проверяется заново; ответ до 15 секунд, только код `200`,
тело до 20 МБ. Запрос уходит с `User-Agent: Vruchay/1.0 (+https://vruchay.ru)`.

## Запрос

| Параметр | Тип | Обязателен | Описание |
|---|---|---|---|
| `id` | UUID | да | идентификатор документа |

Тело:

| Поле | Тип | Обязательно | Описание |
|---|---|---|---|
| `url` | string | да | адрес картинки PNG или JPEG, до 2000 символов |

```json
{ "url": "https://example.ru/img/logo.png" }
```

## Ответ

`201 Created`

| Поле | Тип | Описание |
|---|---|---|
| `fileId` | UUID | идентификатор картинки — в `props.fileId` блока `image` |
| `url` | string | подписанная ссылка на файл в нашем хранилище, действует 15 минут |
| `widthPx`, `heightPx` | integer | размер картинки в пикселях; полей нет, если заголовок файла не прочитался |

```json
{
  "fileId": "e5f6a7b8-c9d0-4e1f-8a2b-3c4d5e6f7081",
  "url": "https://storage.example/…/asset/e5f6a7b8-….png?X-Amz-Signature=…",
  "widthPx": 1200,
  "heightPx": 400
}
```

## Ошибки

| Код | Когда |
|---|---|
| 400 | «Некорректный идентификатор» — `id` не UUID; «Укажите адрес картинки» — пустой `url`; `url` длиннее 2000 |
| 400 | «Картинку не скачать: …» — ссылка не прошла проверку или не скачалась. После двоеточия: «Это не адрес», «Ссылка должна начинаться с http:// или https://», «Адрес с логином и паролем не принимается», «Принимаются только адреса на стандартных портах 80 и 443», «Адрес ведёт во внутреннюю сеть», «Сервер по ссылке ответил N», «Слишком много переадресаций», «Сервер по ссылке не ответил вовремя», «Не удалось скачать файл по ссылке», «Файл больше 20 МБ», «Скачивание оборвалось» |
| 400 | «По ссылке не картинка PNG или JPEG» — скачалось, но это не PNG и не JPEG (SVG, WebP, HTML-страница) |
| 401 | нет токена или сессии |
| 404 | «Документ не найден» — нет такого, чужой или в корзине |
| 413 | тело больше 1 МБ |
| 429 | «Слишком много попыток. Повторите через N с.» |

Предел считается до проверки тела и скачивания: неудачные попытки тоже идут в счёт.
Счётчик у этого маршрута свой, отдельный от фона по ссылке.

## Пример

```bash
curl -X POST "https://vruchay.ru/api/documents/$DOCUMENT_ID/assets/from-url" \
  -H "Authorization: Bearer $VRUCHAY_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"url":"https://example.ru/img/logo.png"}'
```

Поставить картинку на лист — блоком с тем же `fileId`:

```bash
curl -X PATCH "https://vruchay.ru/api/documents/$DOCUMENT_ID/sheets/$SHEET_ID/elements" \
  -H "Authorization: Bearer $VRUCHAY_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"add":[{"id":"logo","type":"image","x":20,"y":15,"w":60,"h":20,"props":{"fileId":"e5f6a7b8-c9d0-4e1f-8a2b-3c4d5e6f7081"}}]}'
```

---

<!-- endpoints/documents/post-documents-id-assets -->
> method: POST | path: /api/documents/{id}/assets | auth: token | roles: any | rate_limit: none

# POST /api/documents/{id}/assets

Загружает картинку для блока на листе — логотип, подпись, печать. К листу файл
не привязан: его `fileId` ставится в `props.fileId` блока `type: "image"`, а сам
блок приезжает обычным сохранением макета — целиком
([`PATCH …/sheets/{sheetId}`](patch-documents-id-sheets-sheetId.md)) или по частям
([`PATCH …/sheets/{sheetId}/elements`](../layout/patch-documents-id-sheets-sheetId-elements.md)).
Только такие файлы годятся для блоков-картинок: фон листа и выданный документ
сохранение отвергнет с `404` «Картинка не найдена».

Тип файла определяется по сигнатуре первых байтов, а не по расширению или
`Content-Type`: принимаются только PNG и JPEG, до 20 МБ. Нужен PNG с прозрачным
фоном, если подпись или печать ложится поверх бланка.

Файл остаётся у организации и тогда, когда блок с ним удалили, — иначе «Отменить»
в редакторе вернуло бы блок без картинки. Сохранение принимает картинку любого
документа той же организации. Хранится она у документа, в который загружена;
при его окончательном удалении из корзины картинка, которая стоит в макете
другого документа организации, переходит к нему, а ничья — удаляется.
Картинка из интернета загружается без скачивания на свою сторону —
[`POST …/assets/from-url`](post-documents-id-assets-from-url.md).

## Пропорции блока

Блок рисует картинку целиком, вписывая её в свою рамку, — лишнее место внутри
блока остаётся пустым. Чтобы его не было, задайте блоку те же пропорции, что
у картинки: `h = w × heightPx / widthPx`. Логотип 1200×400 пикселей в блоке
шириной 60 мм — это высота 20 мм.

```json
{
  "id": "logo",
  "type": "image",
  "x": 20, "y": 15, "w": 60, "h": 20,
  "props": { "fileId": "e5f6a7b8-c9d0-4e1f-8a2b-3c4d5e6f7081" }
}
```

## Запрос

| Параметр | Тип | Обязателен | Описание |
|---|---|---|---|
| `id` | UUID | да | идентификатор документа |

Тело — `multipart/form-data`, одно поле-файл (`file`), не больше одного файла в запросе.

| Поле | Тип | Обязательно | Описание |
|---|---|---|---|
| `file` | файл PNG или JPEG | да | до 20 МБ; имя файла сохраняется как `originalName` (до 255 символов) только для показа |

## Ответ

`201 Created`

| Поле | Тип | Описание |
|---|---|---|
| `fileId` | UUID | идентификатор картинки — в `props.fileId` блока `image` |
| `url` | string | подписанная ссылка на файл, действует 15 минут. Постоянный адрес для показа — [`GET /api/documents/files/{fileId}/raw`](get-documents-files-fileId-raw.md) |
| `widthPx`, `heightPx` | integer | размер картинки в пикселях, из заголовка файла. Полей нет, если заголовок не прочитался |

```json
{
  "fileId": "e5f6a7b8-c9d0-4e1f-8a2b-3c4d5e6f7081",
  "url": "https://storage.example/…/asset/e5f6a7b8-….png?X-Amz-Signature=…",
  "widthPx": 1200,
  "heightPx": 400
}
```

## Ошибки

| Код | Когда |
|---|---|
| 400 | «Некорректный идентификатор» — `id` не UUID; «Файл не передан» — в теле нет файла; «Файл слишком большой, максимум 20 МБ»; «Поддерживаются только изображения PNG и JPEG» |
| 401 | нет токена или сессии |
| 404 | «Документ не найден» — нет такого, чужой или в корзине |
| 406 | тело не `multipart/form-data` — ответ библиотеки разбора: `the request is not multipart` |

## Пример

```bash
curl -X POST "https://vruchay.ru/api/documents/$DOCUMENT_ID/assets" \
  -H "Authorization: Bearer $VRUCHAY_TOKEN" \
  -F "file=@logo.png"
```

---

<!-- endpoints/documents/post-documents-id-duplicate -->
> method: POST | path: /api/documents/{id}/duplicate | auth: token | roles: any | rate_limit: none

# POST /api/documents/{id}/duplicate

Создаёт новый документ на основе существующего: копируются размер листа, настройки
проверки, проект, все листы с макетами и колонки таблицы получателей. Фон листа
переиспользуется ссылкой на тот же файл, без копии в хранилище. **Не копируются**
получатели, название/даты/место мероприятия, дата выдачи, срок действия и привязка
к набору правил. Название копии — `«<исходное> — новое мероприятие»`, у копии
выставляется `sourceDocumentId` на исходник.

## Запрос

| Параметр | Тип | Обязателен | Описание |
|---|---|---|---|
| `id` | UUID | да | идентификатор исходного документа (не в корзине) |

Тела нет.

## Ответ

`201 Created` — новый документ с листами. Форма — как у `GET /api/documents/{id}`,
но без `source` и без `columns`.

```json
{
  "id": "9e8d7c6b-5a4f-4e3d-8c2b-1a0f9e8d7c6b",
  "orgId": "0a1b2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d",
  "title": "Грамота за место — новое мероприятие",
  "pageWidthMm": 297,
  "pageHeightMm": 210,
  "verifyEnabled": true,
  "verifyFields": ["name", "place"],
  "eventName": "",
  "eventDate": "",
  "eventPlace": "",
  "eventHours": "",
  "issueDate": null,
  "expiresIn": null,
  "expiresAt": null,
  "projectId": "9f1c0c8e-0f3c-4a1a-9c1f-1f7d2a3b4c5d",
  "folderId": "9f1c0c8e-0f3c-4a1a-9c1f-1f7d2a3b4c5d",
  "sourceDocumentId": "6f1c3b2a-9d4e-4f5a-8b6c-7d8e9f0a1b2c",
  "ruleSetId": null,
  "createdAt": "2026-09-03T09:10:00.000Z",
  "updatedAt": "2026-09-03T09:10:00.000Z",
  "deletedAt": null,
  "sheets": [
    { "id": "…", "documentId": "9e8d7c6b-…", "position": 0, "backgroundFileId": "d4e5f6a7-…", "layout": [], "schemaVersion": 3 }
  ]
}
```

## Ошибки

| Код | Когда |
|---|---|
| 400 | «Некорректный идентификатор» — `id` не UUID |
| 401 | нет токена или сессии |
| 404 | «Документ не найден» — нет такого, чужой или в корзине |

## Пример

```bash
curl -X POST "https://vruchay.ru/api/documents/$DOCUMENT_ID/duplicate" \
  -H "Authorization: Bearer $VRUCHAY_TOKEN"
```

---

<!-- endpoints/documents/post-documents-id-registry-fileId-revoke -->
> method: POST | path: /api/documents/{id}/registry/{fileId}/revoke | auth: token | roles: owner, admin | rate_limit: none

# POST /api/documents/{id}/registry/{fileId}/revoke

Отзывает проверку подлинности конкретного выданного файла (`revoked: true`) или
возвращает её (`revoked: false`). Сам файл остаётся — он мог быть уже скачан
и распечатан; меняется ответ страницы проверки: предъявленный документ перестаёт
подтверждаться. Нужно, когда документ выдан по ошибке. Файл должен быть выпущенным
(`kind: generated`) экземпляром именно этого документа. Действие пишется в журнал
(`verify.revoke` / `verify.restore`).

## Запрос

| Параметр | Тип | Обязателен | Описание |
|---|---|---|---|
| `id` | UUID | да | идентификатор документа |
| `fileId` | UUID | да | выданный файл (`items[].fileId` из реестра) |

Тело:

| Поле | Тип | Обязательно | Описание |
|---|---|---|---|
| `revoked` | boolean | да | `true` — отозвать, `false` — вернуть |

```json
{ "revoked": true }
```

## Ответ

`201 Created`

| Поле | Тип | Описание |
|---|---|---|
| `ok` | `true` | |
| `revoked` | boolean | новое состояние |
| `name` | string | ФИО получателя из строки (пусто, если нет) |
| `publicId` | UUID | публичный идентификатор экземпляра |
| `code` | string | проверочный код; у старых экземпляров — `publicId` |

```json
{
  "ok": true,
  "revoked": true,
  "name": "Иванов Пётр Ильич",
  "publicId": "f6a7b8c9-d0e1-4f2a-9b3c-4d5e6f7a8b9c",
  "code": "K7M2-9QXR-4TVB"
}
```

## Ошибки

| Код | Когда |
|---|---|
| 400 | «Некорректный идентификатор» — параметр не UUID; `revoked` не boolean |
| 401 | нет токена или сессии |
| 403 | «Недостаточно прав для этого действия» — роль `member` |
| 404 | «Документ не найден» — файл не найден, не выпущенный, не этого документа, чужой или удалён |

## Пример

```bash
curl -X POST "https://vruchay.ru/api/documents/$DOCUMENT_ID/registry/$FILE_ID/revoke" \
  -H "Authorization: Bearer $VRUCHAY_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"revoked":true}'
```

---

<!-- endpoints/documents/post-documents-id-restore -->
> method: POST | path: /api/documents/{id}/restore | auth: token | roles: any | rate_limit: none

# POST /api/documents/{id}/restore

Снимает пометку удаления с документа в корзине. Работает только для документов
с `deletedAt`; живой документ этим маршрутом не найдётся.

## Запрос

| Параметр | Тип | Обязателен | Описание |
|---|---|---|---|
| `id` | UUID | да | идентификатор документа в корзине |

Тела нет.

## Ответ

`201 Created`

| Поле | Тип | Описание |
|---|---|---|
| `ok` | `true` | |

```json
{ "ok": true }
```

## Ошибки

| Код | Когда |
|---|---|
| 400 | «Некорректный идентификатор» — `id` не UUID |
| 401 | нет токена или сессии |
| 404 | «Документ не найден» — нет такого, чужой или не в корзине |

## Пример

```bash
curl -X POST "https://vruchay.ru/api/documents/$DOCUMENT_ID/restore" \
  -H "Authorization: Bearer $VRUCHAY_TOKEN"
```

---

<!-- endpoints/documents/post-documents-id-sheets-sheetId-background-from-url -->
> method: POST | path: /api/documents/{id}/sheets/{sheetId}/background/from-url | auth: token | roles: any | rate_limit: «30 запросов в минуту: токен — на организацию, вход в кабинет — с одного IP»

# POST /api/documents/{id}/sheets/{sheetId}/background/from-url

То же, что [загрузка фона файлом](post-documents-id-sheets-sheetId-background.md),
но картинку скачивает сервер по ссылке. Для программ и агентов, у которых бланк
лежит в интернете, а не файлом рядом: агент в чате передать файл не может, а ссылку —
может. Дальше всё как при загрузке: тип по сигнатуре (PNG или JPEG), до 20 МБ,
фон ставится листу в `backgroundFileId`, прежний удаляется, только если он больше
нигде не стоит.

## Как сервер ходит по ссылке

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

- только `http://` и `https://`, без логина и пароля в адресе, только на портах
  80 и 443;
- имя разрешается на стороне сервера, и если **хоть один** его адрес частный,
  локальный или служебный — отказ. Закрыты: `0.0.0.0/8`, `10.0.0.0/8`,
  `100.64.0.0/10`, `127.0.0.0/8`, `169.254.0.0/16`, `172.16.0.0/12`,
  `192.168.0.0/16`, `198.18.0.0/15`, документационные и зарезервированные сети,
  групповая рассылка (`224.0.0.0/4`, `240.0.0.0/4`); в IPv6 — `::`, `::1`,
  `fc00::/7`, `fe80::/10`, `ff00::/8`, `2001::/32`, `2002::/16`, `64:ff9b::/96`,
  `100::/64` и `2001:db8::/32`; IPv4 внутри IPv6 (`::ffff:10.0.0.1`) судится по IPv4.
  Соединение идёт ровно на проверенный адрес;
- переадресаций — не больше трёх, каждая проверяется заново;
- ответ ждётся до 15 секунд, принимается только код `200`, тело — не больше 20 МБ.

Запрос уходит с заголовком `User-Agent: Vruchay/1.0 (+https://vruchay.ru)`.
Имя файла для показа берётся из последней части пути адреса после переадресаций,
без неё — `image.png` или `image.jpg`.

## Запрос

| Параметр | Тип | Обязателен | Описание |
|---|---|---|---|
| `id` | UUID | да | идентификатор документа |
| `sheetId` | UUID | да | идентификатор листа |

Тело:

| Поле | Тип | Обязательно | Описание |
|---|---|---|---|
| `url` | string | да | адрес картинки PNG или JPEG, до 2000 символов |

```json
{ "url": "https://example.ru/blanks/gramota-a4.png" }
```

## Ответ

`201 Created`

| Поле | Тип | Описание |
|---|---|---|
| `fileId` | UUID | идентификатор файла фона, он же `backgroundFileId` листа |
| `url` | string | подписанная ссылка на файл в нашем хранилище, действует 15 минут |
| `widthPx`, `heightPx` | integer | размер картинки в пикселях; полей нет, если заголовок файла не прочитался |

```json
{
  "fileId": "d4e5f6a7-b8c9-4d0e-9f1a-2b3c4d5e6f70",
  "url": "https://storage.example/…/background/d4e5f6a7-….png?X-Amz-Signature=…",
  "widthPx": 3508,
  "heightPx": 2480
}
```

## Ошибки

| Код | Когда |
|---|---|
| 400 | «Некорректный идентификатор» — параметр не UUID; «Укажите адрес картинки» — пустой `url`; `url` длиннее 2000 |
| 400 | «Картинку не скачать: Это не адрес» / «…: Ссылка должна начинаться с http:// или https://» / «…: Адрес с логином и паролем не принимается» / «…: Принимаются только адреса на стандартных портах 80 и 443» / «…: Адрес ведёт во внутреннюю сеть» |
| 400 | «Картинку не скачать: Сервер по ссылке ответил N» — любой код, кроме `200` и переадресации; «…: Слишком много переадресаций»; «…: Сервер по ссылке не ответил вовремя»; «…: Не удалось скачать файл по ссылке» (имя не разрешилось, соединение не установилось); «…: Файл больше 20 МБ»; «…: Скачивание оборвалось» |
| 400 | «По ссылке не картинка PNG или JPEG» — скачалось, но первые байты не PNG и не JPEG (SVG, WebP, HTML-страница) |
| 401 | нет токена или сессии |
| 404 | «Лист не найден» — лист не из этого документа, документ чужой или в корзине |
| 413 | тело больше 1 МБ |
| 429 | «Слишком много попыток. Повторите через N с.» |

Предел считается до проверки тела и скачивания: неудачные попытки тоже идут в счёт.
Счётчик у этого маршрута свой, отдельный от [`…/assets/from-url`](post-documents-id-assets-from-url.md).

## Пример

```bash
curl -X POST "https://vruchay.ru/api/documents/$DOCUMENT_ID/sheets/$SHEET_ID/background/from-url" \
  -H "Authorization: Bearer $VRUCHAY_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"url":"https://example.ru/blanks/gramota-a4.png"}'
```

---

<!-- endpoints/documents/post-documents-id-sheets-sheetId-background -->
> method: POST | path: /api/documents/{id}/sheets/{sheetId}/background | auth: token | roles: any | rate_limit: none

# POST /api/documents/{id}/sheets/{sheetId}/background

Загружает картинку фона листа (бланк) и ставит её листу в `backgroundFileId`.
Фон заполняет весь лист с сохранением пропорций: если они не совпадают с листом,
лишнее по краям обрезается. Тип файла определяется по сигнатуре первых байтов,
а не по расширению или `Content-Type`: принимаются только PNG и JPEG, до 20 МБ.
Картинка, которая лежит в интернете, ставится фоном без скачивания на свою сторону —
[`POST …/background/from-url`](post-documents-id-sheets-sheetId-background-from-url.md).

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

Файл фона — не картинка для блока: его `fileId` в элемент `image` макета
не подходит (сохранение ответит `404` «Картинка не найдена»). Логотип, подпись
и печать загружаются через [`POST /api/documents/{id}/assets`](post-documents-id-assets.md).
Посмотреть фон можно по [`GET /api/documents/files/{fileId}/url`](get-documents-files-fileId-url.md)
или [`…/raw`](get-documents-files-fileId-raw.md); убрать —
[`DELETE …/background`](delete-documents-id-sheets-sheetId-background.md).

## Запрос

| Параметр | Тип | Обязателен | Описание |
|---|---|---|---|
| `id` | UUID | да | идентификатор документа |
| `sheetId` | UUID | да | идентификатор листа |

Тело — `multipart/form-data`, одно поле-файл (`file`), не больше одного файла в запросе.

| Поле | Тип | Обязательно | Описание |
|---|---|---|---|
| `file` | файл PNG или JPEG | да | до 20 МБ; имя файла сохраняется как `originalName` (до 255 символов) только для показа |

## Ответ

`201 Created`

| Поле | Тип | Описание |
|---|---|---|
| `fileId` | UUID | идентификатор файла фона, он же `backgroundFileId` листа |
| `url` | string | подписанная ссылка на файл, действует 15 минут |
| `widthPx`, `heightPx` | integer | размер картинки в пикселях, из заголовка файла. Полей нет, если заголовок не прочитался |

По размеру в пикселях видно, хватит ли разрешения для печати: на A4 альбомном
(297 мм) 3508 пикселей по ширине — это 300 точек на дюйм, ниже 150 картинка
заметно мылит.

```json
{
  "fileId": "d4e5f6a7-b8c9-4d0e-9f1a-2b3c4d5e6f70",
  "url": "https://storage.vruchay.ru/…/background/d4e5f6a7-….png?X-Amz-Signature=…",
  "widthPx": 3508,
  "heightPx": 2480
}
```

## Ошибки

| Код | Когда |
|---|---|
| 400 | «Некорректный идентификатор» — параметр не UUID; «Файл не передан» — в теле нет файла; «Файл слишком большой, максимум 20 МБ»; «Поддерживаются только изображения PNG и JPEG» |
| 401 | нет токена или сессии |
| 404 | «Лист не найден» — лист не из этого документа, документ чужой или в корзине |
| 406 | тело не `multipart/form-data` — ответ библиотеки разбора: `the request is not multipart` |

## Пример

```bash
curl -X POST "https://vruchay.ru/api/documents/$DOCUMENT_ID/sheets/$SHEET_ID/background" \
  -H "Authorization: Bearer $VRUCHAY_TOKEN" \
  -F "file=@blank.png"
```

---

<!-- endpoints/documents/post-documents-id-sheets -->
> method: POST | path: /api/documents/{id}/sheets | auth: token | roles: any | rate_limit: none

# POST /api/documents/{id}/sheets

Добавляет в конец документа пустой лист (`layout: []`, `schemaVersion: 3`). Размер листа
у всех листов документа общий — `pageWidthMm` × `pageHeightMm` карточки. Макет
заполняется отдельным `PATCH /api/documents/{id}/sheets/{sheetId}`.

## Запрос

| Параметр | Тип | Обязателен | Описание |
|---|---|---|---|
| `id` | UUID | да | идентификатор документа |

Тела нет.

## Ответ

`201 Created` — созданный лист.

| Поле | Тип | Описание |
|---|---|---|
| `id` | UUID | |
| `documentId` | UUID | |
| `position` | integer | порядковый номер, с нуля |
| `backgroundFileId` | null | |
| `layout` | `[]` | |
| `schemaVersion` | `3` | |
| `version` | `0` | номер правки листа; растёт с каждым сохранением макета |

```json
{
  "id": "c3d4e5f6-a7b8-4c9d-8e0f-1a2b3c4d5e6f",
  "documentId": "6f1c3b2a-9d4e-4f5a-8b6c-7d8e9f0a1b2c",
  "position": 1,
  "backgroundFileId": null,
  "layout": [],
  "schemaVersion": 3,
  "version": 0
}
```

## Ошибки

| Код | Когда |
|---|---|
| 400 | «Некорректный идентификатор» — `id` не UUID |
| 401 | нет токена или сессии |
| 404 | «Документ не найден» — нет такого, чужой или в корзине |

## Пример

```bash
curl -X POST "https://vruchay.ru/api/documents/$DOCUMENT_ID/sheets" \
  -H "Authorization: Bearer $VRUCHAY_TOKEN"
```

---

<!-- endpoints/documents/post-documents-id-template -->
> method: POST | path: /api/documents/{id}/template | auth: token | roles: any | rate_limit: none

# POST /api/documents/{id}/template

Сохраняет документ шаблоном — **копией**, а не пометкой на самом документе.
Шаблон — это макет, с которого организация начинает новые документы:
`POST /api/documents` с `templateId`. Сам он не выпускается и в «Моих документах»,
рассылке, формах выдачи и сводке не показывается — у шаблонов свой список,
`GET /api/documents?templates=true`.

Копия уносит только оформление:

| Переносится | Не переносится |
|---|---|
| размер листа, все листы с макетами | получатели — это чужие персональные данные |
| фон листов — ссылкой на тот же файл, без копии в хранилище | мероприятие: название, даты, место, часы |
| колонки таблицы получателей (без строк) | дата выдачи и срок действия |
| постоянные поля со значениями (`constants`) | проект: шаблоны общие на организацию, `projectId` — `null` |
| настройки проверки подлинности (`verifyEnabled`, `verifyFields`) | привязка к набору правил награждения |

Название шаблона — то же, что у документа. У шаблона `isTemplate: true`,
`sourceDocumentId` указывает на документ, версии листов начинаются с нуля.
Исходный документ не меняется и остаётся в своём списке.

Картинки блоков и фон шаблон не копирует, а берёт ссылками на файлы документа.
Если документ потом удалят из корзины навсегда, эти файлы не пропадут: они
перейдут к шаблону (или к другой копии, которая ими пользуется), и шаблон
продолжит печататься как прежде.

## Запрос

| Параметр | Тип | Обязателен | Описание |
|---|---|---|---|
| `id` | UUID | да | документ, с которого снять шаблон (не шаблон и не в корзине) |

Тела нет.

## Ответ

`201 Created` — новый шаблон с листами. Форма — как у `GET /api/documents/{id}`,
но без `source` и без `columns`.

```json
{
  "id": "a7b8c9d0-e1f2-4a3b-8c4d-5e6f7a8b9c0d",
  "orgId": "0a1b2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d",
  "title": "Грамота за место",
  "pageWidthMm": 297,
  "pageHeightMm": 210,
  "verifyEnabled": true,
  "verifyFields": ["name"],
  "eventName": "",
  "eventDate": "",
  "eventPlace": "",
  "eventHours": "",
  "constants": [
    { "key": "signer_role", "title": "Должность подписанта", "value": "Директор" },
    { "key": "signer_name", "title": "Подписант", "value": "А. В. Петров" }
  ],
  "issueDate": null,
  "expiresIn": null,
  "expiresAt": null,
  "projectId": null,
  "sourceDocumentId": "6f1c3b2a-9d4e-4f5a-8b6c-7d8e9f0a1b2c",
  "isTemplate": true,
  "createdAt": "2026-09-03T09:20:00.000Z",
  "updatedAt": "2026-09-03T09:20:00.000Z",
  "deletedAt": null,
  "ruleSetId": null,
  "sheets": [
    {
      "id": "f1e2d3c4-b5a6-4978-8a9b-0c1d2e3f4a5b",
      "documentId": "a7b8c9d0-e1f2-4a3b-8c4d-5e6f7a8b9c0d",
      "position": 0,
      "backgroundFileId": "d4e5f6a7-b8c9-4d0e-9f1a-2b3c4d5e6f70",
      "layout": [ { "id": "title", "type": "text", "…": "…" } ],
      "schemaVersion": 3,
      "version": 0
    }
  ],
  "folderId": null
}
```

## Ошибки

| Код | Когда |
|---|---|
| 400 | «Некорректный идентификатор» — `id` не UUID |
| 401 | нет токена или сессии |
| 404 | «Документ не найден» — нет такого, чужой, в корзине или это уже шаблон (копия шаблона — `POST /api/documents/{id}/duplicate`) |

## Пример

```bash
curl -X POST "https://vruchay.ru/api/documents/$DOCUMENT_ID/template" \
  -H "Authorization: Bearer $VRUCHAY_TOKEN"
```

Новый документ по этому шаблону:

```bash
curl -X POST "https://vruchay.ru/api/documents" \
  -H "Authorization: Bearer $VRUCHAY_TOKEN" \
  -H "Content-Type: application/json" \
  -d "{\"title\":\"Грамота — осенний турнир\",\"templateId\":\"$TEMPLATE_ID\"}"
```

---

<!-- endpoints/documents/post-documents -->
> method: POST | path: /api/documents | auth: token | roles: any | rate_limit: none

# POST /api/documents

Создаёт материал сразу с одним листом и двумя колонками таблицы получателей —
`name` и `email`. Лист пустой (`layout: []`), если не попросить готовую раскладку
(`preset`) или не взять макет шаблона (`templateId`). Дальше раскладку собирают
в редакторе, правкой по частям
([`PATCH …/sheets/{sheetId}/elements`](../layout/patch-documents-id-sheets-sheetId-elements.md))
или загружают свой бланк фоном.

## Размер листа

Размер задаётся одним из трёх способов:

- **`format`** — название формата: `a3`, `a4`, `a5`, `a6`, `letter`, `legal`,
  `square`, `wide`. Без `orientation` лист альбомный: `a4` — это 297×210 мм.
  Формат сильнее миллиметров: если прислать и `format`, и `pageWidthMm`, размер
  возьмётся из формата.
- **`pageWidthMm` и `pageHeightMm`** — миллиметры, 50…600, по умолчанию 297×210.
  `orientation` без `format` поворачивает эти миллиметры: `{"orientation":"portrait"}`
  без размеров даёт 210×297.
- **`templateId`** — размер шаблона. Макет свёрстан под свой лист, поэтому
  `format`, `orientation` и миллиметры при этом не действуют.

Размеры форматов в обеих ориентациях отдаёт
[`GET /api/layout/catalog`](../layout/get-layout-catalog.md) (`page.formats`).

## Готовая раскладка: `preset`

`preset` верстает лист сразу — под размер листа и под колонки документа: заголовок,
вручительная фраза, ФИО в нужном падеже, мероприятие, а внизу, если остаётся место,
подпись (должность слева, фамилия справа) и строка «дата · организация». Строки
с полями, которых у документа нет (`%team`, `%city`, «за %place_word место» без
колонки `place`), раскладка выбрасывает, а не оставляет пустыми.

| Поле | Тип | Обязательно | Описание |
|---|---|---|---|
| `kind` | string | да | вид документа: `award`, `winner`, `laureate`, `participant`, `attendee`, `course`, `graduate`, `school`, `thanks`, `letter`. Названия — в [`POST …/preset`](../layout/post-documents-id-sheets-sheetId-preset.md) |
| `style` | string | да | набор шрифтов и цветов: `classic`, `solemn`, `academic`, `strict`, `modern`, `script`, `handwritten` |
| `titlePrinted` | boolean | нет | `true` — заголовок («Грамота») уже напечатан на бланке, своего раскладка не ставит. По умолчанию `false` |
| `palette` | object | нет | свои цвета вместо цветов набора: `ink` — основной текст, `accent` — заголовок и акценты, `nameInk` — ФИО (по умолчанию как `accent`). Каждый — `#RRGGBB` |

Блоки раскладки получают id вида `preset-award-0`, `preset-award-signer-role`,
координаты округлены до десятых миллиметра. Подпись печатается из постоянных
полей документа, поэтому раскладка заводит их с образцами, если их ещё нет:

| Ключ | Название | Значение-образец |
|---|---|---|
| `signer_role` | Должность подписанта | Руководитель |
| `signer_name` | Подписант | И. О. Фамилия |

Настоящие значения ставятся одним `PATCH /api/documents/{id}` с полем `constants`
(список целиком). `preset` вместе с `templateId` — ошибка: у шаблона свой макет.

## Документ по шаблону: `templateId`

С шаблона снимается копия: листы с макетами и фоном (фон — ссылкой на тот же
файл, без копии в хранилище), колонки таблицы, постоянные поля, настройки проверки
подлинности. Получатели и мероприятие у шаблона не хранятся. Название и проект —
из запроса, `sourceDocumentId` указывает на шаблон. Шаблоны организации —
`GET /api/documents?templates=true`.

## Запрос

Тело:

| Поле | Тип | Обязательно | Описание |
|---|---|---|---|
| `title` | string | да | 1–200 символов после обрезки пробелов. Ошибка: «Введите название» |
| `format` | string | нет | `a3`, `a4`, `a5`, `a6`, `letter`, `legal`, `square`, `wide` |
| `orientation` | `portrait` \| `landscape` | нет | ориентация; с `format` по умолчанию `landscape` |
| `pageWidthMm` | number | нет | 50…600, по умолчанию 297; при `format` не действует |
| `pageHeightMm` | number | нет | 50…600, по умолчанию 210; при `format` не действует |
| `preset` | object | нет | готовая раскладка, см. выше |
| `templateId` | string (UUID) | нет | шаблон организации, с которого снять макет |
| `projectId` | string (UUID) | нет | проект из `GET /api/projects`; без него документ ложится «Без проекта» |
| `folderId` | string (UUID) | нет | устаревшее имя `projectId`; разные значения в обоих полях — 400 |
| `issueVia` | `list` \| `site` \| `link` \| `api` | нет | как документ собираются выдавать: списком, формой на сайте, формой по ссылке или по API. По умолчанию `list`. Не замок — выдать можно всеми способами; кабинет по нему прячет таблицу получателей у формы и API. Ошибка: «Способ выдачи: list, site, link или api» |

```json
{
  "title": "Грамота за место",
  "format": "a4",
  "orientation": "landscape",
  "preset": { "kind": "award", "style": "classic" },
  "projectId": "9f1c0c8e-0f3c-4a1a-9c1f-1f7d2a3b4c5d"
}
```

## Ответ

`201 Created` — документ с листами и колонками.

| Поле | Тип | Описание |
|---|---|---|
| `id` | UUID | |
| `orgId` | UUID | организация |
| `title` | string | |
| `pageWidthMm`, `pageHeightMm` | number | размер листа после применения `format` и `orientation` |
| `verifyEnabled` | boolean | страница проверки подлинности включена (по умолчанию `true`) |
| `verifyFields` | string[] | какие поля получателя раскрывать на странице проверки (по умолчанию `[]`) |
| `eventName`, `eventDate`, `eventPlace`, `eventHours` | string | мероприятие; пустые строки |
| `constants` | object[] | постоянные поля `{ key, title, value }`; `[]`, а с `preset` — `signer_role` и `signer_name` |
| `issueDate` | string \| null | дата выдачи; `null` — день выпуска |
| `expiresIn` | string \| null | срок действия длительностью (`P1Y`) |
| `expiresAt` | string \| null | срок действия фиксированной датой |
| `projectId` | string (UUID) \| null | проект; `null` — «Без проекта» |
| `issueVia` | string | способ выдачи: `list`, `site`, `link` или `api` |
| `folderId` | string (UUID) \| null | устаревшее имя `projectId`, то же значение |
| `sourceDocumentId` | UUID \| null | шаблон, если документ создан по нему |
| `isTemplate` | boolean | `false` |
| `ruleSetId` | UUID \| null | привязанный набор правил награждения |
| `createdAt`, `updatedAt` | string | |
| `deletedAt` | null | |
| `sheets` | object[] | листы по порядку: `id`, `documentId`, `position`, `backgroundFileId`, `layout`, `schemaVersion` (3), `version` (0) |
| `columns` | object[] | колонки таблицы получателей: `id`, `documentId`, `position`, `name`, `title`. **Только у документа без `templateId`**: у копии шаблона колонки есть, но в ответ не входят — их отдаёт `GET /api/documents/{id}/recipients` |

Пустой документ (без `preset` и `templateId`):

```json
{
  "id": "6f1c3b2a-9d4e-4f5a-8b6c-7d8e9f0a1b2c",
  "orgId": "0a1b2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d",
  "title": "Грамота за место",
  "pageWidthMm": 297,
  "pageHeightMm": 210,
  "verifyEnabled": true,
  "verifyFields": [],
  "eventName": "",
  "eventDate": "",
  "eventPlace": "",
  "eventHours": "",
  "constants": [],
  "issueDate": null,
  "expiresIn": null,
  "expiresAt": null,
  "projectId": "9f1c0c8e-0f3c-4a1a-9c1f-1f7d2a3b4c5d",
  "sourceDocumentId": null,
  "isTemplate": false,
  "createdAt": "2026-09-03T09:00:00.000Z",
  "updatedAt": "2026-09-03T09:00:00.000Z",
  "deletedAt": null,
  "ruleSetId": null,
  "sheets": [
    {
      "id": "b2c3d4e5-f6a7-4b8c-9d0e-1f2a3b4c5d6e",
      "documentId": "6f1c3b2a-9d4e-4f5a-8b6c-7d8e9f0a1b2c",
      "position": 0,
      "backgroundFileId": null,
      "layout": [],
      "schemaVersion": 3,
      "version": 0
    }
  ],
  "columns": [
    { "id": "c1d2e3f4-…", "documentId": "6f1c3b2a-…", "position": 0, "name": "name", "title": null },
    { "id": "c2d3e4f5-…", "documentId": "6f1c3b2a-…", "position": 1, "name": "email", "title": null }
  ],
  "folderId": "9f1c0c8e-0f3c-4a1a-9c1f-1f7d2a3b4c5d"
}
```

С `preset` отличаются `constants` и `layout` (показано сокращённо):

```json
{
  "constants": [
    { "key": "signer_role", "title": "Должность подписанта", "value": "Руководитель" },
    { "key": "signer_name", "title": "Подписант", "value": "И. О. Фамилия" }
  ],
  "sheets": [
    {
      "id": "b2c3d4e5-f6a7-4b8c-9d0e-1f2a3b4c5d6e",
      "position": 0,
      "layout": [
        {
          "id": "preset-award-0", "type": "text",
          "x": 38.6, "y": 40.1, "w": 219.8, "h": 19.5,
          "props": { "fontFamily": "PT Serif", "fontSize": 36.8, "bold": true, "color": "#8B2020", "autoFit": true, "doc": { "type": "doc", "content": [ "…" ] } }
        },
        { "id": "preset-award-2", "type": "text", "x": 38.6, "y": 87.6, "w": 219.8, "h": 45, "props": { "fontFamily": "Playfair Display", "fontSize": 51.1, "…": "…" } },
        { "id": "preset-award-signer-role", "type": "text", "x": 38.6, "y": 172.4, "w": 105.6, "h": 6.9, "props": { "…": "…" } }
      ],
      "schemaVersion": 3,
      "version": 0
    }
  ]
}
```

Полная форма блоков — в [справочнике макета](../../reference/layout.md).

## Ошибки

| Код | Когда |
|---|---|
| 400 | «Введите название» — пустой `title`; `title` длиннее 200; размер листа вне 50…600; неизвестный `format`, `orientation`, `preset.kind` или `preset.style`; цвет `palette` не `#RRGGBB` — сообщения Zod в `errors[]` |
| 400 | «Некорректный идентификатор проекта» — `projectId` или `folderId` не UUID; «Некорректный идентификатор шаблона» — `templateId` не UUID |
| 400 | «В projectId и folderId указаны разные проекты — оставьте одно» |
| 400 | «Шаблон и готовая раскладка вместе не задаются: у шаблона свой макет» — `preset` вместе с `templateId` |
| 401 | нет токена или сессии |
| 404 | «Проект не найден» — проекта с таким `projectId` в вашей организации нет (чужой тоже) |
| 404 | «Шаблон не найден» — `templateId` не шаблон этой организации: чужой, в корзине или обычный документ |
| 413 | тело больше 1 МБ |

## Пример

```bash
curl -X POST "https://vruchay.ru/api/documents" \
  -H "Authorization: Bearer $VRUCHAY_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"title":"Грамота за место","format":"a4","preset":{"kind":"award","style":"classic"}}'
```

Документ по шаблону:

```bash
curl -X POST "https://vruchay.ru/api/documents" \
  -H "Authorization: Bearer $VRUCHAY_TOKEN" \
  -H "Content-Type: application/json" \
  -d "{\"title\":\"Грамота — весенний турнир\",\"templateId\":\"$TEMPLATE_ID\"}"
```

---

<!-- endpoints/documents/post-uploads -->
> method: POST | path: /api/uploads | auth: token | roles: any | rate_limit: none

# POST /api/uploads

Выдаёт одноразовую ссылку, по которой файл с диска загружается без токена
в команде: фон листа, картинка для блока или таблица получателей. Нужна
агенту с оболочкой (Claude Code, Codex, Cursor): через MCP файл не передать —
инструменты ходят JSON-ом, а мегабайт base64 модель в аргументах не напишет.
Агент получает ссылку (в MCP — инструмент `get_upload_link`) и отдаёт её
curl'у; тот приносит файл на [`POST /api/v1/uploads/{link}`](../public/post-v1-uploads-link.md).

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

Программе, у которой файл и так в руках, ссылка не обязательна: те же цели
загружаются напрямую с заголовком `Authorization` —
[`POST …/sheets/{sheetId}/background`](post-documents-id-sheets-sheetId-background.md),
[`POST /api/documents/{id}/assets`](post-documents-id-assets.md),
[`POST …/recipients/parse`](../recipients/post-documents-id-recipients-parse.md) и
[`…/import`](../recipients/post-documents-id-recipients-import.md).

## Запрос

Тело — JSON.

| Поле | Тип | Обязательно | Описание |
|---|---|---|---|
| `documentId` | UUID | да | документ своей организации, не в корзине |
| `target` | string | да | `background` — фон листа, `element` — картинка для блока (логотип, подпись, печать), `recipients` — таблица получателей |
| `sheetId` | UUID | для `background` | лист этого документа |
| `mode` | string | нет | для `recipients`: `append` (по умолчанию) — дописать строки, `replace` — заменить строки таблицы; выданные по одному не трогаются |
| `headers` | string | нет | для `recipients`: `auto` (по умолчанию) — решит разбор, `headers` — первая строка заголовки, `none` — данные |

## Ответ

`200 OK`

| Поле | Тип | Описание |
|---|---|---|
| `url` | string | ссылка для `POST` с файлом, `https://vruchay.ru/api/v1/uploads/u.…` |
| `expiresAt` | string (ISO 8601) | до какого момента ссылка принимает файл |
| `field` | string | имя поля формы с файлом — всегда `file` |
| `accepts` | string | что принимается: «PNG или JPEG» или «Excel (.xlsx) или CSV (.csv, .tsv, .txt)» |
| `maxBytes` | integer | предел размера файла: 20 МБ для картинок, 10 МБ для таблицы |
| `curl` | string | готовая команда — подставьте путь вместо `<путь к файлу>` |

```json
{
  "url": "https://vruchay.ru/api/v1/uploads/u.eyJvcmdJZCI6….Qm9v…",
  "expiresAt": "2026-10-24T18:45:00.000Z",
  "field": "file",
  "accepts": "PNG или JPEG",
  "maxBytes": 20971520,
  "curl": "curl -sS -F \"file=@<путь к файлу>\" \"https://vruchay.ru/api/v1/uploads/u.…\""
}
```

## Ошибки

| Код | Когда |
|---|---|
| 400 | «Некорректный идентификатор»; «Для фона нужен sheetId»; `target`, `mode` или `headers` не из списка |
| 401 | нет токена |
| 403 | «Ссылка на загрузку выдаётся программе по токену. В кабинете файл загружается кнопкой.» — запрос из кабинета, по сессии |
| 404 | «Документ не найден» — документ чужой или в корзине; «Лист не найден» — лист не из этого документа |

## Пример

```bash
LINK=$(curl -sS -X POST "https://vruchay.ru/api/uploads" \
  -H "Authorization: Bearer $VRUCHAY_TOKEN" -H "Content-Type: application/json" \
  -d "{\"documentId\": \"$DOCUMENT_ID\", \"target\": \"background\", \"sheetId\": \"$SHEET_ID\"}" | jq -r .url)
curl -sS -F "file=@blank.png" "$LINK"
```

---

<!-- endpoints/folders/delete-folders-id -->
> method: DELETE | path: /api/folders/{id} | auth: token | roles: any | rate_limit: none

# DELETE /api/folders/{id}

Устаревший синоним [`DELETE /api/projects/{id}`](../projects/delete-projects-id.md). Папки библиотеки стали проектами: адрес `/api/folders` по-прежнему отвечает, чтобы подключённые клиенты не сломались, но новый код пишите под `/api/projects`.

Удаляет проект. **Документы при этом не удаляются**: они переходят в «Без проекта» вместе со своим выданным и письмами.

## Пример

```bash
curl -X DELETE "https://vruchay.ru/api/folders/$FOLDER_ID" \
  -H "Authorization: Bearer $TOKEN"
```

---

<!-- endpoints/folders/get-folders -->
> method: GET | path: /api/folders | auth: token | roles: any | rate_limit: none

# GET /api/folders

Устаревший синоним [`GET /api/projects`](../projects/get-projects.md). Папки библиотеки стали проектами: адрес `/api/folders` по-прежнему отвечает, чтобы подключённые клиенты не сломались, но новый код пишите под `/api/projects`.

Тот же список проектов в том же виде: `id`, `name`, `position`, `count`.

## Пример

```bash
curl "https://vruchay.ru/api/folders" -H "Authorization: Bearer $TOKEN"
```

---

<!-- endpoints/folders/patch-folders-id -->
> method: PATCH | path: /api/folders/{id} | auth: token | roles: any | rate_limit: none

# PATCH /api/folders/{id}

Устаревший синоним [`PATCH /api/projects/{id}`](../projects/patch-projects-id.md). Папки библиотеки стали проектами: адрес `/api/folders` по-прежнему отвечает, чтобы подключённые клиенты не сломались, но новый код пишите под `/api/projects`.

Переименовывает проект. Тело, ответ и ошибки те же: чужой или несуществующий — 404 «Проект не найден», занятое имя — 409.

## Пример

```bash
curl -X PATCH "https://vruchay.ru/api/folders/$FOLDER_ID" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"name":"Обучение и семинары"}'
```

---

<!-- endpoints/folders/patch-folders-order -->
> method: PATCH | path: /api/folders/order | auth: token | roles: any | rate_limit: none

# PATCH /api/folders/order

Устаревший синоним [`PATCH /api/projects/order`](../projects/patch-projects-order.md). Папки библиотеки стали проектами: адрес `/api/folders` по-прежнему отвечает, чтобы подключённые клиенты не сломались, но новый код пишите под `/api/projects`.

Задаёт порядок проектов. Список принимается только целиком; чужой или неполный — 404 «Проект не найден».

## Пример

```bash
curl -X PATCH "https://vruchay.ru/api/folders/order" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"ids":["8f1e…","2b40…","c7a9…"]}'
```

---

<!-- endpoints/folders/post-folders -->
> method: POST | path: /api/folders | auth: token | roles: any | rate_limit: none

# POST /api/folders

Устаревший синоним [`POST /api/projects`](../projects/post-projects.md). Папки библиотеки стали проектами: адрес `/api/folders` по-прежнему отвечает, чтобы подключённые клиенты не сломались, но новый код пишите под `/api/projects`.

Заводит проект. Тело, ответ и ошибки те же; в сообщениях об ошибках — «проект»: «Проект с таким названием уже есть» (409).

## Пример

```bash
curl -X POST "https://vruchay.ru/api/folders" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"name":"Обучение"}'
```

---

<!-- endpoints/generation/get-documents-id-issue-jobId -->
> method: GET | path: /api/documents/{id}/issue/{jobId} | auth: token | roles: any | rate_limit: none

# GET /api/documents/{id}/issue/{jobId}

Состояние документа, заказанного `POST /api/documents/{id}/issue`. Нужен, когда
тот ответил `202` (документ не успел за `wait` секунд) или когда связь оборвалась
и `jobId` у вас есть, а ответа нет. Тело ответа — то же, что у выдачи, только код
всегда `200`: смотрите на `status`.

Опрашивайте раз в 1–2 секунды, пока `status` — `processing`. Обычно документ
готов за несколько секунд; если заявок по API и с форм много, очередь общая,
и ожидание бывает дольше.

`email.status` меняется и после того, как документ готов: письмо уходит своей
очередью (`queued` → `sent` → `delivered`/`opened`, либо `bounced`/`failed`).
Хотите знать, дошло ли письмо, — опросите этот адрес позже или смотрите реестр.

## Запрос

| Параметр | Тип | Обязателен | Описание |
|---|---|---|---|
| `id` | UUID (путь) | да | Материал |
| `jobId` | UUID (путь) | да | `jobId` из ответа на выдачу |

Тела нет.

## Ответ

`200 OK` — одно из трёх, поля описаны в
[POST /api/documents/{id}/issue](post-documents-id-issue.md):

```json
{ "status": "processing", "jobId": "5c8f1a2b-…", "statusUrl": "/api/documents/b1c2d3e4-…/issue/5c8f1a2b-…" }
```

```json
{
  "status": "done",
  "jobId": "5c8f1a2b-3d4e-4f50-8a6b-7c8d9e0f1a2b",
  "rowId": "0b1c2d3e-4f5a-4b6c-8d7e-9f0a1b2c3d4e",
  "file": {
    "id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
    "publicId": "f0e1d2c3-b4a5-4968-8776-655443322110",
    "code": "K7M2-9QXR-4TVB",
    "regNumber": "142/2026",
    "verifyUrl": "https://vruchay.ru/c/K7M2-9QXR-4TVB",
    "recipientUrl": "https://vruchay.ru/d/eyJmaWxlSWQiOi….3q2-Xk",
    "downloadUrl": "/api/registry/files/a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d/download"
  },
  "email": { "id": "7d6c5b4a-3f2e-4d1c-8b0a-9f8e7d6c5b4a", "status": "delivered" },
  "emailError": null
}
```

```json
{ "status": "failed", "jobId": "5c8f1a2b-…", "rowId": "0b1c2d3e-…", "error": "Документ не удалось выпустить за три попытки" }
```

`recipientUrl` подписывается заново при каждом ответе и действует 30 дней
от него.

## Ошибки

| Код | Когда |
|---|---|
| 400 | `Некорректный идентификатор` — `id` или `jobId` не UUID |
| 401 | Нет токена или сессии |
| 404 | `Задание не найдено` — нет такого, чужое, это пакет, а не выдача по одному, или оно другого материала |

## Пример

```bash
curl "https://vruchay.ru/api/documents/$DOCUMENT_ID/issue/$JOB_ID" \
  -H "Authorization: Bearer $VRUCHAY_TOKEN"
```

---

<!-- endpoints/generation/get-documents-id-jobs -->
> method: GET | path: /api/documents/{id}/jobs | auth: token | roles: any | rate_limit: none

# GET /api/documents/{id}/jobs

Последние выпуски по материалу — новые первыми. Отсюда берут `jobId`, если он
не сохранён: например, чтобы скачать архив вчерашнего выпуска или продолжить
упавший. Здесь же видно, идёт ли по материалу выпуск прямо сейчас — если
у первой записи `status` равен `queued` или `running`, новый запустить нельзя.

Только пакеты (`kind: "batch"`). Документы, выданные по одному — по заявкам
с формы на сайте, — идут одиночными заданиями и в этот список не попадают:
десяток заявок вытеснил бы из него выпуски, которые вы запускали сами,
а запуску нового пакета они не мешают. Такие документы видны в реестре
(`GET /api/registry`) и строками таблицы получателей с `source: "site"`.

Выдача урезана до десяти записей. Постраничности нет: ни `limit`, ни `offset`
маршрут не принимает, и запросить одиннадцатый выпуск через API нельзя.

Материал не проверяется отдельным запросом: отбор идёт по паре
«организация + материал», поэтому для несуществующего или чужого материала
вернётся пустой массив, а не 404.

## Запрос

| Параметр | Тип | Обязателен | Описание |
|---|---|---|---|
| `id` | UUID (путь) | да | Идентификатор материала |

Тела нет.

## Ответ

`200 OK` — массив записей задания (см.
[reference/generation-job.md](../../reference/generation-job.md)), от новых
к старым по `createdAt`, не больше 10. Поля `stuck` здесь нет — оно есть только
в `GET /api/jobs/{jobId}`.

```json
[
  {
    "id": "5c8f1a2b-3d4e-4f50-8a6b-7c8d9e0f1a2b",
    "orgId": "1a2b3c4d-5e6f-4708-9a0b-1c2d3e4f5a6b",
    "documentId": "b1c2d3e4-f5a6-4b7c-8d9e-0f1a2b3c4d5e",
    "format": "pdf",
    "status": "done",
    "kind": "batch",
    "total": 128,
    "done": 127,
    "failed": 1,
    "error": "Не удалось создать файлов: 1",
    "chunks": 3,
    "attempt": 1,
    "createdAt": "2026-06-15T09:12:44.000Z",
    "startedAt": "2026-06-15T09:12:51.000Z",
    "finishedAt": "2026-06-15T09:15:38.000Z"
  }
]
```

## Ошибки

| Код | Когда |
|---|---|
| 400 | `Некорректный идентификатор` — `id` не UUID |
| 401 | Нет токена или сессии |

## Пример

```bash
curl "https://vruchay.ru/api/documents/$DOCUMENT_ID/jobs" \
  -H "Authorization: Bearer $VRUCHAY_TOKEN"
```

---

<!-- endpoints/generation/get-jobs-jobId-archive -->
> method: GET | path: /api/jobs/{jobId}/archive | auth: token | roles: any | rate_limit: none

# GET /api/jobs/{jobId}/archive

Отдаёт готовый пакет файлом: ZIP со всеми документами задания или один PDF
на всю пачку для печати. Ссылки на хранилище здесь нет — байты идут прямо
в ответе, скачивать надо с тем же заголовком `Authorization`.

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

ZIP собирается потоком: заголовки уходят до того, как прочитан первый файл,
`content-length` в ответе нет. Обратная сторона в том, что беда посреди отдачи
обрывает уже начатый ответ, а не превращается в код ошибки, — недокачанный
архив выглядит как разрыв связи. Общий PDF, наоборот, собирается в памяти
целиком, и поэтому у него есть пределы: перед `?format=pdf` спросите
[`GET /api/jobs/{jobId}/download-options`](get-jobs-jobId-download-options.md).

## Запрос

| Параметр | Тип | Обязателен | Описание |
|---|---|---|---|
| `jobId` | UUID (путь) | да | Идентификатор задания |
| `format` | `zip` \| `pdf` | нет | `zip` (по умолчанию) — архив всех файлов задания; `pdf` — один документ со всеми страницами подряд |
| `name` | string | нет | Шаблон имени файлов внутри архива, 1–120 символов. Для `format=pdf` не нужен и не используется |

Шаблон `name`: `%имя_колонки` подставляет значение колонки таблицы получателей
на момент выпуска, `%number` — порядковый номер в архиве с единицы,
`%publicId` — проверочный идентификатор документа. Неизвестная переменная
становится пустотой. Символы `\ / : * ? " < > |` в шаблоне запрещены;
результат очищается ещё раз, обрезается до 80 символов и получает расширение
`.pdf` или `.jpg` по типу файла. Тёзки не перезаписывают друг друга — второму
дописывается ` (2)`.

Без `name` имя берётся то, под которым файл выпущен (это шаблон `%name` с
номером строки в таблице). Это не то же самое, что передать `name=%name`:
у файлов, выпущенных до появления шаблонов, строки-источника может уже не быть,
и собирать имя было бы не из чего.

## Ответ

`200 OK`, файл.

При `format=zip` (по умолчанию):

- `Content-Type: application/zip`
- `Content-Disposition: attachment; filename="___________.zip"; filename*=UTF-8''%D0%A1%D0%B5%D1%80%D1%82%D0%B8%D1%84%D0%B8%D0%BA%D0%B0%D1%82%D1%8B.zip`
  (имя файла — «Сертификаты.zip»; в кавычках стоит его ASCII-заглушка, настоящее
  имя — в `filename*`)
- `Content-Length` отсутствует, тело идёт потоком

В архив попадают все файлы задания — и PDF, и JPEG, — без вложенных каталогов.

При `format=pdf`:

- `Content-Type: application/pdf`
- `Content-Disposition` с именем «Сертификаты на печать.pdf»

Склеиваются только PDF-файлы задания, в том же порядке, в каком они выпущены.
JPEG в общий файл не попадают. Отдельные нечитаемые исходники молча
пропускаются (запись об этом остаётся в журнале сервера).

## Ошибки

| Код | Когда |
|---|---|
| 400 | `Некорректный идентификатор` — `jobId` не UUID |
| 400 | `Шаблон имени не может быть пустым` / `Шаблон имени длиннее 120 символов` / `В шаблоне имени нельзя использовать символы \ / : * ? " < > \|` |
| 400 | Неизвестное значение `format` — сообщение Zod |
| 400 | `В этом выпуске нет ни одного PDF — общий файл собрать не из чего.` — при `format=pdf` |
| 400 | `Документов слишком много для одного файла: N при пределе 300. Скачайте архивом — в нём тот же комплект.` |
| 400 | `Пачка слишком тяжёлая для одного файла: N МБ при пределе 150 МБ. Скачайте архивом — в нём тот же комплект.` |
| 400 | `Не удалось собрать общий файл: документы этого задания не читаются как PDF` |
| 401 | Нет токена или сессии |
| 404 | `Задание не найдено` — нет такого или оно чужой организации |

Пустое задание (ни одного файла) при `format=zip` отдаётся как пустой архив,
а не ошибкой.

## Пример

```bash
curl "https://vruchay.ru/api/jobs/$JOB_ID/archive?format=zip&name=%25number%20%25name" \
  -H "Authorization: Bearer $VRUCHAY_TOKEN" \
  -o archive.zip
```

Здесь `%25` — это знак `%` в адресе, то есть шаблон `%number %name`: файлы
приедут как «1 Иванова Мария Петровна.pdf».

Один файл на печать:

```bash
curl "https://vruchay.ru/api/jobs/$JOB_ID/archive?format=pdf" \
  -H "Authorization: Bearer $VRUCHAY_TOKEN" \
  -o print.pdf
```

---

<!-- endpoints/generation/get-jobs-jobId-download-options -->
> method: GET | path: /api/jobs/{jobId}/download-options | auth: token | roles: any | rate_limit: none

# GET /api/jobs/{jobId}/download-options

Что сейчас можно сделать с готовым пакетом: сколько в нём файлов, сколько они
весят и поместится ли пачка в один PDF на печать. Спрашивать это нужно до
скачивания, а не после: общий PDF собирается в памяти, и большому пакету в ней
не поместиться — узнать об этом отказом посреди начатого скачивания значит
получить голый JSON вместо файла.

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

Пределы на общий PDF — `PRINT_MERGE_LIMIT_FILES` (по умолчанию 300 документов)
и `PRINT_MERGE_LIMIT_MB` (по умолчанию 150 МБ). Ровно эта же проверка стоит
на самом скачивании, поэтому `print.allowed: false` здесь означает 400 там.

## Запрос

| Параметр | Тип | Обязателен | Описание |
|---|---|---|---|
| `jobId` | UUID (путь) | да | Идентификатор задания |

Тела нет.

## Ответ

`200 OK`

| Поле | Тип | Описание |
|---|---|---|
| `count` | number | Сколько всего файлов в задании — и PDF, и JPEG. Столько записей будет в архиве |
| `pdfCount` | number | Сколько из них PDF. Только они попадают в общий файл на печать |
| `sizeBytes` | number | Суммарный размер PDF-файлов в байтах. JPEG в сумму не входит |
| `print` | object | Вердикт по общему PDF, см. ниже |

`print`:

| Поле | Тип | Описание |
|---|---|---|
| `allowed` | boolean | Можно ли запросить `?format=pdf` |
| `reason` | string \| null | Почему нельзя. Null, когда можно |
| `limitFiles` | number | Действующий предел по числу документов |
| `limitMb` | number | Действующий предел по объёму, МБ |

```json
{
  "count": 127,
  "pdfCount": 127,
  "sizeBytes": 41517284,
  "print": { "allowed": true, "reason": null, "limitFiles": 300, "limitMb": 150 }
}
```

Отказ выглядит так:

```json
{
  "count": 412,
  "pdfCount": 412,
  "sizeBytes": 168000000,
  "print": {
    "allowed": false,
    "reason": "Документов слишком много для одного файла: 412 при пределе 300. Скачайте архивом — в нём тот же комплект.",
    "limitFiles": 300,
    "limitMb": 150
  }
}
```

Три возможных текста `reason`:

- `В этом выпуске нет ни одного PDF — общий файл собрать не из чего.` —
  `pdfCount` равен нулю (в том числе когда выпуск шёл в JPEG);
- `Документов слишком много для одного файла: N при пределе 300. Скачайте
  архивом — в нём тот же комплект.`;
- `Пачка слишком тяжёлая для одного файла: N МБ при пределе 150 МБ. Скачайте
  архивом — в нём тот же комплект.`

## Ошибки

| Код | Когда |
|---|---|
| 400 | `Некорректный идентификатор` — `jobId` не UUID |
| 401 | Нет токена или сессии |
| 404 | `Задание не найдено` — нет такого или оно чужой организации |

## Пример

```bash
curl "https://vruchay.ru/api/jobs/$JOB_ID/download-options" \
  -H "Authorization: Bearer $VRUCHAY_TOKEN"
```

---

<!-- endpoints/generation/get-jobs-jobId-failures -->
> method: GET | path: /api/jobs/{jobId}/failures | auth: token | roles: any | rate_limit: none

# GET /api/jobs/{jobId}/failures

Поимённый разбор неудач задания: по каким строкам документ не создался и
почему. Счётчик `failed` в записи задания говорит «ошибок 12», но по нему
не понять даже, пропали это строки из таблицы или не отрисовались документы,
— здесь ровно это и написано.

Список отдаётся в порядке появления, не больше 200 записей. Постраничности нет:
если неудач больше двухсот, остальные через API не достать.

Причины стираются при продолжении выпуска: `POST /api/jobs/{jobId}/resume`
удаляет все прежние записи, потому что строка либо уже стала файлом, либо будет
напечатана заново. Поэтому забирайте отчёт до продолжения.

## Запрос

| Параметр | Тип | Обязателен | Описание |
|---|---|---|---|
| `jobId` | UUID (путь) | да | Идентификатор задания |

Тела нет.

## Ответ

`200 OK` — массив; пустой, если неудач нет.

| Поле | Тип | Описание |
|---|---|---|
| `rowId` | string (UUID) | Строка таблицы получателей, по которой не вышло |
| `name` | string | Имя из колонки `name` этой строки. Если строки в таблице больше нет — `Строки больше нет в таблице` |
| `reason` | string | Причина по-русски |

```json
[
  {
    "rowId": "9e8d7c6b-5a4f-4e3d-8c2b-1a0f9e8d7c6b",
    "name": "Иванова Мария Петровна",
    "reason": "Не загрузились шрифты макета"
  },
  {
    "rowId": "0f1e2d3c-4b5a-4968-8778-695a4b3c2d1e",
    "name": "Строки больше нет в таблице",
    "reason": "Строку убрали из таблицы, пока шёл выпуск"
  }
]
```

Набор причин закрытый (`render-failure.ts`): `На сервере кончилось место`,
`Браузер, который печатает документы, не удалось запустить`,
`Браузер, который печатает документы, аварийно завершился`,
`Не загрузились шрифты макета`, `Хранилище файлов не отвечает`,
`Страница документа не успела отрисоваться`, `База данных не отвечает`,
`Документ не удалось создать` (причина не опознана) и
`Строку убрали из таблицы, пока шёл выпуск`.

Часть причин считается общей бедой: если подряд падают две строки с такой
причиной (или пять подряд с неопознанной), выпуск останавливается целиком —
об этом скажет поле `error` в записи задания.

## Ошибки

| Код | Когда |
|---|---|
| 400 | `Некорректный идентификатор` — `jobId` не UUID |
| 401 | Нет токена или сессии |
| 404 | `Задание не найдено` — нет такого или оно чужой организации |

## Пример

```bash
curl "https://vruchay.ru/api/jobs/$JOB_ID/failures" \
  -H "Authorization: Bearer $VRUCHAY_TOKEN"
```

---

<!-- endpoints/generation/get-jobs-jobId -->
> method: GET | path: /api/jobs/{jobId} | auth: token | roles: any | rate_limit: none

# GET /api/jobs/{jobId}

Текущее состояние задания. Это тот запрос, которым следят за асинхронным
выпуском: после `POST /api/documents/{id}/generate` опрашивайте этот адрес,
пока `status` не станет закрытым — `done`, `failed` или `canceled`.
Прогресс — это `done` и `failed` против `total`; выпуск закончен, когда
`done + failed` дошло до `total`.

К записи задания добавлено вычисляемое поле `stuck`: задание может честно
стоять в очереди, а может стоять в ней навсегда, и по одному статусу `queued`
эти два случая не различить. Подробности признака и таблица «что разрешено
в каком состоянии» — в
[reference/generation-job.md](../../reference/generation-job.md).

Разумный шаг опроса — несколько секунд: воркер печатает примерно полсотни
документов в минуту и пересчитывает `done` после каждой строки.

## Запрос

| Параметр | Тип | Обязателен | Описание |
|---|---|---|---|
| `jobId` | UUID (путь) | да | Идентификатор задания |

Тела нет.

## Ответ

`200 OK` — запись задания (см.
[reference/generation-job.md](../../reference/generation-job.md)) плюс одно поле:

| Поле | Тип | Описание |
|---|---|---|
| `stuck` | boolean | Задание стоит в очереди дольше 15 минут, ничего не начав. Такое принимается `POST /api/jobs/{jobId}/resume` |

```json
{
  "id": "5c8f1a2b-3d4e-4f50-8a6b-7c8d9e0f1a2b",
  "orgId": "1a2b3c4d-5e6f-4708-9a0b-1c2d3e4f5a6b",
  "documentId": "b1c2d3e4-f5a6-4b7c-8d9e-0f1a2b3c4d5e",
  "format": "pdf",
  "status": "running",
  "total": 128,
  "done": 74,
  "failed": 1,
  "error": null,
  "chunks": 3,
  "attempt": 1,
  "createdAt": "2026-06-15T09:12:44.000Z",
  "startedAt": "2026-06-15T09:12:51.000Z",
  "finishedAt": null,
  "stuck": false
}
```

Закрытое задание с неудачными строками выглядит так: `status` — `done`,
`failed` — сколько-то, `error` — `Не удалось создать файлов: 3`. Выпуск,
остановленный общей бедой, приходит со `status: "failed"` и объяснением
в `error` вида «Выпуск остановлен: хранилище файлов не отвечает. Созданные
документы сохранены, за остальные ничего не списано — когда причина
устранится, выпуск можно продолжить с того же места.»

## Ошибки

| Код | Когда |
|---|---|
| 400 | `Некорректный идентификатор` — `jobId` не UUID |
| 401 | Нет токена или сессии |
| 404 | `Задание не найдено` — нет такого или оно чужой организации |

## Пример

```bash
curl "https://vruchay.ru/api/jobs/$JOB_ID" \
  -H "Authorization: Bearer $VRUCHAY_TOKEN"
```

---

<!-- endpoints/generation/post-documents-id-generate -->
> method: POST | path: /api/documents/{id}/generate | auth: token | roles: any | rate_limit: none

# POST /api/documents/{id}/generate

Заводит задание на выпуск и ставит его в очередь. Выпускаются отмеченные
(`checked`) строки таблицы получателей — по одному документу на строку, с теми
данными, что есть в строке на момент печати.

Правила наград выпуск не применяет. Каждая отмеченная строка печатается по
листам этого документа, а шаблоны из `outputs[]` набора правил
(`POST …/awards/preview`) сами не выпускаются. Чтобы выдать диплом по шаблону,
загрузите нужные строки в тот документ и запустите выпуск там.

Выпуск асинхронный. Ответ приходит сразу и означает только «задание принято»:
`status` в нём — `queued`, `done` — ноль, файлов ещё нет. Документы создаёт
отдельный воркер, пакет разрезан на части по 50 строк, и порядок в очереди
зависит от размера пакета — маленькие идут раньше больших. За ходом дела
следите опросом `GET /api/jobs/{jobId}`, пока `status` не станет одним из
закрытых (`done`, `failed`, `canceled`).

По одному материалу одновременно идёт только один выпуск, а вся организация
может держать в очереди не больше `ORG_ACTIVE_JOBS` пакетов (по умолчанию 3).
Документы, выдаваемые по одному по заявкам с формы на сайте, в этот счёт
не входят и выпуску не мешают. Строки, заведённые формой (`source: "site"`),
неотмеченные — в пакет они попадают, только если отметить их поимённо.

Объём выпуска ограничен условиями организации — их показывает
`GET /api/org/usage`. На бесплатной пробе это `FREE_DOCUMENT_LIMIT` документов
(по умолчанию 50) за всё время плюс бонус за приглашения, на плане — его объём,
считая с даты начала плана. У оплаченного тарифа старого образца предела нет.
Квота проверяется до постановки задания, поэтому об исчерпанном остатке вы
узнаете здесь, а не на сорок седьмом документе из пятидесяти. К выпущенному
прибавляется бронь: строки уже принятых заданий, которые воркер ещё не
напечатал, включая заявки с формы на сайте, ещё не ставшие документом. Выпуск отказывает, если выпущено + бронь + отмеченные строки больше
предела, и всегда, если срок плана вышел (`expired: true`), даже при
ненулевом остатке. Принятое задание доходит до конца, даже если за это время
условия кончились: воркер квоту не проверяет.

Тело обязательно: без него схема не находит объект и запрос отклоняется. Чтобы
выпустить в PDF, достаточно `{}`.

## Запрос

| Параметр | Тип | Обязателен | Описание |
|---|---|---|---|
| `id` | UUID (путь) | да | Идентификатор материала |

Тело:

| Поле | Тип | Обязательно | Описание |
|---|---|---|---|
| `format` | `pdf` \| `jpg` | нет | В чём выпускать. По умолчанию `pdf`. JPEG — снимок страницы целиком, качество 92; электронную подпись он не носит |

```json
{ "format": "pdf" }
```

## Ответ

`201 Created` — созданное задание, см.
[reference/generation-job.md](../../reference/generation-job.md).

```json
{
  "id": "5c8f1a2b-3d4e-4f50-8a6b-7c8d9e0f1a2b",
  "orgId": "1a2b3c4d-5e6f-4708-9a0b-1c2d3e4f5a6b",
  "documentId": "b1c2d3e4-f5a6-4b7c-8d9e-0f1a2b3c4d5e",
  "format": "pdf",
  "status": "queued",
  "total": 128,
  "done": 0,
  "failed": 0,
  "error": null,
  "chunks": 0,
  "attempt": 1,
  "createdAt": "2026-06-15T09:12:44.000Z",
  "startedAt": null,
  "finishedAt": null
}
```

`chunks` в этом ответе ещё ноль: число частей записывается в момент постановки
в очередь, уже после того как запись создана. В `GET /api/jobs/{jobId}` оно
будет настоящим.

## Ошибки

| Код | Когда |
|---|---|
| 400 | `Некорректный идентификатор` — `id` не UUID |
| 400 | `Это шаблон — создайте по нему документ и выпускайте из него` — материал помечен как шаблон |
| 400 | `В документе нет ни одного блока — сначала соберите макет` — ни на одном листе материала нет блоков |
| 400 | `Не отмечено ни одной строки в таблице получателей` |
| 400 | `Генерация по этому документу уже идёт` — по материалу есть задание в статусе `queued` или `running` |
| 400 | `Одновременно можно выпускать не больше N пакетов. Дождитесь окончания или отмените лишний — документы уже созданные при отмене никуда не денутся.` — N это `ORG_ACTIVE_JOBS`, по умолчанию 3 |
| 400 | Проба кончилась: `Бесплатная проба закончилась: выпущено X документов из Y. Напишите нам — обсудим условия и добавим документов. Или пригласите коллегу в разделе «Пригласить друга» — за каждого, кто начнёт работать, добавим ещё документов.` |
| 400 | На пробе отмечено больше, чем осталось: `На бесплатной пробе осталось N документов из Y, а отмечено M. Снимите лишние отметки — или напишите нам, обсудим условия. Или пригласите коллегу в разделе «Пригласить друга» — за каждого, кто начнёт работать, добавим ещё документов.` |
| 400 | План израсходован: `План «Название» израсходован: выпущено X документов из Y. Уже начатый выпуск дойдёт до конца, а новый не начнётся. Напишите нам — обсудим условия и добавим документов.` |
| 400 | На плане отмечено больше, чем осталось: `По плану «Название» осталось N документов из Y, а отмечено M. Снимите лишние отметки — или напишите нам, обсудим условия.` |
| 400 | Срок плана вышел (при любом остатке): `Срок плана «Название» закончился ДД.ММ.ГГГГ, новый выпуск не начнётся. Уже выданные документы остаются действительными и проверяются по QR-коду. Напишите нам — обсудим условия и добавим документов.` |
| 400 | Неизвестное значение `format` или отсутствующее тело — сообщение Zod |
| 401 | Нет токена или сессии |
| 404 | `Документ не найден` — нет такого, чужой или в корзине |
| 503 | `Очередь заданий сейчас недоступна, выпуск не начат. Ничего не списано — попробуйте ещё раз через минуту.` — Redis не принял пакет; задание при этом сразу закрывается со статусом `failed`, и позже его можно продолжить |

В отказах по остатку X — выпущенное вместе с бронью, Y — предел, N —
остаток за вычетом брони. Если бронь не пуста, сразу после «из Y» сервер
вставляет ` (из них K держит незаконченный выпуск)`, где K — число строк брони.
Например: `Бесплатная проба закончилась: выпущено 50 документов из 50 (из них 12
держит незаконченный выпуск). Напишите нам…` или `По плану «Год» осталось 8
документов из 500 (из них 30 держит незаконченный выпуск), а отмечено 40. …`.
Отказ по сроку плана остатка и брони не называет.

Тексты отказов написаны для человека и могут меняться. Программе надёжнее
заранее спросить `GET /api/org/usage` и сравнить `left` и `expired`, чем
разбирать `message`.

## Пример

```bash
curl -X POST "https://vruchay.ru/api/documents/$DOCUMENT_ID/generate" \
  -H "Authorization: Bearer $VRUCHAY_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"format":"pdf"}'
```

---

<!-- endpoints/generation/post-documents-id-issue -->
> method: POST | path: /api/documents/{id}/issue | auth: token | roles: any | rate_limit: «120 запросов в минуту на организацию»

# POST /api/documents/{id}/issue

Выпускает один документ по данным из запроса — без таблицы и без пакета. Для
своего сайта, CRM, учебной платформы или бота: человек закончил курс — программа
присылает его имя, в ответе готовый документ с кодом проверки и ссылками.

Документ выходит тем же путём, что пакет и форма на сайте: код проверки и QR на
листе, регистрационный номер, срок действия, отпечаток файла, запись в реестре.
В таблицу получателей материала добавляется неотмеченная строка с `source: "api"`:
«отметить все» и повторная загрузка таблицы её не трогают, в пакет она попадает,
только если отметить её поимённо. Документ списывается с того же остатка, что
и выпуск: квота проверяется и бронируется в момент запроса, под тем же замком
организации, поэтому при исчерпанном остатке отказ приходит сразу. Выдача по API
не занимает место среди пакетов организации и не ждёт конца идущего выпуска.

Запрос ждёт готовый документ до `wait` секунд (по умолчанию 15) и отвечает:

- `201 Created` и `status: "done"` — документ готов, в ответе его код и ссылки;
- `202 Accepted` и `status: "processing"` — не успел; дальше опрашивайте
  `statusUrl` (`GET /api/documents/{id}/issue/{jobId}`) раз в 1–2 секунды;
- `200 OK` и `status: "failed"` — документ не вышел (сбой печати), причина в `error`.

Обычно документ готов за 1–3 секунды. `wait: 0` — ответить сразу, без ожидания.
Если клиент оборвал соединение, ожидание прекращается, а документ всё равно
доделывается — найти его можно тем же `GET` по `jobId`.

## Повтор запроса: `Idempotency-Key`

Связь оборвалась, ответа нет — выпустился документ или нет? Пришлите заголовок
`Idempotency-Key` с вашим идентификатором (номер заказа, UUID заявки): повтор
с тем же ключом не печатает второй документ и не списывает второй раз, а отдаёт
задание первого запроса — с тем же кодом ответа и заголовком
`Idempotent-Replayed: true`. Ключ действует в пределах организации и не стареет.

- Тот же ключ с другим документом — `409`.
- Тело повтора с первым не сравнивается: ключ означает «тот же запрос».
- Неудача на нашей стороне ключ не сжигает: если задание закончилось
  `status: "failed"`, повтор с тем же ключом заводит новое.

## Поля документа

`data` — значения полей документа по их именам: колонки таблицы получателей
(`name`, `course`, …) и поля листа (`%grade` на листе — `grade` здесь). Какие
поля есть у документа, видно в кабинете на шаге «Выпуск», вкладка «API»: там
готовый пример запроса с настоящими именами. Поля, которые сервис заполняет сам
(дата, код, номер, мероприятие, падежи), и постоянные поля материала прислать
нельзя. Неизвестное имя — `400` со списком имён (без присланных значений):
опечатка `nmae` хуже отказа, на листе вышло бы пустое ФИО.

`name` обязательно. Падежи (`%name_dat` и другие), латиница и сокращённое имя
считаются из него, как в пакете.

## Письмо

`send: true` — после печати документ уходит участнику письмом по шаблону
материала (шаг «Письмо»), с файлом во вложении. Нужны адрес (`email` или
`data.email`) и возможность `mailing` в плане. Если письмо поставить не удалось
(почта не настроена, исчерпан суточный объём общего домена), документ всё равно
выпущен и списан: в ответе `email: null` и причина в `emailError`.

## Запрос

| Параметр | Тип | Обязателен | Описание |
|---|---|---|---|
| `id` | UUID (путь) | да | Материал, по которому выдаётся документ |
| `Idempotency-Key` | заголовок | нет | Ключ повтора: 8–100 знаков, латиница, цифры и `_ - : .` |

Тело — строгий объект: неизвестное поле верхнего уровня (`sendd`) — ошибка.

| Поле | Тип | Обязательно | Описание |
|---|---|---|---|
| `data` | object | да | Поля документа: имя → строка до 500 символов, не больше 60 полей. `name` обязательно |
| `email` | string | нет | Адрес получателя, до 254 символов. Записывается и в строку как `email` |
| `send` | boolean | нет | Отправить документ письмом. По умолчанию `false` |
| `format` | `pdf` \| `jpg` | нет | По умолчанию `pdf` |
| `wait` | число 0–30 | нет | Сколько секунд ждать готовый документ. По умолчанию 15 |

```json
{
  "data": { "name": "Иванова Анна Сергеевна", "course": "Основы рисунка" },
  "email": "ivanova@vruchay.ru",
  "send": true
}
```

## Ответ

`201 Created` — документ готов:

| Поле | Тип | Описание |
|---|---|---|
| `status` | `done` | |
| `jobId` | UUID | Одиночное задание выпуска (`kind: "single"`) |
| `rowId` | UUID | Строка в таблице получателей (`source: "api"`) |
| `file.id` | UUID | Документ в реестре |
| `file.publicId` | UUID | Публичный идентификатор экземпляра |
| `file.code` | string | Проверочный код с листа: `K7M2-9QXR-4TVB` |
| `file.regNumber` | string | Регистрационный номер: `142/2026` |
| `file.verifyUrl` | string \| null | Страница проверки. `null` — проверка выключена у материала |
| `file.recipientUrl` | string | Страница получателя с PDF — подписанная ссылка, действует 30 дней. Её можно отдать человеку как есть |
| `file.downloadUrl` | string | Сам файл — по тому же токену, см. `GET /api/registry/files/{fileId}/download` |
| `email` | object \| null | Письмо: `id` и `status` (`queued`, `sent`, `delivered`, `opened`, `bounced`, `failed`). `null` — письмо не просили или оно не ушло |
| `emailError` | string \| null | Письмо просили, а оно не ушло: почему |

```json
{
  "status": "done",
  "jobId": "5c8f1a2b-3d4e-4f50-8a6b-7c8d9e0f1a2b",
  "rowId": "0b1c2d3e-4f5a-4b6c-8d7e-9f0a1b2c3d4e",
  "file": {
    "id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
    "publicId": "f0e1d2c3-b4a5-4968-8776-655443322110",
    "code": "K7M2-9QXR-4TVB",
    "regNumber": "142/2026",
    "verifyUrl": "https://vruchay.ru/c/K7M2-9QXR-4TVB",
    "recipientUrl": "https://vruchay.ru/d/eyJmaWxlSWQiOi….3q2-Xk",
    "downloadUrl": "/api/registry/files/a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d/download"
  },
  "email": { "id": "7d6c5b4a-3f2e-4d1c-8b0a-9f8e7d6c5b4a", "status": "queued" },
  "emailError": null
}
```

`202 Accepted` — документ ещё печатается:

```json
{
  "status": "processing",
  "jobId": "5c8f1a2b-3d4e-4f50-8a6b-7c8d9e0f1a2b",
  "statusUrl": "/api/documents/b1c2d3e4-f5a6-4b7c-8d9e-0f1a2b3c4d5e/issue/5c8f1a2b-3d4e-4f50-8a6b-7c8d9e0f1a2b"
}
```

`200 OK` — документ не вышел:

```json
{
  "status": "failed",
  "jobId": "5c8f1a2b-3d4e-4f50-8a6b-7c8d9e0f1a2b",
  "rowId": "0b1c2d3e-4f5a-4b6c-8d7e-9f0a1b2c3d4e",
  "error": "Браузер, который печатает документы, аварийно завершился"
}
```

Ничего не списано; повторите запрос — с тем же `Idempotency-Key` можно.

## Ошибки

| Код | Когда |
|---|---|
| 400 | `Некорректный идентификатор` — `id` не UUID |
| 400 | Тело не прошло проверку — сообщение Zod и `errors[]`; неизвестное поле верхнего уровня тоже здесь |
| 400 | `Idempotency-Key — от 8 до 100 знаков: латиница, цифры и _ - : .` |
| 400 | `У документа нет полей: nmae. Его поля: name, course, email` — в `errors[]` поле `data.nmae` |
| 400 | `Укажите имя получателя в data.name` |
| 400 | `Проверьте адрес почты` — `email` или `data.email` не адрес |
| 400 | `Чтобы отправить письмо, укажите email получателя` — `send: true` без адреса |
| 400 | `Это шаблон — создайте по нему документ и выпускайте из него` |
| 400 | `В документе нет ни одного блока — сначала соберите макет` |
| 400 | Остаток кончился или срок плана вышел — те же тексты, что у `POST /api/documents/{id}/generate`; бронь не заводится, строка не добавляется |
| 401 | Нет токена или сессии |
| 403 | `send: true`, а рассылки нет в плане: `«Рассылка писем участникам» не входит в ваш план. Напишите нам — добавим.` (или текст об истёкшем плане) |
| 404 | `Документ не найден` — нет такого, чужой или в корзине |
| 409 | `Этот Idempotency-Key уже использован для другого документа` |
| 429 | `Не больше 120 документов по API в минуту. Повторите через N с.` — предел на организацию, а не на адрес |
| 503 | `Очередь заданий сейчас недоступна, документ не выпущен. Ничего не списано — попробуйте ещё раз через минуту.` |

## Пример

```bash
curl -X POST "https://vruchay.ru/api/documents/$DOCUMENT_ID/issue" \
  -H "Authorization: Bearer $VRUCHAY_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: order-000042" \
  -d '{"data":{"name":"Иванова Анна Сергеевна"},"email":"ivanova@vruchay.ru","send":true}'
```

---

<!-- endpoints/generation/post-jobs-jobId-archive-link -->
> method: POST | path: /api/jobs/{jobId}/archive-link | auth: token | roles: any | rate_limit: none

# POST /api/jobs/{jobId}/archive-link

Выдаёт подписанную ссылку на скачивание пакета — ту, что можно отдать человеку.
[`GET /api/jobs/{jobId}/archive`](get-jobs-jobId-archive.md) отдаёт файл только
с заголовком `Authorization`, и открыть такой адрес в браузере нельзя. Агенту в чате
(Claude, ChatGPT) нечего дать человеку, кроме ссылки, — эта ссылка открывается
без входа: [`GET /api/v1/archive/{token}`](../public/get-v1-archive-token.md).

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

Файлы собираются в момент скачивания, а не в момент выдачи ссылки: ссылка,
выданная посреди выпуска, через минуту отдаст больше документов, чем было в `count`.
Квоту ни выдача ссылки, ни скачивание не трогают.

## Запрос

| Параметр | Тип | Обязателен | Описание |
|---|---|---|---|
| `jobId` | UUID | да | задание выпуска этой организации |

Тело необязательно:

| Поле | Тип | Обязательно | Описание |
|---|---|---|---|
| `format` | `zip` \| `pdf` | нет | `zip` (по умолчанию) — архив, по файлу на человека; `pdf` — один PDF на всю пачку для печати |
| `name` | string | нет | шаблон имён файлов в архиве, 1–120 символов. Для `pdf` не используется |

Шаблон `name` — тот же, что у [`GET /api/jobs/{jobId}/archive`](get-jobs-jobId-archive.md):
`%имя_колонки` — значение колонки таблицы получателей на момент выпуска, `%number` —
порядковый номер в архиве с единицы, `%publicId` — проверочный идентификатор
документа. Неизвестная переменная становится пустотой. Символы `\ / : * ? " < > |`
запрещены; готовое имя очищается ещё раз, обрезается до 80 символов и получает
расширение `.pdf` или `.jpg`. Тёзкам дописывается ` (2)`. Без `name` файлы
называются так, как выпущены.

```json
{ "format": "zip", "name": "%number %name" }
```

## Ответ

`200 OK`

| Поле | Тип | Описание |
|---|---|---|
| `url` | string | ссылка на скачивание, без входа: `https://vruchay.ru/api/v1/archive/{token}` |
| `expiresAt` | string (ISO 8601) | когда ссылка перестанет работать — через час |
| `count` | integer | сколько файлов в задании сейчас |

```json
{
  "url": "https://vruchay.ru/api/v1/archive/eyJvcmdJZCI6IjBhMWIy….Xk3q2-Lm8pQ",
  "expiresAt": "2026-09-03T10:15:00.000Z",
  "count": 214
}
```

## Ошибки

| Код | Когда |
|---|---|
| 400 | «Некорректный идентификатор» — `jobId` не UUID |
| 400 | «Шаблон имени не может быть пустым» / «Шаблон имени длиннее 120 символов» / «В шаблоне имени нельзя использовать символы \ / : * ? " < > \|»; неизвестный `format` — сообщение Zod |
| 401 | нет токена или сессии |
| 404 | «Задание не найдено» — нет такого или оно чужой организации |
| 413 | тело больше 1 МБ |

Пределы общего PDF (не больше 300 документов и 150 МБ, только PDF-файлы) здесь
не проверяются — их проверит скачивание по ссылке. Спросить заранее —
[`GET /api/jobs/{jobId}/download-options`](get-jobs-jobId-download-options.md).

## Пример

```bash
curl -X POST "https://vruchay.ru/api/jobs/$JOB_ID/archive-link" \
  -H "Authorization: Bearer $VRUCHAY_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"format":"zip","name":"%number %name"}'
```

---

<!-- endpoints/generation/post-jobs-jobId-cancel -->
> method: POST | path: /api/jobs/{jobId}/cancel | auth: token | roles: any | rate_limit: none

# POST /api/jobs/{jobId}/cancel

Отменяет остаток начатого выпуска. Разрешено только для активного задания —
`queued` или `running`; закрытое отменять нечего.

Уже созданные документы остаются: человек отказывается от остатка, а не от
сделанного. Их можно скачать тем же `GET /api/jobs/{jobId}/archive`. Квота за
ненапечатанные строки не списана — списывается она в момент создания файла,
а не при постановке задания; отменённое задание перестаёт быть активным,
и вместе с ним из расчёта уходит его бронь.

Отмена не мгновенная в том смысле, что часть, уже взятую воркером в работу,
прервать посреди отрисовки нельзя — иначе в хранилище остался бы файл, которого
нет в базе. Части, до которых воркер не добрался, снимаются с очереди сразу,
а работающая увидит отмену между строками и остановится. Поэтому `done` может
подрасти ещё на один-два документа после ответа.

## Запрос

| Параметр | Тип | Обязателен | Описание |
|---|---|---|---|
| `jobId` | UUID (путь) | да | Идентификатор задания |

Тела нет.

## Ответ

`201 Created` — запись задания (см.
[reference/generation-job.md](../../reference/generation-job.md)) плюс одно поле:

| Поле | Тип | Описание |
|---|---|---|
| `refunded` | number | Сколько строк осталось необработанными: `total − done − failed`, но не меньше нуля. Столько квоты и не списалось |

```json
{
  "id": "5c8f1a2b-3d4e-4f50-8a6b-7c8d9e0f1a2b",
  "orgId": "1a2b3c4d-5e6f-4708-9a0b-1c2d3e4f5a6b",
  "documentId": "b1c2d3e4-f5a6-4b7c-8d9e-0f1a2b3c4d5e",
  "format": "pdf",
  "status": "canceled",
  "total": 128,
  "done": 74,
  "failed": 1,
  "error": null,
  "chunks": 3,
  "attempt": 1,
  "createdAt": "2026-06-15T09:12:44.000Z",
  "startedAt": "2026-06-15T09:12:51.000Z",
  "finishedAt": "2026-06-15T09:15:02.000Z",
  "refunded": 53
}
```

Отменённое задание остаётся продолжаемым: `POST /api/jobs/{jobId}/resume`
доделает его с того же места.

## Ошибки

| Код | Когда |
|---|---|
| 400 | `Некорректный идентификатор` — `jobId` не UUID |
| 400 | `Это задание уже завершено — отменять нечего` — статус не `queued` и не `running` |
| 400 | `Документ по заявке отменить нельзя — он выпускается за секунды` — задание одиночное (`kind: "single"`, заявка с формы на сайте) |
| 401 | Нет токена или сессии |
| 404 | `Задание не найдено` — нет такого или оно чужой организации |

## Пример

```bash
curl -X POST "https://vruchay.ru/api/jobs/$JOB_ID/cancel" \
  -H "Authorization: Bearer $VRUCHAY_TOKEN"
```

---

<!-- endpoints/generation/post-jobs-jobId-resume -->
> method: POST | path: /api/jobs/{jobId}/resume | auth: token | roles: any | rate_limit: none

# POST /api/jobs/{jobId}/resume

Доделывает то же самое задание с того места, где оно встало. Принимается для
упавшего (`failed`), отменённого (`canceled`) и зависшего задания — то есть
такого, у которого `stuck: true` в `GET /api/jobs/{jobId}`. Идущий выпуск
(`queued` без признака зависания или `running`) и завершённый (`done`)
продолжать нечего.

Это не то же самое, что новый выпуск. Новый выпуск — новое задание и новая
оплата: список могли исправить и захотеть второй комплект. Продолжение идёт под
тем же `jobId`, за уже созданные документы второй раз не списывается, а квота
проверяется только для оставшихся строк — так же, как при запуске: с учётом
брони других активных заданий и с отказом при истёкшем плане. Если доделывать
нечего, квота не проверяется вовсе.

Строки берутся отмеченные сейчас, а не те, что были отмечены при запуске: если
отметки за это время сняли, документы по ним не выпускаются. Поэтому `total`
пересчитывается, `failed` обнуляется, прежние причины неудач стираются,
а `attempt` увеличивается на единицу. Если доделывать оказалось нечего (все
отмеченные строки уже выпущены), задание просто закрывается: приходит запись
со `status: "done"`, у которой `total` и `done` равны числу уже выпущенных
файлов, и в очередь ничего не ставится.

Как и запуск, продолжение асинхронное: ответ означает «принято», а не «готово».

## Запрос

| Параметр | Тип | Обязателен | Описание |
|---|---|---|---|
| `jobId` | UUID (путь) | да | Идентификатор задания |

Тела нет.

## Ответ

`201 Created` — обновлённая запись задания, см.
[reference/generation-job.md](../../reference/generation-job.md).

```json
{
  "id": "5c8f1a2b-3d4e-4f50-8a6b-7c8d9e0f1a2b",
  "orgId": "1a2b3c4d-5e6f-4708-9a0b-1c2d3e4f5a6b",
  "documentId": "b1c2d3e4-f5a6-4b7c-8d9e-0f1a2b3c4d5e",
  "format": "pdf",
  "status": "queued",
  "total": 128,
  "done": 74,
  "failed": 0,
  "error": null,
  "chunks": 3,
  "attempt": 2,
  "createdAt": "2026-06-15T09:12:44.000Z",
  "startedAt": "2026-06-15T09:12:51.000Z",
  "finishedAt": null
}
```

`chunks` здесь ещё от прошлой попытки: новое число частей записывается уже после
того, как запись обновлена.

## Ошибки

| Код | Когда |
|---|---|
| 400 | `Некорректный идентификатор` — `jobId` не UUID |
| 400 | `Документ по заявке выпускается заново самой заявкой` — задание одиночное (`kind: "single"`, заявка с формы на сайте): его повторяет сервис |
| 400 | `Этот выпуск уже завершён — продолжать нечего` — `status` = `done` |
| 400 | `Этот выпуск и так идёт` — `status` = `running` либо `queued` без признака `stuck` |
| 400 | `Этот выпуск уже продолжили` — задание перестало быть продолжаемым, пока запрос ждал замка организации (два продолжения разом) |
| 400 | `Одновременно можно выпускать не больше N пакетов…` — у организации уже N других активных заданий (`ORG_ACTIVE_JOBS`, по умолчанию 3) |
| 400 | Отказ по квоте — те же тексты, что у [запуска выпуска](post-documents-id-generate.md): на пробе или на плане остатка не хватает на оставшиеся строки (`Бесплатная проба закончилась…`, `На бесплатной пробе осталось N документов…`, `План «…» израсходован…`, `По плану «…» осталось N документов…`) либо срок плана вышел (`Срок плана «…» закончился…`) — при любом остатке |
| 401 | Нет токена или сессии |
| 404 | `Задание не найдено` — нет такого или оно чужой организации |
| 503 | `Очередь заданий сейчас недоступна, выпуск не начат. Ничего не списано — попробуйте ещё раз через минуту.` — задание при этом снова закрывается как `failed` |

## Пример

```bash
curl -X POST "https://vruchay.ru/api/jobs/$JOB_ID/resume" \
  -H "Authorization: Bearer $VRUCHAY_TOKEN"
```

---

<!-- endpoints/integrations/delete-integrations-tilda-id -->
> method: DELETE | path: /api/integrations/tilda/{id} | auth: token | roles: owner, admin | rate_limit: none

# DELETE /api/integrations/tilda/{id}

Удаляет интеграцию насовсем. Удаление настоящее, не пометка: заявки, принятые через эту форму, уходят вместе с ней (каскад по внешнему ключу), и в `GET /api/integrations/tilda/requests` их больше не будет. Выпущенные файлы и отправленные письма при этом остаются — они живут отдельно от заявки.

Если форма нужна только на паузу, лучше выключить интеграцию: `PATCH /api/integrations/tilda/{id}` с `active: false`. Выключенная перестаёт принимать заявки сразу, а журнал выдачи сохраняется.

Страница с формой ничего об удалении не знает: скрипт по адресу `/api/v1/tilda-js/{token}` начнёт отвечать «Интеграция не найдена», и форма работать перестанет.

## Запрос

| Параметр | Тип | Обязателен | Описание |
|---|---|---|---|
| id | string (UUID) | да | Идентификатор интеграции |

Тела нет.

## Ответ

`200 OK`

| Поле | Тип | Описание |
|---|---|---|
| ok | boolean | Всегда `true` |

```json
{ "ok": true }
```

## Ошибки

| Код | Когда |
|---|---|
| 400 | `id` не UUID: «Некорректный идентификатор» |
| 401 | Нет токена или сессии |
| 403 | Роль `member`: «Недостаточно прав для этого действия» |
| 404 | Интеграции нет или она чужая: «Интеграция не найдена» |

## Пример

```bash
curl -X DELETE "https://vruchay.ru/api/integrations/tilda/$INTEGRATION_ID" \
  -H "Authorization: Bearer $VRUCHAY_TOKEN"
```

---

<!-- endpoints/integrations/get-integrations-tilda-id -->
> method: GET | path: /api/integrations/tilda/{id} | auth: token | roles: any | rate_limit: none

# GET /api/integrations/tilda/{id}

Одна интеграция организации целиком. Поля те же, что в списке, но без счётчика заявок (`_count`).

В `connect` — готовое подключение: ссылка `https://vruchay.ru/f/{token}` у формы по ссылке (на своём домене организации, если он подключён и подтверждён: `https://forms.school.ru/f/{token}`) и HTML для вставки на страницу по каждому материалу у формы на сайте, с адресами стилей `/api/v1/tilda-css/{token}` и скрипта `/api/v1/tilda-js/{token}`. Скрипт по этому адресу отдаёт форме её публичные настройки — способ подтверждения, текст окна успеха, текст согласия, — поэтому выключенная интеграция (`active: false`) перестаёт работать сразу, без правки страницы.

## Запрос

| Параметр | Тип | Обязателен | Описание |
|---|---|---|---|
| id | string (UUID) | да | Идентификатор интеграции |

## Ответ

`200 OK` — поля перечислены в `GET /api/integrations/tilda`, кроме `_count`; `connect` есть.

```json
{
  "id": "3f9a1b2c-4d5e-4f60-8a71-2b3c4d5e6f70",
  "orgId": "7b2a4c6e-1d3f-4e5a-9b8c-0d1e2f3a4b5c",
  "name": "Форма на странице мероприятия",
  "token": "00000000-0000-4000-8000-0000000000ab",
  "kind": "site",
  "formFields": ["name"],
  "allowedDomains": ["vruchay.ru"],
  "documentIds": ["9e8d7c6b-5a4f-4e3d-2c1b-0a9f8e7d6c5b"],
  "authMode": "email_code",
  "singleFilePerEmail": true,
  "dailyLimit": 500,
  "successMessage": "Спасибо! Документ отправлен на вашу почту",
  "showDownload": true,
  "sendEmail": true,
  "copyToEmail": null,
  "active": true,
  "prefillFromAccount": true,
  "allowEdit": true,
  "showShare": true,
  "showVerifyLink": false,
  "checkList": false,
  "requireAccount": false,
  "createdAt": "2026-08-20T09:20:00.000Z",
  "connect": {
    "formUrl": null,
    "scriptUrl": "https://vruchay.ru/api/v1/tilda-js/00000000-0000-4000-8000-0000000000ab",
    "styleUrl": "https://vruchay.ru/api/v1/tilda-css/00000000-0000-4000-8000-0000000000ab",
    "embedHtml": {
      "9e8d7c6b-5a4f-4e3d-2c1b-0a9f8e7d6c5b": "<!-- Имя и почту подставьте своим шаблоном или оставьте пустыми — человек заполнит сам. -->\n<div data-vruchay-certificate\n     data-doc-id=\"9e8d7c6b-5a4f-4e3d-2c1b-0a9f8e7d6c5b\"\n     data-label=\"Получить документ\"\n     data-name=\"\"\n     data-email=\"\"></div>\n<link rel=\"stylesheet\" href=\"https://vruchay.ru/api/v1/tilda-css/00000000-0000-4000-8000-0000000000ab\">\n<script src=\"https://vruchay.ru/api/v1/tilda-js/00000000-0000-4000-8000-0000000000ab\"></script>"
    }
  }
}
```

## Ошибки

| Код | Когда |
|---|---|
| 400 | `id` не UUID: «Некорректный идентификатор» |
| 401 | Нет токена или сессии |
| 404 | Интеграции нет или она чужая: «Интеграция не найдена» |

## Пример

```bash
curl "https://vruchay.ru/api/integrations/tilda/$INTEGRATION_ID" \
  -H "Authorization: Bearer $VRUCHAY_TOKEN"
```

---

<!-- endpoints/integrations/get-integrations-tilda-requests -->
> method: GET | path: /api/integrations/tilda/requests | auth: token | roles: any | rate_limit: none

# GET /api/integrations/tilda/requests

Последние 200 заявок организации, новые первыми, — журнал выдачи документов посторонним людям через формы на сайте. Заявка создаётся публичным маршрутом формы; здесь её только читают. Пагинации нет, ограничение в 200 записей не настраивается.

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

Путь `requests` разбирается раньше `/{id}`, поэтому за заявками ходят сюда, а не в `GET /api/integrations/tilda/{id}`.

## Запрос

| Параметр | Тип | Обязателен | Описание |
|---|---|---|---|
| integrationId | string (UUID) | нет | Только заявки этой интеграции |
| documentId | string (UUID) | нет | Только заявки по этому материалу |

Неизвестные параметры строки запроса отбрасываются молча.

## Ответ

`200 OK` — массив заявок.

| Поле | Тип | Описание |
|---|---|---|
| id | string (UUID) | Идентификатор заявки |
| email | string | Адрес, который человек указал в форме |
| fields | object | Остальные поля формы: имя переменной → значение. Становятся переменными документа |
| status | string | `pending_otp` — ждём код из письма; `processing` — документ готовится; `done` — выдан; `failed` — не получилось; `rejected` — заявка отклонена (в том числе потому, что квота организации исчерпана или срок плана вышел) |
| error | string \| null | Что пошло не так. У `failed` — причина неудачи выпуска; у `rejected` — причина отказа, например текст об исчерпанной квоте; у `done` — почему не ушло письмо (почта не настроена, исчерпан суточный объём общего домена): документ выдан, и участник может скачать его в окне формы |
| createdAt | string (ISO 8601) | Когда заявка принята |
| doneAt | string (ISO 8601) \| null | Когда документ выдан |
| documentId | string (UUID) | Материал, документ по которому запрашивали |

Адрес учётной записи площадки, IP и user-agent в ответ не попадают — они нужны только для разбора злоупотреблений.

```json
[
  {
    "id": "d4e5f607-1829-4a3b-8c4d-5e6f70819243",
    "email": "ivanova@vruchay.ru",
    "fields": { "name": "Иванова Мария Сергеевна", "place": "1" },
    "status": "done",
    "error": null,
    "createdAt": "2026-08-21T10:00:00.000Z",
    "doneAt": "2026-08-21T10:00:34.000Z",
    "documentId": "9e8d7c6b-5a4f-4e3d-2c1b-0a9f8e7d6c5b"
  }
]
```

## Ошибки

| Код | Когда |
|---|---|
| 400 | `integrationId` или `documentId` не UUID: «Invalid UUID» |
| 401 | Нет токена или сессии |

## Пример

```bash
curl "https://vruchay.ru/api/integrations/tilda/requests?integrationId=$INTEGRATION_ID" \
  -H "Authorization: Bearer $VRUCHAY_TOKEN"
```

---

<!-- endpoints/integrations/get-integrations-tilda -->
> method: GET | path: /api/integrations/tilda | auth: token | roles: any | rate_limit: none

# GET /api/integrations/tilda

Все интеграции организации, новые первыми, вместе со счётчиком принятых заявок. Интеграция — это форма выдачи, через которую посторонний человек запрашивает свой документ: на сайте организации (`kind: "site"` — Тильда, WordPress, любая страница) или на нашей странице по ссылке (`kind: "link"`, `https://vruchay.ru/f/{token}`). У формы на сайте — белый список доменов страниц, у обеих — белый список материалов, способ подтверждения адреса и суточный предел.

`token` в ответе — публичный: он входит в адреса стилей и скрипта на странице клиента (`/api/v1/tilda-css/{token}` и `/api/v1/tilda-js/{token}`) и в ссылку формы, поэтому виден всем. Секретом он не является, а защиту держат список разрешённых доменов, подтверждение адреса кодом, сверка со списком и лимиты. Сами публичные маршруты формы описаны отдельно.

Собирать адреса из токена самому не нужно: у каждой интеграции есть `connect` — готовая ссылка для формы по ссылке и готовый HTML для вставки на страницу по каждому материалу. Их можно сразу отдать человеку или вставить на сайт.

Пагинации нет. Фильтр один — по материалу: так шаг «Выпуск» показывает формы своего документа.

## Запрос

| Параметр | Тип | Обязателен | Описание |
|---|---|---|---|
| `documentId` | UUID (строка запроса) | нет | Только формы, выдающие этот материал. Чужой материал даёт пустой список |

## Ответ

`200 OK` — массив интеграций.

| Поле | Тип | Описание |
|---|---|---|
| id | string (UUID) | Идентификатор интеграции |
| orgId | string (UUID) | Организация |
| name | string | Название для кабинета |
| token | string (UUID) | Публичный токен интеграции; попадает в адрес скрипта на странице или в ссылку формы |
| kind | `site` \| `link` | Форма на сайте или по ссылке |
| formFields | string[] | Что спрашивает форма по ссылке; у формы на сайте не применяется |
| allowedDomains | string[] | Домены страниц, с которых принимаются заявки; поддомены считаются своими |
| documentIds | string[] (UUID) | Материалы, которые разрешено запрашивать через эту форму |
| authMode | `none` \| `email_code` | Подтверждать ли адрес кодом из письма |
| singleFilePerEmail | boolean | Один документ в руки: повторная заявка того же адреса (или учётной записи площадки) отдаёт выданное. Выключено — каждая заявка выпускает новый документ со своим письмом |
| dailyLimit | number | Предел заявок по интеграции за сутки |
| successMessage | string | Текст окна успеха |
| showDownload | boolean | Показывать ли кнопку скачивания в окне |
| sendEmail | boolean | Слать ли документ письмом |
| copyToEmail | string \| null | Адрес для копии заявки; `null` — копию не слать |
| active | boolean | Выключенная интеграция заявки не принимает |
| prefillFromAccount | boolean | Подставлять имя и почту из личного кабинета площадки |
| allowEdit | boolean | Можно ли править подставленные данные в окне «проверьте данные» |
| showShare | boolean | Кнопки «поделиться» в окне успеха; видны только вместе с `showVerifyLink` |
| showVerifyLink | boolean | Ссылка на проверку подлинности в окне успеха |
| checkList | boolean | Сверка со списком: документ выдаётся только адресам, которые есть в таблице получателей материала |
| requireAccount | boolean | Принимать заявку только изнутри личного кабинета площадки — когда она передала адрес учётной записи |
| createdAt | string (ISO 8601) | Дата создания |
| _count.requests | number | Сколько заявок принято за всё время |
| connect | object | Готовое подключение формы — собрано сервисом |
| connect.formUrl | string \| null | Ссылка для участников: `https://vruchay.ru/f/{token}` у формы по ссылке (на своём домене организации, если он подключён и подтверждён: `https://forms.school.ru/f/{token}`); `null` у формы на сайте |
| connect.scriptUrl | string | Адрес скрипта формы: `https://vruchay.ru/api/v1/tilda-js/{token}` |
| connect.styleUrl | string | Адрес стилей формы: `https://vruchay.ru/api/v1/tilda-css/{token}` |
| connect.embedHtml | object | Готовый HTML для обычной страницы — по ключу на каждый материал из `documentIds`: блок с кнопкой «Получить документ» (`data-vruchay-certificate`) и подключение стилей и скрипта. Имя и почту в блоке можно подставить своим шаблоном или оставить пустыми — человек заполнит сам. У формы по ссылке — пустой объект |

```json
[
  {
    "id": "3f9a1b2c-4d5e-4f60-8a71-2b3c4d5e6f70",
    "orgId": "7b2a4c6e-1d3f-4e5a-9b8c-0d1e2f3a4b5c",
    "name": "Форма на странице мероприятия",
    "token": "00000000-0000-4000-8000-0000000000ab",
    "kind": "site",
    "formFields": ["name"],
    "allowedDomains": ["vruchay.ru"],
    "documentIds": ["9e8d7c6b-5a4f-4e3d-2c1b-0a9f8e7d6c5b"],
    "authMode": "email_code",
    "singleFilePerEmail": true,
    "dailyLimit": 500,
    "successMessage": "Спасибо! Документ отправлен на вашу почту",
    "showDownload": true,
    "sendEmail": true,
    "copyToEmail": null,
    "active": true,
    "prefillFromAccount": true,
    "allowEdit": true,
    "showShare": true,
    "showVerifyLink": false,
    "checkList": false,
    "requireAccount": false,
    "createdAt": "2026-08-20T09:20:00.000Z",
    "_count": { "requests": 143 },
    "connect": {
      "formUrl": null,
      "scriptUrl": "https://vruchay.ru/api/v1/tilda-js/00000000-0000-4000-8000-0000000000ab",
      "styleUrl": "https://vruchay.ru/api/v1/tilda-css/00000000-0000-4000-8000-0000000000ab",
      "embedHtml": {
        "9e8d7c6b-5a4f-4e3d-2c1b-0a9f8e7d6c5b": "<!-- Имя и почту подставьте своим шаблоном или оставьте пустыми — человек заполнит сам. -->\n<div data-vruchay-certificate\n     data-doc-id=\"9e8d7c6b-5a4f-4e3d-2c1b-0a9f8e7d6c5b\"\n     data-label=\"Получить документ\"\n     data-name=\"\"\n     data-email=\"\"></div>\n<link rel=\"stylesheet\" href=\"https://vruchay.ru/api/v1/tilda-css/00000000-0000-4000-8000-0000000000ab\">\n<script src=\"https://vruchay.ru/api/v1/tilda-js/00000000-0000-4000-8000-0000000000ab\"></script>"
      }
    }
  }
]
```

## Ошибки

| Код | Когда |
|---|---|
| 400 | `documentId` не UUID — «Invalid UUID» |
| 401 | Нет токена или сессии |

## Пример

```bash
curl "https://vruchay.ru/api/integrations/tilda?documentId=$DOCUMENT_ID" \
  -H "Authorization: Bearer $VRUCHAY_TOKEN"
```

---

<!-- endpoints/integrations/patch-integrations-tilda-id -->
> method: PATCH | path: /api/integrations/tilda/{id} | auth: token | roles: owner, admin | rate_limit: none

# PATCH /api/integrations/tilda/{id}

Меняет настройки интеграции. Право владельца и администратора — как и создание: настройка формы решает, кому и какие документы выдаются без входа в сервис.

Обновление частичное: меняется ровно то, что передано. Запрос `{"active": false}` выключает форму и не трогает остальные настройки — значения по умолчанию при правке не подставляются.

Вид формы (`kind`) не меняется: код на страницах сайта перестал бы работать без объяснений, поэтому поле в теле молча отбрасывается. Правила вида проверяются по сохранённому: форме по ссылке нельзя задать домены и больше одного материала, а `formFields` сверяется с полями её материала.

`token` и `orgId` не меняются, поэтому ссылка формы и код на страницах сайта после правки остаются прежними. Домены нормализуются, материалы проверяются на принадлежность организации — как при создании.

## Запрос

| Параметр | Тип | Обязателен | Описание |
|---|---|---|---|
| id | string (UUID) | да | Идентификатор интеграции |

Тело — те же поля, что в `POST /api/integrations/tilda`, кроме `kind`, все необязательные; ограничения те же, умолчаний нет. Пустой `allowedDomains` — ошибка. Неизвестные поля отбрасываются молча.

```json
{
  "name": "Форма на странице мероприятия",
  "allowedDomains": ["vruchay.ru", "edu.vruchay.ru"],
  "documentIds": ["9e8d7c6b-5a4f-4e3d-2c1b-0a9f8e7d6c5b"],
  "authMode": "none",
  "singleFilePerEmail": true,
  "dailyLimit": 1000,
  "successMessage": "Спасибо! Грамота отправлена на вашу почту",
  "showDownload": true,
  "sendEmail": true,
  "copyToEmail": "arhiv@vruchay.ru",
  "active": false
}
```

## Ответ

`200 OK` — интеграция после изменения; поля перечислены в `GET /api/integrations/tilda` (кроме `_count`), включая `connect`. Если изменились материалы, `connect.embedHtml` уже собран по новому списку.

```json
{
  "id": "3f9a1b2c-4d5e-4f60-8a71-2b3c4d5e6f70",
  "orgId": "7b2a4c6e-1d3f-4e5a-9b8c-0d1e2f3a4b5c",
  "name": "Форма на странице мероприятия",
  "token": "00000000-0000-4000-8000-0000000000ab",
  "kind": "site",
  "formFields": ["name"],
  "allowedDomains": ["vruchay.ru", "edu.vruchay.ru"],
  "documentIds": ["9e8d7c6b-5a4f-4e3d-2c1b-0a9f8e7d6c5b"],
  "authMode": "none",
  "singleFilePerEmail": true,
  "dailyLimit": 1000,
  "successMessage": "Спасибо! Грамота отправлена на вашу почту",
  "showDownload": true,
  "sendEmail": true,
  "copyToEmail": "arhiv@vruchay.ru",
  "active": false,
  "prefillFromAccount": true,
  "allowEdit": true,
  "showShare": true,
  "showVerifyLink": false,
  "checkList": false,
  "requireAccount": false,
  "createdAt": "2026-08-20T09:20:00.000Z",
  "connect": {
    "formUrl": null,
    "scriptUrl": "https://vruchay.ru/api/v1/tilda-js/00000000-0000-4000-8000-0000000000ab",
    "styleUrl": "https://vruchay.ru/api/v1/tilda-css/00000000-0000-4000-8000-0000000000ab",
    "embedHtml": {
      "9e8d7c6b-5a4f-4e3d-2c1b-0a9f8e7d6c5b": "<!-- Имя и почту подставьте своим шаблоном или оставьте пустыми — человек заполнит сам. -->\n<div data-vruchay-certificate\n     data-doc-id=\"9e8d7c6b-5a4f-4e3d-2c1b-0a9f8e7d6c5b\"\n     data-label=\"Получить документ\"\n     data-name=\"\"\n     data-email=\"\"></div>\n<link rel=\"stylesheet\" href=\"https://vruchay.ru/api/v1/tilda-css/00000000-0000-4000-8000-0000000000ab\">\n<script src=\"https://vruchay.ru/api/v1/tilda-js/00000000-0000-4000-8000-0000000000ab\"></script>"
    }
  }
}
```

## Ошибки

| Код | Когда |
|---|---|
| 400 | `id` не UUID: «Некорректный идентификатор» |
| 400 | `name` пустой: «Введите название»; `allowedDomains` пуст: «Укажите хотя бы один домен, иначе форма не будет работать»; `documentIds` пуст: «Выберите хотя бы один документ» |
| 400 | После нормализации доменов не осталось ничего: «Укажите домен страницы, на которой стоит форма» |
| 400 | Форма по ссылке: домены — «Форма по ссылке живёт на нашем адресе — домены ей не нужны»; не один материал — «Форма по ссылке выдаёт ровно один документ»; поле не из материала — «У документа нет полей: code» |
| 400 | Прочие нарушения границ — те же сообщения, что в `POST /api/integrations/tilda` |
| 401 | Нет токена или сессии |
| 403 | Роль `member`: «Недостаточно прав для этого действия» |
| 404 | Интеграции нет или она чужая: «Интеграция не найдена» |
| 404 | В `documentIds` попал чужой, удалённый или несуществующий материал: «Документ не найден» |

## Пример

```bash
curl -X PATCH "https://vruchay.ru/api/integrations/tilda/$INTEGRATION_ID" \
  -H "Authorization: Bearer $VRUCHAY_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name":"Форма на странице мероприятия","allowedDomains":["vruchay.ru"],"documentIds":["'"$DOCUMENT_ID"'"],"active":false}'
```

---

<!-- endpoints/integrations/post-integrations-tilda -->
> method: POST | path: /api/integrations/tilda | auth: token | roles: owner, admin | rate_limit: none

# POST /api/integrations/tilda

Заводит форму выдачи — интеграцию одного из двух видов (`kind`):

- `site` (по умолчанию) — форма на сайте организации: на указанных доменах она сможет запрашивать документы из указанного списка материалов;
- `link` — форма по ссылке: страница на нашем адресе `https://vruchay.ru/f/{token}`, для тех, у кого своего сайта нет. Доменов у неё нет, материал ровно один, а поля, которые она спрашивает, задаёт `formFields`.

Интеграция открывает выдачу документов посторонним людям, поэтому создавать её вправе только владелец и администратор. Вид после создания не меняется. В кабинете формы настраиваются в самом документе: шаг «Выпуск», вкладки «Форма на сайте» и «Форма по ссылке».

Токен интеграции сервис выдаёт сам — задать его нельзя. Подключать форму можно прямо по ответу: в `connect` лежат ссылка для формы по ссылке (`https://vruchay.ru/f/{token}`) и готовый HTML для вставки на страницу по каждому материалу формы на сайте — с адресами стилей `/api/v1/tilda-css/{token}` и скрипта `/api/v1/tilda-js/{token}`. Собирать их из токена самому не нужно.

Домены нормализуются при сохранении: убираются `http://`, `https://`, `www.`, путь после первой косой черты, регистр приводится к нижнему, повторы отбрасываются. Поддомены разрешённого домена считаются своими, то есть `vruchay.ru` открывает и `edu.vruchay.ru`. Материалы проверяются на принадлежность организации: чужой идентификатор в белый список не запишется.

## Форма по ссылке

Участник открывает ссылку, вводит поля из `formFields`, почту и согласие, при `authMode: "email_code"` — код из письма, и получает документ сразу двумя путями: кнопкой скачивания на странице и письмом. У формы по ссылке `showDownload` и `sendEmail` всегда `true` — присланное `false` сервер заменяет, а `authMode` по умолчанию `none`: ссылку открывают, чтобы получить документ сразу, от перебора её держат `dailyLimit` и `singleFilePerEmail`. Что видит страница, отдаёт `GET /api/v1/forms/{token}`; заявки идут теми же публичными адресами `v1/tilda/*`, что у формы на сайте, но принимаются только с нашей страницы — с чужого сайта форму по ссылке не встроить. Поля вне `formFields` отбрасываются, все поля из списка обязательны. Строка в таблице получателей — с `source: "link"`.

`formFields` — имена полей материала: колонки таблицы получателей и поля листа. Служебные поля (дата, код, номер, мероприятие, падежи) и постоянные поля материала сюда не впишешь — участник не должен напечатать на листе своё. `email` из списка убирается молча: почту форма спрашивает всегда. У формы на сайте `formFields` не применяется — поля задаёт разметка на странице.

Окно формы на сайте настраивают `prefillFromAccount`, `allowEdit`, `showVerifyLink` и `showShare` (таблица ниже). На страницу формы по ссылке они не влияют: подставлять там нечего, а ссылка на проверку в её окне успеха есть всегда.

## Как форма выдаёт документ

Каждая заявка выпускает такой же документ, как пакет из кабинета: с кодом проверки и QR на листе, регистрационным номером, сроком действия и отпечатком файла. Участник попадает в таблицу получателей материала неотмеченной строкой с `source: "site"`, а документ — в реестр с его именем и адресом.

Документ с формы считается в квоту организации наравне с выпуском. Когда квота исчерпана или срок плана вышел, форма перестаёт выдавать: участник видит «Выдача документов по этой форме сейчас недоступна — напишите организатору», заявка закрывается со статусом `rejected` и причиной в `error` (см. `GET /api/integrations/tilda/requests`), а в журнал действий пишется событие `issue.refused`. То же — если материал превратили в шаблон или стёрли его макет. Квота бронируется в момент приёма заявки (при `authMode: "email_code"` — в момент подтверждения кода), поэтому отказ участник получает сразу, а не после ожидания письма.

`singleFilePerEmail` — «один документ в руки»: повторная заявка того же адреса доставки или той же учётной записи площадки получает уже выданный документ, второй не выпускается. Выключено — каждая заявка выпускает новый документ и уходит своим письмом. Заявка запоминает настройку на момент подачи: включение однократности задним числом не делает прежние заявки ошибочными.

`checkList` — сверка со списком: заявка принимается, только если адрес доставки или адрес учётной записи площадки есть в таблице получателей материала — в колонке `email`, а если такой нет, в первой колонке, в имени которой есть `mail`. Сверяются только строки, загруженные в таблицу или добавленные через API; строки, которые завела сама форма, списком не считаются, иначе первая же заявка вписывала бы человека в список навсегда. Колонки с адресом нет — отказ всем. Проверка идёт до отправки кода, и участник видит «Этого адреса нет в списке участников. Проверьте адрес или напишите организатору».

`requireAccount` — заявка принимается, только если площадка передала адрес учётной записи (у Тильды в личном кабинете — поле `ma_email`), то есть форма стоит внутри личного кабинета и человек в него вошёл. Иначе участник видит «Войдите в личный кабинет, чтобы получить документ». Доказательством личности это не служит — данные приходят из браузера, — но отсекает заявки со страниц, куда форму скопировали.

`copyToEmail` — копия уходит по каждой заявке. Письмо участнику ставится с его строкой, поэтому переменные шаблона письма (`%name` и другие) подставляются и в копию.

Если письмо участнику поставить не удалось (рассылки писем нет в плане или срок плана вышел, почта не настроена, исчерпан суточный объём общего домена), заявка всё равно считается выполненной — документ выпущен, причина записана в `error`, а окно формы предлагает скачать документ даже при `showDownload: false`: иначе участнику его не получить.

## Запрос

| Поле | Тип | Обязательно | Описание |
|---|---|---|---|
| name | string | да | Название для кабинета, 1–100 символов после обрезки пробелов |
| kind | `site` \| `link` | нет | Вид формы; по умолчанию `site` |
| allowedDomains | string[] | для `site` | Домены страниц с формой, 1–20 штук, каждый 3–253 символа. У `link` — пусто или не передавать |
| documentIds | string[] (UUID) | да | Материалы организации, 1–50 штук; у `link` — ровно один |
| formFields | string[] | нет | Только для `link`: что спрашивает форма, до 30 имён полей материала; по умолчанию `["name"]` |
| authMode | `none` \| `email_code` | нет | Подтверждение адреса кодом из письма; по умолчанию `email_code` у формы на сайте и `none` у формы по ссылке |
| checkList | boolean | нет | Сверка со списком получателей (см. выше); по умолчанию `false` |
| requireAccount | boolean | нет | Только из личного кабинета площадки (см. выше); по умолчанию `false` |
| singleFilePerEmail | boolean | нет | Один документ в руки (см. выше); по умолчанию `true` |
| dailyLimit | number | нет | Предел заявок за сутки, 1–10 000; по умолчанию 500. Числовая строка принимается |
| successMessage | string | нет | Текст окна успеха, до 300 символов; по умолчанию «Спасибо! Документ отправлен на вашу почту», а при `sendEmail: false` — «Спасибо! Документ готов». Текст по умолчанию следует за `sendEmail` и при правке; свой текст не меняется |
| showDownload | boolean | нет | Кнопка скачивания в окне; по умолчанию `true`. Когда письмо не ушло, кнопка показывается и при `false`. У формы по ссылке всегда `true` |
| sendEmail | boolean | нет | Слать ли документ письмом; по умолчанию `true`. У формы по ссылке всегда `true` |
| copyToEmail | string | нет | Адрес для копии, до 254 символов; пустая строка означает «копию не слать» и сохраняется как `null` |
| active | boolean | нет | Принимать ли заявки; по умолчанию `true` |
| prefillFromAccount | boolean | нет | Подставлять имя и почту из личного кабинета площадки (`data-name`/`data-email`, `ma_name`/`ma_email`, `window.tildamembers`); по умолчанию `true` |
| allowEdit | boolean | нет | Можно ли поправить подставленные имя и почту в окне «проверьте данные»; по умолчанию `true`. `false` делает поля окна только для чтения — это удобство, а не защита: заявку шлёт браузер посетителя |
| showVerifyLink | boolean | нет | Ссылка «Проверить подлинность документа» в окне успеха; по умолчанию `false`. Нужна включённая проверка подлинности у материала |
| showShare | boolean | нет | Кнопки «поделиться» ссылкой проверки в окне успеха; по умолчанию `true`, но видны только при `showVerifyLink: true` |

```json
{
  "name": "Форма на странице мероприятия",
  "allowedDomains": ["https://www.vruchay.ru/events"],
  "documentIds": ["9e8d7c6b-5a4f-4e3d-2c1b-0a9f8e7d6c5b"],
  "authMode": "email_code",
  "singleFilePerEmail": true,
  "dailyLimit": 500,
  "successMessage": "Спасибо! Грамота отправлена на вашу почту",
  "showDownload": true,
  "sendEmail": true,
  "copyToEmail": "",
  "active": true
}
```

## Ответ

`201 Created` — созданная интеграция; поля перечислены в `GET /api/integrations/tilda` (кроме `_count`), включая готовое подключение `connect`. В примере видно, во что превратился домен из запроса.

```json
{
  "id": "3f9a1b2c-4d5e-4f60-8a71-2b3c4d5e6f70",
  "orgId": "7b2a4c6e-1d3f-4e5a-9b8c-0d1e2f3a4b5c",
  "name": "Форма на странице мероприятия",
  "token": "00000000-0000-4000-8000-0000000000ab",
  "kind": "site",
  "formFields": ["name"],
  "allowedDomains": ["vruchay.ru"],
  "documentIds": ["9e8d7c6b-5a4f-4e3d-2c1b-0a9f8e7d6c5b"],
  "authMode": "email_code",
  "singleFilePerEmail": true,
  "dailyLimit": 500,
  "successMessage": "Спасибо! Грамота отправлена на вашу почту",
  "showDownload": true,
  "sendEmail": true,
  "copyToEmail": null,
  "active": true,
  "prefillFromAccount": true,
  "allowEdit": true,
  "showShare": true,
  "showVerifyLink": false,
  "checkList": false,
  "requireAccount": false,
  "createdAt": "2026-08-20T09:20:00.000Z",
  "connect": {
    "formUrl": null,
    "scriptUrl": "https://vruchay.ru/api/v1/tilda-js/00000000-0000-4000-8000-0000000000ab",
    "styleUrl": "https://vruchay.ru/api/v1/tilda-css/00000000-0000-4000-8000-0000000000ab",
    "embedHtml": {
      "9e8d7c6b-5a4f-4e3d-2c1b-0a9f8e7d6c5b": "<!-- Имя и почту подставьте своим шаблоном или оставьте пустыми — человек заполнит сам. -->\n<div data-vruchay-certificate\n     data-doc-id=\"9e8d7c6b-5a4f-4e3d-2c1b-0a9f8e7d6c5b\"\n     data-label=\"Получить документ\"\n     data-name=\"\"\n     data-email=\"\"></div>\n<link rel=\"stylesheet\" href=\"https://vruchay.ru/api/v1/tilda-css/00000000-0000-4000-8000-0000000000ab\">\n<script src=\"https://vruchay.ru/api/v1/tilda-js/00000000-0000-4000-8000-0000000000ab\"></script>"
    }
  }
}
```

## Ошибки

| Код | Когда |
|---|---|
| 400 | `name` пуст: «Введите название»; длиннее 100: «Too big: expected string to have <=100 characters» |
| 400 | `allowedDomains` у `site` пуст: «Укажите хотя бы один домен, иначе форма не будет работать»; больше 20; элемент короче 3 символов: «Too small: expected string to have >=3 characters» |
| 400 | У `link` есть домены: «Форма по ссылке живёт на нашем адресе — домены ей не нужны»; материалов не один: «Форма по ссылке выдаёт ровно один документ» |
| 400 | `formFields` у `link`: пустой — «Выберите хотя бы одно поле, которое спрашивает форма»; поле не из материала — «У документа нет полей: code»; больше 30 — «Не больше 30 полей в форме» |
| 400 | После нормализации доменов не осталось ничего: «Укажите домен страницы, на которой стоит форма» |
| 400 | `documentIds` пуст: «Выберите хотя бы один документ»; больше 50; элемент не UUID: «Invalid UUID» |
| 400 | `dailyLimit` вне 1–10 000: «Too small: expected number to be >=1» / «Too big: expected number to be <=10000» |
| 400 | `copyToEmail` не адрес и не пустая строка: «Invalid email address» |
| 400 | `authMode` не из списка: «Invalid option: expected one of "none"\|"email_code"»; `checkList` или `requireAccount` не булево: «Invalid input: expected boolean, received string» |
| 401 | Нет токена или сессии |
| 403 | Роль `member`: «Недостаточно прав для этого действия» |
| 403 | Возможности `tilda` нет в плане: ««Приём заявок с сайта» не входит в ваш план. Напишите нам — добавим.» Срок плана вышел: «Срок плана «…» закончился, поэтому раздел «Приём заявок с сайта» сейчас недоступен. Уже выданные документы остаются действительными. Напишите нам — обсудим продление.» См. [Аутентификация](../../authentication.md) |
| 404 | В `documentIds` попал чужой, удалённый или несуществующий материал: «Документ не найден» |

## Пример

```bash
curl -X POST "https://vruchay.ru/api/integrations/tilda" \
  -H "Authorization: Bearer $VRUCHAY_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name":"Форма на странице мероприятия","allowedDomains":["vruchay.ru"],"documentIds":["'"$DOCUMENT_ID"'"]}'
```

У формы по ссылке в ответе `connect.formUrl` — готовая ссылка для участников (`https://vruchay.ru/f/{token}`), а `connect.embedHtml` пуст:

```bash
curl -X POST "https://vruchay.ru/api/integrations/tilda" \
  -H "Authorization: Bearer $VRUCHAY_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name":"Олимпиада, ссылка","kind":"link","documentIds":["'"$DOCUMENT_ID"'"],"formFields":["name","grade"]}'
```

---

<!-- endpoints/layout/get-documents-id-fields -->
> method: GET | path: /api/documents/{id}/fields | auth: token | roles: any | rate_limit: none

# GET /api/documents/{id}/fields

Какие поля есть у документа и где они стоят: чем можно заполнить `%поле` на листе,
какие поля уже стоят на каждом листе, какие из них ничем не заполнятся и что
принимает выдача по одному. Нужен перед вёрсткой («какие подстановки мне доступны»)
и перед выдачей по API («какие ключи слать в `data`»).

Поле на листе заполняется из одного из трёх источников:

- **колонки** таблицы получателей — своё значение у каждого человека;
- **постоянные поля** (`constants`) — одно значение на все документы материала:
  руководитель, номер приказа. Меняются через `PATCH /api/documents/{id}`;
- **служебные поля** — их сервис заполняет сам: дата выдачи, номер, код проверки,
  мероприятие (из карточки документа), падежи и латиница ФИО.

Колонка с тем же именем, что у постоянного или служебного поля, сильнее: значение
из таблицы перекрывает догадку сервиса.

## Запрос

| Параметр | Тип | Обязателен | Описание |
|---|---|---|---|
| `id` | UUID | да | идентификатор документа |

Тела нет.

## Ответ

`200 OK`

| Поле | Тип | Описание |
|---|---|---|
| `columns` | object[] | колонки таблицы получателей по порядку: `id`, `name` (на листе — `%name`), `title` — заголовок для человека или `null` |
| `constants` | object[] | постоянные поля: `key`, `title`, `value` |
| `event` | object | мероприятие из карточки документа — значения служебных полей `event`, `event_date`, `event_place`, `hours`; незаполненное — пустая строка |
| `system` | object[] | все служебные поля: `name`, `title`, `example` — пример значения или пояснение |
| `sheets` | object[] | листы по порядку: `sheetId`, `position`, `fields` — имена полей, которые стоят на листе (в тексте, в шаблоне QR и в адресе ссылки), без повторов |
| `unknown` | string[] | поля с листов, которых нет ни среди колонок, ни среди постоянных, ни среди служебных: на печати на их месте будет пусто. Заведите колонку или поправьте имя на листе |
| `issue` | object[] | поля, которые принимает [выдача по одному](../generation/post-documents-id-issue.md) в `data` и форма выдачи: `name`, `title`. Это колонки и поля с листов без служебных и постоянных; `name` всегда первым, `email` всегда есть |

Лист, макет которого не читается схемой, даёт пустой `fields`.

```json
{
  "columns": [
    { "id": "c1d2e3f4-a5b6-4c7d-8e9f-0a1b2c3d4e5f", "name": "name", "title": null },
    { "id": "c2d3e4f5-a6b7-4c8d-9e0f-1a2b3c4d5e6f", "name": "email", "title": null },
    { "id": "c3d4e5f6-a7b8-4c9d-8e0f-2a3b4c5d6e7f", "name": "place", "title": "Место" }
  ],
  "constants": [
    { "key": "signer_role", "title": "Должность подписанта", "value": "Директор" },
    { "key": "signer_name", "title": "Подписант", "value": "А. В. Петров" }
  ],
  "event": {
    "event": "Конкурс «Мастер года»",
    "event_date": "17–19 июня 2026 года",
    "event_place": "г. Москва",
    "hours": ""
  },
  "system": [
    { "name": "date", "title": "Дата выдачи", "example": "04.08.2026" },
    { "name": "code", "title": "Проверочный код", "example": "для сверки с QR" },
    { "name": "name_dat", "title": "ФИО в дательном падеже", "example": "Вручается Иванову Ивану" },
    { "name": "place_word", "title": "Место словом", "example": "первое, второе — из колонки «Место»" }
  ],
  "sheets": [
    {
      "sheetId": "b2c3d4e5-f6a7-4b8c-9d0e-1f2a3b4c5d6e",
      "position": 0,
      "fields": ["name_dat", "team", "place_word", "event", "signer_role", "signer_name", "date", "org"]
    }
  ],
  "unknown": ["team"],
  "issue": [
    { "name": "name", "title": "Фамилия, имя, отчество" },
    { "name": "email", "title": "Почта" },
    { "name": "place", "title": "Место" },
    { "name": "team", "title": "team" }
  ]
}
```

`system` в примере сокращён: в ответе все служебные поля — тот же список, что
`variables` в [справочнике вёрстки](get-layout-catalog.md).

## Ошибки

| Код | Когда |
|---|---|
| 400 | «Некорректный идентификатор» — `id` не UUID |
| 401 | нет токена или сессии |
| 404 | «Документ не найден» — нет такого, чужой или в корзине |

## Пример

```bash
curl "https://vruchay.ru/api/documents/$DOCUMENT_ID/fields" \
  -H "Authorization: Bearer $VRUCHAY_TOKEN"
```

---

<!-- endpoints/layout/get-layout-catalog -->
> method: GET | path: /api/layout/catalog | auth: token | roles: any | rate_limit: none

# GET /api/layout/catalog

Всё, что программе нужно знать до первого блока, одним ответом: единицы, форматы
листа, пределы, значения по умолчанию у каждого вида блока, шрифты, готовые
раскладки и служебные поля для подстановки. Справочник один на всех и от
организации не зависит; собирается при старте сервера.

Значения по умолчанию не переписаны руками: сервер получает их разбором
минимального блока той же схемой, по которой принимает макет, — разойтись
со схемой они не могут. Как из этого собрать лист — в
[руководстве по вёрстке](../../layout-guide.md), все поля блоков —
в [справочнике макета](../../reference/layout.md).

## Запрос

Параметров и тела нет.

## Ответ

`200 OK` — объект с семью разделами.

| Поле | Тип | Описание |
|---|---|---|
| `units` | object | единицы макета словами: `position` — мм от левого верхнего угла листа (x вправо, y вниз), `size` — мм, `fontSize` — pt (1 pt = 0,3528 мм), `rotation` — градусы по часовой вокруг центра блока, `color` — `#RRGGBB` |
| `page.defaultMm` | `{ width, height }` | размер нового листа: 297×210 |
| `page.minMm`, `page.maxMm` | number | пределы стороны листа: 50 и 600 мм |
| `page.bleedMm` | number | обрез типографии — 3 мм с каждой стороны |
| `page.safeMarginMm` | number | безопасное поле — 5 мм; текст и QR ближе к краю получают замечание `outside_safe_area` |
| `page.formats` | object[] | форматы для `format` в `POST /api/documents`: `id`, `label`, размеры `portrait` и `landscape` (`{ widthMm, heightMm }`). Идентификаторы: `a3`, `a4`, `a5`, `a6`, `letter`, `legal`, `square`, `wide` |
| `limits.elementsPerSheet` | number | блоков на листе — 500 |
| `limits.textLength` | number | символов текста в блоке — 5000 |
| `limits.coordinateMm` | number | 5000 мм: `x` и `y` — от −5000 до 5000, `w` и `h` — больше нуля и до 5000 |
| `limits.fontSizePt` | number | наибольший кегль — 500 pt |
| `limits.richTextNodes` | number | узлов в дереве текста одного блока — 1000 |
| `limits.marksPerRun` | number | оформлений (жирный, цвет, кегль…) на один кусок текста — 8 |
| `elements.text`, `.image`, `.qr`, `.link`, `.shape` | object | по каждому виду блока: `size` — размер нового блока `{ w, h }` в мм, `props` — значения по умолчанию. Из `props` убраны поля, у которых умолчания нет: дерево текста `doc`, `fileId` картинки, `url` ссылки |
| `elements.shape.sizes` | object | размер новой фигуры по `props.kind`: `rect` 60×40, `ellipse` 40×40, `line` 80×2 |
| `elements.common` | object | общие поля любого блока по умолчанию: `rotation` 0, `z` 0, `opacity` 1, `locked` и `hidden` — `false`, `groupId` и `name` — `null` |
| `fonts` | object[] | гарнитуры по группам: `id` (`sans`, `serif`, `display`, `script`), `title`, `fonts[]` — `family` (это значение ставится в `fontFamily`), `bold` и `italic` — есть ли у гарнитуры жирное и курсивное начертание, `hint` — подсказка («как Arial») |
| `presets.kinds` | object[] | виды готовых раскладок: `id` (для `preset.kind`), `title`, `hint`, `group` |
| `presets.styles` | object[] | наборы оформления: `id` (для `preset.style`), `title`, `hint` |
| `variables` | object[] | служебные поля, которые сервис заполняет сам: `name` (на листе — `%name`), `title`, `hint` — пример значения или пояснение. Дата выдачи и её записи, номер, код проверки, регистрационный номер, организация, срок действия, мероприятие, падежи и латиница ФИО, место и часы словами |

Шрифт не из `fonts` печать подменит системным — проверка листа скажет об этом
замечанием `unknown_font`. Жирное или курсивное начертание у гарнитуры, где
его нет (`bold: false`, `italic: false`), браузер нарисует сам — грубее, чем
настоящим начертанием.

Сокращённый пример (полный ответ — около 12 КБ):

```json
{
  "units": {
    "position": "мм от левого верхнего угла листа (x вправо, y вниз)",
    "size": "мм",
    "fontSize": "pt; 1 pt = 0.3528 мм",
    "rotation": "градусы по часовой, вокруг центра блока",
    "color": "#RRGGBB"
  },
  "page": {
    "defaultMm": { "width": 297, "height": 210 },
    "minMm": 50,
    "maxMm": 600,
    "bleedMm": 3,
    "safeMarginMm": 5,
    "formats": [
      { "id": "a4", "label": "A4", "portrait": { "widthMm": 210, "heightMm": 297 }, "landscape": { "widthMm": 297, "heightMm": 210 } },
      { "id": "wide", "label": "Экран 16:9", "portrait": { "widthMm": 167, "heightMm": 297 }, "landscape": { "widthMm": 297, "heightMm": 167 } }
    ]
  },
  "limits": { "elementsPerSheet": 500, "textLength": 5000, "coordinateMm": 5000, "fontSizePt": 500, "richTextNodes": 1000, "marksPerRun": 8 },
  "elements": {
    "text": {
      "size": { "w": 120, "h": 20 },
      "props": {
        "fontFamily": "PT Sans", "fontSize": 16, "color": "#000000",
        "align": "center", "verticalAlign": "middle", "lineHeight": 1.2, "letterSpacing": 0,
        "bold": false, "italic": false, "underline": false, "uppercase": false,
        "strokeWidth": 0, "strokeColor": "#ffffff", "autoFit": false, "padding": 0,
        "background": null, "borderWidth": 0, "borderColor": "#000000", "shadow": false
      }
    },
    "image": { "size": { "w": 50, "h": 50 }, "props": {} },
    "qr": {
      "size": { "w": 30, "h": 30 },
      "props": { "template": "", "color": "#000000", "background": "#ffffff", "dotStyle": "square", "cornerStyle": "square", "cornerColor": null, "margin": 0, "level": "M" }
    },
    "link": { "size": { "w": 80, "h": 10 }, "props": {} },
    "shape": {
      "size": { "w": 60, "h": 40 },
      "props": { "kind": "rect", "fill": null, "stroke": "#000000", "strokeWidth": 0.5, "radius": 0, "dash": 0 },
      "sizes": { "rect": { "w": 60, "h": 40 }, "ellipse": { "w": 40, "h": 40 }, "line": { "w": 80, "h": 2 } }
    },
    "common": { "rotation": 0, "z": 0, "opacity": 1, "locked": false, "hidden": false, "groupId": null, "name": null }
  },
  "fonts": [
    {
      "id": "serif",
      "title": "С засечками",
      "fonts": [
        { "family": "PT Serif", "bold": true, "italic": true },
        { "family": "Tinos", "hint": "как Times New Roman", "bold": true, "italic": true }
      ]
    }
  ],
  "presets": {
    "kinds": [ { "id": "award", "title": "Грамота за место", "hint": "Соревнования, олимпиады, конкурсы", "group": "За результат" } ],
    "styles": [ { "id": "classic", "title": "Классический", "hint": "Засечки. Для наградных бланков с рамкой" } ]
  },
  "variables": [
    { "name": "date", "title": "Дата выдачи", "hint": "04.08.2026" },
    { "name": "name_dat", "title": "ФИО в дательном падеже", "hint": "Вручается Иванову Ивану" }
  ]
}
```

## Ошибки

| Код | Когда |
|---|---|
| 401 | нет токена или сессии |

## Пример

```bash
curl "https://vruchay.ru/api/layout/catalog" \
  -H "Authorization: Bearer $VRUCHAY_TOKEN"
```

---

<!-- endpoints/layout/patch-documents-id-sheets-sheetId-elements -->
> method: PATCH | path: /api/documents/{id}/sheets/{sheetId}/elements | auth: token | roles: any | rate_limit: none

# PATCH /api/documents/{id}/sheets/{sheetId}/elements

Добавляет, меняет и убирает блоки листа одним запросом — без пересылки всего макета.
Чтобы подвинуть один блок, не нужно присылать остальные четыреста, и правка не
затрёт соседнюю: сервер применяет её к тому листу, что лежит в базе прямо сейчас.
Результат проходит ту же схему, что и [сохранение листа целиком](../documents/patch-documents-id-sheets-sheetId.md),
поэтому лист получается ровно таким, какой принял бы редактор. В ответе — лист
и замечания проверки вёрстки, как у [`POST …/check`](post-documents-id-sheets-sheetId-check.md).

Поля блоков — в [справочнике макета](../../reference/layout.md), как верстать —
в [руководстве по вёрстке](../../layout-guide.md).

## Как применяется правка

Операции выполняются по порядку: **сначала `remove`, потом `update`, потом `add`**.
Поэтому «заменить блок» укладывается в один запрос: убрать старый id и добавить
новый блок с тем же id.

**`add`** — новые блоки. Можно присылать неполные:

- без `id` блок получит случайный UUID;
- без `w`/`h` — размер по умолчанию своего вида: текст 120×20 мм, картинка 50×50,
  QR 30×30, ссылка 80×10, фигура 60×40 (`kind: "ellipse"` — 40×40, `"line"` — 80×2);
- без `x`/`y` — встанет по центру листа (координаты округляются до 0,1 мм);
- без `z` — поверх всех остальных; несколько новых блоков без `z` ложатся друг
  на друга в порядке массива;
- без `props` — `{}`, дальше схема подставит значения по умолчанию.

Текст можно прислать строкой: `"props": {"text": "Награждается %name_dat"}` —
схема превратит её в дерево `props.doc`. Новые блоки встают в конец массива листа.

**`update`** — `[{ "id": "…", …поля }]`, частичная правка существующих блоков:

- верхние поля (`x`, `y`, `w`, `h`, `rotation`, `z`, `hidden`, `name`…) заменяются;
- `props` сливается на один уровень: `{"props": {"fontSize": 40}}` меняет кегль
  и не трогает остальное;
- новый `props.text` без `props.doc` заменяет текст блока целиком;
- `type` правкой не меняется — только убрать и добавить; `id` — ключ поиска, его
  не переименовать;
- место блока в массиве не меняется.

**`remove`** — id блоков, которые убрать.

Пометка `locked` защищает блок только в редакторе: правка по API её не проверяет.

## Одновременные правки и `baseVersion`

Без `baseVersion` сервер читает лист, применяет правку и записывает её только
поверх той версии, которую прочитал. Если между чтением и записью лист успели
поменять (редактор, другой агент), сервер читает его заново и применяет правку
к свежему листу — до четырёх попыток, потом `409`. Соседняя правка не теряется.

С `baseVersion` строже: лист должен быть ровно той версии, которую вы видели
(`sheets[].version` в `GET /api/documents/{id}` или `sheet.version` в прошлом
ответе), иначе `409` без попытки применить. Это нужно, когда правка рассчитана
на конкретное состояние листа — например, «сдвинуть всё, что ниже заголовка».

## Картинки

`props.fileId` нового блока `image` (и нового `fileId` в правке) — только
картинка, загруженная для блоков этой организации:
[`POST /api/documents/{id}/assets`](../documents/post-documents-id-assets.md)
или [`…/assets/from-url`](../documents/post-documents-id-assets-from-url.md).
Фон листа, выданный документ и чужой файл — `404` «Картинка не найдена».

## Запрос

| Параметр | Тип | Обязателен | Описание |
|---|---|---|---|
| `id` | UUID | да | идентификатор документа |
| `sheetId` | UUID | да | идентификатор листа этого документа |

Тело — хотя бы одна из трёх операций:

| Поле | Тип | Обязательно | Описание |
|---|---|---|---|
| `add` | object[] | нет | до 500 новых блоков |
| `update` | object[] | нет | до 500 правок; у каждой обязателен `id` (строка 1–100 символов) |
| `remove` | string[] | нет | до 500 id блоков, каждый 1–100 символов |
| `baseVersion` | integer ≥ 0 | нет | версия листа, к которой сделана правка; не совпала — `409` |

```json
{
  "baseVersion": 5,
  "remove": ["old-signature"],
  "update": [
    { "id": "title", "y": 30, "props": { "fontSize": 40, "color": "#8B2020" } },
    { "id": "intro", "props": { "text": "Награждается" } }
  ],
  "add": [
    {
      "id": "name",
      "type": "text",
      "x": 30, "y": 80, "w": 237, "h": 24,
      "props": { "text": "%name_dat", "fontFamily": "Playfair Display", "fontSize": 36, "autoFit": true }
    },
    { "type": "qr", "x": 252, "y": 165, "w": 25, "h": 25 }
  ]
}
```

## Ответ

`200 OK`

| Поле | Тип | Описание |
|---|---|---|
| `sheet` | object | лист после правки: `id`, `documentId`, `position`, `backgroundFileId`, `layout` (весь макет после нормализации схемой), `schemaVersion` (3), `version` — новый номер правки |
| `problems` | object[] | замечания проверки вёрстки по уже сохранённому листу: `code`, `severity`, `elementId`, `message`. Коды и их смысл — в [`POST …/check`](post-documents-id-sheets-sheetId-check.md). Пустой массив — замечаний нет |

Замечания правку не отменяют: лист уже сохранён. Исправьте их следующей правкой.

```json
{
  "sheet": {
    "id": "b2c3d4e5-f6a7-4b8c-9d0e-1f2a3b4c5d6e",
    "documentId": "6f1c3b2a-9d4e-4f5a-8b6c-7d8e9f0a1b2c",
    "position": 0,
    "backgroundFileId": "d4e5f6a7-b8c9-4d0e-9f1a-2b3c4d5e6f70",
    "layout": [
      { "id": "title", "type": "text", "x": 20, "y": 30, "w": 257, "h": 25, "…": "…" },
      { "id": "intro", "type": "text", "…": "…" },
      { "id": "name", "type": "text", "x": 30, "y": 80, "w": 237, "h": 24, "z": 3, "…": "…" },
      { "id": "0b7e4c1a-2f3d-4e5a-9b6c-7d8e9f0a1b2c", "type": "qr", "x": 252, "y": 165, "w": 25, "h": 25, "z": 4, "…": "…" }
    ],
    "schemaVersion": 3,
    "version": 6
  },
  "problems": [
    {
      "code": "text_overflow",
      "severity": "error",
      "elementId": "name",
      "message": "Текст «Константинопольскому-Задунайскому Александру Вячеславовичу» не помещается в блок 237×24 мм: даже с автоподбором кегля не влезает: увеличьте блок или уменьшите текст."
    }
  ]
}
```

## Ошибки

| Код | Когда |
|---|---|
| 400 | «Некорректный идентификатор» — `id` или `sheetId` не UUID |
| 400 | «Пустая правка: передайте add, update или remove»; больше 500 элементов в операции; у правки нет `id`; `baseVersion` не целое или меньше нуля — сообщения Zod в `errors[]` |
| 400 | «Нет блока с id «X»» — `update` или `remove` называет блок, которого на листе нет |
| 400 | «Блок с id «X» уже есть на листе» — `add` с занятым id (уберите старый в `remove` того же запроса) |
| 400 | «Вид блока «X» правкой не меняется — уберите его и добавьте новый» — `update` с другим `type` |
| 400 | «На листе есть блоки с одинаковым id — сохраните лист целиком, чтобы их развести» — старый лист с повторами id; правка по id на нём неоднозначна |
| 400 | Лист после правки не прошёл схему. `message` — до трёх причин вида `name.props.fontSize: Too big: expected number to be <=500`, `errors[]` — все: `field` — путь от id блока (`name.props.fontSize`, а не номер в массиве), `message` — причина. Без подробностей — «Лист не прошёл проверку схемы» |
| 401 | нет токена или сессии |
| 404 | «Документ не найден» — нет такого, чужой или в корзине |
| 404 | «Лист не найден» — лист не из этого документа |
| 404 | «Картинка не найдена» — в новом блоке `image` `fileId`, которого нет среди картинок организации |
| 409 | «Лист изменился (версия N, а правка сделана к версии M). Перечитайте лист и повторите правку.» — `baseVersion` не совпал; в теле `version` с текущим номером: `{"message": "…", "version": 7}` |
| 409 | «Лист правят одновременно слишком часто — повторите запрос» — четыре попытки подряд лист менялся между чтением и записью |
| 413 | тело больше 1 МБ |

## Пример

```bash
curl -X PATCH "https://vruchay.ru/api/documents/$DOCUMENT_ID/sheets/$SHEET_ID/elements" \
  -H "Authorization: Bearer $VRUCHAY_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"update":[{"id":"title","props":{"fontSize":40}}],"add":[{"type":"text","y":120,"props":{"text":"%event"}}]}'
```

---

<!-- endpoints/layout/post-documents-id-sheets-sheetId-check -->
> method: POST | path: /api/documents/{id}/sheets/{sheetId}/check | auth: token | roles: any | rate_limit: none

# POST /api/documents/{id}/sheets/{sheetId}/check

Проверка листа глазами вёрстки — для программы, которая верстает вслепую. Человек
в редакторе видит, что блок уехал за край или фамилия не влезла; агент по API этого
не видит, поэтому всё, что заметил бы глаз, здесь сказано словами: какой блок, что
с ним и как поправить. Ничего не сохраняет и ничего не печатает.

Проверяется сохранённый лист или черновик из тела запроса (`layout`) — так можно
проверить правку до сохранения. Те же замечания приходят в ответ на
[правку по частям](patch-documents-id-sheets-sheetId-elements.md) и
[готовую раскладку](post-documents-id-sheets-sheetId-preset.md). Как лист выглядит
на самом деле, показывает [превью](post-documents-id-sheets-sheetId-preview.md).

## На чём примеряется текст

Текст меряется по метрикам шрифтов, без браузера. Блоки без полей — один раз как
есть. Блоки с полями — на двух наборах данных, и замечание приходит по худшему:

1. **Образец**: `name` — «Иванов Иван», `email` — «ivanov@example.ru», `place` — «1»
   и производные поля от них (`name_dat` — «Иванову Ивану», `place_word` — «первое»);
   у остальных колонок образца нет — на этом наборе они пустые, а блок, в котором
   после подстановки не осталось текста, не меряется.
2. **Худшая строка таблицы**: по каждой колонке — самое длинное значение среди
   первых 10 000 строк получателей. Длинное ФИО и длинное название команды
   у разных людей всё равно должны влезать каждое в свой блок, поэтому они
   примеряются вместе. Таблица пустая — этого набора нет.

В обоих наборах служебные поля заполнены как при выпуске: дата — сегодняшняя,
номер — 1, `total` — число строк, мероприятие и постоянные поля — из документа,
`org` — название организации. Код проверки — образец `WWWW-WWWW-WWWW` (самая
широкая буква на всю длину настоящего кода), регистрационный номер — `1000/2026`.

## Замечания

`severity: "error"` — так печатать нельзя: блок не напечатается, текст обрежется
или на месте поля будет пусто. `"warning"` — напечатается, но, скорее всего,
не так, как задумано.

| `code` | `severity` | Когда |
|---|---|---|
| `duplicate_id` | error | два блока с одним `id`: правка по id не поймёт, какой из них имелся в виду |
| `off_page` | error | блок целиком за пределами листа (с учётом поворота) и не напечатается |
| `partly_off_page` | warning | текст, QR или ссылка частью выходит за край листа — часть срежется. Фигуры и картинки под обрез — обычное дело (рамка, подложка), о них замечаний нет |
| `outside_safe_area` | warning | текст или QR ближе 5 мм к краю листа: принтер может не пропечатать, типография — срезать. Лучше отступить на 10–15 мм |
| `text_overflow` | error | текст не помещается в блок — у статичного текста как есть, у текста с полями на худшем из наборов данных. В сообщении — сам текст (до 60 символов) и подсказка: увеличить блок, уменьшить кегль или включить `props.autoFit` |
| `unknown_field` | error | `%поле` на листе не найдено среди колонок, постоянных и служебных полей — на печати будет пусто. По замечанию на каждый блок, где оно стоит. Список полей — [`GET /api/documents/{id}/fields`](get-documents-id-fields.md) |
| `unknown_font` | warning | гарнитуры нет в наборе (`fonts` в [справочнике](get-layout-catalog.md)) — при печати её заменит системная. Проверяется и шрифт блока, и шрифты внутри текста |
| `missing_image` | error | у блока `image` нет картинки: `fileId` не среди картинок организации или файл удалён. Без неё лист не напечатается |
| `small_qr` | warning | QR меньше 15 мм по короткой стороне — телефон может не прочитать его с листа; лучше 20–30 мм |
| `qr_not_square` | warning | у QR `w` и `h` отличаются больше чем на 0,5 мм: QR рисуется квадратом по меньшей стороне, лишнее место останется пустым |
| `qr_hidden_without_verify` | warning | QR с пустым `props.template` ведёт на страницу проверки, а проверка у документа выключена (`verifyEnabled: false`) — такой QR не напечатается |
| `verify_without_qr` | warning | проверка подлинности включена (`verifyEnabled: true`), а QR с пустым `props.template` нет ни на одном листе документа — в реестре документ есть, а получателю проверить его нечем. QR на обороте засчитывается, скрытый QR и QR со своим `template` — нет. Готовая раскладка при включённой проверке ставит QR сама. `elementId` — `null`: замечание про документ, а не про блок |
| `empty_text` | warning | в текстовом блоке нет ни текста, ни полей |
| `overlap` | warning | блоки накрывают друг друга там, где это почти всегда ошибка: QR на тексте или на другом QR (пересечение больше 1 мм²), текст на тексте больше чем на пятую часть меньшего блока. Текст поверх рамки и печать поверх подписи — не замечание. `elementId` — тот блок пары, что стоит в макете позже |
| `heavy_images` | warning | фон и картинки листа вместе тяжелее 4 МБ. Картинки ложатся в PDF почти без сжатия, так что PDF каждого участника выйдет не легче, и почта может не принять его вложением — тогда письмо уйдёт с кнопкой «Открыть документ», без файла. `elementId` — самая тяжёлая картинка или `null`, если тяжелее всего фон. Сохраните картинки в JPEG или уменьшите: листу A4 хватает 3500 пикселей по длинной стороне |

Скрытые блоки (`hidden: true`) не проверяются — кроме повтора `id`.

## Запрос

| Параметр | Тип | Обязателен | Описание |
|---|---|---|---|
| `id` | UUID | да | идентификатор документа |
| `sheetId` | UUID | да | идентификатор листа этого документа |

Тело необязательно:

| Поле | Тип | Обязательно | Описание |
|---|---|---|---|
| `layout` | Element[] | нет | черновик листа — весь массив блоков, как в [сохранении листа](../documents/patch-documents-id-sheets-sheetId.md). Без поля проверяется сохранённый лист |

```json
{}
```

## Ответ

`200 OK` — проверка ничего не создаёт.

| Поле | Тип | Описание |
|---|---|---|
| `version` | integer | версия **сохранённого** листа — и тогда, когда проверялся черновик |
| `problems` | object[] | замечания; пустой массив — замечаний нет |
| `problems[].code` | string | код из таблицы выше |
| `problems[].severity` | `error` \| `warning` | |
| `problems[].elementId` | string \| null | блок, к которому относится замечание |
| `problems[].message` | string | что не так и как поправить, по-русски |

```json
{
  "version": 6,
  "problems": [
    {
      "code": "unknown_field",
      "severity": "error",
      "elementId": "team",
      "message": "Поле «team» не найдено среди колонок, постоянных и служебных полей документа — на листе останется пусто. Заведите колонку «team» или поправьте имя (список полей — GET /documents/{id}/fields)."
    },
    {
      "code": "outside_safe_area",
      "severity": "warning",
      "elementId": "qr",
      "message": "Блок ближе 5 мм к краю листа: принтер может не пропечатать, а типография — срезать. Отступите от краёв хотя бы на 5 мм, лучше на 10–15."
    },
    {
      "code": "small_qr",
      "severity": "warning",
      "elementId": "qr",
      "message": "QR 12×12 мм — телефон может не прочитать его с листа. Нужно не меньше 15×15 мм, лучше 20–30."
    }
  ]
}
```

## Ошибки

| Код | Когда |
|---|---|
| 400 | «Некорректный идентификатор» — `id` или `sheetId` не UUID |
| 400 | черновик `layout` не прошёл схему макета — сообщения Zod; путь в `errors[].field` — по номеру блока в массиве (`layout.3.props.fontSize`) |
| 401 | нет токена или сессии |
| 404 | «Документ не найден» — нет такого, чужой или в корзине |
| 404 | «Лист не найден» — лист не из этого документа |
| 413 | тело больше 1 МБ |

## Пример

```bash
curl -X POST "https://vruchay.ru/api/documents/$DOCUMENT_ID/sheets/$SHEET_ID/check" \
  -H "Authorization: Bearer $VRUCHAY_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{}'
```

Проверить черновик до сохранения:

```bash
curl -X POST "https://vruchay.ru/api/documents/$DOCUMENT_ID/sheets/$SHEET_ID/check" \
  -H "Authorization: Bearer $VRUCHAY_TOKEN" \
  -H "Content-Type: application/json" \
  -d @draft.json
```

---

<!-- endpoints/layout/post-documents-id-sheets-sheetId-preset -->
> method: POST | path: /api/documents/{id}/sheets/{sheetId}/preset | auth: token | roles: any | rate_limit: none

# POST /api/documents/{id}/sheets/{sheetId}/preset

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

Раскладка собирается под размер листа и под колонки документа: заголовок,
вручительная фраза («Награждается», «Настоящий сертификат подтверждает, что»),
ФИО в нужном падеже, строка команды и города, за что награда и мероприятие — набор
строк зависит от вида. Внизу, если остаётся место, подпись (должность слева,
фамилия справа) и строка «дата · организация». Строки с полями, которых у документа нет (`%team`, `%city`,
«за %place_word место» без колонки `place`), выбрасываются, а не остаются пустыми.
Колонки `result`, `category`, `coach`, `event_place`, `number`, если они есть
и в композицию не вошли, добавляются строками «подпись: значение» — не больше двух.

Подпись печатается из постоянных полей документа. Если их нет (и нет колонок
с такими именами), раскладка заводит их с образцами — `signer_role` «Руководитель»
и `signer_name` «И. О. Фамилия»; настоящие значения ставятся через
`PATCH /api/documents/{id}` с полем `constants`.

Блоки получают id вида `preset-award-0`, `preset-award-signer-role`,
`preset-award-issued`, координаты округлены до десятых миллиметра. Фон листа
не меняется. То же при создании документа — поле `preset` в `POST /api/documents`.

Если у документа включена проверка подлинности, раскладка ставит и QR проверки
(`preset-{kind}-qr`, пустой `template`, цвет основного текста): в правый нижний угол
поля, стороной 11 % короткой стороны листа, но от 20 до 30 мм (на A4 — 23 мм).
Подвал уступает ему место — фамилия подписанта кончается левее QR, строка
«дата · кто выдал» сужается с обеих сторон и остаётся по центру, — а стопка
текста поднимается выше его верхнего края. QR не ставится, если он уже есть
на другом листе документа или, при `mode: append`, на этом листе.

## Виды документов (`kind`)

| `kind` | Название | Для чего | Группа |
|---|---|---|---|
| `award` | Грамота за место | Соревнования, олимпиады, конкурсы | За результат |
| `winner` | Диплом победителя | Первое место и победа в номинации | За результат |
| `laureate` | Диплом лауреата | Творческие конкурсы и фестивали | За результат |
| `participant` | Диплом участника | Всем, кто вышел на старт | За участие |
| `attendee` | Сертификат участника | Конференции, мастер-классы, форумы | За участие |
| `course` | Сертификат об обучении | Курсы и семинары, с объёмом часов | Обучение |
| `graduate` | Свидетельство об окончании | Выпускникам программы | Обучение |
| `school` | Похвальная грамота | Школьникам — за успехи в учёбе | Отличия и благодарности |
| `thanks` | Благодарность | Судьям, тренерам, волонтёрам | Отличия и благодарности |
| `letter` | Благодарственное письмо | Родителям, партнёрам, организациям | Отличия и благодарности |

## Стили (`style`)

| `style` | Название | Описание |
|---|---|---|
| `classic` | Классический | Засечки. Для наградных бланков с рамкой |
| `solemn` | Торжественный | Крупные засечки вразрядку, для гербовых |
| `academic` | Академический | Спокойные засечки, для дипломов и свидетельств |
| `strict` | Строгий | Гротеск. Для минималистичных бланков |
| `modern` | Современный | Геометричный гротеск, для корпоративных |
| `script` | Каллиграфия | Рукописное имя пером |
| `handwritten` | Рукописный | Живое перо, для детских и творческих |

Тот же список с подсказками отдаёт [`GET /api/layout/catalog`](get-layout-catalog.md)
(`presets.kinds`, `presets.styles`).

## Запрос

| Параметр | Тип | Обязателен | Описание |
|---|---|---|---|
| `id` | UUID | да | идентификатор документа |
| `sheetId` | UUID | да | идентификатор листа этого документа |

Тело:

| Поле | Тип | Обязательно | Описание |
|---|---|---|---|
| `kind` | string | да | вид документа, см. таблицу |
| `style` | string | да | стиль, см. таблицу |
| `titlePrinted` | boolean | нет | `true` — заголовок («Грамота», «Диплом») уже напечатан на бланке, своего раскладка не ставит и начинает ниже. По умолчанию `false` |
| `palette` | object | нет | свои цвета вместо цветов стиля |
| `palette.ink` | string | да, если есть `palette` | основной текст, `#RRGGBB` |
| `palette.accent` | string | да, если есть `palette` | заголовок и акценты, `#RRGGBB` |
| `palette.nameInk` | string | нет | ФИО, `#RRGGBB`; по умолчанию как `accent` |
| `mode` | `replace` \| `append` | нет | `replace` (по умолчанию) — заменить макет листа раскладкой; `append` — дописать её к блокам, что уже есть. Новый блок, чей id уже занят, получает суффикс `-N`, где N — число блоков, что были на листе |

```json
{
  "kind": "course",
  "style": "modern",
  "titlePrinted": false,
  "palette": { "ink": "#1F2937", "accent": "#0F766E" },
  "mode": "replace"
}
```

Правка идёт тем же путём, что [правка по частям](patch-documents-id-sheets-sheetId-elements.md):
поверх свежей версии листа, с повтором при одновременной чужой правке; `version`
листа растёт. `baseVersion` здесь не принимается.

## Ответ

`200 OK`

| Поле | Тип | Описание |
|---|---|---|
| `sheet` | object | лист после раскладки: `id`, `documentId`, `position`, `backgroundFileId`, `layout`, `schemaVersion` (3), `version` |
| `problems` | object[] | замечания проверки вёрстки, как у [`POST …/check`](post-documents-id-sheets-sheetId-check.md). На пустом листе — обычно пусто; на бланке с рамкой проверьте превью: раскладка не знает, где у бланка рамка |

```json
{
  "sheet": {
    "id": "b2c3d4e5-f6a7-4b8c-9d0e-1f2a3b4c5d6e",
    "documentId": "6f1c3b2a-9d4e-4f5a-8b6c-7d8e9f0a1b2c",
    "position": 0,
    "backgroundFileId": null,
    "layout": [
      { "id": "preset-course-0", "type": "text", "x": 38.6, "y": 40.1, "w": 219.8, "h": 14.6, "props": { "fontFamily": "Montserrat", "fontSize": 27.6, "color": "#2C6E63", "…": "…" } },
      { "id": "preset-course-2", "type": "text", "x": 38.6, "y": 75.7, "w": 219.8, "h": 33.8, "props": { "fontFamily": "Montserrat", "fontSize": 38.3, "…": "…" } },
      { "id": "preset-course-3", "type": "text", "x": 38.6, "y": 114.9, "w": 219.8, "h": 8.5, "props": { "fontFamily": "Inter", "fontSize": 16.1, "color": "#2B2F33", "…": "…" } },
      { "id": "preset-course-signer-role", "type": "text", "x": 38.6, "y": 176.6, "w": 106.6, "h": 5.2, "…": "…" },
      { "id": "preset-course-signer-name", "type": "text", "x": 151.7, "y": 176.6, "w": 79.6, "h": 5.2, "…": "…" },
      { "id": "preset-course-issued", "type": "text", "x": 65.6, "y": 183.8, "w": 165.8, "h": 5.2, "…": "…" },
      { "id": "preset-course-qr", "type": "qr", "x": 235.4, "y": 166, "w": 23, "h": 23, "props": { "template": "", "color": "#2B2F33", "…": "…" } }
    ],
    "schemaVersion": 3,
    "version": 1
  },
  "problems": []
}
```

## Ошибки

| Код | Когда |
|---|---|
| 400 | «Некорректный идентификатор» — `id` или `sheetId` не UUID |
| 400 | неизвестный `kind`, `style` или `mode`; цвет `palette` не `#RRGGBB`; в `palette` нет `ink` или `accent` — сообщения Zod в `errors[]` |
| 401 | нет токена или сессии |
| 404 | «Документ не найден» — нет такого, чужой или в корзине |
| 404 | «Лист не найден» — лист не из этого документа |
| 409 | «Лист правят одновременно слишком часто — повторите запрос» |
| 413 | тело больше 1 МБ |

## Пример

```bash
curl -X POST "https://vruchay.ru/api/documents/$DOCUMENT_ID/sheets/$SHEET_ID/preset" \
  -H "Authorization: Bearer $VRUCHAY_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"kind":"award","style":"classic","titlePrinted":true}'
```

---

<!-- endpoints/layout/post-documents-id-sheets-sheetId-preview -->
> method: POST | path: /api/documents/{id}/sheets/{sheetId}/preview | auth: token | roles: any | rate_limit: «30 превью в минуту на организацию»

# POST /api/documents/{id}/sheets/{sheetId}/preview

Отрисовывает один лист так, как он напечатается, и отдаёт картинкой JPEG: с фоном,
шрифтами, QR и данными получателя. Программа видит то же, что человек в редакторе,
и может проверить вёрстку глазами — [проверка листа](post-documents-id-sheets-sheetId-check.md)
говорит словами, что не так, а превью показывает, как это выглядит.

Печатает тот же Chromium в том же воркере, что выпускает документы, по тому же
сохранённому макету — превью стоит в общей очереди отрисовок между документами
идущего выпуска. Обычно картинка готова за пару секунд; при занятом воркере дольше.

**Квоту не тратит и в реестр ничего не пишет**: код, номер, запись о выдаче и
файл в хранилище не появляются.

## Чьими данными заполнен лист

- `rowId` — этой строкой таблицы получателей, отмечена она или нет;
- без `rowId` — первой строкой таблицы;
- таблица пустая — образцом: `name` — «Иванов Иван», `email` — «ivanov@example.ru»,
  `place` — «1», а у остальных колонок вместо значения стоит их заголовок в скобках —
  `[Команда]`, без заголовка — имя колонки: `[team]`. Так видно, куда встанет поле.

Служебные поля заполнены как при выпуске: дата выдачи (из карточки документа или
сегодняшняя), номер строки, всего строк, срок действия, мероприятие, постоянные
поля, название организации. Код проверки и регистрационный номер — заглушки той же
длины, что настоящие: `PRVW-0000-0000` и `0/2026`; QR ведёт на страницу проверки
с этим кодом, а если проверка у документа выключена, пустой QR не рисуется.
В настоящем документе будут свои код и номер.

## Запрос

| Параметр | Тип | Обязателен | Описание |
|---|---|---|---|
| `id` | UUID | да | идентификатор документа |
| `sheetId` | UUID | да | идентификатор листа этого документа |

Тело необязательно:

| Поле | Тип | Обязательно | Описание |
|---|---|---|---|
| `rowId` | string (UUID) | нет | строка таблицы получателей этого документа, чьими данными заполнить лист |

```json
{ "rowId": "0b1c2d3e-4f5a-4b6c-8d7e-9f0a1b2c3d4e" }
```

## Ответ

`200 OK`, тело — картинка JPEG.

- `Content-Type: image/jpeg`
- `Cache-Control: no-store`

Размер в пикселях — размер листа при 96 точках на дюйм: `мм × 96 / 25,4`.
A4 альбомный (297×210 мм) — около 1123×794 пикселей, A4 книжный — 794×1123.
Качество JPEG — 80: превью для вёрстки, а не для печати; для печати есть выпуск.

## Ошибки

| Код | Когда |
|---|---|
| 400 | «Некорректный идентификатор» — `id` или `sheetId` не UUID; «Некорректный идентификатор строки» — `rowId` не UUID |
| 401 | нет токена или сессии |
| 404 | «Документ не найден» — нет такого, чужой или в корзине |
| 404 | «Лист не найден» — лист не из этого документа |
| 404 | «Строка не найдена» — `rowId` не из таблицы этого документа |
| 413 | тело больше 1 МБ |
| 422 | лист не отрисовался: «Не загрузились картинки на листе — проверьте fileId блоков-картинок» или «Лист не отрисовался — проверьте его (POST …/check) и повторите». Подробности остаются в журнале сервера |
| 429 | «Слишком много превью. Повторите через N с.» — больше 30 превью за минуту от организации, считая и кабинет, и все токены |
| 503 | «Очередь печати недоступна — повторите через минуту» — превью не поставилось в очередь |
| 503 | «Превью не успело отрисоваться за 30 секунд — повторите запрос» — воркер занят или лист отрисовывается слишком долго |

Ошибки приходят JSON-ом, как везде: проверяйте `Content-Type` ответа, прежде чем
сохранять его картинкой. Превью считается в предел, если документ, лист и строка
нашлись, — даже когда отрисовка потом не удалась.

## Пример

```bash
curl -X POST "https://vruchay.ru/api/documents/$DOCUMENT_ID/sheets/$SHEET_ID/preview" \
  -H "Authorization: Bearer $VRUCHAY_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{}' \
  -o preview.jpg
```

---

<!-- endpoints/mail/delete-mail-domains-id -->
> method: DELETE | path: /api/mail/domains/{id} | auth: token | roles: owner, admin | rate_limit: none

# DELETE /api/mail/domains/{id}

Удаляет домен вместе со всеми отправителями на нём (каскадно). У шаблонов писем, где такой отправитель был указан, поле `senderId` обнуляется — дальше письма по этим шаблонам пойдут с отправителя по умолчанию: с другого подтверждённого домена организации, если он есть, иначе с общего адреса сервиса.

Убрать одного отправителя, не трогая домен, — `DELETE /api/mail/senders/{id}`.

## Запрос

| Параметр | Тип | Обязателен | Описание |
|---|---|---|---|
| id | string (UUID) | да | Идентификатор домена |

Тела нет.

## Ответ

`200 OK`

| Поле | Тип | Описание |
|---|---|---|
| ok | boolean | Всегда `true` |

```json
{ "ok": true }
```

## Ошибки

| Код | Когда |
|---|---|
| 400 | `id` не UUID: «Некорректный идентификатор» |
| 401 | Нет токена или сессии |
| 403 | Роль `member`: «Недостаточно прав для этого действия» |
| 404 | Домена нет или он чужой: «Домен не найден» |

## Пример

```bash
curl -X DELETE "https://vruchay.ru/api/mail/domains/$DOMAIN_ID" \
  -H "Authorization: Bearer $VRUCHAY_TOKEN"
```

---

<!-- endpoints/mail/delete-mail-senders-id -->
> method: DELETE | path: /api/mail/senders/{id} | auth: token | roles: owner, admin | rate_limit: none

# DELETE /api/mail/senders/{id}

Удаляет одного отправителя; домен и остальные отправители на нём остаются. Чтобы убрать домен целиком вместе со всеми его отправителями, есть `DELETE /api/mail/domains/{id}`.

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

Поиск идёт по паре «идентификатор + организация», поэтому чужой отправитель неотличим от несуществующего — оба дают 404.

Где что: `/api/mail/*` — настройка почты (домены и отправители) и письмо о выдаче документа одного материала; `/api/mailing/*` — раздел «Рассылка»: шаблоны обоих потоков, расчёт получателей, отправка по нескольким материалам, журнал, сводка и рассылки без документа.

## Запрос

| Параметр | Тип | Обязателен | Описание |
|---|---|---|---|
| id | string (UUID) | да | Отправитель организации |

Тела нет.

## Ответ

`200 OK`

| Поле | Тип | Описание |
|---|---|---|
| ok | boolean | Всегда `true` |

```json
{ "ok": true }
```

## Ошибки

| Код | Когда |
|---|---|
| 400 | `id` не UUID: «Некорректный идентификатор» |
| 401 | Нет токена или сессии |
| 403 | Роль `member`: «Недостаточно прав для этого действия» |
| 404 | Отправителя нет или он чужой: «Отправитель не найден» |

## Пример

```bash
curl -X DELETE "https://vruchay.ru/api/mail/senders/$SENDER_ID" \
  -H "Authorization: Bearer $VRUCHAY_TOKEN"
```

---

<!-- endpoints/mail/get-mail-domains -->
> method: GET | path: /api/mail/domains | auth: token | roles: any | rate_limit: none

# GET /api/mail/domains

Возвращает все домены, которые организация заявила для отправки писем от своего имени, вместе с отправителями на каждом из них. Порядок — по дате добавления.

Группа `mail` — это настройка почты (домены, отправители) и простая транзакционная отправка со страницы материала. Массовая рассылка с двумя потоками писем, ручными списками адресов и журналом доставки живёт в группе `mailing`; оба раздела ставят письма в одну и ту же очередь.

Пока у организации нет ни одного подтверждённого домена, письма уходят с общего адреса сервиса (`noreply@vruchay.ru`, имя отправителя — название организации, обратный адрес — почта владельца). У общего адреса есть суточный и разовый лимит писем; со своего домена лимита нет.

## Запрос

Параметров нет.

## Ответ

`200 OK` — массив доменов.

| Поле | Тип | Описание |
|---|---|---|
| id | string (UUID) | Идентификатор домена |
| orgId | string (UUID) | Организация |
| domain | string | Имя домена в нижнем регистре |
| provider | string | Провайдер, при котором выданы записи: `smtp` или `dashamail` (см. [добавление домена](post-mail-domains.md)) |
| status | `pending` \| `verified` \| `failed` | Результат последней проверки DNS: `verified` — записи найдены, `pending` — ещё нет или проверка не запускалась. `failed` проверка сейчас не ставит, он встречается только у старых записей |
| dnsRecords | array | Записи, которые нужно прописать в DNS: `{type, host, value, purpose}` |
| providerRef | string \| null | Не заполняется ни при одном провайдере, всегда `null` |
| verificationToken | string (UUID) | Секрет подтверждения владения; он же входит в значение записи `_vruchay-verify` |
| verifiedAt | string (ISO 8601) \| null | Когда домен впервые подтверждён |
| lastCheckedAt | string (ISO 8601) \| null | Когда последний раз нажимали «Проверить» |
| createdAt | string (ISO 8601) | Дата добавления |
| senders | array | Отправители на этом домене (поля — как в ответе `POST /api/mail/senders`) |

```json
[
  {
    "id": "0f6d1e3a-9c1b-4a4e-8b6a-2f1a5c7d9e01",
    "orgId": "7b2a4c6e-1d3f-4e5a-9b8c-0d1e2f3a4b5c",
    "domain": "vruchay.ru",
    "provider": "smtp",
    "status": "verified",
    "dnsRecords": [
      { "type": "TXT", "host": "_vruchay-verify", "value": "vruchay-verify=00000000-0000-4000-8000-0000000000cd", "purpose": "Подтверждение владения доменом. Уникальна для вашей организации" },
      { "type": "TXT", "host": "@", "value": "v=spf1 include:vruchay.ru ~all", "purpose": "SPF: разрешает нашему серверу отправлять письма от вашего домена" },
      { "type": "TXT", "host": "_dmarc", "value": "v=DMARC1; p=none; rua=mailto:postmaster@vruchay.ru", "purpose": "DMARC: политика для писем, не прошедших проверку. Начинаем с p=none" }
    ],
    "providerRef": null,
    "verificationToken": "00000000-0000-4000-8000-0000000000cd",
    "verifiedAt": "2026-08-20T09:15:00.000Z",
    "lastCheckedAt": "2026-08-20T09:15:00.000Z",
    "createdAt": "2026-08-19T14:02:11.000Z",
    "senders": [
      {
        "id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
        "orgId": "7b2a4c6e-1d3f-4e5a-9b8c-0d1e2f3a4b5c",
        "domainId": "0f6d1e3a-9c1b-4a4e-8b6a-2f1a5c7d9e01",
        "email": "awards@vruchay.ru",
        "displayName": "Центр «Развитие»",
        "isDefault": false,
        "replyTo": "",
        "signature": "",
        "createdAt": "2026-08-20T09:20:00.000Z"
      }
    ]
  }
]
```

## Ошибки

| Код | Когда |
|---|---|
| 401 | Нет токена или сессии |

## Пример

```bash
curl "https://vruchay.ru/api/mail/domains" \
  -H "Authorization: Bearer $VRUCHAY_TOKEN"
```

---

<!-- endpoints/mail/get-mail-emails -->
> method: GET | path: /api/mail/emails | auth: token | roles: any | rate_limit: none

# GET /api/mail/emails

Последние 200 писем организации, новые первыми, — сырые записи журнала без перевода причин отказа. Для разбора доставки удобнее `GET /api/mailing/log`: там причина недоставки переведена на понятный язык и есть сводка по состояниям. Пагинации нет.

Состояния письма: `queued` → `sent` → `delivered` → `opened`; `bounced` и `failed` — конечные. Назад состояние не откатывается. `sent` ставит воркер после приёма письма шлюзом; `delivered` и `bounced` приходят от почтового провайдера, `opened` — по пикселю открытия. О доставке сообщает только провайдер с API (`dashamail`): при `smtp` событий доставки нет, и после `sent` письмо может стать только `opened` — по пикселю.

## Запрос

| Параметр | Тип | Обязателен | Описание |
|---|---|---|---|
| documentId | string (UUID) | нет | Только письма по этому материалу. Значение, не похожее на UUID, молча игнорируется — вернутся все письма |

## Ответ

`200 OK` — массив писем.

| Поле | Тип | Описание |
|---|---|---|
| id | string (UUID) | Идентификатор письма |
| orgId | string (UUID) | Организация |
| documentId | string (UUID) \| null | Материал |
| rowId | string (UUID) \| null | Строка получателя; `null` для адреса из ручного списка или после переимпорта таблицы |
| templateId | string (UUID) \| null | Шаблон на момент постановки |
| fileId | string (UUID) \| null | Приложенный файл |
| kind | `transactional` \| `marketing` | Поток письма на момент постановки |
| toEmail | string | Адрес получателя; после срока хранения обезличивается в пустую строку |
| subject | string | Тема с подставленными переменными |
| provider | string | Провайдер, через который шло письмо: `smtp` или `dashamail` |
| providerMessageId | string \| null | Идентификатор у шлюза после отправки: при `smtp` — Message-ID письма, при `dashamail` — номер транзакции DashaMail |
| bodyHtml | string \| null | Готовый текст только у служебных писем сервиса (уведомление о сроке); иначе `null` — тело собирается из шаблона |
| attachFile | boolean | Прикладывать ли файл при отправке |
| status | string | Состояние, см. выше |
| error | string \| null | Текст ошибки шлюза, обезличенный, до 500 символов |
| queuedAt | string (ISO 8601) | Постановка в очередь |
| sentAt | string (ISO 8601) \| null | Приём шлюзом |
| statusUpdatedAt | string (ISO 8601) | Последняя смена состояния |

```json
[
  {
    "id": "c0ffee00-1111-4222-8333-444455556666",
    "orgId": "7b2a4c6e-1d3f-4e5a-9b8c-0d1e2f3a4b5c",
    "documentId": "9e8d7c6b-5a4f-4e3d-2c1b-0a9f8e7d6c5b",
    "rowId": "1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d",
    "templateId": "5d4c3b2a-1f0e-4d9c-8b7a-6f5e4d3c2b1a",
    "fileId": "f1e2d3c4-b5a6-4978-8a9b-0c1d2e3f4a5b",
    "kind": "transactional",
    "toEmail": "petrov@vruchay.ru",
    "subject": "Ваш сертификат, Пётр Петров",
    "provider": "smtp",
    "providerMessageId": "<20260821100512.abc@smtp.vruchay.ru>",
    "bodyHtml": null,
    "attachFile": true,
    "status": "sent",
    "error": null,
    "queuedAt": "2026-08-21T10:05:00.000Z",
    "sentAt": "2026-08-21T10:05:12.000Z",
    "statusUpdatedAt": "2026-08-21T10:05:12.000Z"
  }
]
```

## Ошибки

| Код | Когда |
|---|---|
| 401 | Нет токена или сессии |

## Пример

```bash
curl "https://vruchay.ru/api/mail/emails?documentId=$DOCUMENT_ID" \
  -H "Authorization: Bearer $VRUCHAY_TOKEN"
```

---

<!-- endpoints/mail/get-mail-templates-documentId -->
> method: GET | path: /api/mail/templates/{documentId} | auth: token | roles: any | rate_limit: none

# GET /api/mail/templates/{documentId}

Возвращает транзакционный шаблон письма (поток `transactional` — «выдача документа») для материала, вместе с выбранным отправителем. Этот же шаблон используют `POST /api/mail/send/{documentId}`, выдача по заявке с формы на сайте и уведомления о сроке документа.

Рекламный шаблон (`marketing`) этим маршрутом не виден — за обоими потоками ходите в `GET /api/mailing/templates/{documentId}?kind=…`.

Принадлежность материала организации отдельно не проверяется: шаблон ищется по паре «организация + материал», и для чужого или несуществующего материала ответ просто пустой.

## Запрос

| Параметр | Тип | Обязателен | Описание |
|---|---|---|---|
| documentId | string (UUID) | да | Идентификатор материала |

## Ответ

`200 OK`. Если шаблон ещё не сохранён — тело ответа пустое (сервис возвращает `null`).

| Поле | Тип | Описание |
|---|---|---|
| id | string (UUID) | Идентификатор шаблона |
| orgId | string (UUID) | Организация |
| documentId | string (UUID) | Материал |
| senderId | string (UUID) \| null | Отправитель организации; `null` — письма идут с отправителя по умолчанию |
| kind | string | Всегда `transactional` |
| advertiserName | null | У транзакционного шаблона не заполняется |
| subject | string | Тема; допускает переменные строки получателя |
| bodyHtml | string | HTML тела после санитизации |
| attachGeneratedFile | boolean | Прикладывать ли выпущенный файл |
| createdAt, updatedAt | string (ISO 8601) | Даты |
| sender | object \| null | Отправитель целиком (поля — как в `POST /api/mail/senders`) |

```json
{
  "id": "5d4c3b2a-1f0e-4d9c-8b7a-6f5e4d3c2b1a",
  "orgId": "7b2a4c6e-1d3f-4e5a-9b8c-0d1e2f3a4b5c",
  "documentId": "9e8d7c6b-5a4f-4e3d-2c1b-0a9f8e7d6c5b",
  "senderId": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
  "kind": "transactional",
  "advertiserName": null,
  "subject": "Ваш сертификат, %name",
  "bodyHtml": "<p>Здравствуйте, %name!</p><p>Сертификат во вложении.</p>",
  "attachGeneratedFile": true,
  "createdAt": "2026-08-21T10:00:00.000Z",
  "updatedAt": "2026-08-21T10:00:00.000Z",
  "sender": {
    "id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
    "orgId": "7b2a4c6e-1d3f-4e5a-9b8c-0d1e2f3a4b5c",
    "domainId": "0f6d1e3a-9c1b-4a4e-8b6a-2f1a5c7d9e01",
    "email": "awards@vruchay.ru",
    "displayName": "Центр «Развитие»",
    "isDefault": false,
    "createdAt": "2026-08-20T09:20:00.000Z"
  }
}
```

## Ошибки

| Код | Когда |
|---|---|
| 400 | `documentId` не UUID: «Некорректный идентификатор» |
| 401 | Нет токена или сессии |

## Пример

```bash
curl "https://vruchay.ru/api/mail/templates/$DOCUMENT_ID" \
  -H "Authorization: Bearer $VRUCHAY_TOKEN"
```

---

<!-- endpoints/mail/patch-mail-senders-id -->
> method: PATCH | path: /api/mail/senders/{id} | auth: token | roles: owner, admin | rate_limit: none

# PATCH /api/mail/senders/{id}

Меняет то, что организация пишет о себе сама: отображаемое имя, адрес для ответов, подпись и пометку «основной». Сам адрес и домен не меняются — они подтверждены DNS, и их подмена означала бы отправку от чужого имени. Нужен другой адрес — заведите нового отправителя (`POST /api/mail/senders`).

Обновление частичное: меняется ровно то, что передано, остальное остаётся как было. Неизвестные поля отбрасываются молча; если после этого не осталось ни одного поля, запрос отклоняется.

- `replyTo` — куда попадёт участник, нажав «Ответить». Нужен, когда письма уходят с адреса вроде `noreply@`, а отвечать надо живому человеку. Приводится к нижнему регистру; пустая строка убирает адрес, и ответы идут на сам адрес отправителя.
- `signature` — HTML-подпись под письмом о выдаче документа, отделённая чертой. Очищается от опасной разметки при сохранении, как тело письма, поэтому в ответе может прийти не то, что было отправлено. К рекламным письмам не добавляется.
- `isDefault` — принимает только `true`: основным отправителя не «снимают», а назначают другого. Пометка снимается с прежнего основного в той же транзакции, так что основной у организации всегда один.

Основной отправитель подставляется в письма, у шаблона которых `senderId` не указан: берётся основной на подтверждённом домене, если такого нет — самый ранний созданный на подтверждённом домене, если нет и его — общий адрес сервиса. Пометка на отправителе с неподтверждённым доменом ставится, но действовать начнёт только после подтверждения домена. Подпись и адрес для ответов попадают в письмо, когда отправитель указан в шаблоне явно (`senderId`); при подстановке основного отправителя по умолчанию они не добавляются.

Где что: `/api/mail/*` — настройка почты (домены и отправители) и письмо о выдаче документа одного материала; `/api/mailing/*` — раздел «Рассылка»: шаблоны обоих потоков, расчёт получателей, отправка по нескольким материалам, журнал, сводка и рассылки без документа.

## Запрос

| Параметр | Тип | Обязателен | Описание |
|---|---|---|---|
| id | string (UUID) | да | Отправитель организации |

Тело — хотя бы одно поле:

| Поле | Тип | Обязательно | Описание |
|---|---|---|---|
| displayName | string | нет | Имя отправителя, 1–100 символов после обрезки пробелов |
| replyTo | string | нет | Адрес для ответов, до 254 символов; пустая строка — убрать |
| signature | string | нет | HTML-подпись, до 2000 символов после обрезки пробелов; пустая строка — убрать |
| isDefault | `true` | нет | Сделать отправителя основным |

```json
{
  "displayName": "Центр «Развитие»",
  "replyTo": "Priemnaya@example.ru",
  "signature": "<p>С уважением,<br>приёмная комиссия</p>",
  "isDefault": true
}
```

## Ответ

`200 OK` — отправитель после изменения.

| Поле | Тип | Описание |
|---|---|---|
| id | string (UUID) | Идентификатор отправителя |
| orgId | string (UUID) | Организация |
| domainId | string (UUID) | Домен |
| email | string | Адрес отправителя, не меняется |
| displayName | string | Имя отправителя |
| isDefault | boolean | Основной ли это отправитель |
| replyTo | string | Адрес для ответов в нижнем регистре; пустая строка — не задан |
| signature | string | Подпись после очистки; пустая строка — не задана |
| createdAt | string (ISO 8601) | Дата создания |

```json
{
  "id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
  "orgId": "7b2a4c6e-1d3f-4e5a-9b8c-0d1e2f3a4b5c",
  "domainId": "0f6d1e3a-9c1b-4a4e-8b6a-2f1a5c7d9e01",
  "email": "awards@example.ru",
  "displayName": "Центр «Развитие»",
  "isDefault": true,
  "replyTo": "priemnaya@example.ru",
  "signature": "<p>С уважением,<br />приёмная комиссия</p>",
  "createdAt": "2026-08-20T09:20:00.000Z"
}
```

## Ошибки

| Код | Когда |
|---|---|
| 400 | `id` не UUID: «Некорректный идентификатор» |
| 400 | Тела нет: «Invalid input: expected object, received undefined»; ни одного известного поля: «Нечего обновлять» |
| 400 | `displayName` пустой: «Укажите имя отправителя»; длиннее 100: «Too big: expected string to have <=100 characters» |
| 400 | `replyTo` длиннее 254: «Too big: expected string to have <=254 characters»; не похож на адрес: «Некорректный адрес для ответов» |
| 400 | `signature` длиннее 2000: «Too big: expected string to have <=2000 characters» |
| 400 | `isDefault` не `true`: «Invalid input: expected true» |
| 401 | Нет токена или сессии |
| 403 | Роль `member`: «Недостаточно прав для этого действия» |
| 404 | Отправителя нет или он чужой: «Отправитель не найден» |

## Пример

```bash
curl -X PATCH "https://vruchay.ru/api/mail/senders/$SENDER_ID" \
  -H "Authorization: Bearer $VRUCHAY_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"replyTo":"priemnaya@example.ru","isDefault":true}'
```

---

<!-- endpoints/mail/post-mail-domains-id-check -->
> method: POST | path: /api/mail/domains/{id}/check | auth: token | roles: owner, admin | rate_limit: none

# POST /api/mail/domains/{id}/check

Запрашивает DNS домена и сверяет записи из `dnsRecords` с тем, что реально опубликовано. Результат записывается в `status`: `verified`, если найдены все записи, иначе `pending`. При провайдере `dashamail` мало увидеть записи самим: домен становится `verified`, только когда их подтвердил и DashaMail, а пока он не согласен или не ответил, остаётся `pending`. Проверка синхронная — ответ приходит после опроса DNS.

Если домен заявляли при другом провайдере (например, сервис перешёл с `smtp` на `dashamail`), проверка сперва выдаёт записи заново под нынешнего провайдера с тем же токеном подтверждения: в ответе меняются `provider` и `dnsRecords`, и недостающие записи нужно дописать в DNS. Уже прописанная запись `_vruchay-verify` остаётся в силе.

Записи после правки у регистратора расходятся до 48 часов, поэтому `pending` после проверки сразу после добавления — обычное дело; проверку можно повторять сколько угодно. Обратное тоже верно: если записи из DNS убрали, повторная проверка вернёт домен в `pending`, и отправка с его отправителей станет невозможна — статус домена проверяется перед каждой рассылкой.

## Запрос

| Параметр | Тип | Обязателен | Описание |
|---|---|---|---|
| id | string (UUID) | да | Идентификатор домена из `GET /api/mail/domains` |

Тела нет.

## Ответ

`201 Created` — обновлённый домен (те же поля, что в `POST /api/mail/domains`, без списка отправителей). `lastCheckedAt` — время этой проверки; `verifiedAt` ставится при первом успешном подтверждении и сохраняется при последующих, а при неудачной проверке сбрасывается в `null`.

```json
{
  "id": "0f6d1e3a-9c1b-4a4e-8b6a-2f1a5c7d9e01",
  "orgId": "7b2a4c6e-1d3f-4e5a-9b8c-0d1e2f3a4b5c",
  "domain": "vruchay.ru",
  "provider": "smtp",
  "status": "verified",
  "dnsRecords": [
    { "type": "TXT", "host": "_vruchay-verify", "value": "vruchay-verify=00000000-0000-4000-8000-0000000000cd", "purpose": "Подтверждение владения доменом. Уникальна для вашей организации" },
    { "type": "TXT", "host": "@", "value": "v=spf1 include:vruchay.ru ~all", "purpose": "SPF: разрешает нашему серверу отправлять письма от вашего домена" },
    { "type": "TXT", "host": "_dmarc", "value": "v=DMARC1; p=none; rua=mailto:postmaster@vruchay.ru", "purpose": "DMARC: политика для писем, не прошедших проверку. Начинаем с p=none" }
  ],
  "providerRef": null,
  "verificationToken": "00000000-0000-4000-8000-0000000000cd",
  "verifiedAt": "2026-08-20T09:15:00.000Z",
  "lastCheckedAt": "2026-08-20T09:15:00.000Z",
  "createdAt": "2026-08-19T14:02:11.000Z"
}
```

## Ошибки

| Код | Когда |
|---|---|
| 400 | `id` не UUID: «Некорректный идентификатор» |
| 401 | Нет токена или сессии |
| 403 | Роль `member`: «Недостаточно прав для этого действия» |
| 404 | Домена нет или он принадлежит другой организации: «Домен не найден» |
| 400, 503 | Домен заявлен при другом провайдере, и подключить его к DashaMail не вышло — те же ответы, что у [добавления домена](post-mail-domains.md) |

## Пример

```bash
curl -X POST "https://vruchay.ru/api/mail/domains/$DOMAIN_ID/check" \
  -H "Authorization: Bearer $VRUCHAY_TOKEN"
```

---

<!-- endpoints/mail/post-mail-domains -->
> method: POST | path: /api/mail/domains | auth: token | roles: owner, admin | rate_limit: none

# POST /api/mail/domains

Заявляет домен организации для отправки писем от его имени. В ответ приходит набор DNS-записей, которые нужно прописать у регистратора: уникальная TXT-запись подтверждения владения, SPF, DMARC, а при провайдере `dashamail` ещё и DKIM. Сам факт добавления ничего не подтверждает — домен создаётся в состоянии `pending`, а проверка запускается отдельно через `POST /api/mail/domains/{id}/check`.

Имя домена нормализуется: обрезаются пробелы, приводится к нижнему регистру, отбрасываются `http(s)://` и всё после первой косой черты. Домен, который уже подтверждён другой организацией, заявить нельзя.

Какие записи придут, зависит от почтового провайдера сервиса (`provider` в ответе; выбирается настройкой сервера `MAIL_PROVIDER`, по умолчанию `smtp`):

- `smtp` — три TXT-записи: подтверждение владения `_vruchay-verify`, SPF на `@` с `include:` нашего почтового сервера и DMARC на `_dmarc`.
- `dashamail` — домен при добавлении подключается к аккаунту сервиса в DashaMail, и вместо SPF нашего сервера приходят записи DashaMail: SPF на `@` с `include:_spf.dashasender.ru` и ключ DKIM — TXT-запись на имени вида `<селектор>._domainkey`, значение выдаёт DashaMail. Подтверждение владения и DMARC те же. Запись SPF у домена должна быть одна: если она уже есть, в неё дописывают `include:_spf.dashasender.ru`, а не заводят вторую.

## Запрос

Тело:

| Поле | Тип | Обязательно | Описание |
|---|---|---|---|
| domain | string | да | Имя домена, 4–253 символа после обрезки пробелов. Допустимы буквы, цифры и дефис, минимум одна точка |

```json
{ "domain": "vruchay.ru" }
```

## Ответ

`201 Created` — созданный домен (без списка отправителей).

| Поле | Тип | Описание |
|---|---|---|
| id | string (UUID) | Идентификатор домена |
| orgId | string (UUID) | Организация |
| domain | string | Нормализованное имя |
| provider | string | Провайдер, при котором заявлен домен: `smtp` или `dashamail` |
| status | string | Всегда `pending` при создании |
| dnsRecords | array | Записи `{type, host, value, purpose}`, которые нужно прописать в DNS: три при `smtp`, четыре при `dashamail` |
| providerRef | null | Не заполняется ни при одном провайдере |
| verificationToken | string (UUID) | Секрет, входящий в значение записи `_vruchay-verify` |
| verifiedAt | null | Пока не подтверждён |
| lastCheckedAt | null | Проверка ещё не запускалась |
| createdAt | string (ISO 8601) | Дата добавления |

```json
{
  "id": "0f6d1e3a-9c1b-4a4e-8b6a-2f1a5c7d9e01",
  "orgId": "7b2a4c6e-1d3f-4e5a-9b8c-0d1e2f3a4b5c",
  "domain": "vruchay.ru",
  "provider": "smtp",
  "status": "pending",
  "dnsRecords": [
    { "type": "TXT", "host": "_vruchay-verify", "value": "vruchay-verify=00000000-0000-4000-8000-0000000000cd", "purpose": "Подтверждение владения доменом. Уникальна для вашей организации" },
    { "type": "TXT", "host": "@", "value": "v=spf1 include:vruchay.ru ~all", "purpose": "SPF: разрешает нашему серверу отправлять письма от вашего домена" },
    { "type": "TXT", "host": "_dmarc", "value": "v=DMARC1; p=none; rua=mailto:postmaster@vruchay.ru", "purpose": "DMARC: политика для писем, не прошедших проверку. Начинаем с p=none" }
  ],
  "providerRef": null,
  "verificationToken": "00000000-0000-4000-8000-0000000000cd",
  "verifiedAt": null,
  "lastCheckedAt": null,
  "createdAt": "2026-08-19T14:02:11.000Z"
}
```

## Ошибки

| Код | Когда |
|---|---|
| 400 | Поле `domain` отсутствует: «Invalid input: expected string, received undefined» |
| 400 | Короче 4 символов: «Too small: expected string to have >=4 characters»; длиннее 253: «Too big: expected string to have <=253 characters» |
| 400 | После нормализации имя не похоже на домен: «Некорректное имя домена» |
| 400 | «Этот домен уже подтверждён другой организацией. Если он принадлежит вам, напишите в поддержку» |
| 400 | Только при `dashamail`: DashaMail не принимает имя — «Почтовый сервис не принимает такое имя домена» |
| 400 | Только при `dashamail`: домен подключён к чужому аккаунту DashaMail, например к собственному аккаунту организации, — «Этот домен уже подключён к другому аккаунту DashaMail — например, к вашему собственному. Подключите для писем «Вручай» другой поддомен или напишите в поддержку» |
| 401 | Нет токена или сессии |
| 403 | Роль `member`: «Недостаточно прав для этого действия» |
| 503 | Только при `dashamail`: DashaMail не ответил или отказал — «Не удалось подключить домен в почтовом сервисе. Попробуйте позже или напишите в поддержку»; DashaMail не выдал ключ DKIM — «Почтовый сервис не выдал ключ подписи для домена. Напишите в поддержку» |
| 500 | Тот же домен уже добавлен в этой организации — уникальность `(orgId, domain)` в коде не перехватывается, Nest отдаёт общую ошибку |

## Пример

```bash
curl -X POST "https://vruchay.ru/api/mail/domains" \
  -H "Authorization: Bearer $VRUCHAY_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"domain":"vruchay.ru"}'
```

---

<!-- endpoints/mail/post-mail-send-documentId -->
> method: POST | path: /api/mail/send/{documentId} | auth: token | roles: any | rate_limit: none

# POST /api/mail/send/{documentId}

Ставит в очередь письма о выдаче документа всем отмеченным (`checked`) строкам таблицы получателей материала. Используется транзакционный шаблон из `POST /api/mail/templates/{documentId}` — без него отправка отклоняется. Ответ приходит сразу после постановки в очередь; сама отправка асинхронная: воркер делает до 5 попыток с нарастающей задержкой (первая — 15 с), состояние каждого письма видно в `GET /api/mail/emails?documentId=…` или `GET /api/mailing/log`.

Кому письмо не ставится — возвращается в `skipped` с причиной: нет адреса в строке, адрес некорректен, или шаблон требует вложение, а файл по строке ещё не выпущен. Если в шаблоне не указан отправитель (`senderId: null`), перед постановкой проверяется лимит общего домена: не больше `SHARED_DOMAIN_BATCH_LIMIT` (по умолчанию 300) писем за раз и `SHARED_DOMAIN_DAILY_LIMIT` (по умолчанию 500) за сутки на организацию. Проверка смотрит на шаблон, а не на адрес, с которого письма фактически уйдут: лимит действует и тогда, когда у организации есть основной отправитель на своём домене. Чтобы лимита не было, укажите в шаблоне отправителя на подтверждённом домене.

Действие необратимое и пишется в журнал аудита (`mail.send`). Защиты от повторного запуска здесь нет: второй вызов подряд попадёт на уникальный индекс «одно живое письмо на адрес и поток по материалу», и вся транзакция откатится с ошибкой 500 — для повторов и ручных списков используйте `POST /api/mailing/send`, где дубли пропускаются.

## Запрос

| Параметр | Тип | Обязателен | Описание |
|---|---|---|---|
| documentId | string (UUID) | да | Материал |

Тела нет.

## Ответ

`201 Created`

| Поле | Тип | Описание |
|---|---|---|
| queued | number | Сколько писем поставлено в очередь |
| skipped | array | Кому не поставлено: `{name, reason}` |

```json
{
  "queued": 47,
  "skipped": [
    { "name": "Иванов Пётр", "reason": "нет адреса" },
    { "name": "Сидорова Анна", "reason": "некорректный адрес «anna@»" },
    { "name": "Кузнецов Олег", "reason": "документ ещё не создан" }
  ]
}
```

## Ошибки

| Код | Когда |
|---|---|
| 400 | `documentId` не UUID: «Некорректный идентификатор» |
| 400 | Шаблона нет: «Сначала сохраните письмо» |
| 400 | Отправитель шаблона удалён: «Отправитель не найден» |
| 400 | Домен отправителя перестал быть подтверждённым: «Домен vruchay.ru не подтверждён — отправка невозможна» |
| 400 | Ни своего отправителя, ни общего адреса сервиса: «Отправка писем не настроена — обратитесь в поддержку» |
| 400 | Превышен разовый лимит общего домена: «За один раз с общего адреса уходит не больше 300 писем, а отмечено 350. Разбейте на несколько рассылок или подключите свой домен в настройках — со своего домена ограничения нет.» |
| 400 | Превышен суточный лимит общего домена: «За сутки с общего адреса уходит не больше 500 писем: сегодня уже отправлено 480, осталось 20. Подключите свой домен в настройках — со своего домена ограничения нет, и письма вызывают больше доверия.» |
| 401 | Нет токена или сессии |
| 403 | Возможности `mailing` нет в плане: ««Рассылка писем участникам» не входит в ваш план. Напишите нам — добавим.» Срок плана вышел: «Срок плана «…» закончился, поэтому раздел «Рассылка писем участникам» сейчас недоступен. Уже выданные документы остаются действительными. Напишите нам — обсудим продление.» См. [Аутентификация](../../authentication.md) |
| 500 | Кому-то из списка письмо этого потока по этому материалу уже стоит в очереди или ушло — нарушение уникального индекса не перехватывается |

## Пример

```bash
curl -X POST "https://vruchay.ru/api/mail/send/$DOCUMENT_ID" \
  -H "Authorization: Bearer $VRUCHAY_TOKEN"
```

---

<!-- endpoints/mail/post-mail-senders-id-test -->
> method: POST | path: /api/mail/senders/{id}/test | auth: token | roles: owner, admin | rate_limit: none

# POST /api/mail/senders/{id}/test

Отправляет проверочное письмо от имени этого отправителя — чтобы увидеть, что письмо доходит, в поле «от кого» стоит ожидаемое имя, а ответ уходит на заданный адрес для ответов. В письме — короткий служебный текст и подпись отправителя, тема «Проверка отправки — Вручай».

Получатель не задаётся: адрес в теле запроса не принимается, иначе кнопка «проверить» стала бы способом слать что угодно кому угодно с подтверждённого домена.

- Вход по сессии — письмо уходит на почту вошедшего.
- Вход по токену API — письмо уходит тому, кто выдал токен: своей почты у токена нет, а за то, что шлёт программа, отвечает выдавший. Если этот человек больше не состоит в организации, пробу по токену получать некому — запрос отклоняется, проверку надо сделать из кабинета.

Домен отправителя должен быть подтверждён. Письмо уходит сразу, мимо очереди: в журнал писем оно не попадает, в лимиты общего домена и квоту организации не входит. Отдельного ограничения частоты у маршрута нет.

Где что: `/api/mail/*` — настройка почты (домены и отправители) и письмо о выдаче документа одного материала; `/api/mailing/*` — раздел «Рассылка»: шаблоны обоих потоков, расчёт получателей, отправка по нескольким материалам, журнал, сводка и рассылки без документа.

## Запрос

| Параметр | Тип | Обязателен | Описание |
|---|---|---|---|
| id | string (UUID) | да | Отправитель организации |

Тела нет.

## Ответ

`201 Created`

| Поле | Тип | Описание |
|---|---|---|
| ok | boolean | Всегда `true` |
| providerMessageId | string | Идентификатор письма у почтового шлюза; вид зависит от шлюза. Пригодится, если письмо не дошло и разбираться придётся с поддержкой |

```json
{
  "ok": true,
  "providerMessageId": "7f3a9c21e4b8"
}
```

## Ошибки

| Код | Когда |
|---|---|
| 400 | `id` не UUID: «Некорректный идентификатор» |
| 400 | Вход по токену, а выдавшего токен уже нет в организации: «Пробное письмо по токену уходит человеку, который выдал токен, а его в организации уже нет. Отправьте пробу из кабинета.» |
| 400 | Домен отправителя не подтверждён: «Домен example.ru не подтверждён — отправка невозможна» |
| 401 | Нет токена или сессии |
| 403 | Роль `member`: «Недостаточно прав для этого действия» |
| 404 | Отправителя нет или он чужой: «Отправитель не найден» |
| 500 | Почтовый шлюз не принял письмо |

## Пример

```bash
curl -X POST "https://vruchay.ru/api/mail/senders/$SENDER_ID/test" \
  -H "Authorization: Bearer $VRUCHAY_TOKEN"
```

---

<!-- endpoints/mail/post-mail-senders -->
> method: POST | path: /api/mail/senders | auth: token | roles: owner, admin | rate_limit: none

# POST /api/mail/senders

Создаёт отправителя — адрес и отображаемое имя, от которых уходят письма. Отправитель привязан к домену организации, и домен обязан быть уже подтверждён (`status: verified`), а адрес — заканчиваться на `@<домен>`. Так письма от чужого имени не уходят с нашей инфраструктуры.

Адрес приводится к нижнему регистру. Новый отправитель создаётся неосновным (`isDefault: false`), без адреса для ответов и без подписи — их задаёт `PATCH /api/mail/senders/{id}`, там же отправителя назначают основным. Пока основного нет, по умолчанию берётся самый ранний созданный на подтверждённом домене. Удалить одного отправителя — `DELETE /api/mail/senders/{id}`, проверить письмом себе — `POST /api/mail/senders/{id}/test`.

## Запрос

Тело:

| Поле | Тип | Обязательно | Описание |
|---|---|---|---|
| domainId | string (UUID) | да | Подтверждённый домен организации |
| email | string | да | Адрес на этом домене, до 254 символов |
| displayName | string | да | Имя отправителя, 1–100 символов |

```json
{
  "domainId": "0f6d1e3a-9c1b-4a4e-8b6a-2f1a5c7d9e01",
  "email": "awards@vruchay.ru",
  "displayName": "Центр «Развитие»"
}
```

## Ответ

`201 Created`

| Поле | Тип | Описание |
|---|---|---|
| id | string (UUID) | Идентификатор отправителя — его указывают в `senderId` шаблонов писем |
| orgId | string (UUID) | Организация |
| domainId | string (UUID) | Домен |
| email | string | Адрес в нижнем регистре |
| displayName | string | Имя отправителя |
| isDefault | boolean | Основной ли отправитель; у нового — `false` |
| replyTo | string | Адрес для ответов; у нового — пустая строка |
| signature | string | Подпись под письмом о выдаче; у нового — пустая строка |
| createdAt | string (ISO 8601) | Дата создания |

```json
{
  "id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
  "orgId": "7b2a4c6e-1d3f-4e5a-9b8c-0d1e2f3a4b5c",
  "domainId": "0f6d1e3a-9c1b-4a4e-8b6a-2f1a5c7d9e01",
  "email": "awards@vruchay.ru",
  "displayName": "Центр «Развитие»",
  "isDefault": false,
  "replyTo": "",
  "signature": "",
  "createdAt": "2026-08-20T09:20:00.000Z"
}
```

## Ошибки

| Код | Когда |
|---|---|
| 400 | `domainId` не UUID: «Invalid UUID» |
| 400 | `email` не похож на адрес: «Некорректный адрес»; длиннее 254: «Too big: expected string to have <=254 characters» |
| 400 | `displayName` пустой: «Укажите имя отправителя»; длиннее 100: «Too big: expected string to have <=100 characters» |
| 400 | Домен не в состоянии `verified`: «Домен ещё не подтверждён. Пропишите DNS-записи и нажмите «Проверить»» |
| 400 | Адрес не на этом домене: «Адрес должен быть на домене vruchay.ru» |
| 401 | Нет токена или сессии |
| 403 | Роль `member`: «Недостаточно прав для этого действия» |
| 404 | Домена нет или он чужой: «Домен не найден» |
| 500 | Такой адрес уже заведён в организации — уникальность `(orgId, email)` в коде не перехватывается |

## Пример

```bash
curl -X POST "https://vruchay.ru/api/mail/senders" \
  -H "Authorization: Bearer $VRUCHAY_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"domainId":"'"$DOMAIN_ID"'","email":"awards@vruchay.ru","displayName":"Центр «Развитие»"}'
```

---

<!-- endpoints/mail/post-mail-templates-documentId-test-send -->
> method: POST | path: /api/mail/templates/{documentId}/test-send | auth: token | roles: any | rate_limit: «5 запросов в минуту на организацию»

# POST /api/mail/templates/{documentId}/test-send

Отправляет черновик письма о выдаче документа на свою почту — до сохранения. Тело запроса то же, что у `POST /api/mail/templates/{documentId}`, но ничего не сохраняется: черновик мог не сохраняться ни разу, а таблица получателей может быть пустой.

Переменные заполняются образцом, а не строкой таблицы: `%name` — «Иванов Иван», `%email` — «ivanov@example.ru», остальные колонки таблицы материала — текстом «значение имя_колонки». Тема получает приставку `[Проверка]`, тело очищается от опасной разметки так же, как при сохранении. Документа во вложении нет никогда: у образца его нет. Если `attachGeneratedFile` равно `true`, вверху письма стоит врезка, что настоящий документ прикладывается только участнику.

Получатель не задаётся:

- вход по сессии — письмо уходит на почту вошедшего;
- вход по токену API — тому, кто выдал токен. Если его больше нет в организации, запрос отклоняется: пробу надо отправить из кабинета.

Письмо уходит с отправителя по умолчанию — основного на подтверждённом домене организации или с общего адреса сервиса с названием организации в поле «от кого». Поле `senderId` проверяется только на вид UUID и на выбор отправителя пробы не влияет; подпись отправителя в пробу не добавляется. Письмо идёт мимо очереди и журнала: в `GET /api/mailing/log` его не будет, в лимиты общего домена и квоту организации оно не входит.

Частота ограничена: 5 запросов в минуту на организацию, отдельный счётчик от проб раздела «Рассылка». Превышение возвращает `400`, а не `429`. Нужна возможность `mailing` в плане.

Где что: `/api/mail/*` — настройка почты (домены и отправители) и письмо о выдаче документа одного материала; `/api/mailing/*` — раздел «Рассылка»: шаблоны обоих потоков, расчёт получателей, отправка по нескольким материалам, журнал, сводка и рассылки без документа. Сохранённый шаблон проверяют пробой `POST /api/mailing/test` — там переменные берутся из первой строки таблицы, а вложением идёт настоящий документ.

## Запрос

| Параметр | Тип | Обязателен | Описание |
|---|---|---|---|
| documentId | string (UUID) | да | Материал организации (не удалённый) |

Тело (лишние поля игнорируются):

| Поле | Тип | Обязательно | Описание |
|---|---|---|---|
| subject | string | да | Тема, 1–300 символов после обрезки пробелов |
| bodyHtml | string | да | HTML тела, до 50 000 символов |
| attachGeneratedFile | boolean | нет | Обещает ли письмо вложение; по умолчанию `true` — тогда в пробе появится врезка |
| senderId | string (UUID) | нет | Принимается ради совместимости с телом сохранения; на пробу не влияет |

```json
{
  "subject": "Ваш сертификат, %name",
  "bodyHtml": "<p>Здравствуйте, %name!</p><p>Сертификат во вложении.</p>",
  "attachGeneratedFile": true
}
```

## Ответ

`201 Created`

| Поле | Тип | Описание |
|---|---|---|
| to | string | Адрес, на который ушло письмо |

```json
{ "to": "director@example.ru" }
```

## Ошибки

| Код | Когда |
|---|---|
| 400 | `documentId` не UUID: «Некорректный идентификатор» |
| 400 | `subject` пустой: «Введите тему письма»; длиннее 300: «Too big: expected string to have <=300 characters» |
| 400 | `bodyHtml` отсутствует: «Invalid input: expected string, received undefined»; длиннее 50 000: «Too big: expected string to have <=50000 characters» |
| 400 | `senderId` не UUID: «Invalid UUID»; `attachGeneratedFile` не булево: «Invalid input: expected boolean, received string» |
| 400 | Слишком часто: «Слишком часто. Подождите N секунд и попробуйте снова» |
| 400 | Вход по токену, а выдавшего токен уже нет в организации: «Пробное письмо по токену уходит человеку, который выдал токен, а его в организации уже нет. Отправьте пробу из кабинета.» |
| 400 | Почта не настроена: «Отправка писем не настроена — обратитесь в поддержку» |
| 401 | Нет токена или сессии |
| 403 | Возможности `mailing` нет в плане: ««Рассылка писем участникам» не входит в ваш план. Напишите нам — добавим.» Срок плана вышел: «Срок плана «…» закончился, поэтому раздел «Рассылка писем участникам» сейчас недоступен. Уже выданные документы остаются действительными. Напишите нам — обсудим продление.» См. [Аутентификация](../../authentication.md) |
| 404 | Материала нет, он удалён или чужой: «Документ не найден» |
| 500 | Почтовый шлюз не принял письмо |

## Пример

```bash
curl -X POST "https://vruchay.ru/api/mail/templates/$DOCUMENT_ID/test-send" \
  -H "Authorization: Bearer $VRUCHAY_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"subject":"Ваш сертификат, %name","bodyHtml":"<p>Здравствуйте, %name!</p>","attachGeneratedFile":true}'
```

---

<!-- endpoints/mail/post-mail-templates-documentId -->
> method: POST | path: /api/mail/templates/{documentId} | auth: token | roles: any | rate_limit: none

# POST /api/mail/templates/{documentId}

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

Поток здесь фиксирован: `transactional`. Рекламное письмо с обязательным рекламодателем сохраняется через `POST /api/mailing/templates/{documentId}`. Лишние поля в теле игнорируются (схема не строгая), в отличие от `mailing`.

## Запрос

| Параметр | Тип | Обязателен | Описание |
|---|---|---|---|
| documentId | string (UUID) | да | Материал организации (не удалённый) |

Тело:

| Поле | Тип | Обязательно | Описание |
|---|---|---|---|
| senderId | string (UUID) | нет | Отправитель организации из `POST /api/mail/senders`. Не указан — отправитель по умолчанию |
| subject | string | да | Тема, 1–300 символов после обрезки пробелов |
| bodyHtml | string | да | HTML тела, до 50 000 символов |
| attachGeneratedFile | boolean | нет | Прикладывать выпущенный файл; по умолчанию `true` |

```json
{
  "senderId": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
  "subject": "Ваш сертификат, %name",
  "bodyHtml": "<p>Здравствуйте, %name!</p><p>Сертификат во вложении.</p>",
  "attachGeneratedFile": true
}
```

## Ответ

`201 Created` — сохранённый шаблон (без вложенного отправителя).

| Поле | Тип | Описание |
|---|---|---|
| id | string (UUID) | Идентификатор шаблона |
| orgId | string (UUID) | Организация |
| documentId | string (UUID) | Материал |
| senderId | string (UUID) \| null | Отправитель |
| kind | string | `transactional` |
| advertiserName | null | Не используется |
| subject | string | Тема |
| bodyHtml | string | HTML после санитизации |
| attachGeneratedFile | boolean | Прикладывать ли файл |
| createdAt, updatedAt | string (ISO 8601) | Даты |

```json
{
  "id": "5d4c3b2a-1f0e-4d9c-8b7a-6f5e4d3c2b1a",
  "orgId": "7b2a4c6e-1d3f-4e5a-9b8c-0d1e2f3a4b5c",
  "documentId": "9e8d7c6b-5a4f-4e3d-2c1b-0a9f8e7d6c5b",
  "senderId": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
  "kind": "transactional",
  "advertiserName": null,
  "subject": "Ваш сертификат, %name",
  "bodyHtml": "<p>Здравствуйте, %name!</p><p>Сертификат во вложении.</p>",
  "attachGeneratedFile": true,
  "createdAt": "2026-08-21T10:00:00.000Z",
  "updatedAt": "2026-08-21T10:05:00.000Z"
}
```

## Ошибки

| Код | Когда |
|---|---|
| 400 | `documentId` не UUID: «Некорректный идентификатор» |
| 400 | `subject` пустой: «Введите тему письма»; длиннее 300: «Too big: expected string to have <=300 characters» |
| 400 | `bodyHtml` отсутствует: «Invalid input: expected string, received undefined»; длиннее 50 000: «Too big: expected string to have <=50000 characters» |
| 400 | `senderId` не UUID: «Invalid UUID»; `attachGeneratedFile` не булево: «Invalid input: expected boolean, received string» |
| 401 | Нет токена или сессии |
| 404 | Материала нет, он удалён или чужой: «Документ не найден» |
| 404 | `senderId` не принадлежит организации: «Отправитель не найден» |

## Пример

```bash
curl -X POST "https://vruchay.ru/api/mail/templates/$DOCUMENT_ID" \
  -H "Authorization: Bearer $VRUCHAY_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"subject":"Ваш сертификат, %name","bodyHtml":"<p>Здравствуйте, %name!</p>","attachGeneratedFile":true}'
```

---

<!-- endpoints/mailing/get-mailing-log -->
> method: GET | path: /api/mailing/log | auth: token | roles: any | rate_limit: none

# GET /api/mailing/log

Последние 200 писем организации, новые первыми, с переведённой на человеческий язык причиной недоставки и сводкой по состояниям. Пагинации нет; `take: 200` не настраивается.

Отличие от `GET /api/mail/emails`: там сырые записи журнала целиком (включая `provider`, `providerMessageId`, `attachFile`), здесь — витрина разбора: только нужные поля, название материала подставлено, а вместо ответа шлюза («550 5.1.1 … User unknown») стоит объяснение и признак, есть ли смысл повторять. Именно этим признаком пользуется `POST /api/mailing/resend`.

Сводка `summary` и число `unseenProblems` считаются по всем письмам организации (или по материалу и проекту, если они указаны) — они не ограничены ни двумя сотнями строк, ни фильтром `problemsOnly`.

С `projectId` в журнал попадают письма материалов этого проекта и рассылки без документа, отмеченные этим проектом. `none` — письма материалов без проекта и рассылки без проекта. Перенос материала в другой проект переносит и его письма.

## Запрос

Строка запроса — строгая схема: неизвестный параметр отвергается.

| Параметр | Тип | Обязателен | Описание |
|---|---|---|---|
| documentId | string (UUID) | нет | Только письма по этому материалу; материал проверяется на принадлежность организации |
| projectId | string (UUID) \| `none` | нет | Только письма проекта ([`GET /api/projects`](../projects/get-projects.md)); `none` — без проекта |
| search | string | нет | Поиск без учёта регистра, до 200 символов: часть адреса получателя, темы, названия материала или названия рассылки без документа. На `summary` и `unseenProblems` не влияет |
| problemsOnly | `true` \| `false` | нет | `true` — только `bounced` и `failed`. Любое другое значение из списка равно «показывать всё» |

## Ответ

`200 OK`

| Поле | Тип | Описание |
|---|---|---|
| items | array | Письма, новые первыми |
| items[].id | string (UUID) | Идентификатор письма |
| items[].documentId | string (UUID) \| null | Материал |
| items[].documentTitle | string | Название материала; у письма рассылки без документа — название рассылки; пустая строка, если не нашлось ни того, ни другого |
| items[].toEmail | string | Адрес получателя; после срока хранения обезличивается в пустую строку |
| items[].subject | string | Тема с подставленными переменными |
| items[].status | string | `queued`, `sent`, `delivered`, `opened`, `bounced`, `failed` |
| items[].kind | `transactional` \| `marketing` | Поток письма |
| items[].queuedAt | string (ISO 8601) | Постановка в очередь |
| items[].sentAt | string (ISO 8601) \| null | Приём шлюзом |
| items[].problem | object \| null | Разбор недоставки; `null` — с письмом всё в порядке |
| items[].problem.reason | string | Причина словами |
| items[].problem.retryable | boolean | Есть ли смысл повторять отправку на этот адрес |
| items[].problem.details | string \| null | Ответ шлюза как есть; `null`, если провайдер причину не сообщил |
| items[].problemSeen | boolean | Недоставку уже посмотрели (`POST /api/mailing/problems/seen`); у дошедших писем всегда `false` |
| summary | object | Число писем по каждому состоянию: `{"sent": 120, "bounced": 3}` |
| unseenProblems | number | Сколько недошедших писем ещё не смотрели — по ним главная кабинета напоминает о недоставке |

```json
{
  "items": [
    {
      "id": "c0ffee00-1111-4222-8333-444455556666",
      "documentId": "9e8d7c6b-5a4f-4e3d-2c1b-0a9f8e7d6c5b",
      "documentTitle": "Грамоты участникам",
      "toEmail": "petrov@vruchay.ru",
      "subject": "Ваш сертификат, Петров Олег Иванович",
      "status": "bounced",
      "kind": "transactional",
      "queuedAt": "2026-08-21T10:05:00.000Z",
      "sentAt": "2026-08-21T10:05:12.000Z",
      "problem": {
        "reason": "Такого адреса не существует",
        "retryable": false,
        "details": "550 5.1.1 Recipient address rejected: User unknown"
      },
      "problemSeen": false
    }
  ],
  "summary": { "sent": 120, "delivered": 96, "bounced": 3 },
  "unseenProblems": 1
}
```

Значения `problem.reason`: «Ящик получателя переполнен — письмо не поместилось», «Такого адреса не существует», «Почта получателя отклонила письмо по своим правилам», «Почта получателя временно недоступна — попробуйте позже», «Не удалось связаться с почтовым сервером получателя», «Домен получателя не принимает почту», «Письмо не доставлено» (причина не разобрана), «Письмо не доставлено, причину почта получателя не сообщила» (провайдер её не прислал).

## Ошибки

| Код | Когда |
|---|---|
| 400 | `documentId` не UUID: «Invalid UUID»; `projectId` не UUID и не `none`: «Некорректный идентификатор проекта» |
| 400 | `problemsOnly` не `true` и не `false`: «Invalid option: expected one of "true"\|"false"» |
| 400 | `search` длиннее 200 символов: «Too big: expected string to have <=200 characters» |
| 400 | Неизвестный параметр строки запроса: «Unrecognized key: "…"» |
| 401 | Нет токена или сессии |
| 404 | Материала нет, он удалён или чужой: «Материал не найден» |
| 404 | Проекта нет или он чужой: «Проект не найден» |

## Пример

```bash
curl "https://vruchay.ru/api/mailing/log?documentId=$DOCUMENT_ID&problemsOnly=true" \
  -H "Authorization: Bearer $VRUCHAY_TOKEN"
```

---

<!-- endpoints/mailing/get-mailing-stats -->
> method: GET | path: /api/mailing/stats | auth: token | roles: any | rate_limit: none

# GET /api/mailing/stats

Сколько писем организации ушло, дошло, прочитано и не дошло за отрезок дней — итогом, по дням и с разбивкой по материалам и рассылкам без документа. Это экран «Рассылка → Сводка» в кабинете.

Дни считаются по Москве, письмо относится к дню постановки в очередь, а не к дню события: строка «15 сентября» отвечает на вопрос, что стало с письмами, отправленными в этот день. Прочтение через неделю попадёт в тот же день. Без дат отрезок — последние 30 дней, считая сегодняшний; задать можно и один конец: без `to` это сегодня, без `from` — 29 дней до `to`. Отрезок не длиннее 366 дней.

Счёт нарастающий, по состоянию письма сейчас:

- `total` — все письма;
- `sent` — принятые шлюзом: `sent`, `delivered`, `opened` и `bounced` (письмо ушло и вернулось);
- `delivered` — `delivered` и `opened`;
- `opened` — прочитанные;
- `failed` — не дошедшие: `bounced` и `failed` (шлюз не принял вовсе);
- `queued` — ещё в очереди.

Адресов в сводке нет — только счётчики. Сами письма — в `GET /api/mailing/log`.

С `projectId` считаются письма материалов этого проекта и рассылки без документа, отмеченные этим проектом; `none` — материалы и рассылки без проекта.

Где что: `/api/mailing/*` — раздел «Рассылка»: шаблоны обоих потоков, расчёт получателей, отправка по нескольким материалам, журнал, сводка и рассылки без документа; `/api/mail/*` — настройка почты (домены и отправители) и письмо о выдаче документа одного материала.

## Запрос

Строка запроса — строгая схема: неизвестный параметр отвергается.

| Параметр | Тип | Обязателен | Описание |
|---|---|---|---|
| from | string (`ГГГГ-ММ-ДД`) | нет | Первый день отрезка по Москве, включительно |
| to | string (`ГГГГ-ММ-ДД`) | нет | Последний день отрезка по Москве, включительно; по умолчанию сегодня |
| projectId | string (UUID) \| `none` | нет | Только письма проекта ([`GET /api/projects`](../projects/get-projects.md)); `none` — без проекта |

## Ответ

`200 OK`

| Поле | Тип | Описание |
|---|---|---|
| from | string | Первый день отрезка, `ГГГГ-ММ-ДД` |
| to | string | Последний день отрезка, `ГГГГ-ММ-ДД` |
| totals | object | Итог за отрезок: `total`, `sent`, `delivered`, `opened`, `failed`, `queued` |
| days | array | По строке на каждый день отрезка, пустые дни — нулями |
| days[].day | string | День, `ГГГГ-ММ-ДД` |
| days[].total … days[].queued | number | Те же счётчики, что в `totals` |
| sources | array | Разбивка по источникам, больше писем — выше; не больше 100 строк |
| sources[].id | string (UUID) | Материал или рассылка без документа |
| sources[].type | `document` \| `mailing` | Письма материала (всех его шаблонов вместе) или рассылки без документа |
| sources[].title | string | Название материала или рассылки |
| sources[].eventName | string | Мероприятие материала; у рассылки — пустая строка |
| sources[].total … sources[].queued | number | Те же счётчики, что в `totals` |

```json
{
  "from": "2026-09-13",
  "to": "2026-10-12",
  "totals": { "total": 62, "sent": 60, "delivered": 55, "opened": 31, "failed": 4, "queued": 1 },
  "days": [
    { "day": "2026-09-13", "total": 0, "sent": 0, "delivered": 0, "opened": 0, "failed": 0, "queued": 0 },
    { "day": "2026-09-14", "total": 47, "sent": 47, "delivered": 44, "opened": 25, "failed": 3, "queued": 0 }
  ],
  "sources": [
    {
      "id": "9e8d7c6b-5a4f-4e3d-2c1b-0a9f8e7d6c5b",
      "type": "document",
      "title": "Грамоты участникам",
      "eventName": "Конкурс «Мастер года»",
      "total": 47, "sent": 47, "delivered": 44, "opened": 25, "failed": 3, "queued": 0
    },
    {
      "id": "e1d2c3b4-a5f6-4e7d-8c9b-0a1f2e3d4c5b",
      "type": "mailing",
      "title": "Приглашение на церемонию",
      "eventName": "",
      "total": 15, "sent": 13, "delivered": 11, "opened": 6, "failed": 1, "queued": 1
    }
  ]
}
```

В примере `days` сокращён: на самом деле в нём по строке на каждый из 30 дней.

## Ошибки

| Код | Когда |
|---|---|
| 400 | Дата не в виде `ГГГГ-ММ-ДД`: «Дата в виде ГГГГ-ММ-ДД; Такой даты нет»; несуществующая дата вроде `2026-02-31`: «Такой даты нет» |
| 400 | `from` позже `to`: «Начало отрезка позже конца»; отрезок длиннее года: «Отрезок не длиннее 366 дней» |
| 400 | `projectId` не UUID и не `none`: «Некорректный идентификатор проекта» |
| 400 | Неизвестный параметр строки запроса: «Unrecognized key: "…"» |
| 401 | Нет токена или сессии |
| 404 | Проекта нет или он чужой: «Проект не найден» |

## Пример

```bash
curl "https://vruchay.ru/api/mailing/stats?from=2026-09-01&to=2026-09-30" \
  -H "Authorization: Bearer $VRUCHAY_TOKEN"
```

---

<!-- endpoints/mailing/get-mailing-templates-documentId -->
> method: GET | path: /api/mailing/templates/{documentId} | auth: token | roles: any | rate_limit: none

# GET /api/mailing/templates/{documentId}

Возвращает шаблон письма для материала в указанном потоке — вместе с отправителем. Поток обязателен: у материала может быть два шаблона, транзакционный (`transactional`, выдача документа) и рекламный (`marketing`), и они хранятся отдельными записями, которые никогда не перезаписывают друг друга.

Чем раздел `mailing` отличается от `mail`: `mail` — это настройка почты материала (домены, отправители, письмо о выдаче) и отправка со страницы материала, и там поток всегда транзакционный. `mailing` — самостоятельный раздел «Рассылка»: оба потока, несколько материалов за раз, список адресов руками, проверка согласия на рекламу и журнал доставки с переведёнными причинами. Транзакционный шаблон здесь и в `GET /api/mail/templates/{documentId}` — одна и та же запись, читаемая двумя маршрутами.

Ещё одно отличие от `mail`: здесь материал сначала проверяется на принадлежность организации, поэтому чужой или удалённый даёт 404, а не пустой ответ.

## Запрос

| Параметр | Тип | Обязателен | Описание |
|---|---|---|---|
| documentId | string (UUID) | да | Материал организации (не удалённый) |
| kind | `transactional` \| `marketing` | да | Поток письма; параметр строки запроса |

## Ответ

`200 OK`. Шаблона этого потока ещё нет — тело пустое (сервис возвращает `null`).

| Поле | Тип | Описание |
|---|---|---|
| id | string (UUID) | Идентификатор шаблона |
| orgId | string (UUID) | Организация |
| documentId | string (UUID) | Материал |
| senderId | string (UUID) \| null | Отправитель организации; `null` — письма идут с общего адреса сервиса |
| kind | `transactional` \| `marketing` | Поток |
| advertiserName | string \| null | Рекламодатель; заполнен только у рекламного шаблона |
| subject | string | Тема; допускает переменные строки получателя |
| bodyHtml | string | HTML тела после санитизации |
| attachGeneratedFile | boolean | Прикладывать ли выпущенный файл |
| createdAt, updatedAt | string (ISO 8601) | Даты |
| sender | object \| null | Отправитель целиком (поля — как в `POST /api/mail/senders`) |

```json
{
  "id": "5d4c3b2a-1f0e-4d9c-8b7a-6f5e4d3c2b1a",
  "orgId": "7b2a4c6e-1d3f-4e5a-9b8c-0d1e2f3a4b5c",
  "documentId": "9e8d7c6b-5a4f-4e3d-2c1b-0a9f8e7d6c5b",
  "senderId": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
  "kind": "marketing",
  "advertiserName": "ООО «Развитие»",
  "subject": "Мероприятия сезона, %name",
  "bodyHtml": "<p>Здравствуйте, %name!</p>",
  "attachGeneratedFile": false,
  "createdAt": "2026-08-21T10:00:00.000Z",
  "updatedAt": "2026-08-21T10:00:00.000Z",
  "sender": {
    "id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
    "orgId": "7b2a4c6e-1d3f-4e5a-9b8c-0d1e2f3a4b5c",
    "domainId": "0f6d1e3a-9c1b-4a4e-8b6a-2f1a5c7d9e01",
    "email": "awards@vruchay.ru",
    "displayName": "Центр «Развитие»",
    "isDefault": false,
    "createdAt": "2026-08-20T09:20:00.000Z"
  }
}
```

## Ошибки

| Код | Когда |
|---|---|
| 400 | `documentId` не UUID: «Некорректный идентификатор» |
| 400 | `kind` не передан или не из списка: «Invalid option: expected one of "transactional"\|"marketing"» |
| 401 | Нет токена или сессии |
| 404 | Материала нет, он удалён или чужой: «Материал не найден» |

## Пример

```bash
curl "https://vruchay.ru/api/mailing/templates/$DOCUMENT_ID?kind=marketing" \
  -H "Authorization: Bearer $VRUCHAY_TOKEN"
```

---

<!-- endpoints/mailing/get-mailing-text-id -->
> method: GET | path: /api/mailing/text/{id} | auth: token | roles: any | rate_limit: none

# GET /api/mailing/text/{id}

Одна рассылка без документа: текст, поток, отправитель, проект — и сколько писем по ней уже поставлено. После первого письма текст замораживается (`locked: true`): тело собирается из сохранённого текста в момент отправки, и правка задним числом переписала бы письма, стоящие в очереди. Дослать тем, кого забыли, можно и после этого — `POST /api/mailing/text/{id}/send`.

Сюда проходят только рассылки без документа. Идентификатор шаблона письма материала даёт 404, как и чужой.

Где что: `/api/mailing/*` — раздел «Рассылка»: шаблоны обоих потоков, расчёт получателей, отправка по нескольким материалам, журнал, сводка и рассылки без документа; `/api/mail/*` — настройка почты (домены и отправители) и письмо о выдаче документа одного материала.

## Запрос

| Параметр | Тип | Обязателен | Описание |
|---|---|---|---|
| id | string (UUID) | да | Рассылка из `POST /api/mailing/text` |

## Ответ

`200 OK` — поля черновика из `POST /api/mailing/text` и ещё два:

| Поле | Тип | Описание |
|---|---|---|
| id | string (UUID) | Идентификатор рассылки |
| name | string | Название |
| kind | `transactional` \| `marketing` | Поток |
| subject | string | Тема |
| bodyHtml | string | HTML после очистки |
| advertiserName | string \| null | Рекламодатель; `null` у транзакционной |
| senderId | string (UUID) \| null | Отправитель; `null` — по умолчанию |
| projectId | string (UUID) \| null | Проект; `null` — без проекта |
| createdAt | string (ISO 8601) | Дата создания |
| sent | number | Сколько писем по рассылке создано за всё время — в любом состоянии, включая недоставленные |
| locked | boolean | `true`, если `sent > 0`: текст больше не меняется |

```json
{
  "id": "e1d2c3b4-a5f6-4e7d-8c9b-0a1f2e3d4c5b",
  "name": "Приглашение на церемонию",
  "kind": "transactional",
  "subject": "Церемония награждения 24 октября",
  "bodyHtml": "<p>Здравствуйте!</p><p>Ждём вас 24 октября в 15:00 в актовом зале.</p>",
  "advertiserName": null,
  "senderId": null,
  "projectId": "5a6b7c8d-9e0f-4a1b-8c2d-3e4f5a6b7c8d",
  "createdAt": "2026-10-12T08:30:00.000Z",
  "sent": 15,
  "locked": true
}
```

## Ошибки

| Код | Когда |
|---|---|
| 400 | `id` не UUID: «Некорректный идентификатор» |
| 401 | Нет токена или сессии |
| 404 | Рассылки нет, она чужая или это шаблон письма материала: «Рассылка не найдена» |

## Пример

```bash
curl "https://vruchay.ru/api/mailing/text/$MAILING_ID" \
  -H "Authorization: Bearer $VRUCHAY_TOKEN"
```

---

<!-- endpoints/mailing/patch-mailing-text-id -->
> method: PATCH | path: /api/mailing/text/{id} | auth: token | roles: any | rate_limit: none

# PATCH /api/mailing/text/{id}

Переписывает черновик рассылки без документа. Править можно, пока по рассылке не создано ни одного письма; после первой отправки текст заморожен (`locked` в `GET /api/mailing/text/{id}`), и для нового текста заводят новую рассылку.

Несмотря на метод, правка не частичная: тело — та же полная схема, что у `POST /api/mailing/text`, и черновик перезаписывается целиком. Отсюда два следствия:

- `senderId` без поля сбрасывается в `null` — письма пойдут с отправителя по умолчанию. Чтобы сохранить отправителя, передайте его снова;
- поток можно сменить: при переходе на `transactional` рекламодатель стирается.

Исключение одно — `projectId`: без поля проект не меняется, `null` переносит рассылку в «Без проекта».

Где что: `/api/mailing/*` — раздел «Рассылка»: шаблоны обоих потоков, расчёт получателей, отправка по нескольким материалам, журнал, сводка и рассылки без документа; `/api/mail/*` — настройка почты (домены и отправители) и письмо о выдаче документа одного материала.

## Запрос

| Параметр | Тип | Обязателен | Описание |
|---|---|---|---|
| id | string (UUID) | да | Рассылка из `POST /api/mailing/text` |

Тело — строгая схема, лишние поля запрещены; поля и ограничения те же, что в `POST /api/mailing/text`.

| Поле | Тип | Обязательно | Описание |
|---|---|---|---|
| kind | `transactional` \| `marketing` | да | Поток письма |
| name | string | да | Название, 1–200 символов |
| subject | string | да | Тема, 1–300 символов |
| bodyHtml | string | да | HTML тела, 1–50 000 символов |
| senderId | string (UUID) | нет | Отправитель организации; без поля — по умолчанию |
| projectId | string (UUID) \| null | нет | Проект; без поля — не менять, `null` — без проекта |
| advertiserName | string | при `marketing` | Рекламодатель, 1–200 символов |

```json
{
  "kind": "marketing",
  "name": "Осенний сезон",
  "subject": "Мероприятия сезона",
  "bodyHtml": "<p>Приглашаем на осенний старт.</p>",
  "senderId": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
  "advertiserName": "ООО «Развитие»"
}
```

## Ответ

`200 OK` — черновик после изменения; поля те же, что в ответе `POST /api/mailing/text` (без `sent` и `locked`).

```json
{
  "id": "e1d2c3b4-a5f6-4e7d-8c9b-0a1f2e3d4c5b",
  "name": "Осенний сезон",
  "kind": "marketing",
  "subject": "Мероприятия сезона",
  "bodyHtml": "<p>Приглашаем на осенний старт.</p>",
  "advertiserName": "ООО «Развитие»",
  "senderId": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
  "projectId": "5a6b7c8d-9e0f-4a1b-8c2d-3e4f5a6b7c8d",
  "createdAt": "2026-10-12T08:30:00.000Z"
}
```

## Ошибки

| Код | Когда |
|---|---|
| 400 | `id` не UUID: «Некорректный идентификатор» |
| 400 | Нарушения схемы тела — те же сообщения, что в `POST /api/mailing/text` |
| 400 | По рассылке уже создано хотя бы одно письмо: «Рассылка уже ушла, текст менять нельзя — создайте новую» |
| 401 | Нет токена или сессии |
| 404 | Рассылки нет, она чужая или это шаблон письма материала: «Рассылка не найдена» |
| 404 | `senderId` не принадлежит организации: «Отправитель не найден» |
| 404 | Проекта нет или он чужой: «Проект не найден» |

## Пример

```bash
curl -X PATCH "https://vruchay.ru/api/mailing/text/$MAILING_ID" \
  -H "Authorization: Bearer $VRUCHAY_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"kind":"transactional","name":"Приглашение на церемонию","subject":"Церемония переносится на 31 октября","bodyHtml":"<p>Церемония переносится на 31 октября, 15:00.</p>"}'
```

---

<!-- endpoints/mailing/post-mailing-audience -->
> method: POST | path: /api/mailing/audience | auth: token | roles: any | rate_limit: none

# POST /api/mailing/audience

Показывает до отправки, кому письмо уйдёт и кому не уйдёт, — тем же расчётом, что и `POST /api/mailing/send`. Ничего не отправляет и ничего не пишет в базу. Считается по **сохранённому** шаблону, а не по набранному в форме тексту: пока письмо не сохранено через `POST /api/mailing/templates/{documentId}`, в ответе приходит `refusal` и пустой список.

Материал здесь один — в отличие от отправки, где их может быть до двадцати. Причины пропуска: нет адреса, некорректный адрес, адрес уже встречался в этой же рассылке, по этому материалу в этом потоке письмо на адрес уже уходило, нет согласия на рекламную рассылку (только для `marketing`), документ по строке ещё не выпущен (когда шаблон обещает вложение).

`refusal` — не ошибка запроса, а причина, по которой отправка не состоится: нет шаблона, отправитель удалён, домен отправителя перестал быть подтверждённым, почта вообще не настроена, у рекламного письма стёрли рекламодателя. Лимиты общего домена здесь **не** проверяются — они срабатывают только при самой отправке.

## Запрос

Тела запроса — строгая схема, лишние поля запрещены.

| Поле | Тип | Обязательно | Описание |
|---|---|---|---|
| documentId | string (UUID) | да | Материал организации (не удалённый) |
| kind | `transactional` \| `marketing` | да | Поток письма; шаблон ищется по нему же |
| source | `table` \| `manual` | да | Получатели из таблицы материала (только отмеченные строки) или из списка адресов |
| emails | string | нет | Список адресов одной строкой, до 60 000 символов; разделители — перевод строки, запятая, точка с запятой, пробел. Учитывается только при `source: "manual"` |

При `source: "manual"` адрес, найденный в таблице материала, получает её данные и выпущенный по строке файл; не найденный уходит без имени и без вложения.

```json
{
  "documentId": "9e8d7c6b-5a4f-4e3d-2c1b-0a9f8e7d6c5b",
  "kind": "transactional",
  "source": "table"
}
```

## Ответ

`201 Created`

| Поле | Тип | Описание |
|---|---|---|
| documentId | string (UUID) | Материал |
| title | string | Название материала |
| refusal | string \| null | Почему отправка невозможна; `null` — препятствий нет |
| willSend | number | Сколько писем уйдёт |
| letters | array | Первые 50 получателей: `{email, name}`; список намеренно обрезан |
| skipped | array | Все пропущенные: `{name, email, reason}` |

```json
{
  "documentId": "9e8d7c6b-5a4f-4e3d-2c1b-0a9f8e7d6c5b",
  "title": "Грамоты участникам",
  "refusal": null,
  "willSend": 2,
  "letters": [
    { "email": "ivanova@vruchay.ru", "name": "Иванова Мария Сергеевна" },
    { "email": "petrov@vruchay.ru", "name": "Петров Олег Иванович" }
  ],
  "skipped": [
    { "name": "Кузнецов Олег", "email": "", "reason": "нет адреса" },
    { "name": "Сидорова Анна", "email": "anna@", "reason": "некорректный адрес «anna@»" }
  ]
}
```

Возможные значения `reason`: «нет адреса», «некорректный адрес «…»», «адрес уже есть в этой рассылке», «по этому материалу письмо на этот адрес уже уходило», «нет согласия на рекламную рассылку», «документ ещё не создан».

Возможные значения `refusal`: «Письмо ещё не сохранено: считаем по сохранённому тексту, а не по набранному» (или «Рекламное письмо ещё не сохранено — укажите тему и рекламодателя, дальше оно сохранится само» при `kind: "marketing"`), «Отправитель не найден», «Домен vruchay.ru не подтверждён — отправка невозможна», «Отправка писем не настроена — обратитесь в поддержку», «Укажите рекламодателя: по закону получатель должен видеть, чья это реклама».

## Ошибки

| Код | Когда |
|---|---|
| 400 | `documentId` не UUID: «Invalid UUID» |
| 400 | `kind` или `source` не из списка: «Invalid option: expected one of "transactional"\|"marketing"», «Invalid option: expected one of "table"\|"manual"» |
| 400 | Лишнее поле в теле: «Unrecognized key: "…"» |
| 400 | `emails` длиннее 60 000 символов: «Too big: expected string to have <=60000 characters» |
| 401 | Нет токена или сессии |
| 404 | Материала нет, он удалён или чужой: «Материал не найден» |

## Пример

```bash
curl -X POST "https://vruchay.ru/api/mailing/audience" \
  -H "Authorization: Bearer $VRUCHAY_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"documentId":"'"$DOCUMENT_ID"'","kind":"transactional","source":"table"}'
```

---

<!-- endpoints/mailing/post-mailing-problems-seen -->
> method: POST | path: /api/mailing/problems/seen | auth: token | roles: any | rate_limit: none

# POST /api/mailing/problems/seen

Отмечает все недошедшие письма организации (`bounced` и `failed`) посмотренными. Главная кабинета напоминает только о непосмотренных: строка «1 письмо не дошло — посмотрите, что случилось» гаснет и загорается снова, когда не дойдёт следующее письмо. Кабинет зовёт этот запрос сам, когда открывают папку «Не доставлено» или скрывают строку на главной.

Отметка общая на организацию, а не на человека: недоставку, которую разобрал коллега, второй раз разбирать не нужно. Сами письма не меняются и из журнала не пропадают — состояние, причина и повторная отправка через `POST /api/mailing/resend` остаются прежними.

Повторный вызов ничего не ломает: уже отмеченные письма не трогаются, в ответе будет `0`.

## Запрос

Тела нет.

## Ответ

`200 OK`

| Поле | Тип | Описание |
|---|---|---|
| seen | number | Сколько писем отмечено этим вызовом |

```json
{ "seen": 3 }
```

## Ошибки

| Код | Когда |
|---|---|
| 401 | Нет токена или сессии |

## Пример

```bash
curl -X POST "https://vruchay.ru/api/mailing/problems/seen" \
  -H "Authorization: Bearer $VRUCHAY_TOKEN"
```

---

<!-- endpoints/mailing/post-mailing-resend -->
> method: POST | path: /api/mailing/resend | auth: token | roles: any | rate_limit: none

# POST /api/mailing/resend

Ставит новые письма вместо недоставленных по одному материалу: берутся все письма в состояниях `bounced` и `failed`, по каждому адресу — последняя попытка. Прежние записи не трогаются и остаются в журнале: он обязан помнить, что первая попытка была и чем кончилась.

Кого пропускаем: адреса, по которым после провала письмо уже переотправлялось и держится живым (`queued`, `sent`, `delivered`, `opened`); адреса с безнадёжной причиной — несуществующий ящик, отказ по правилам домена, домен не принимает почту (`problem.retryable: false` в `GET /api/mailing/log`); обезличенные адреса, у которых после срока хранения стёрт адрес участника, — таких в ответе не видно вовсе. Безнадёжные возвращаются в `skipped` с той же формулировкой, что и в журнале: чинить их надо в таблице получателей, а не повтором.

Поток считается отдельно: недоставленное рекламное письмо не закрывается ушедшим письмом о выдаче документа и наоборот. Новое письмо наследует у прошлой попытки поток, строку, шаблон, файл и тему. Два нажатия подряд не удвоят письма — дубли отсекает уникальный индекс. Отправка асинхронная, как и в `POST /api/mailing/send`. Действие пишется в журнал аудита (`mailing.resend`).

## Запрос

Тело — строгая схема, лишние поля запрещены.

| Поле | Тип | Обязательно | Описание |
|---|---|---|---|
| documentId | string (UUID) | да | Материал организации (не удалённый) |

```json
{ "documentId": "9e8d7c6b-5a4f-4e3d-2c1b-0a9f8e7d6c5b" }
```

## Ответ

`201 Created`

| Поле | Тип | Описание |
|---|---|---|
| title | string | Название материала |
| queued | number | Сколько писем поставлено заново |
| skipped | array | Безнадёжные адреса: `{name, email, reason}`; `name` всегда пустая строка — берётся адрес из письма, а не строка таблицы |

```json
{
  "title": "Грамоты участникам",
  "queued": 4,
  "skipped": [
    {
      "name": "",
      "email": "petrov@vruchay.ru",
      "reason": "Такого адреса не существует"
    }
  ]
}
```

## Ошибки

| Код | Когда |
|---|---|
| 400 | `documentId` не UUID: «Invalid UUID»; лишнее поле в теле: «Unrecognized key: "…"» |
| 401 | Нет токена или сессии |
| 403 | Возможности `mailing` нет в плане: ««Рассылка писем участникам» не входит в ваш план. Напишите нам — добавим.» Срок плана вышел: «Срок плана «…» закончился, поэтому раздел «Рассылка писем участникам» сейчас недоступен. Уже выданные документы остаются действительными. Напишите нам — обсудим продление.» См. [Аутентификация](../../authentication.md) |
| 404 | Материала нет, он удалён или чужой: «Материал не найден» |

Проверок отправителя, потока и лимитов общего домена здесь нет: письмо уже было создано раньше, повтор его не пересоздаёт заново, а копирует.

## Пример

```bash
curl -X POST "https://vruchay.ru/api/mailing/resend" \
  -H "Authorization: Bearer $VRUCHAY_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"documentId":"'"$DOCUMENT_ID"'"}'
```

---

<!-- endpoints/mailing/post-mailing-send -->
> method: POST | path: /api/mailing/send | auth: token | roles: any | rate_limit: none

# POST /api/mailing/send

Ставит письма в очередь по каждому из выбранных материалов — до двадцати за вызов, по письму на материал каждому получателю. Ответ приходит сразу после постановки; отправка асинхронная: воркер шлёт не быстрее пяти писем в секунду и делает до пяти попыток с нарастающей задержкой (первая — 15 с). Что с письмами дальше — `GET /api/mailing/log`.

Отличие от `POST /api/mail/send/{documentId}`: там один материал, только транзакционный поток и только отмеченные строки таблицы, а повторный вызов подряд роняет всю отправку. Здесь материалов несколько, поток выбирается явно, получателей можно задать списком адресов, а дубли пропускаются молча — второе нажатие «Отправить» не удвоит письма и не сломает рассылку.

Расчёт получателей тот же, что в `POST /api/mailing/audience`; проверить состав лучше заранее. Рекламная рассылка уходит только тем, кто дал согласие; письмо дополняется пометкой «Реклама», рекламодателем и ссылкой отписки.

Если у шаблона материала не указан отправитель (`senderId: null`), перед постановкой проверяется лимит общего домена: не больше `SHARED_DOMAIN_BATCH_LIMIT` (по умолчанию 300) писем за раз и `SHARED_DOMAIN_DAILY_LIMIT` (по умолчанию 500) за сутки на организацию. Проверка смотрит на шаблон, а не на адрес, с которого письма фактически уйдут: лимит действует и тогда, когда у организации есть основной отправитель на своём домене. Чтобы лимита не было, укажите в шаблоне отправителя на подтверждённом домене.

Материалы обрабатываются по очереди, и отказ по третьему материалу не отменяет уже поставленные письма по первым двум: ответа в этом случае не будет вовсе, а часть писем уже уйдёт. Действие необратимое и пишется в журнал аудита (`mailing.send`) — с потоком, источником и числом писем по каждому материалу.

## Запрос

Тело — строгая схема, лишние поля запрещены.

| Поле | Тип | Обязательно | Описание |
|---|---|---|---|
| documentIds | string[] (UUID) | да | Материалы, 1–20 |
| kind | `transactional` \| `marketing` | да | Поток письма; шаблон каждого материала ищется по нему же |
| source | `table` \| `manual` | да | Получатели из отмеченных строк таблицы или из списка адресов |
| emails | string | при `source: "manual"` | Список адресов одной строкой, до 60 000 символов; разделители — перевод строки, запятая, точка с запятой, пробел |

```json
{
  "documentIds": [
    "9e8d7c6b-5a4f-4e3d-2c1b-0a9f8e7d6c5b",
    "1c2d3e4f-5a6b-4c7d-8e9f-0a1b2c3d4e5f"
  ],
  "kind": "transactional",
  "source": "table"
}
```

## Ответ

`201 Created`

| Поле | Тип | Описание |
|---|---|---|
| results | array | По записи на материал |
| results[].documentId | string (UUID) | Материал |
| results[].title | string | Название материала |
| results[].queued | number | Сколько писем поставлено в очередь по этому материалу |
| results[].skipped | array | Пропущенные: `{name, email, reason}` |
| queued | number | Сумма по всем материалам |

```json
{
  "results": [
    {
      "documentId": "9e8d7c6b-5a4f-4e3d-2c1b-0a9f8e7d6c5b",
      "title": "Грамоты участникам",
      "queued": 47,
      "skipped": [
        { "name": "Кузнецов Олег", "email": "", "reason": "нет адреса" }
      ]
    },
    {
      "documentId": "1c2d3e4f-5a6b-4c7d-8e9f-0a1b2c3d4e5f",
      "title": "Сертификаты судей",
      "queued": 12,
      "skipped": []
    }
  ],
  "queued": 59
}
```

`queued` — число действительно созданных писем. Адреса, которым по этому материалу и потоку уже писали, отсеиваются расчётом и попадают в `skipped`; те, что проскочили в промежуток между расчётом и записью (два быстрых нажатия подряд), отсекает уникальный индекс базы — они выпадают молча, и в `skipped` их нет.

## Ошибки

| Код | Когда |
|---|---|
| 400 | `documentIds` пуст: «Выберите хотя бы один материал»; больше двадцати: «За раз можно разослать не больше двадцати материалов»; элемент не UUID: «Invalid UUID» |
| 400 | `source: "manual"` без адресов: «Укажите хотя бы один адрес» |
| 400 | `kind` или `source` не из списка; лишнее поле в теле: «Unrecognized key: "…"» |
| 400 | У материала нет шаблона этого потока: «Для материала «Грамоты участникам» письмо не настроено» |
| 400 | Поток шаблона не совпал с потоком отправки: «Рекламный текст нельзя отправить письмом о выдаче документа: такое письмо целиком считается рекламой» / «Для рекламной рассылки нужен рекламный текст письма, а не письмо о выдаче документа» |
| 400 | У рекламного шаблона нет рекламодателя: «Укажите рекламодателя: по закону получатель должен видеть, чья это реклама» |
| 400 | Отправитель шаблона удалён: «Отправитель не найден» |
| 400 | Домен отправителя не подтверждён: «Домен vruchay.ru не подтверждён — отправка невозможна» |
| 400 | Почта не настроена вовсе: «Отправка писем не настроена — обратитесь в поддержку» |
| 400 | Превышен разовый лимит общего домена: «За один раз с общего адреса уходит не больше 300 писем, а отмечено 350. Разбейте на несколько рассылок или подключите свой домен в настройках — со своего домена ограничения нет.» |
| 400 | Превышен суточный лимит общего домена: «За сутки с общего адреса уходит не больше 500 писем: сегодня уже отправлено 480, осталось 20. Подключите свой домен в настройках — со своего домена ограничения нет, и письма вызывают больше доверия.» |
| 401 | Нет токена или сессии |
| 403 | Возможности `mailing` нет в плане: ««Рассылка писем участникам» не входит в ваш план. Напишите нам — добавим.» Срок плана вышел: «Срок плана «…» закончился, поэтому раздел «Рассылка писем участникам» сейчас недоступен. Уже выданные документы остаются действительными. Напишите нам — обсудим продление.» См. [Аутентификация](../../authentication.md) |
| 404 | Материала нет, он удалён или чужой: «Материал не найден» |

## Пример

```bash
curl -X POST "https://vruchay.ru/api/mailing/send" \
  -H "Authorization: Bearer $VRUCHAY_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"documentIds":["'"$DOCUMENT_ID"'"],"kind":"transactional","source":"manual","emails":"ivanova@vruchay.ru, petrov@vruchay.ru"}'
```

---

<!-- endpoints/mailing/post-mailing-templates-documentId -->
> method: POST | path: /api/mailing/templates/{documentId} | auth: token | roles: any | rate_limit: none

# POST /api/mailing/templates/{documentId}

Создаёт или обновляет шаблон письма для материала в указанном потоке. У материала по одному шаблону на поток: повторный вызов с тем же `kind` перезаписывает свою запись и не трогает чужую — правка рекламного текста не меняет письмо о выдаче документа. HTML тела очищается от опасной разметки при сохранении, поэтому в ответе может прийти не то, что было отправлено.

Отличие от `POST /api/mail/templates/{documentId}`: там поток всегда `transactional` и лишние поля тела молча игнорируются. Здесь поток указывается явно, а схема строгая — попытка передать рекламодателя вместе с письмом о выдаче документа отклоняется на входе, а не «проходит и не применяется». Транзакционный шаблон при этом один и тот же: сохранённое здесь с `kind: "transactional"` видно и в `GET /api/mail/templates/{documentId}`.

Для рекламного письма `advertiserName` обязателен: получатель должен видеть, чья это реклама. Рекламное письмо сервис дополняет при отправке пометкой «Реклама», именем рекламодателя и ссылкой отписки — в тексте шаблона их писать не нужно.

## Запрос

| Параметр | Тип | Обязателен | Описание |
|---|---|---|---|
| documentId | string (UUID) | да | Материал организации (не удалённый) |

Тело — размеченное объединение по полю `kind`; лишние поля запрещены.

| Поле | Тип | Обязательно | Описание |
|---|---|---|---|
| kind | `transactional` \| `marketing` | да | Поток письма |
| subject | string | да | Тема, 1–300 символов после обрезки пробелов |
| bodyHtml | string | да | HTML тела, до 50 000 символов |
| attachGeneratedFile | boolean | нет | Прикладывать выпущенный файл; по умолчанию `true` |
| senderId | string (UUID) | нет | Отправитель организации из `POST /api/mail/senders`. Не указан — общий адрес сервиса |
| advertiserName | string | только при `kind: "marketing"` | Рекламодатель, 1–200 символов после обрезки пробелов; в транзакционном теле поле запрещено |

```json
{
  "kind": "marketing",
  "subject": "Мероприятия сезона, %name",
  "bodyHtml": "<p>Здравствуйте, %name! Приглашаем на осенний старт.</p>",
  "attachGeneratedFile": false,
  "senderId": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
  "advertiserName": "ООО «Развитие»"
}
```

## Ответ

`201 Created` — сохранённый шаблон (без вложенного отправителя).

| Поле | Тип | Описание |
|---|---|---|
| id | string (UUID) | Идентификатор шаблона |
| orgId | string (UUID) | Организация |
| documentId | string (UUID) | Материал |
| senderId | string (UUID) \| null | Отправитель |
| kind | `transactional` \| `marketing` | Поток |
| advertiserName | string \| null | Рекламодатель; у транзакционного шаблона всегда `null` |
| subject | string | Тема |
| bodyHtml | string | HTML после санитизации |
| attachGeneratedFile | boolean | Прикладывать ли файл |
| createdAt, updatedAt | string (ISO 8601) | Даты |

```json
{
  "id": "5d4c3b2a-1f0e-4d9c-8b7a-6f5e4d3c2b1a",
  "orgId": "7b2a4c6e-1d3f-4e5a-9b8c-0d1e2f3a4b5c",
  "documentId": "9e8d7c6b-5a4f-4e3d-2c1b-0a9f8e7d6c5b",
  "senderId": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
  "kind": "marketing",
  "advertiserName": "ООО «Развитие»",
  "subject": "Мероприятия сезона, %name",
  "bodyHtml": "<p>Здравствуйте, %name! Приглашаем на осенний старт.</p>",
  "attachGeneratedFile": false,
  "createdAt": "2026-08-21T10:00:00.000Z",
  "updatedAt": "2026-08-21T10:05:00.000Z"
}
```

## Ошибки

| Код | Когда |
|---|---|
| 400 | `documentId` не UUID: «Некорректный идентификатор» |
| 400 | `kind` отсутствует или не из списка: «Invalid discriminator value. Expected 'transactional' \| 'marketing'» |
| 400 | Лишнее поле в теле (например, `advertiserName` при `kind: "transactional"`): «Unrecognized key: "advertiserName"» |
| 400 | `subject` пустой: «Введите тему письма»; длиннее 300: «Too big: expected string to have <=300 characters» |
| 400 | `bodyHtml` отсутствует: «Invalid input: expected string, received undefined»; длиннее 50 000: «Too big: expected string to have <=50000 characters» |
| 400 | `advertiserName` при `kind: "marketing"` пуст: «Укажите рекламодателя: получатель должен видеть, чья это реклама»; из сервиса — «Укажите рекламодателя: по закону получатель должен видеть, чья это реклама» |
| 400 | `senderId` не UUID: «Invalid UUID»; `attachGeneratedFile` не булево: «Invalid input: expected boolean, received string» |
| 401 | Нет токена или сессии |
| 404 | Материала нет, он удалён или чужой: «Материал не найден» |
| 404 | `senderId` не принадлежит организации: «Отправитель не найден» |

## Пример

```bash
curl -X POST "https://vruchay.ru/api/mailing/templates/$DOCUMENT_ID" \
  -H "Authorization: Bearer $VRUCHAY_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"kind":"marketing","subject":"Мероприятия сезона, %name","bodyHtml":"<p>Здравствуйте, %name!</p>","attachGeneratedFile":false,"advertiserName":"ООО «Развитие»"}'
```

---

<!-- endpoints/mailing/post-mailing-test -->
> method: POST | path: /api/mailing/test | auth: token | roles: any | rate_limit: «5 запросов в минуту на организацию»

# POST /api/mailing/test

Отправляет письмо по сохранённому шаблону материала на свою почту — чтобы посмотреть на него своими глазами. Получатель в запросе не задаётся: «проверочная отправка» на чужой адрес была бы рассылкой без согласия, только названной иначе.

- Вход по сессии — письмо уходит на почту вошедшего.
- Вход по токену API — тому, кто выдал токен: своей почты у токена нет, а за то, что шлёт программа, отвечает выдавший. Если этого человека больше нет в организации, запрос отклоняется — пробу надо отправить из кабинета.

Письмо идёт мимо очереди и мимо журнала доставки: в `GET /api/mailing/log` оно не появится, состояний у него нет. Тема получает приставку `[Проверка]`, переменные подставляются из первой строки таблицы материала, вложением идёт выпущенный по ней файл. Если шаблон обещает вложение, а файла по первой строке ещё нет, письмо всё равно уйдёт — с предупреждающей врезкой вверху. Так же — врезкой и без файла — оно уйдёт, если PDF больше, чем принимает почта: участнику такое письмо придёт без вложения, с кнопкой «Открыть документ». У рекламного письма ссылка отписки в проверочном письме ведёт в никуда намеренно: отписываться не от чего.

Письмо уходит с отправителя по умолчанию — основного на подтверждённом домене организации или с общего адреса сервиса, — даже если в шаблоне указан свой `senderId`.

Частота ограничена: 5 запросов в минуту на организацию, один счётчик с `POST /api/mailing/text/{id}/test`. Превышение возвращает `400`, а не `429`, — это не общий ThrottleGuard, а отдельный счётчик раздела.

Где что: `/api/mailing/*` — раздел «Рассылка»: шаблоны обоих потоков, расчёт получателей, отправка по нескольким материалам, журнал, сводка и рассылки без документа; `/api/mail/*` — настройка почты (домены и отправители) и письмо о выдаче документа одного материала. Черновик письма о выдаче до сохранения проверяет `POST /api/mail/templates/{documentId}/test-send`.

## Запрос

Тело — строгая схема, лишние поля запрещены.

| Поле | Тип | Обязательно | Описание |
|---|---|---|---|
| documentId | string (UUID) | да | Материал организации (не удалённый) |
| kind | `transactional` \| `marketing` | да | Поток; шаблон ищется по нему же |

```json
{
  "documentId": "9e8d7c6b-5a4f-4e3d-2c1b-0a9f8e7d6c5b",
  "kind": "transactional"
}
```

## Ответ

`201 Created`

| Поле | Тип | Описание |
|---|---|---|
| to | string | Адрес, на который ушло письмо: почта вошедшего или выдавшего токен |
| title | string | Название материала |
| attached | boolean | Ушёл ли документ вложением: `false`, если файла нет или он больше, чем принимает почта |

```json
{
  "to": "director@vruchay.ru",
  "title": "Грамоты участникам",
  "attached": true
}
```

## Ошибки

| Код | Когда |
|---|---|
| 400 | `documentId` не UUID: «Invalid UUID»; `kind` не из списка; лишнее поле: «Unrecognized key: "…"» |
| 400 | Шаблона этого потока нет: «Сначала сохраните письмо» |
| 400 | Слишком часто: «Слишком часто. Подождите N секунд и попробуйте снова» |
| 400 | Вход по токену, а выдавшего токен уже нет в организации: «Пробное письмо по токену уходит человеку, который выдал токен, а его в организации уже нет. Отправьте пробу из кабинета.» |
| 400 | Почта не настроена: «Отправка писем не настроена — обратитесь в поддержку» |
| 401 | Нет токена или сессии |
| 403 | Возможности `mailing` нет в плане: ««Рассылка писем участникам» не входит в ваш план. Напишите нам — добавим.» Срок плана вышел: «Срок плана «…» закончился, поэтому раздел «Рассылка писем участникам» сейчас недоступен. Уже выданные документы остаются действительными. Напишите нам — обсудим продление.» См. [Аутентификация](../../authentication.md) |
| 404 | Материала нет, он удалён или чужой: «Материал не найден» |
| 500 | Почтовый шлюз не принял письмо |

## Пример

```bash
curl -X POST "https://vruchay.ru/api/mailing/test" \
  -H "Authorization: Bearer $VRUCHAY_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"documentId":"'"$DOCUMENT_ID"'","kind":"transactional"}'
```

---

<!-- endpoints/mailing/post-mailing-text-id-audience -->
> method: POST | path: /api/mailing/text/{id}/audience | auth: token | roles: any | rate_limit: none

# POST /api/mailing/text/{id}/audience

Показывает до отправки, кому письмо рассылки без документа уйдёт и кому не уйдёт, — тем же расчётом, что и `POST /api/mailing/text/{id}/send`, по тому же списку адресов. Ничего не отправляет и ничего не пишет в базу. Считается по сохранённому черновику.

Список разбирается так же, как при отправке: разделители — перевод строки, запятая, точка с запятой, пробел; адреса приводятся к нижнему регистру, повторы схлопываются молча ещё до расчёта. Дальше по каждому адресу:

- некорректный — пропускается;
- письмо этой рассылки на адрес уже стоит в очереди, отправлено, доставлено или прочитано — пропускается, второе такое же письмо не уйдёт. Адрес, по которому прошлое письмо не дошло (`bounced` или `failed`), снова попадает в отправку;
- у рекламной рассылки — адрес без действующего согласия на рекламу пропускается. Согласие считается по последней записи для адреса: отзыв по ссылке отписки перекрывает данное раньше согласие.

`refusal` — не ошибка запроса, а причина, по которой отправка не состоится: отправитель удалён, домен отправителя перестал быть подтверждённым, почта вообще не настроена, у рекламной рассылки нет рекламодателя. Лимиты общего домена здесь **не** проверяются — они срабатывают только при отправке.

Где что: `/api/mailing/*` — раздел «Рассылка»: шаблоны обоих потоков, расчёт получателей, отправка по нескольким материалам, журнал, сводка и рассылки без документа; `/api/mail/*` — настройка почты (домены и отправители) и письмо о выдаче документа одного материала. Для писем по материалу то же делает `POST /api/mailing/audience`.

## Запрос

| Параметр | Тип | Обязателен | Описание |
|---|---|---|---|
| id | string (UUID) | да | Рассылка из `POST /api/mailing/text` |

Тело — строгая схема, лишние поля запрещены.

| Поле | Тип | Обязательно | Описание |
|---|---|---|---|
| emails | string | да | Список адресов одной строкой, 1–60 000 символов после обрезки пробелов |

```json
{ "emails": "ivanova@example.com\npetrov@example.com, Ivanova@example.com; anna@" }
```

## Ответ

`201 Created`

| Поле | Тип | Описание |
|---|---|---|
| refusal | string \| null | Почему отправка невозможна; `null` — препятствий нет |
| willSend | number | Сколько писем уйдёт |
| letters | array | Первые 50 получателей: `{email, name}`; `name` всегда пустая строка — таблицы у рассылки нет. Список намеренно обрезан |
| skipped | array | Все пропущенные: `{name, email, reason}`; в `name` стоит сам адрес |

```json
{
  "refusal": null,
  "willSend": 2,
  "letters": [
    { "email": "ivanova@example.com", "name": "" },
    { "email": "petrov@example.com", "name": "" }
  ],
  "skipped": [
    { "name": "anna@", "email": "anna@", "reason": "некорректный адрес «anna@»" }
  ]
}
```

Возможные значения `reason`: «некорректный адрес «…»», «в этой рассылке письмо на этот адрес уже уходило», «нет согласия на рекламную рассылку» (только у `marketing`).

Возможные значения `refusal`: «Отправитель не найден», «Домен example.ru не подтверждён — отправка невозможна», «Отправка писем не настроена — обратитесь в поддержку», «Укажите рекламодателя: по закону получатель должен видеть, чья это реклама».

## Ошибки

| Код | Когда |
|---|---|
| 400 | `id` не UUID: «Некорректный идентификатор» |
| 400 | `emails` нет: «Invalid input: expected string, received undefined»; пустой: «Укажите хотя бы один адрес»; длиннее 60 000: «Too big: expected string to have <=60000 characters» |
| 400 | Лишнее поле в теле: «Unrecognized key: "…"» |
| 401 | Нет токена или сессии |
| 404 | Рассылки нет, она чужая или это шаблон письма материала: «Рассылка не найдена» |

## Пример

```bash
curl -X POST "https://vruchay.ru/api/mailing/text/$MAILING_ID/audience" \
  -H "Authorization: Bearer $VRUCHAY_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"emails":"ivanova@example.com, petrov@example.com"}'
```

---

<!-- endpoints/mailing/post-mailing-text-id-send -->
> method: POST | path: /api/mailing/text/{id}/send | auth: token | roles: any | rate_limit: none

# POST /api/mailing/text/{id}/send

Ставит в очередь письма рассылки без документа — по письму на каждый адрес из списка. Ответ приходит сразу после постановки; отправка асинхронная: воркер шлёт не быстрее пяти писем в секунду и делает до пяти попыток с нарастающей задержкой (первая — 15 с). Что с письмами дальше — `GET /api/mailing/log` (в `documentTitle` у таких писем стоит название рассылки) и `GET /api/mailing/stats`.

Расчёт получателей тот же, что в `POST /api/mailing/text/{id}/audience`; проверить состав лучше заранее. Рекламная рассылка уходит только тем, кто дал согласие; письмо дополняется пометкой «Реклама», рекламодателем и ссылкой отписки.

Повторный вызов безопасен: адреса, которым письмо этой рассылки уже стоит в очереди или ушло, отсеиваются расчётом и попадают в `skipped`, а проскочившие в промежуток между расчётом и записью (два быстрых нажатия подряд) отсекает уникальный индекс базы — они выпадают молча. Поэтому дослать забытым можно тем же маршрутом: передайте новые адреса или весь список заново. Адрес, по которому прошлое письмо не дошло, получит письмо снова.

Тело каждого письма собирается из сохранённого текста в момент отправки. Поэтому после первого созданного письма черновик замораживается — `PATCH /api/mailing/text/{id}` начнёт отвечать отказом. Если в очередь не встало ни одного письма (`queued: 0`), черновик остаётся открытым для правки.

Если у рассылки нет своего отправителя (`senderId: null`), письма идут с отправителя по умолчанию — основного на подтверждённом домене организации, а если его нет, с общего адреса сервиса. Лимит общего домена проверяется перед постановкой у всякой рассылки без `senderId`: не больше `SHARED_DOMAIN_BATCH_LIMIT` (по умолчанию 300) писем за раз и `SHARED_DOMAIN_DAILY_LIMIT` (по умолчанию 500) за сутки на организацию. Проверка смотрит на шаблон, а не на то, с какого адреса письма фактически уйдут, поэтому, чтобы лимита не было, отправителя на своём домене нужно указать в рассылке явно.

Действие необратимое и пишется в журнал аудита (`mailing.send`, источник `text`) — с числом поставленных и пропущенных писем.

Где что: `/api/mailing/*` — раздел «Рассылка»: шаблоны обоих потоков, расчёт получателей, отправка по нескольким материалам, журнал, сводка и рассылки без документа; `/api/mail/*` — настройка почты (домены и отправители) и письмо о выдаче документа одного материала. Письма с документами по материалам рассылает `POST /api/mailing/send`.

## Запрос

| Параметр | Тип | Обязателен | Описание |
|---|---|---|---|
| id | string (UUID) | да | Рассылка из `POST /api/mailing/text` |

Тело — строгая схема, лишние поля запрещены.

| Поле | Тип | Обязательно | Описание |
|---|---|---|---|
| emails | string | да | Список адресов одной строкой, 1–60 000 символов после обрезки пробелов; разделители — перевод строки, запятая, точка с запятой, пробел |

```json
{ "emails": "ivanova@example.com\npetrov@example.com\nanna@" }
```

## Ответ

`201 Created`

| Поле | Тип | Описание |
|---|---|---|
| name | string | Название рассылки |
| queued | number | Сколько писем действительно поставлено в очередь |
| skipped | array | Пропущенные: `{name, email, reason}`; в `name` стоит сам адрес |

```json
{
  "name": "Приглашение на церемонию",
  "queued": 2,
  "skipped": [
    { "name": "anna@", "email": "anna@", "reason": "некорректный адрес «anna@»" }
  ]
}
```

Возможные значения `reason` — те же, что в `POST /api/mailing/text/{id}/audience`.

## Ошибки

| Код | Когда |
|---|---|
| 400 | `id` не UUID: «Некорректный идентификатор» |
| 400 | `emails` нет: «Invalid input: expected string, received undefined»; пустой: «Укажите хотя бы один адрес»; длиннее 60 000: «Too big: expected string to have <=60000 characters»; лишнее поле: «Unrecognized key: "…"» |
| 400 | Отправитель рассылки удалён: «Отправитель не найден» |
| 400 | Домен отправителя не подтверждён: «Домен example.ru не подтверждён — отправка невозможна» |
| 400 | Почта не настроена вовсе: «Отправка писем не настроена — обратитесь в поддержку» |
| 400 | У рекламной рассылки нет рекламодателя: «Укажите рекламодателя: по закону получатель должен видеть, чья это реклама» |
| 400 | Превышен разовый лимит общего домена: «За один раз с общего адреса уходит не больше 300 писем, а отмечено 350. Разбейте на несколько рассылок или подключите свой домен в настройках — со своего домена ограничения нет.» |
| 400 | Превышен суточный лимит общего домена: «За сутки с общего адреса уходит не больше 500 писем: сегодня уже отправлено 480, осталось 20. Подключите свой домен в настройках — со своего домена ограничения нет, и письма вызывают больше доверия.» |
| 401 | Нет токена или сессии |
| 403 | Возможности `mailing` нет в плане: ««Рассылка писем участникам» не входит в ваш план. Напишите нам — добавим.» Срок плана вышел: «Срок плана «…» закончился, поэтому раздел «Рассылка писем участникам» сейчас недоступен. Уже выданные документы остаются действительными. Напишите нам — обсудим продление.» См. [Аутентификация](../../authentication.md) |
| 404 | Рассылки нет, она чужая или это шаблон письма материала: «Рассылка не найдена» |

## Пример

```bash
curl -X POST "https://vruchay.ru/api/mailing/text/$MAILING_ID/send" \
  -H "Authorization: Bearer $VRUCHAY_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"emails":"ivanova@example.com, petrov@example.com"}'
```

---

<!-- endpoints/mailing/post-mailing-text-id-test -->
> method: POST | path: /api/mailing/text/{id}/test | auth: token | roles: any | rate_limit: «5 запросов в минуту на организацию»

# POST /api/mailing/text/{id}/test

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

- Вход по сессии — письмо уходит на почту вошедшего.
- Вход по токену API — тому, кто выдал токен: своей почты у токена нет, а за то, что шлёт программа, отвечает выдавший. Если этого человека больше нет в организации, запрос отклоняется — пробу надо отправить из кабинета.

Тема получает приставку `[Проверка]`, `%email` подставляется адресом получателя пробы. У рекламной рассылки внизу стоят пометка «Реклама» с рекламодателем и ссылка отписки — в пробе она ведёт в никуда намеренно: отписываться не от чего.

Проба уходит с отправителя по умолчанию — основного на подтверждённом домене организации или с общего адреса сервиса, — даже если у рассылки указан свой `senderId`; подпись отправителя в неё не добавляется. Письмо идёт мимо очереди и журнала: в `GET /api/mailing/log` его не будет, в счёт `sent` рассылки оно не входит и черновик не замораживает.

Частота ограничена: 5 запросов в минуту на организацию — один счётчик с `POST /api/mailing/test`. Превышение возвращает `400`, а не `429`. Нужна возможность `mailing` в плане.

Где что: `/api/mailing/*` — раздел «Рассылка»: шаблоны обоих потоков, расчёт получателей, отправка по нескольким материалам, журнал, сводка и рассылки без документа; `/api/mail/*` — настройка почты (домены и отправители) и письмо о выдаче документа одного материала.

## Запрос

| Параметр | Тип | Обязателен | Описание |
|---|---|---|---|
| id | string (UUID) | да | Рассылка из `POST /api/mailing/text` |

Тело не нужно.

## Ответ

`201 Created`

| Поле | Тип | Описание |
|---|---|---|
| to | string | Адрес, на который ушло письмо |

```json
{ "to": "director@example.ru" }
```

## Ошибки

| Код | Когда |
|---|---|
| 400 | `id` не UUID: «Некорректный идентификатор» |
| 400 | Слишком часто: «Слишком часто. Подождите N секунд и попробуйте снова» |
| 400 | Вход по токену, а выдавшего токен уже нет в организации: «Пробное письмо по токену уходит человеку, который выдал токен, а его в организации уже нет. Отправьте пробу из кабинета.» |
| 400 | Почта не настроена: «Отправка писем не настроена — обратитесь в поддержку» |
| 401 | Нет токена или сессии |
| 403 | Возможности `mailing` нет в плане: ««Рассылка писем участникам» не входит в ваш план. Напишите нам — добавим.» Срок плана вышел: «Срок плана «…» закончился, поэтому раздел «Рассылка писем участникам» сейчас недоступен. Уже выданные документы остаются действительными. Напишите нам — обсудим продление.» См. [Аутентификация](../../authentication.md) |
| 404 | Рассылки нет, она чужая или это шаблон письма материала: «Рассылка не найдена» |
| 500 | Почтовый шлюз не принял письмо |

## Пример

```bash
curl -X POST "https://vruchay.ru/api/mailing/text/$MAILING_ID/test" \
  -H "Authorization: Bearer $VRUCHAY_TOKEN"
```

---

<!-- endpoints/mailing/post-mailing-text -->
> method: POST | path: /api/mailing/text | auth: token | roles: any | rate_limit: none

# POST /api/mailing/text

Заводит черновик рассылки без документа — письма, к которому прикладывать нечего: приглашение на следующее мероприятие, перенос церемонии, напоминание забрать грамоту. Получатели — список адресов, таблица материала здесь не участвует.

Порядок работы: создать черновик → при необходимости поправить (`PATCH /api/mailing/text/{id}`) → проверить состав (`POST /api/mailing/text/{id}/audience`) и письмо себе (`POST /api/mailing/text/{id}/test`) → отправить (`POST /api/mailing/text/{id}/send`). Отдельного списка рассылок в API нет: идентификатор возвращается здесь, а позже находится в сводке `GET /api/mailing/stats` (строки `type: "mailing"`).

Поток выбирается явно. Транзакционное письмо уходит всем адресам из списка. Рекламное — только тем, кто дал согласие на рекламу, с пометкой «Реклама», рекламодателем и ссылкой отписки; без рекламодателя оно не сохраняется. Вложения у такой рассылки нет по определению, поэтому поле `attachGeneratedFile` отклоняется, а не игнорируется.

Таблицы нет, поэтому из переменных в теме и тексте подставляется только `%email` — адрес получателя. Остальные (`%name` и прочие) превращаются в пустую строку. HTML тела очищается от опасной разметки при сохранении.

Без `senderId` письма уйдут с отправителя по умолчанию — основного на подтверждённом домене организации или с общего адреса сервиса (там действуют лимиты общего домена). Подтверждённость домена при сохранении не проверяется — только при отправке.

Где что: `/api/mailing/*` — раздел «Рассылка»: шаблоны обоих потоков, расчёт получателей, отправка по нескольким материалам, журнал, сводка и рассылки без документа; `/api/mail/*` — настройка почты (домены и отправители) и письмо о выдаче документа одного материала.

## Запрос

Тело — строгая схема, лишние поля запрещены.

| Поле | Тип | Обязательно | Описание |
|---|---|---|---|
| kind | `transactional` \| `marketing` | да | Поток письма |
| name | string | да | Название рассылки, 1–200 символов после обрезки пробелов; по нему рассылка узнаётся в журнале и сводке |
| subject | string | да | Тема, 1–300 символов после обрезки пробелов |
| bodyHtml | string | да | HTML тела, 1–50 000 символов после обрезки пробелов |
| senderId | string (UUID) | нет | Отправитель организации (`GET /api/mail/domains`) |
| projectId | string (UUID) \| null | нет | Проект ([`GET /api/projects`](../projects/get-projects.md)); `null` или без поля — «Без проекта» |
| advertiserName | string | при `marketing` | Рекламодатель, 1–200 символов после обрезки пробелов. У `transactional` поле запрещено |

```json
{
  "kind": "transactional",
  "name": "Приглашение на церемонию",
  "subject": "Церемония награждения 24 октября",
  "bodyHtml": "<p>Здравствуйте!</p><p>Ждём вас 24 октября в 15:00 в актовом зале.</p>",
  "projectId": "5a6b7c8d-9e0f-4a1b-8c2d-3e4f5a6b7c8d"
}
```

## Ответ

`201 Created` — сохранённый черновик.

| Поле | Тип | Описание |
|---|---|---|
| id | string (UUID) | Идентификатор рассылки — для всех остальных маршрутов `/api/mailing/text/{id}` |
| name | string | Название |
| kind | `transactional` \| `marketing` | Поток |
| subject | string | Тема |
| bodyHtml | string | HTML после очистки |
| advertiserName | string \| null | Рекламодатель; `null` у транзакционной |
| senderId | string (UUID) \| null | Отправитель; `null` — по умолчанию |
| projectId | string (UUID) \| null | Проект; `null` — без проекта |
| createdAt | string (ISO 8601) | Дата создания |

```json
{
  "id": "e1d2c3b4-a5f6-4e7d-8c9b-0a1f2e3d4c5b",
  "name": "Приглашение на церемонию",
  "kind": "transactional",
  "subject": "Церемония награждения 24 октября",
  "bodyHtml": "<p>Здравствуйте!</p><p>Ждём вас 24 октября в 15:00 в актовом зале.</p>",
  "advertiserName": null,
  "senderId": null,
  "projectId": "5a6b7c8d-9e0f-4a1b-8c2d-3e4f5a6b7c8d",
  "createdAt": "2026-10-12T08:30:00.000Z"
}
```

## Ошибки

| Код | Когда |
|---|---|
| 400 | `kind` нет или он не из списка: «Invalid discriminator value. Expected 'transactional' \| 'marketing'» |
| 400 | `name` пустой: «Назовите рассылку»; `subject` пустой: «Введите тему письма»; `bodyHtml` пустой: «Напишите текст письма»; превышена длина: «Too big: expected string to have <=… characters» |
| 400 | `senderId` не UUID: «Invalid UUID»; `projectId` не UUID: «Некорректный идентификатор проекта» |
| 400 | У `marketing` рекламодатель пустой: «Укажите рекламодателя: получатель должен видеть, чья это реклама»; поля нет вовсе: «Invalid input: expected string, received undefined» |
| 400 | Лишнее поле — например, `attachGeneratedFile` или `advertiserName` у транзакционной: «Unrecognized key: "…"» |
| 401 | Нет токена или сессии |
| 404 | `senderId` не принадлежит организации: «Отправитель не найден» |
| 404 | Проекта нет или он чужой: «Проект не найден» |

## Пример

```bash
curl -X POST "https://vruchay.ru/api/mailing/text" \
  -H "Authorization: Bearer $VRUCHAY_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"kind":"transactional","name":"Приглашение на церемонию","subject":"Церемония награждения 24 октября","bodyHtml":"<p>Ждём вас 24 октября в 15:00.</p>"}'
```

---

<!-- endpoints/mcp/post-mcp -->
> method: POST | path: /mcp | auth: token | roles: any | rate_limit: «300 вызовов в минуту на организацию»

# POST /mcp

Сервер [Model Context Protocol](https://modelcontextprotocol.io) на сайте:
`https://vruchay.ru/mcp` — без `/api`, его вписывают в настройки агента руками.
Через него Claude, ChatGPT, Cursor и другие клиенты MCP работают с «Вручай»
инструментами: создают документ, верстают лист, смотрят превью, загружают
получателей, выпускают и рассылают. Как подключить клиента — в [руководстве по MCP](../../mcp.md).

Каждый инструмент — тонкая обёртка над REST этого же API: сервер делает обычный
запрос к `/api/…` внутри процесса, с тем же заголовком `Authorization`, с каким
пришёл агент. Поэтому права роли, возможности плана, проверка тела, пределы
частоты и журнал действий работают так же, как у REST, а ошибки приходят теми же
словами.

## Протокол

- **JSON-RPC 2.0 поверх HTTP POST** (транспорт Streamable HTTP): одно сообщение
  в теле запроса — один JSON в ответе, `Content-Type: application/json`.
  Потоков SSE и уведомлений от сервера нет.
- **Без сессий**: сервер не выдаёт `Mcp-Session-Id` и не держит состояния между
  запросами — всё нужное есть в запросе и в токене.
- **Два поколения протокола на одном адресе.** Какое — решает сам запрос:
  - **`2026-07-28`** — без рукопожатия. Версия приходит в каждом запросе:
    в `params._meta["io.modelcontextprotocol/protocolVersion"]` и заголовком
    `MCP-Protocol-Version`. Заголовок `Mcp-Method` повторяет `method`, `Mcp-Name`
    у `tools/call` и `prompts/get` — имя (не-ASCII — в обёртке `=?base64?…?=`).
    Методы: `server/discover`, `tools/list`, `tools/call`, `prompts/list`,
    `prompts/get`. В каждом ответе — `resultType: "complete"` и
    `_meta["io.modelcontextprotocol/serverInfo"]`; списки — с `ttlMs` (час)
    и `cacheScope: "public"`.
  - **`2025-11-25`, `2025-06-18`, `2025-03-26`** — с рукопожатием `initialize`.
    На него сервер отвечает версией клиента, если знает её, иначе `2025-11-25`.
    Методы: `initialize`, `ping`, `tools/list`, `tools/call`, `prompts/list`,
    `prompts/get`. Ответ всегда `200`, ошибки — внутри JSON-RPC.
- **Уведомления** (сообщение без `id`, например `notifications/initialized`) —
  `202 Accepted` без тела.
- **Пачки** (массив сообщений) не поддерживаются — ошибка `-32600`.
- `GET /mcp` и `DELETE /mcp` — `405` с `Allow: POST`: потока уведомлений и сессий нет.
- **Ресурсов, выборки (sampling) и журнала (logging) нет** — `-32601`.

## Вход

Только заголовком `Authorization: Bearer vru_…` — токен из кабинета
(«Настройки → Токены API») или выданный [входом через OAuth](../oauth/post-oauth-token.md).
Cookie кабинета не принимаются: адрес вызывают программы, и вход в браузере не должен
давать чужой странице ходить сюда от имени человека.

Без токена или с неверным — `401` с заголовком, по которому Claude.ai и ChatGPT сами
находят вход через OAuth и предлагают человеку войти:

```http
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer resource_metadata="https://vruchay.ru/.well-known/oauth-protected-resource/mcp"
Content-Type: application/json

{"error":"unauthorized","message":"Нужен токен: Authorization: Bearer vru_… или вход через OAuth"}
```

Если заголовок `Authorization` был, но токен не подошёл (отозван, опечатка),
к `WWW-Authenticate` добавляется `, error="invalid_token"`. Описание входа —
[`GET /.well-known/oauth-authorization-server`](../oauth/get-well-known-oauth.md).

Адрес открыт для вызовов из браузера с любого сайта (`Access-Control-Allow-Origin: *`,
без учётных данных): так работают веб-клиенты вроде MCP Inspector. Предварительный
запрос `OPTIONS` получает `204`. Заголовок `Origin` сервер поэтому не сверяет:
спецификация требует этого против подмены DNS у локальных серверов, а наш вход —
только токеном в заголовке, который чужая страница взять не может.

## Пределы

- **300 запросов к `/mcp` в минуту на организацию** — считается каждый POST с верным
  токеном, включая `initialize` и уведомления. Сверх предела — `429` с ошибкой JSON-RPC
  `-32600` «Слишком часто. Повторите через N с.» и заголовком `Retry-After` (секунды).
- **Тело — до 2 МБ** (у остального API — 1 МБ): таблица получателей одним вызовом
  `import_recipients` — это около двух тысяч строк. Больше — `413`; большие таблицы
  загружайте файлом через REST.
- Пределы REST действуют внутри инструментов как обычно: выдача по одному —
  120 в минуту, превью — 30 в минуту, картинки по ссылке — 30 в минуту на организацию.
- Ответ инструмента — не больше 80 000 символов; длиннее обрезается с припиской
  «…(обрезано: ответ слишком длинный — сузьте запрос)».

## Ответы инструментов

`tools/call` отвечает `result` с `content` — массивом частей `{ "type": "text", "text": "…" }`
(у `preview_sheet` — ещё `{ "type": "image", "data": "<base64>", "mimeType": "image/jpeg" }`).
Текст — JSON ответа REST, у документов и листов сжатый: блоки листа — id, вид, место
(x, y, w, h в мм), имя и начало текста, без деревьев.

Ошибка инструмента — не ошибка протокола, а обычный `result` с `isError: true`, чтобы
агент прочитал причину и исправился:

| Текст | Когда |
|---|---|
| `Ошибка 404: Документ не найден` | REST ответил ошибкой — код и `message` как в REST; если были ошибки полей, следующей строкой `Поля: [{"field":"…","message":"…"}]` (до 10) |
| `Аргументы не подошли — documentId: Invalid UUID` | аргументы не прошли схему инструмента, запрос в REST не уходил (до 5 причин) |
| `Успело выполниться: {"added":3,"updated":0,"removed":0,"fixed":0}` | дописывается к ошибке `edit_recipients`, если часть правок прошла |
| `Не получилось: …` | инструмент упал по другой причине |

## Инструменты

`tools/list` отдаёт 36 инструментов. У каждого — `name`, `title`, `description`,
`inputSchema` (JSON Schema) и `annotations`:

- `readOnlyHint: true` — только читает; таким клиенты не просят подтверждения;
- у остальных `destructiveHint` (может стереть или заменить то, что было),
  `idempotentHint` (повтор с теми же аргументами ничего не добавит) и
  `openWorldHint` (действие выходит наружу: письма, чужие сайты и ссылки).

| Инструмент | Что делает | Пометки | Под ним |
|---|---|---|---|
| `get_account` | кто подключён, остаток (`left`, `expired`, `features`) и проекты; вызывать первым | читает | `GET /api/auth/me`, `/api/org/usage`, `/api/projects` |
| `get_guide` | документация для агента по теме: `start`, `layout`, `layout_reference`, `integrations`, `recipes`, `mcp`, `errors` | читает | — |
| `get_layout_catalog` | справочник вёрстки: единицы, форматы, шрифты, раскладки, служебные поля, умолчания блоков | читает | `GET /api/layout/catalog` |
| `list_documents` | документы, шаблоны (`templates`) или корзина (`trashed`), поиск, проект | читает | `GET /api/documents` |
| `get_document` | карточка и листы; блоки сжаты, `full: true` — макет целиком; `fields` — поля документа, `jobs` — 10 последних выпусков | читает | `GET /api/documents/{id}`, `…/fields`, `…/jobs` |
| `create_document` | новый документ: `format`, `orientation` или миллиметры, `preset`, `templateId`, `projectId`; `copyOf` — копия своего документа, с `asTemplate` — шаблоном | пишет | `POST /api/documents`; `POST …/{id}/duplicate` или `…/template`, затем `PATCH` |
| `update_document` | название, размер с `resize`, мероприятие, дата выдачи, `constants`, проверка, срок, проект | заменяет, повторяемо | `PATCH /api/documents/{id}` |
| `delete_document` | в корзину или обратно (`restore`); насовсем — только владелец в кабинете | заменяет, повторяемо | `DELETE /api/documents/{id}`, `POST …/restore` |
| `create_project` | новый проект | пишет | `POST /api/projects` |
| `edit_layout` | добавить, поменять, убрать блоки; в ответе сжатый лист и `problems` | заменяет | `PATCH …/sheets/{sheetId}/elements` |
| `set_layout` | записать лист целиком; заодно возвращает `problems` | заменяет, повторяемо | `PATCH …/sheets/{sheetId}`, затем `POST …/check` |
| `apply_preset` | свёрстать лист готовой раскладкой (`kind`, `style`, `titlePrinted`, `mode`) | заменяет | `POST …/sheets/{sheetId}/preset` |
| `add_sheet` | ещё один лист (оборот) | пишет | `POST /api/documents/{id}/sheets` |
| `delete_sheet` | удалить лист; единственный нельзя | заменяет, повторяемо | `DELETE …/sheets/{sheetId}` |
| `upload_image` | картинка по ссылке: `target: "element"` — для блока, `"background"` — фон листа | заменяет, наружу | `POST …/assets/from-url` или `…/background/from-url` |
| `get_upload_link` | одноразовая ссылка на 15 минут для `curl -F "file=@…"`: фон (`background`, с `sheetId`), картинка блока (`element`) или таблица (`recipients`, `mode`, `headers`) с диска | заменяет | [`POST /api/uploads`](../documents/post-uploads.md), файл — [`POST /api/v1/uploads/{link}`](../public/post-v1-uploads-link.md) |
| `check_sheet` | замечания вёрстки: за краем, не влезает, неизвестное поле, нет картинки, мелкий QR | читает | `POST …/sheets/{sheetId}/check` |
| `preview_sheet` | картинка листа с данными строки или образца; квоту не тратит | читает | `POST …/sheets/{sheetId}/preview` |
| `get_recipients` | колонки и строки таблицы страницами (`limit` до 500) | читает | `GET /api/documents/{id}/recipients` |
| `import_recipients` | таблица: колонки, заголовки, строки; `append` или `replace`; за вызов — до 2 МБ, около 2000 строк, больше — частями с `append` | заменяет | `POST …/recipients/import` |
| `edit_recipients` | точечно: добавить, поменять, удалить строки, отметить все; `applySuggestions` — принять однозначные исправления проверки | заменяет | `…/recipients/rows`, `…/recipients/checked`, `…/validation/fix` |
| `validate_recipients` | пустые имена, кривая почта, дубли, невлезающий текст; `scope` — `checked` или `all` | читает | `POST …/validation` |
| `start_generation` | выпуск по отмеченным строкам, тратит квоту; `resume` — доделать последний прерванный | пишет | `POST /api/documents/{id}/generate`; `POST /api/jobs/{jobId}/resume` |
| `get_job` | ход выпуска; `waitSeconds` — ждать до 60 с; при сбоях — причины по строкам | читает | `GET /api/jobs/{jobId}` (и `…/failures`) |
| `cancel_job` | остановить выпуск: недошедшие строки не выпускаются и квоту не тратят | повторяемо | `POST /api/jobs/{jobId}/cancel` |
| `get_download_link` | ссылка на скачивание на час, открывается без входа; `zip` или `pdf`, шаблон имён | читает | `POST /api/jobs/{jobId}/archive-link` |
| `issue_document` | один документ одному человеку, с письмом по желанию; `idempotencyKey` обязателен; тратит квоту | повторяемо, наружу | `POST /api/documents/{id}/issue` |
| `list_forms` | формы выдачи на сайте и по ссылке, с кодом вставки | читает | `GET /api/integrations/tilda` |
| `setup_form` | создать или изменить форму выдачи, в том числе `dailyLimit` | заменяет, наружу | `POST` / `PATCH /api/integrations/tilda…` |
| `set_mail_template` | шаблон письма с документом | заменяет, повторяемо | `POST /api/mail/templates/{documentId}` |
| `get_mail` | письмо документа: шаблон, кому уйдёт сейчас (`audience`), журнал доставки (`log`) | читает | `GET /api/mail/templates/{id}`, `POST /api/mailing/audience`, `GET /api/mailing/log` |
| `send_mail` | разослать (кто уже получил — пропускается), повторить недоставленные (`onlyFailed`) или переслать выданные (`fileIds`) | заменяет, наружу | `POST /api/mailing/send`, `…/mailing/resend`, `/api/registry/resend` |
| `search_registry` | реестр выданного: поиск и отборы; `fileId` — карточка и ссылка на файл на 15 минут | читает | `GET /api/registry`, `…/registry/files/{fileId}`, `GET /api/documents/files/{fileId}/url` |
| `revoke_documents` | отозвать проверку: без `expectedCount` — предпросмотр, с ним — отзыв; `restore` — вернуть | заменяет, повторяемо | `POST /api/registry/revoke/preview`, `POST /api/registry/revoke` |
| `reissue_documents` | выпустить новые документы вместо выданных по исправленным строкам; тратит квоту | заменяет | `POST /api/registry/reissue` |
| `api_request` | любой запрос к `/api/…`, которого нет отдельным инструментом, по [llms.txt](https://vruchay.ru/llms.txt) | заменяет, наружу | любой разрешённый путь |

«Читает» — `readOnlyHint: true`; «заменяет» — `destructiveHint: true`;
«повторяемо» — `idempotentHint: true`; «наружу» — `openWorldHint: true`;
«пишет» — всё это `false`.

## Подсказки

`prompts/list` отдаёт готовые задания со [страницы промптов](../../agent-prompt.md):
`do_task`, `design_sheet`, `issue_from_table`, `site_form`, `crm_integration`,
`new_event`, `fix_issued`. У каждой один необязательный аргумент `details` — что нужно своими
словами (до 4000 знаков). `prompts/get` возвращает одно сообщение `user` с текстом
задания и подробностями. В Claude Code подсказки видны командами
`/mcp__vruchay__design_sheet` и так далее.

`api_request` не пускает туда, что токену не положено или незачем: `/api/oauth`,
`/api/tokens`, `/api/team`, `/api/team-invite`, `/api/auth` (кроме `/api/auth/me`),
`/api/push`, `/api/render`, `/api/billing/checkout`, `/api/v1/…`, `/api/platform`,
`/api/invoices`, `/api/leads`. Путь — только латиница, цифры, `-`, `_`, `.` и `/`,
без сегментов `.` и `..`, `//` и закодированных разделителей; параметры — в `query`, а не в пути.
Ответ не в JSON (файл) через MCP не передаётся — для файлов есть `get_download_link`.

## Запрос

Заголовки: `Authorization: Bearer vru_…`, `Content-Type: application/json`;
в поколении `2026-07-28` ещё `MCP-Protocol-Version`, `Mcp-Method` и `Mcp-Name`.

Тело — одно сообщение JSON-RPC 2.0:

| Поле | Тип | Обязательно | Описание |
|---|---|---|---|
| `jsonrpc` | `"2.0"` | да | |
| `id` | string \| number \| null | нет | без `id` — уведомление, ответа не будет |
| `method` | string | да | см. «Протокол» |
| `params` | object | нет | для `tools/call` и `prompts/get` — `name` и `arguments` (объект); в поколении `2026-07-28` — ещё `_meta` с версией |

```json
{
  "jsonrpc": "2.0",
  "id": 7,
  "method": "tools/call",
  "params": {
    "name": "create_document",
    "arguments": { "title": "Сертификат участника", "format": "a4", "preset": { "kind": "attendee", "style": "modern" } }
  }
}
```

## Ответ

`200 OK` — ответ JSON-RPC.

На `initialize`:

```json
{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "protocolVersion": "2025-06-18",
    "capabilities": { "tools": { "listChanged": false }, "prompts": { "listChanged": false } },
    "serverInfo": { "name": "vruchay", "title": "Вручай", "version": "1.1.0" },
    "instructions": "«Вручай» (vruchay.ru) выпускает именные документы — …"
  }
}
```

На `server/discover` (поколение `2026-07-28`):

```json
{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "resultType": "complete",
    "supportedVersions": ["2026-07-28", "2025-11-25", "2025-06-18", "2025-03-26"],
    "capabilities": { "tools": { "listChanged": false }, "prompts": { "listChanged": false } },
    "instructions": "«Вручай» (vruchay.ru) выпускает именные документы — …",
    "ttlMs": 3600000,
    "cacheScope": "public",
    "_meta": { "io.modelcontextprotocol/serverInfo": { "name": "vruchay", "title": "Вручай", "version": "1.1.0" } }
  }
}
```

`instructions` — короткая памятка агенту: порядок работы (`get_account` → документ →
вёрстка с проверкой и превью → способ выдачи), единицы, подводные камни; подробности —
в `get_guide`.

На `tools/call` (текст сокращён: в настоящем ответе ещё мероприятие, `constants`,
`verifyEnabled` и все блоки листа):

```json
{
  "jsonrpc": "2.0",
  "id": 7,
  "result": {
    "content": [
      { "type": "text", "text": "{\"id\":\"6f1c3b2a-…\",\"title\":\"Сертификат участника\",\"pageWidthMm\":297,\"pageHeightMm\":210,\"isTemplate\":false,\"sheets\":[{\"id\":\"b2c3d4e5-…\",\"position\":0,\"version\":0,\"backgroundFileId\":null,\"elements\":[{\"id\":\"preset-attendee-0\",\"type\":\"text\",\"x\":38.6,\"y\":40.1,\"w\":219.8,\"h\":16.5,\"z\":0,\"text\":\"Сертификат\",\"fontFamily\":\"Montserrat\",\"fontSize\":31.1,\"bold\":true,\"color\":\"#2C6E63\",\"align\":\"center\",\"autoFit\":true},…]}]}" }
    ]
  }
}
```

Ошибка инструмента:

```json
{
  "jsonrpc": "2.0",
  "id": 8,
  "result": {
    "content": [ { "type": "text", "text": "Ошибка 404: Документ не найден" } ],
    "isError": true
  }
}
```

## Ошибки

Ошибки протокола приходят как `error` JSON-RPC. В поколении с рукопожатием — всегда
в теле `200 OK`; в поколении `2026-07-28` — с кодом HTTP из последней колонки:

| Код JSON-RPC | Сообщение | Когда | HTTP в `2026-07-28` |
|---|---|---|---|
| `-32600` | «Ожидался запрос JSON-RPC 2.0» | нет `jsonrpc: "2.0"` или `method`, `params` не объект, `id` не строка и не число | 200 |
| `-32600` | «Пачки запросов не поддерживаются — отправляйте по одному» | в теле массив | 200 |
| `-32601` | «Метод X не поддерживается» | метода нет (в `2026-07-28` — и `initialize`, `ping`) | 404 |
| `-32602` | «Нет инструмента X», «Нет подсказки X» | неизвестное `name` | 200 |
| `-32602` | «arguments должен быть объектом» | `arguments` — не объект | 200 |
| `-32020` | «Заголовок … не совпадает …» | `MCP-Protocol-Version`, `Mcp-Method` или `Mcp-Name` нет либо они расходятся с телом | 400 |
| `-32022` | «Unsupported protocol version», в `data` — `supported` и `requested` | незнакомая версия в `_meta` | 400 |

Ошибки HTTP:

| Код | Когда |
|---|---|
| 400 | тело не разбирается как JSON — ответ веб-сервера, а не JSON-RPC |
| 401 | нет заголовка `Authorization: Bearer …` или токен неизвестен либо отозван; см. «Вход» |
| 405 | `GET /mcp` и `DELETE /mcp`: «Только POST», заголовок `Allow: POST` |
| 413 | тело больше 2 МБ |
| 429 | больше 300 запросов в минуту от организации; заголовок `Retry-After`, тело — `{"jsonrpc":"2.0","id":null,"error":{"code":-32600,"message":"Слишком часто. Повторите через N с."}}` |

## Пример

```bash
curl -X POST "https://vruchay.ru/mcp" \
  -H "Authorization: Bearer $VRUCHAY_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
```

Вызов инструмента:

```bash
curl -X POST "https://vruchay.ru/mcp" \
  -H "Authorization: Bearer $VRUCHAY_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"get_account","arguments":{}}}'
```

---

<!-- endpoints/oauth/get-oauth-authorize-preview -->
> method: GET | path: /api/oauth/authorize/preview | auth: humans-only | roles: any | rate_limit: none

# GET /api/oauth/authorize/preview

Проверяет запрос разрешения OAuth и отдаёт то, что страница согласия показывает
человеку: какое приложение просит доступ, куда уйдёт разрешение, к какой организации
и может ли этот человек его дать. Зовёт его сама страница согласия
`https://vruchay.ru/oauth/authorize` — с той же строкой запроса, с какой туда пришёл
браузер от клиента MCP. Весь путь — в [описании входа через OAuth](get-well-known-oauth.md).

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

При неверном клиенте или адресе возврата никуда не переходим: ошибка показывается
человеку на странице, иначе страница стала бы открытым перенаправлением на любой
адрес.

**По токену API недоступно, только из кабинета (cookie-сессия).**

## Запрос

Параметры строки запроса — запрос разрешения OAuth 2.1, как его формирует клиент:

| Параметр | Тип | Обязателен | Описание |
|---|---|---|---|
| `response_type` | string | да | только `code` |
| `client_id` | string | да | `vmc_…` из [регистрации](post-oauth-register.md) или https-адрес описания клиента (CIMD: JSON с `client_id`, равным этому адресу, `client_name` и `redirect_uris`), до 4000 символов |
| `redirect_uri` | string | нет | адрес возврата, до 500 символов; должен совпасть с одним из зарегистрированных (у `localhost` и `127.0.0.1` порт не сравнивается). Можно не присылать, если у клиента зарегистрирован ровно один |
| `code_challenge` | string | да | PKCE: base64url от SHA-256 строки `code_verifier`, 43–128 знаков `A–Z a–z 0–9 - _` |
| `code_challenge_method` | string | да | только `S256` |
| `state` | string | нет | до 1000 символов; вернётся клиенту как есть |
| `scope` | string | нет | до 200 символов; на права не влияет — их выбирает человек ролью |
| `resource` | string | нет | до 500 символов; если есть — только `https://vruchay.ru` или `https://vruchay.ru/mcp` (с косой чертой в конце или без) |

Тела нет.

## Ответ

`200 OK`

| Поле | Тип | Описание |
|---|---|---|
| `client` | string | название приложения из регистрации («Claude»). Его выбирает себе сам клиент, поэтому рядом показывается адрес возврата |
| `redirectHost` | string | куда уйдёт разрешение: хост адреса возврата (`claude.ai`, `127.0.0.1:33418`); у адреса своей схемы без хоста — сама схема (`myapp://`) |
| `organization` | string | название организации, к которой просят доступ |
| `canGrant` | boolean | может ли этот человек разрешить: `true` у владельца и управляющего. Остальным страница предлагает попросить их |
| `slotsLeft` | integer | сколько ещё агентов можно подключить через OAuth, 0–10. При нуле подключение пройдёт, только если оно заменяет прежнее подключение того же приложения этим же человеком |
| `scope` | string \| null | `scope` из запроса |

```json
{
  "client": "Claude",
  "redirectHost": "claude.ai",
  "organization": "СШОР «Энергия»",
  "canGrant": true,
  "slotsLeft": 9,
  "scope": "mcp"
}
```

## Ошибки

| Код | Когда |
|---|---|
| 400 | первая причина из проверки параметров: «Поддерживается только response_type=code», «Нужен code_challenge (PKCE)», «Поддерживается только code_challenge_method=S256» или сообщение Zod (нет `client_id`, параметр длиннее предела) |
| 400 | «Неизвестное приложение: client_id не наш или повреждён» — подпись `client_id` не сходится, а для адреса описания — документ не скачался, не https, не называет себя этим адресом или в нём недопустимый адрес возврата |
| 400 | «Адрес возврата не совпадает с зарегистрированным у приложения» — или `redirect_uri` не прислан, а зарегистрировано несколько |
| 400 | «Доступ запрошен к чужому ресурсу» — `resource` не наш |
| 401 | «Требуется вход в систему» — нет cookie-сессии |
| 403 | «Это действие доступно только человеку, вошедшему в кабинет. По токену API его выполнить нельзя.» — запрос по токену API |

## Пример

Из кабинета, сессией человека:

```bash
curl "https://vruchay.ru/api/oauth/authorize/preview?response_type=code&client_id=$CLIENT_ID&redirect_uri=https%3A%2F%2Fclaude.ai%2Fapi%2Fmcp%2Fauth_callback&code_challenge=$CODE_CHALLENGE&code_challenge_method=S256&state=xyz&scope=mcp" \
  -H "Cookie: gramota_session=…"
```

---

<!-- endpoints/oauth/get-well-known-oauth -->
> method: GET | path: /.well-known/oauth-authorization-server | auth: public | roles: any | rate_limit: none

# GET /.well-known/oauth-authorization-server

Описание входа через OAuth 2.1 для клиентов MCP — Claude.ai, Claude Desktop,
ChatGPT, Cursor. По нему клиент сам узнаёт, куда вести человека за разрешением
и где менять код на токен, и подключается кнопкой: без токена, скопированного
руками. Документы статичны, лежат в корне сайта, вне `/api`, и открыты без входа.

## Как клиент находит вход

1. Клиент зовёт [`POST /mcp`](../mcp/post-mcp.md) без токена и получает `401`
   с заголовком `WWW-Authenticate: Bearer resource_metadata="https://vruchay.ru/.well-known/oauth-protected-resource/mcp"`.
2. Из **описания ресурса** (RFC 9728) узнаёт сервер авторизации — `https://vruchay.ru`.
3. Из **описания сервера** (RFC 8414, этот адрес) — адреса регистрации, согласия
   и обмена кода.
4. Регистрируется сам: [`POST /api/oauth/register`](post-oauth-register.md) (RFC 7591).
5. Открывает человеку страницу согласия `https://vruchay.ru/oauth/authorize?…`
   с PKCE. Человек входит в кабинет, если ещё не вошёл, видит, какое приложение
   просит доступ и куда он уйдёт, выбирает права и разрешает
   ([`POST /api/oauth/authorize`](post-oauth-authorize.md)).
6. Браузер возвращается на адрес клиента с одноразовым кодом, и клиент меняет его
   на токен: [`POST /api/oauth/token`](post-oauth-token.md).
7. Дальше — обычный токен `vru_…` в `Authorization: Bearer` для `/mcp` и `/api`.
   Его видно и можно отозвать в «Настройки → Интеграции → Токены API».

## Адреса

| Адрес | Что отдаёт |
|---|---|
| `/.well-known/oauth-authorization-server` | описание сервера авторизации |
| `/.well-known/oauth-authorization-server/mcp` | то же |
| `/.well-known/openid-configuration` | то же — некоторые клиенты ищут вход только по этому имени |
| `/.well-known/openid-configuration/mcp` | то же |
| `/.well-known/oauth-protected-resource` | описание ресурса |
| `/.well-known/oauth-protected-resource/mcp` | то же — этот адрес называет `401` от `/mcp` |

Это не OpenID Connect: токена личности (`id_token`) и `userinfo` нет, адрес
`openid-configuration` отдаёт то же описание OAuth.

## Запрос

Параметров и тела нет.

## Ответ

`200 OK`, `Cache-Control: public, max-age=3600`, `Access-Control-Allow-Origin: *`.

Описание сервера:

| Поле | Значение |
|---|---|
| `issuer` | `https://vruchay.ru` |
| `authorization_endpoint` | `https://vruchay.ru/oauth/authorize` — страница согласия в кабинете, а не API |
| `token_endpoint` | `https://vruchay.ru/api/oauth/token` |
| `registration_endpoint` | `https://vruchay.ru/api/oauth/register` |
| `response_types_supported` | `["code"]` |
| `response_modes_supported` | `["query"]` |
| `grant_types_supported` | `["authorization_code"]` — токены обновления не выдаются |
| `code_challenge_methods_supported` | `["S256"]` — PKCE обязателен |
| `token_endpoint_auth_methods_supported` | `["none"]` — только публичные клиенты, без секрета |
| `authorization_response_iss_parameter_supported` | `true` — в адрес возврата добавляется `iss` (RFC 9207), клиент сверяет его с `issuer` |
| `scopes_supported` | `["mcp"]` |
| `service_documentation` | `https://vruchay.ru/docs/mcp` |

```json
{
  "issuer": "https://vruchay.ru",
  "authorization_endpoint": "https://vruchay.ru/oauth/authorize",
  "token_endpoint": "https://vruchay.ru/api/oauth/token",
  "registration_endpoint": "https://vruchay.ru/api/oauth/register",
  "response_types_supported": ["code"],
  "response_modes_supported": ["query"],
  "grant_types_supported": ["authorization_code"],
  "code_challenge_methods_supported": ["S256"],
  "token_endpoint_auth_methods_supported": ["none"],
  "authorization_response_iss_parameter_supported": true,
  "scopes_supported": ["mcp"],
  "service_documentation": "https://vruchay.ru/docs/mcp"
}
```

Описание ресурса (`/.well-known/oauth-protected-resource`, `…/mcp`):

```json
{
  "resource": "https://vruchay.ru/mcp",
  "authorization_servers": ["https://vruchay.ru"],
  "bearer_methods_supported": ["header"],
  "scopes_supported": ["mcp"],
  "resource_name": "Вручай",
  "resource_documentation": "https://vruchay.ru/docs/mcp"
}
```

`bearer_methods_supported: ["header"]` — токен принимается только заголовком
`Authorization`, не параметром адреса и не в теле.

## Ошибки

Нет: документы отдаются всегда.

## Пример

```bash
curl "https://vruchay.ru/.well-known/oauth-authorization-server"
```

---

<!-- endpoints/oauth/post-oauth-authorize -->
> method: POST | path: /api/oauth/authorize | auth: humans-only | roles: owner, admin | rate_limit: none

# POST /api/oauth/authorize

Кнопка «Разрешить» на странице согласия `https://vruchay.ru/oauth/authorize`:
человек разрешает клиенту MCP (Claude.ai, ChatGPT, Cursor…) доступ к своей
организации и выбирает права. Сервер выдаёт одноразовый код и отвечает адресом,
на который страница возвращает браузер; клиент меняет код на токен через
[`POST /api/oauth/token`](post-oauth-token.md). Весь путь — в
[описании входа через OAuth](get-well-known-oauth.md).

Разрешить может только **владелец или управляющий**, вошедший в кабинет, и только
если в плане организации есть доступ по API. Отказать может любой сотрудник —
[`POST /api/oauth/deny`](post-oauth-deny.md).

Ответ — адрес, а не переадресация: решение отправляется запросом, а переход
делает сама страница. Обычная форма с переходом на чужой адрес запрещена политикой
сайта.

Сам токен на этом шаге ещё не создаётся — только код. Код живёт **10 минут**
и обменивается один раз; при обмене сервер снова проверяет, что разрешивший
по-прежнему владелец или управляющий, и считает предел в десять подключений.
Разрешение пишется в журнал действий: `oauth.grant`, «Подключён агент «Claude»».

**По токену API недоступно, только из кабинета (cookie-сессия).**

## Запрос

Тело — JSON:

| Поле | Тип | Обязательно | Описание |
|---|---|---|---|
| `params` | object | да | параметры запроса разрешения — те же, что пришли на страницу согласия в строке запроса и проверялись в [`GET /api/oauth/authorize/preview`](get-oauth-authorize-preview.md): `response_type`, `client_id`, `redirect_uri`, `code_challenge`, `code_challenge_method`, `state`, `scope`, `resource` |
| `role` | `member` \| `admin` | нет | права агента, как у токена API. `member` (по умолчанию) — «Выпускать документы»: документы, вёрстка, получатели, выпуск, письма. `admin` — «Полный доступ»: вдобавок формы на сайте, домены почты, удаление и отзыв документов. Людьми, токенами и оплатой агент не управляет ни с какой ролью |

```json
{
  "params": {
    "response_type": "code",
    "client_id": "vmc_eyJuIjoiQ2xhdWRlIiwiciI6WyJodHRwczovL2NsYXVkZS5haS9hcGkvbWNwL2F1dGhfY2FsbGJhY2siXX0.9cJ2x…",
    "redirect_uri": "https://claude.ai/api/mcp/auth_callback",
    "code_challenge": "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
    "code_challenge_method": "S256",
    "state": "af0ifjsldkj",
    "scope": "mcp"
  },
  "role": "member"
}
```

## Ответ

`201 Created`

| Поле | Тип | Описание |
|---|---|---|
| `redirectUrl` | string | адрес возврата клиента с параметрами: `state` (если был), `iss` — `https://vruchay.ru` (RFC 9207, по нему клиент убеждается, что код выдал именно этот сервер), `code` — одноразовый код. Собственные параметры адреса возврата сохраняются |

```json
{
  "redirectUrl": "https://claude.ai/api/mcp/auth_callback?state=af0ifjsldkj&iss=https%3A%2F%2Fvruchay.ru&code=Xk3q2…"
}
```

## Ошибки

| Код | Когда |
|---|---|
| 400 | тело не прошло проверку: нет `params`, `role` не `member`/`admin` — сообщения Zod |
| 400 | параметры запроса разрешения не годятся — те же причины, что у [`GET /api/oauth/authorize/preview`](get-oauth-authorize-preview.md): «Поддерживается только response_type=code», «Нужен code_challenge (PKCE)», «Поддерживается только code_challenge_method=S256», «Неизвестное приложение: client_id не наш или повреждён», «Адрес возврата не совпадает с зарегистрированным у приложения», «Доступ запрошен к чужому ресурсу» |
| 401 | «Требуется вход в систему» — нет cookie-сессии |
| 403 | «Это действие доступно только человеку, вошедшему в кабинет. По токену API его выполнить нельзя.» — запрос по токену API |
| 403 | «Недостаточно прав для этого действия» — роль `member` |
| 403 | `«Доступ по API-токену» не входит в ваш план. Напишите нам — добавим.` — или, если срок плана вышел, `Срок плана «…» закончился, поэтому раздел «Доступ по API-токену» сейчас недоступен. …` |
| 413 | тело больше 1 МБ |

## Пример

Из кабинета, сессией владельца или управляющего:

```bash
curl -X POST "https://vruchay.ru/api/oauth/authorize" \
  -H "Cookie: gramota_session=…" \
  -H "Content-Type: application/json" \
  -d @consent.json
```

---

<!-- endpoints/oauth/post-oauth-deny -->
> method: POST | path: /api/oauth/deny | auth: humans-only | roles: any | rate_limit: none

# POST /api/oauth/deny

Кнопка «Отказать» на странице согласия `https://vruchay.ru/oauth/authorize`:
человек не пускает клиента MCP в организацию. Сервер ничего не выдаёт и отвечает
адресом, на который страница возвращает браузер, — с ошибкой `access_denied`
по RFC 6749, чтобы клиент понял отказ и не ждал кода. Весь путь — в
[описании входа через OAuth](get-well-known-oauth.md).

Отказать может любой сотрудник, вошедший в кабинет, — права владельца или
управляющего и доступ по API в плане для этого не нужны. Разрешение —
[`POST /api/oauth/authorize`](post-oauth-authorize.md).

**По токену API недоступно, только из кабинета (cookie-сессия).**

## Запрос

Тело — JSON:

| Поле | Тип | Обязательно | Описание |
|---|---|---|---|
| `params` | object | да | параметры запроса разрешения — те же, что пришли на страницу согласия в строке запроса: `response_type`, `client_id`, `redirect_uri`, `code_challenge`, `code_challenge_method`, `state`, `scope`, `resource`. Проверяются так же, как при разрешении: отказ уходит только на зарегистрированный адрес возврата |

```json
{
  "params": {
    "response_type": "code",
    "client_id": "vmc_eyJuIjoiQ2xhdWRlIiwiciI6WyJodHRwczovL2NsYXVkZS5haS9hcGkvbWNwL2F1dGhfY2FsbGJhY2siXX0.9cJ2x…",
    "redirect_uri": "https://claude.ai/api/mcp/auth_callback",
    "code_challenge": "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
    "code_challenge_method": "S256",
    "state": "af0ifjsldkj"
  }
}
```

## Ответ

`201 Created`

| Поле | Тип | Описание |
|---|---|---|
| `redirectUrl` | string | адрес возврата клиента с параметрами `state` (если был), `iss` — `https://vruchay.ru` и `error=access_denied` |

```json
{
  "redirectUrl": "https://claude.ai/api/mcp/auth_callback?state=af0ifjsldkj&iss=https%3A%2F%2Fvruchay.ru&error=access_denied"
}
```

## Ошибки

| Код | Когда |
|---|---|
| 400 | нет `params` или это не объект — сообщение Zod |
| 400 | параметры запроса разрешения не годятся — те же причины, что у [`GET /api/oauth/authorize/preview`](get-oauth-authorize-preview.md): «Поддерживается только response_type=code», «Нужен code_challenge (PKCE)», «Поддерживается только code_challenge_method=S256», «Неизвестное приложение: client_id не наш или повреждён», «Адрес возврата не совпадает с зарегистрированным у приложения», «Доступ запрошен к чужому ресурсу» |
| 401 | «Требуется вход в систему» — нет cookie-сессии |
| 403 | «Это действие доступно только человеку, вошедшему в кабинет. По токену API его выполнить нельзя.» — запрос по токену API |
| 413 | тело больше 1 МБ |

## Пример

Из кабинета, сессией человека:

```bash
curl -X POST "https://vruchay.ru/api/oauth/deny" \
  -H "Cookie: gramota_session=…" \
  -H "Content-Type: application/json" \
  -d @consent.json
```

---

<!-- endpoints/oauth/post-oauth-register -->
> method: POST | path: /api/oauth/register | auth: public | roles: any | rate_limit: «600 запросов в час с одного IP»

# POST /api/oauth/register

Динамическая регистрация клиента OAuth (RFC 7591): Claude.ai, ChatGPT, Cursor
и другие клиенты MCP регистрируются сами, без человека, перед тем как попросить
у него разрешение. Весь путь входа — в [описании входа через OAuth](get-well-known-oauth.md).

Записи о клиенте сервер не хранит. `client_id` — это подписанные ключом сервера
название клиента и его адреса возврата: подделать их нельзя, хранить незачем.
Claude.ai регистрируется заново при каждом переподключении, и копить такие записи
значило бы копить мусор. Отсюда два следствия: зарегистрироваться повторно можно
сколько угодно раз, а изменить адреса возврата можно только новой регистрацией.

Поддерживаются только **публичные клиенты**: секрета нет, защита кода — PKCE (S256).

## Адреса возврата

Каждый адрес из `redirect_uris` проверяется (RFC 8252, OAuth 2.1):

- `https://…` — любой;
- `http://…` — только на свою машину: `localhost`, `127.0.0.1`, `[::1]`. Так
  возвращаются настольные агенты; порт у таких адресов при обмене не сравнивается;
- своя схема приложения (`cursor://…`) — латиница, цифры, `+ . -`, не короче трёх
  знаков;
- нельзя: `javascript:`, `data:`, `file:`, `vbscript:`, `blob:`, `about:` и любой
  адрес с `#фрагментом`.

## Запрос

Тело — JSON (метаданные клиента по RFC 7591):

| Поле | Тип | Обязательно | Описание |
|---|---|---|---|
| `redirect_uris` | string[] | да | 1–10 адресов возврата, каждый до 500 символов |
| `client_name` | string | нет | название, которое человек увидит на странице согласия; до 100 символов |
| `token_endpoint_auth_method` | string | нет | только `none` |
| `grant_types` | string[] | нет | принимается и не учитывается: всегда `authorization_code` |
| `response_types` | string[] | нет | принимается и не учитывается: всегда `code` |
| `scope` | string | нет | до 200 символов; принимается и не учитывается |

Прочие поля метаданных (`logo_uri`, `client_uri`…) молча пропускаются.

```json
{
  "client_name": "Claude",
  "redirect_uris": ["https://claude.ai/api/mcp/auth_callback"],
  "grant_types": ["authorization_code"],
  "response_types": ["code"],
  "token_endpoint_auth_method": "none"
}
```

## Ответ

`201 Created`

| Поле | Тип | Описание |
|---|---|---|
| `client_id` | string | `vmc_` и подписанные данные клиента; передаётся в согласие и в обмен кода |
| `client_id_issued_at` | integer | время выдачи, секунды Unix |
| `client_name` | string | название после очистки: без управляющих символов и угловых скобок, до 60 символов; пустое — «ИИ-агент» |
| `redirect_uris` | string[] | как в запросе |
| `grant_types` | string[] | `["authorization_code"]` |
| `response_types` | string[] | `["code"]` |
| `token_endpoint_auth_method` | string | `none` |

```json
{
  "client_id": "vmc_eyJuIjoiQ2xhdWRlIiwiciI6WyJodHRwczovL2NsYXVkZS5haS9hcGkvbWNwL2F1dGhfY2FsbGJhY2siXX0.9cJ2x…",
  "client_id_issued_at": 1789030800,
  "client_name": "Claude",
  "redirect_uris": ["https://claude.ai/api/mcp/auth_callback"],
  "grant_types": ["authorization_code"],
  "response_types": ["code"],
  "token_endpoint_auth_method": "none"
}
```

## Ошибки

Ошибки — в формате RFC 7591: `{"error": "…", "error_description": "…"}`, по-русски
в `error_description`.

| Код | `error` | Когда |
|---|---|---|
| 400 | `invalid_client_metadata` | «Нужен список redirect_uris» — нет `redirect_uris`, их больше 10, адрес длиннее 500 или другое поле не того вида (`client_name` длиннее 100, `scope` длиннее 200) |
| 400 | `invalid_redirect_uri` | адрес возврата не прошёл проверку: «Адрес возврата — не адрес», «В адресе возврата не должно быть #фрагмента», «Такая схема адреса возврата не принимается», «http допустим только для localhost и 127.0.0.1», «Неизвестная схема адреса возврата» — с самим адресом (до 100 символов) после двоеточия |
| 400 | `invalid_client_metadata` | «Поддерживаются только публичные клиенты (token_endpoint_auth_method=none)» |
| 429 | — | «Слишком много попыток. Повторите через N с.» (`{"message": "…"}`) |

## Пример

```bash
curl -X POST "https://vruchay.ru/api/oauth/register" \
  -H "Content-Type: application/json" \
  -d '{"client_name":"Мой агент","redirect_uris":["http://127.0.0.1:33418/callback"]}'
```

---

<!-- endpoints/oauth/post-oauth-token -->
> method: POST | path: /api/oauth/token | auth: public | roles: any | rate_limit: «300 запросов в минуту с одного IP»

# POST /api/oauth/token

Последний шаг входа через OAuth 2.1: клиент меняет одноразовый код, полученный
на свой адрес возврата после [согласия человека](post-oauth-authorize.md), на токен
доступа. Весь путь — в [описании входа через OAuth](get-well-known-oauth.md).

Поддерживается только `grant_type=authorization_code` с PKCE (S256). Токенов
обновления нет: выданный токен не истекает сам и действует, пока его не отзовут
и пока разрешивший остаётся в организации владельцем или управляющим. Когда он
перестанет работать, клиент проходит вход заново.

## Какой токен выдаётся

Обычный токен API `vru_…` — тот же, что выдаётся в кабинете, и работает так же:
в `Authorization: Bearer` для [`/mcp`](../mcp/post-mcp.md) и для `/api`.

- **Роль** — та, что человек выбрал на странице согласия: `member` («Выпускать
  документы») или `admin` («Полный доступ»).
- **Название** — название клиента из регистрации («Claude»), с пометкой
  `oauthClient`. Токен виден в `GET /api/tokens` и в «Настройки → Интеграции →
  Токены API», там же его отзывают.
- **Повторное подключение** того же приложения тем же человеком заменяет прежний
  токен: старый отзывается в момент выдачи нового. «Тем же приложением» считается
  то же название клиента и адрес возврата с тем же сайтом (схема и хост; у `localhost`
  и `127.0.0.1` порт не учитывается). Claude.ai, переподключённый пять раз, оставит
  один токен, а не пять; Claude Code и Codex на одном компьютере друг друга не вытесняют.
- **Не больше десяти подключений через OAuth** у организации одновременно. Этот
  предел отдельный от десяти токенов, выданных руками, и одно не отнимает место
  у другого.

## Запрос

Тело — `application/x-www-form-urlencoded` (как в RFC 6749) или JSON, поля одинаковые:

| Поле | Тип | Обязательно | Описание |
|---|---|---|---|
| `grant_type` | string | да | только `authorization_code` |
| `code` | string | да | код из адреса возврата (`?code=…`) |
| `code_verifier` | string | да | исходная строка PKCE: 43–128 знаков из `A–Z a–z 0–9 - . _ ~`; base64url её SHA-256 должен совпасть с `code_challenge` из запроса разрешения |
| `client_id` | string | да | `vmc_…` из [регистрации](post-oauth-register.md). Можно вместо поля прислать заголовком `Authorization: Basic base64("<client_id>:")` |
| `redirect_uri` | string | если был в запросе разрешения | тот же адрес возврата, что в запросе разрешения. Не был — можно не присылать; прислали — должен совпасть |

Код **одноразовый и живёт 10 минут**. Сервер забирает его при первом же обмене,
ещё до остальных проверок: неудачный обмен (не тот `code_verifier`, не тот
`redirect_uri`) код тоже сжигает, и вход надо начинать заново.

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

```http
POST /api/oauth/token HTTP/1.1
Host: vruchay.ru
Content-Type: application/x-www-form-urlencoded

grant_type=authorization_code&code=Xk3q2…&code_verifier=dBjftJeZ4CVP…&client_id=vmc_eyJuIjoi…&redirect_uri=https%3A%2F%2Fclaude.ai%2Fapi%2Fmcp%2Fauth_callback
```

## Ответ

`200 OK`, заголовки `Cache-Control: no-store` и `Pragma: no-cache`.

| Поле | Тип | Описание |
|---|---|---|
| `access_token` | string | токен API: `vru_` и 43 знака. Показывается только в этом ответе — сервер хранит лишь его хеш |
| `token_type` | string | `Bearer` |
| `scope` | string | `scope` из запроса разрешения, без него — `mcp` |

`expires_in` и `refresh_token` в ответе нет.

```json
{
  "access_token": "vru_8hK2…",
  "token_type": "Bearer",
  "scope": "mcp"
}
```

## Ошибки

Ошибки — в формате RFC 6749: `{"error": "…", "error_description": "…"}`,
описание по-русски. Код ответа — `400`.

| `error` | `error_description` | Когда |
|---|---|---|
| `unsupported_grant_type` | «Поддерживается только authorization_code» | `grant_type` другой или его нет |
| `invalid_request` | «Нужны code, code_verifier и client_id» | одного из полей нет (и `client_id` нет в `Authorization: Basic`) |
| `invalid_grant` | «Код неверный, просрочен или уже использован» | кода нет, прошло 10 минут или его уже обменивали |
| `invalid_grant` | «Код выдан другому клиенту» | `client_id` не тот, для которого выдан код |
| `invalid_grant` | «redirect_uri не совпадает с тем, что был в запросе разрешения» | адрес возврата не совпал или не прислан, хотя был в запросе разрешения |
| `invalid_grant` | «code_verifier не подходит (PKCE)» | `code_verifier` не того вида или его SHA-256 не совпал с `code_challenge` |
| `invalid_grant` | «Разрешивший больше не может выдавать доступ в этой организации» | человек ушёл из организации или больше не владелец и не управляющий |
| `invalid_grant` | «У организации уже десять подключённых агентов. Отключите ненужные в «Настройки → Интеграции».» | десять действующих подключений через OAuth, и это подключение не заменяет прежнее |

| Код | Когда |
|---|---|
| 429 | «Слишком много попыток. Повторите через N с.» — тело `{"message": "…"}` |

## Пример

```bash
curl -X POST "https://vruchay.ru/api/oauth/token" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  --data-urlencode "grant_type=authorization_code" \
  --data-urlencode "code=$CODE" \
  --data-urlencode "code_verifier=$CODE_VERIFIER" \
  --data-urlencode "client_id=$CLIENT_ID" \
  --data-urlencode "redirect_uri=http://127.0.0.1:33418/callback"
```

---

<!-- endpoints/org/delete-org-domain -->
> method: DELETE | path: /api/org/domain | auth: token | roles: owner, admin | rate_limit: none

# DELETE /api/org/domain

Отключает свой домен: формы по ссылке на нём перестают открываться, ссылки
на vruchay.ru работают как прежде. Повторное подключение выдаёт новый секрет TXT.

## Ответ

`200 OK`

```json
{ "ok": true }
```

## Ошибки

| Код | Когда |
|---|---|
| 401 | Нет действующего токена и нет cookie-сессии. |
| 403 | Роль `member`. |

## Пример

```bash
curl -X DELETE "https://vruchay.ru/api/org/domain" -H "Authorization: Bearer $VRUCHAY_TOKEN"
```

---

<!-- endpoints/org/get-org-domain -->
> method: GET | path: /api/org/domain | auth: token | roles: any | rate_limit: none

# GET /api/org/domain

Свой домен организации, на котором открываются её формы по ссылке:
`https://forms.school.ru/f/{token}` вместо `https://vruchay.ru/f/{token}`.
Домен у организации один и общий для всех её форм; ссылки на vruchay.ru
продолжают работать. QR-коды в документах и страница проверки остаются на vruchay.ru.

Домен не подключён — ответ `null`.

## Ответ

`200 OK`

| Поле | Тип | Описание |
|---|---|---|
| `host` | string | Имя хоста в ASCII (кириллица — в punycode) |
| `verified` | boolean | Записи проверены, домен работает |
| `records` | array | Что добавить у регистратора: `{ type, host, value }` — `CNAME` на адрес сервиса и `TXT` `_vruchay.{host}` с секретом организации |
| `addresses` | string[] | Адреса сервиса для записи `A` — если домен без поддомена и CNAME на корне зоны регистратор не принимает |

```json
{
  "host": "forms.school.ru",
  "verified": false,
  "records": [
    { "type": "CNAME", "host": "forms.school.ru", "value": "vruchay.ru" },
    { "type": "TXT", "host": "_vruchay.forms.school.ru", "value": "vruchay-domain=6c1d0e7a-1f0b-4f6e-9a35-2f0d8c1b7e44" }
  ],
  "addresses": ["109.196.101.28"]
}
```

## Ошибки

| Код | Когда |
|---|---|
| 401 | Нет действующего токена и нет cookie-сессии. |

## Пример

```bash
curl "https://vruchay.ru/api/org/domain" -H "Authorization: Bearer $VRUCHAY_TOKEN"
```

---

<!-- endpoints/org/get-org-preferences -->
> method: GET | path: /api/org/preferences | auth: humans-only | roles: any | rate_limit: none

# GET /api/org/preferences

Тема, формат дат и плотность интерфейса текущего сотрудника. Это настройка человека, а не организации: у сотрудника, состоящего в нескольких организациях, она одна на все. Менять — `PATCH /api/org/preferences`.

**По токену API недоступно, только из кабинета (cookie-сессия).** Настройки принадлежат человеку, а у токена нет ни экрана, ни своих настроек.

## Запрос

Параметров нет.

## Ответ

`200 OK`

| Поле | Тип | Описание |
|---|---|---|
| theme | string | `system`, `light` или `dark`. |
| dateFormat | string | `numeric`, `long` или `iso`. |
| density | string | `comfortable` или `compact`. |

```json
{
  "theme": "system",
  "dateFormat": "numeric",
  "density": "comfortable"
}
```

## Ошибки

| Код | Когда |
|---|---|
| 401 | Нет cookie-сессии. |
| 403 | Запрос пришёл по токену API: «Это действие доступно только человеку, вошедшему в кабинет. По токену API его выполнить нельзя.» |

## Пример

```bash
curl "https://vruchay.ru/api/org/preferences" \
  -b cookies.txt
```

---

<!-- endpoints/org/get-org-public-profile -->
> method: GET | path: /api/org/public-profile | auth: token | roles: any | rate_limit: none

# GET /api/org/public-profile

Что организация показывает о себе снаружи: на публичной странице `/org/<slug>` и на странице проверки документа. Читать могут все сотрудники — им надо знать, что видят проверяющие; менять — только владелец и управляющий (`PATCH /api/org/public-profile`).

Все настройки видимости выключены по умолчанию: организация сама решает, показываться ли ей наружу. Значок верифицированного эмитента (`verifiedIssuer`) проставляет владелец сервиса после проверки домена и ИНН; через API организация его изменить не может.

## Запрос

Параметров нет.

## Ответ

`200 OK`

| Поле | Тип | Описание |
|---|---|---|
| name | string | Название организации. |
| slug | string \| null | Адрес публичной страницы: `/org/<slug>`. `null` — адрес не задан. |
| description | string | Описание для публичной страницы (пустая строка — нет). |
| inn | string | ИНН: 10 или 12 цифр; пустая строка — не указан. |
| website | string | Сайт организации; пустая строка — не указан. |
| contactEmail | string | Почта для связи проверяющего («сообщить о проблеме»); пустая — нет. |
| contactPhone | string | Телефон для связи; пустая — нет. |
| logoFileId | string (UUID) \| null | Идентификатор файла логотипа. |
| logoUrl | string \| null | Временная ссылка на логотип (действует 15 минут). `null` — логотипа нет. |
| verifiedIssuer | boolean | Значок «Верифицированный эмитент». |
| verifiedAt | string (ISO 8601) \| null | Когда значок был выдан. |
| publicPageEnabled | boolean | Показывать ли публичную страницу организации. |
| publicSearchByName | boolean | Разрешён ли поиск документов по ФИО в публичном реестре. |
| publicIndexable | boolean | Пускать ли поисковики на страницу организации и страницы проверки. `false` — везде `noindex`. |
| verifyNameMode | string | Как показывать получателя на странице проверки: `full` — как в материале, `initials` — фамилия и инициалы, `none` — только факт подлинности и название организации. |
| trashDays | number | Сколько дней материал лежит в корзине до безвозвратного удаления вместе с выданными документами: от 1 до 90, по умолчанию 7. |
| verifyDomain | string | Домен для страницы проверки, который указала организация (`PATCH /api/org/verify-domain`); пустая строка — общий домен сервиса. Сейчас значение только хранится: ссылки и QR-коды в документах ведут на общий домен. |

```json
{
  "name": "Учебный центр «Развитие»",
  "slug": "fgso",
  "description": "Учебный центр. Программы и аттестации с 2004 года.",
  "inn": "6316123456",
  "website": "https://fgso.vruchay.ru",
  "contactEmail": "info@fgso.vruchay.ru",
  "contactPhone": "+7 846 000-00-00",
  "logoFileId": "8a5f0e1c-2b3d-4e5f-9a6b-7c8d9e0f1a2b",
  "logoUrl": "https://storage.vruchay.ru/…?X-Amz-Signature=…",
  "verifiedIssuer": false,
  "verifiedAt": null,
  "publicPageEnabled": true,
  "publicSearchByName": false,
  "publicIndexable": false,
  "verifyNameMode": "initials",
  "trashDays": 7,
  "verifyDomain": ""
}
```

## Ошибки

| Код | Когда |
|---|---|
| 401 | Нет действующего токена и нет cookie-сессии. `{"statusCode":401,"message":"Требуется вход в систему","error":"Unauthorized"}` |

## Пример

```bash
curl "https://vruchay.ru/api/org/public-profile" \
  -H "Authorization: Bearer $VRUCHAY_TOKEN"
```

---

<!-- endpoints/org/get-org-usage -->
> method: GET | path: /api/org/usage | auth: token | roles: any | rate_limit: none

# GET /api/org/usage

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

Откуда берутся условия (поле `source`):

- `plan` — организации назначен план. Его назначает владелец сервиса после разговора об условиях или сервис сам после оплаты картой (`POST /api/billing/checkout`) либо по счёту (`POST /api/org/invoices`). Планы не удаляются, действующим считается последний начавшийся из неистёкших. Если истекли все, действует последний истёкший: организация не возвращается на бесплатную пробу. План с датой начала в будущем до этой даты в расчёт не берётся.
- `trial` — плана нет, и организация на бесплатной пробе.
- `legacy-paid` — плана нет, но организацию перевели на оплаченный тариф до того, как появились планы. Предела у неё нет.

Как считается:

- `used` — число выпущенных файлов организации (файлы вида `generated`). На плане считаются только файлы, созданные с даты начала плана; на пробе и на `legacy-paid` — за всё время. Удаление документа или файла из счётчика не вычитает; перевыпуск считается наравне с обычным выпуском.
- На плане `limit` — объём плана (`documentLimit`). При оплате картой в новый план переносится неизрасходованный остаток прежнего действующего плана или пробы, он уже входит в `limit`.
- На пробе `limit` = базовая проба (`FREE_DOCUMENT_LIMIT`, по умолчанию 50) + `bonus` за приглашения: 50 документов, если организацию пригласили по коду, и по 50 за каждого приглашённого, который выпустил не меньше 10 документов (не больше 20 таких приглашённых). Значения задаются настройками сервера и могут отличаться от умолчаний. Бонус действует только на пробе: на плане и на `legacy-paid` `bonus` равен 0.
- На `legacy-paid` `limit` и `left` равны `null`: предела нет.

Остаток ещё не всё. Если `expired: true`, новый выпуск и перевыпуск не начнутся, даже когда `left` больше нуля. Уже выданные документы при этом остаются действительными и проверяются по QR-коду. Истёкший план закрывает и возможности из `features`: рассылку, приём заявок с сайта, выдачу токенов и правила награждения (см. [Аутентификация](../../authentication.md)).

Обратите внимание: `left` не учитывает бронь незавершённых выпусков. Проверка при запуске выпуска прибавляет к `used` строки заданий в статусах `queued` и `running`, которые воркер ещё не напечатал, поэтому пока идёт выпуск, фактический запас меньше, чем показывает этот ответ.

## Запрос

Параметров нет.

## Ответ

`200 OK`

| Поле | Тип | Описание |
|---|---|---|
| plan | string | `free` на бесплатной пробе, `paid` во всех остальных случаях (план или `legacy-paid`). |
| planName | string | Как называть условия человеку: название плана, «Бесплатная проба» или «Оплаченный тариф». |
| source | string | `trial`, `plan` или `legacy-paid`, см. выше. |
| used | number | Выпущено документов: на плане — с даты его начала, иначе за всё время. |
| limit | number \| null | Предел: объём плана или проба с бонусами. `null` — предела нет (`legacy-paid`). |
| left | number \| null | Осталось: `max(0, limit − used)`. `null` — предела нет. |
| bonus | number | Документы сверх базовой пробы, заработанные приглашениями. Не на пробе — 0. |
| warn | string | Насколько близко к концу: `none`, `low` (осталось не больше 20 % предела), `critical` (не больше 10 %), `exhausted` (остатка нет), `expired` (срок плана вышел). На `legacy-paid` всегда `none`. |
| endsAt | string (ISO 8601) \| null | Когда кончается срок плана. `null` — срока нет: проба, `legacy-paid` или план без даты окончания. |
| expired | boolean | Срок плана вышел: новый выпуск не начнётся при любом остатке. На пробе и `legacy-paid` всегда `false`. |
| neverExpires | boolean | План не сгорает, даже если у него указана дата окончания (так покупаются документы поштучно). |
| features | string[] \| null | Что входит в план: подмножество `mailing`, `tilda`, `api`, `awards`. `null` — ограничений нет (проба и `legacy-paid`). |

На бесплатной пробе:

```json
{
  "plan": "free",
  "planName": "Бесплатная проба",
  "source": "trial",
  "used": 37,
  "limit": 100,
  "left": 63,
  "bonus": 50,
  "warn": "none",
  "endsAt": null,
  "expired": false,
  "neverExpires": false,
  "features": null
}
```

На действующем плане:

```json
{
  "plan": "paid",
  "planName": "Тариф «Год»: 2500 документов на 12 мес.",
  "source": "plan",
  "used": 2080,
  "limit": 2500,
  "left": 420,
  "bonus": 0,
  "warn": "low",
  "endsAt": "2027-09-01T09:00:00.000Z",
  "expired": false,
  "neverExpires": false,
  "features": ["mailing", "tilda", "api", "awards"]
}
```

План истёк — остаток есть, но выпуск откажет:

```json
{
  "plan": "paid",
  "planName": "Учебный год 2025/26",
  "source": "plan",
  "used": 870,
  "limit": 1000,
  "left": 130,
  "bonus": 0,
  "warn": "expired",
  "endsAt": "2026-08-31T21:00:00.000Z",
  "expired": true,
  "neverExpires": false,
  "features": ["mailing", "api"]
}
```

Оплаченный тариф старого образца, без предела:

```json
{
  "plan": "paid",
  "planName": "Оплаченный тариф",
  "source": "legacy-paid",
  "used": 1240,
  "limit": null,
  "left": null,
  "bonus": 0,
  "warn": "none",
  "endsAt": null,
  "expired": false,
  "neverExpires": false,
  "features": null
}
```

## Ошибки

| Код | Когда |
|---|---|
| 401 | Нет действующего токена и нет cookie-сессии. `{"statusCode":401,"message":"Требуется вход в систему","error":"Unauthorized"}` |

## Пример

```bash
curl "https://vruchay.ru/api/org/usage" \
  -H "Authorization: Bearer $VRUCHAY_TOKEN"
```

---

<!-- endpoints/org/get-org -->
> method: GET | path: /api/org | auth: token | roles: any | rate_limit: none

# GET /api/org

Название организации, отметка о тарифе и данные текущего сотрудника. Поле `plan` — сырое значение колонки организации, а не условия: `paid` не значит «без предела». Предел, остаток, срок и состав плана показывает `GET /api/org/usage` — ориентируйтесь на него.

При входе по токену API `userName` и `email` — это данные сотрудника, который выдал токен. Если его уже нет в организации или токен остался без выдавшего, оба поля пустые.

## Запрос

Параметров нет.

## Ответ

`200 OK`

| Поле | Тип | Описание |
|---|---|---|
| orgName | string | Название организации. |
| plan | string | `free` или `paid` — колонка организации как есть. `free` ставится при регистрации. `paid` ставится, когда владелец сервиса назначает план или переводит организацию на оплаченный тариф, и после оплаты картой; владелец сервиса может и вернуть `free`. Предела `paid` не означает: у организации с планом предел и срок свои, их показывает `GET /api/org/usage`. От этой колонки зависит электронная подпись PDF, см. [AGENTS.md](../../AGENTS.md). |
| userName | string | Имя сотрудника. |
| email | string | Почта сотрудника. |

```json
{
  "orgName": "Учебный центр «Развитие»",
  "plan": "free",
  "userName": "Мария Иванова",
  "email": "maria@vruchay.ru"
}
```

## Ошибки

| Код | Когда |
|---|---|
| 401 | Нет действующего токена и нет cookie-сессии. `{"statusCode":401,"message":"Требуется вход в систему","error":"Unauthorized"}` |

## Пример

```bash
curl "https://vruchay.ru/api/org" \
  -H "Authorization: Bearer $VRUCHAY_TOKEN"
```

---

<!-- endpoints/org/patch-org-domain -->
> method: PATCH | path: /api/org/domain | auth: token | roles: owner, admin | rate_limit: none

# PATCH /api/org/domain

Подключает или меняет свой домен организации для форм по ссылке. Домен
начинает работать только после проверки записей — `POST /api/org/domain/check`.
Смена адреса снимает подтверждение; повтор того же адреса его сохраняет.
Нужна возможность плана «Приём заявок с сайта».

## Запрос

| Поле | Тип | Обязательно | Описание |
|---|---|---|---|
| `host` | string | да | Имя хоста, например `forms.school.ru`. Схема, путь, порт и точка в конце отбрасываются, регистр — нижний, кириллица переводится в punycode. Ошибка: «Введите домен, например docs.school.ru» |

```json
{ "host": "forms.school.ru" }
```

## Ответ

`200 OK` — то же, что `GET /api/org/domain`.

## Ошибки

| Код | Когда |
|---|---|
| 400 | Не домен (IP-адрес, одно слово); адрес самого сервиса или его поддомен: «Это адрес Вручай — укажите домен вашей организации». |
| 401 | Нет действующего токена и нет cookie-сессии. |
| 403 | Роль `member` или возможности нет в плане. |
| 409 | «Этот домен уже подключён в другом кабинете». |

## Пример

```bash
curl -X PATCH "https://vruchay.ru/api/org/domain" \
  -H "Authorization: Bearer $VRUCHAY_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"host":"forms.school.ru"}'
```

---

<!-- endpoints/org/patch-org-me -->
> method: PATCH | path: /api/org/me | auth: humans-only | roles: any | rate_limit: none

# PATCH /api/org/me

Меняет имя текущего сотрудника — то, что отвечает `GET /api/org` в `userName` и `GET /api/auth/me` в `name`. Доступно любой роли: это имя самого человека. Пробелы по краям обрезаются; пустая строка допускается.

**По токену API недоступно, только из кабинета (cookie-сессия).** Своего имени у токена нет: по нему запрос переименовал бы сотрудника, выдавшего токен.

## Запрос

Тело (`application/json`):

| Поле | Тип | Обязательно | Описание |
|---|---|---|---|
| name | string | да | Имя, не длиннее 200 знаков после обрезки пробелов. |

```json
{
  "name": "Мария Иванова"
}
```

## Ответ

`200 OK`

| Поле | Тип | Описание |
|---|---|---|
| ok | boolean | Всегда `true`. |

```json
{ "ok": true }
```

## Ошибки

| Код | Когда |
|---|---|
| 400 | `name` отсутствует, не строка или длиннее 200 знаков — стандартное сообщение Zod. |
| 401 | Нет cookie-сессии. `{"statusCode":401,"message":"Требуется вход в систему","error":"Unauthorized"}` |
| 403 | Запрос пришёл по токену API: «Это действие доступно только человеку, вошедшему в кабинет. По токену API его выполнить нельзя.» |
| 413 | Тело больше 1 МБ. |

## Пример

```bash
curl -X PATCH "https://vruchay.ru/api/org/me" \
  -b cookies.txt \
  -H "Content-Type: application/json" \
  -d '{"name":"Мария Иванова"}'
```

---

<!-- endpoints/org/patch-org-preferences -->
> method: PATCH | path: /api/org/preferences | auth: humans-only | roles: any | rate_limit: none

# PATCH /api/org/preferences

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

**По токену API недоступно, только из кабинета (cookie-сессия).** Иначе запрос по токену менял бы настройки сотрудника, выдавшего токен.

## Запрос

Тело (`application/json`):

| Поле | Тип | Обязательно | Описание |
|---|---|---|---|
| theme | string | нет | `system`, `light` или `dark`. |
| dateFormat | string | нет | `numeric`, `long` или `iso`. |
| density | string | нет | `comfortable` или `compact`. |

```json
{ "theme": "dark" }
```

## Ответ

`200 OK`

```json
{
  "theme": "dark",
  "dateFormat": "numeric",
  "density": "comfortable"
}
```

## Ошибки

| Код | Когда |
|---|---|
| 400 | Значение не из списка — сообщение Zod. |
| 401 | Нет cookie-сессии. |
| 403 | Запрос пришёл по токену API: «Это действие доступно только человеку, вошедшему в кабинет. По токену API его выполнить нельзя.» |
| 413 | Тело больше 1 МБ. |

## Пример

```bash
curl -X PATCH "https://vruchay.ru/api/org/preferences" \
  -b cookies.txt \
  -H "Content-Type: application/json" \
  -d '{"theme":"dark"}'
```

---

<!-- endpoints/org/patch-org-public-profile -->
> method: PATCH | path: /api/org/public-profile | auth: token | roles: owner, admin | rate_limit: none

# PATCH /api/org/public-profile

Правит публичное лицо организации. Все поля необязательны, передаются только меняемые, но хотя бы одно быть должно. Ответ — полный профиль в той же форме, что у `GET /api/org/public-profile`.

Два ограничения по существу:

- `slug` должен быть уникален среди всех организаций и не совпадать со служебными адресами сервиса (`api`, `org`, `verify`, `c`, `login`, `register`, `settings`, `admin`, `vruchay`, `support`, `help`, `about`, `privacy`, `search`). Значение приводится к нижнему регистру. `null` снимает адрес.
- `publicSearchByName: true` принимается только вместе с `consentConfirmed: true` — подтверждением, что согласия участников на распространение персональных данных собраны (ст. 10.1 152-ФЗ). Само поле `consentConfirmed` не хранится.

Значок `verifiedIssuer` этим запросом не меняется, `verifyDomain` — тоже: для него есть `PATCH /api/org/verify-domain`.

## Запрос

Тело (`application/json`), все поля необязательны:

| Поле | Тип | Обязательно | Описание |
|---|---|---|---|
| slug | string \| null | нет | Адрес публичной страницы: латиница, цифры и дефис, 3–50 знаков, не начинается и не заканчивается дефисом. `null` — убрать адрес. |
| description | string | нет | До 2000 знаков. |
| inn | string | нет | 10 или 12 цифр, либо пустая строка. |
| website | string | нет | Ссылка, начинающаяся с `http://` или `https://`, до 300 знаков, либо пустая строка. |
| contactEmail | string | нет | Адрес почты (приводится к нижнему регистру), до 254 знаков, либо пустая строка. |
| contactPhone | string | нет | До 40 знаков, формат не проверяется. |
| publicPageEnabled | boolean | нет | Показывать публичную страницу. |
| publicSearchByName | boolean | нет | Разрешить поиск по ФИО. `true` — только вместе с `consentConfirmed: true`. |
| consentConfirmed | boolean | нет | Подтверждение сбора согласий. Не сохраняется. |
| publicIndexable | boolean | нет | Разрешить индексацию поисковиками. |
| verifyNameMode | string | нет | `full`, `initials` или `none`. |
| trashDays | integer | нет | Срок корзины в днях, от 1 до 90. Через столько дней после отправки в корзину материал удаляется навсегда вместе с выданными документами. |

```json
{
  "slug": "fgso",
  "publicPageEnabled": true,
  "verifyNameMode": "initials",
  "contactEmail": "info@fgso.vruchay.ru"
}
```

## Ответ

`200 OK` — профиль целиком, поля те же, что у `GET /api/org/public-profile`.

```json
{
  "name": "Учебный центр «Развитие»",
  "slug": "fgso",
  "description": "",
  "inn": "",
  "website": "",
  "contactEmail": "info@fgso.vruchay.ru",
  "contactPhone": "",
  "logoFileId": null,
  "logoUrl": null,
  "verifiedIssuer": false,
  "verifiedAt": null,
  "publicPageEnabled": true,
  "publicSearchByName": false,
  "publicIndexable": false,
  "verifyNameMode": "initials",
  "trashDays": 7,
  "verifyDomain": ""
}
```

## Ошибки

| Код | Когда |
|---|---|
| 400 | Пустое тело `{}`: «Нечего обновлять» (поле `""` в `errors`). |
| 400 | `publicSearchByName: true` без `consentConfirmed: true`: «Поиск по ФИО включается только с подтверждением, что согласия участников собраны» (поле `""`). |
| 400 | `slug` не по форме: «Адрес: латиница, цифры и дефис, от 3 до 50 знаков». |
| 400 | `slug` из служебного списка: «Этот адрес занят сервисом». |
| 400 | `slug` уже занят другой организацией: «Этот адрес уже занят другой организацией» (проверяет база при записи). |
| 400 | `website` не ссылка: «Ссылка должна начинаться с http:// или https://». |
| 400 | `contactEmail` не адрес: «Некорректный адрес почты». |
| 400 | `inn` не 10/12 цифр: «ИНН — десять или двенадцать цифр». |
| 400 | `trashDays` меньше 1: «Меньше суток — человек не успеет заметить ошибку»; больше 90: «Дольше трёх месяцев корзина перестаёт быть корзиной»; не целое — стандартное сообщение Zod. |
| 400 | Превышена длина `description`/`website`/`contactEmail`/`contactPhone`, неверный тип, `verifyNameMode` не из списка — стандартное сообщение Zod. |
| 401 | Нет действующего токена и нет cookie-сессии. `{"statusCode":401,"message":"Требуется вход в систему","error":"Unauthorized"}` |
| 403 | Роль `member`: «Недостаточно прав для этого действия». |
| 413 | Тело больше 1 МБ. |

## Пример

```bash
curl -X PATCH "https://vruchay.ru/api/org/public-profile" \
  -H "Authorization: Bearer $VRUCHAY_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"slug":"fgso","publicPageEnabled":true,"verifyNameMode":"initials"}'
```

---

<!-- endpoints/org/patch-org-verify-domain -->
> method: PATCH | path: /api/org/verify-domain | auth: token | roles: owner, admin | rate_limit: none

# PATCH /api/org/verify-domain

Сохраняет домен, на котором организация хочет показывать страницу проверки документов, например `sertifikaty.vruchay.ru`. Пустая строка — общий домен сервиса.

Сейчас значение только хранится и возвращается в `verifyDomain` у `GET /api/org/public-profile`. Обслуживание своего домена (сертификат, маршрутизация) этим запросом не включается: ссылки и QR-коды в выпущенных документах по-прежнему ведут на общий домен сервиса.

## Запрос

Тело (`application/json`):

| Поле | Тип | Обязательно | Описание |
|---|---|---|---|
| domain | string | да | Имя домена: латиница, цифры и дефис, не меньше одной точки, до 253 знаков. Пробелы по краям обрезаются, регистр приводится к нижнему. Пустая строка — вернуть общий домен. |

```json
{ "domain": "sertifikaty.vruchay.ru" }
```

## Ответ

`200 OK`

```json
{ "ok": true }
```

## Ошибки

| Код | Когда |
|---|---|
| 400 | Не похоже на домен: «Похоже на опечатку: домен выглядит как sertifikaty.vruchay.ru»; поля нет или длиннее 253 знаков — сообщение Zod. |
| 401 | Нет действующего токена и нет cookie-сессии. |
| 403 | Роль `member`: «Недостаточно прав для этого действия». |
| 413 | Тело больше 1 МБ. |

## Пример

```bash
curl -X PATCH "https://vruchay.ru/api/org/verify-domain" \
  -H "Authorization: Bearer $VRUCHAY_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"domain":"sertifikaty.vruchay.ru"}'
```

---

<!-- endpoints/org/patch-org -->
> method: PATCH | path: /api/org | auth: token | roles: owner, admin | rate_limit: none

# PATCH /api/org

Меняет название организации. Название стоит в имени отправителя писем участникам, в приглашении друга и на витрине отзывов — то есть говорит от лица всей организации, поэтому право есть только у владельца и управляющего. Пробелы по краям обрезаются.

## Запрос

Тело (`application/json`):

| Поле | Тип | Обязательно | Описание |
|---|---|---|---|
| name | string | да | Новое название: от 2 до 200 знаков после обрезки пробелов. |

```json
{
  "name": "Учебный центр «Развитие»"
}
```

## Ответ

`200 OK`

| Поле | Тип | Описание |
|---|---|---|
| ok | boolean | Всегда `true`. |

```json
{ "ok": true }
```

## Ошибки

| Код | Когда |
|---|---|
| 400 | Короче двух знаков: «Название не может быть короче двух букв». Длиннее 200 или не строка — стандартное сообщение Zod. |
| 401 | Нет действующего токена и нет cookie-сессии. `{"statusCode":401,"message":"Требуется вход в систему","error":"Unauthorized"}` |
| 403 | Роль `member` (в том числе токен с ролью `member`): «Недостаточно прав для этого действия». |
| 413 | Тело больше 1 МБ. |

## Пример

```bash
curl -X PATCH "https://vruchay.ru/api/org" \
  -H "Authorization: Bearer $VRUCHAY_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name":"Учебный центр «Развитие»"}'
```

---

<!-- endpoints/org/post-org-domain-check -->
> method: POST | path: /api/org/domain/check | auth: token | roles: owner, admin | rate_limit: none

# POST /api/org/domain/check

Проверяет обе записи: хост ведёт на сервис (CNAME или A с теми же адресами,
что у vruchay.ru) и TXT `_vruchay.{host}` содержит секрет организации. Обе на
месте — домен подтверждён: форма по ссылке открывается на нём, а сертификат
выпускается сам при первом заходе. Неудачная проверка уже подтверждённый
домен не снимает. DNS обновляется до суток — проверку можно повторять.

## Ответ

`200 OK` — то же, что `GET /api/org/domain`, и `problems` — что мешает, словами:

```json
{
  "host": "forms.school.ru",
  "verified": false,
  "records": [ … ],
  "addresses": ["109.196.101.28"],
  "problems": ["Запись TXT для _vruchay.forms.school.ru пока не видна"]
}
```

## Ошибки

| Код | Когда |
|---|---|
| 401 | Нет действующего токена и нет cookie-сессии. |
| 403 | Роль `member` или возможности нет в плане. |
| 404 | «Домен не подключён». |

## Пример

```bash
curl -X POST "https://vruchay.ru/api/org/domain/check" -H "Authorization: Bearer $VRUCHAY_TOKEN"
```

---

<!-- endpoints/org/post-org-public-profile-logo -->
> method: POST | path: /api/org/public-profile/logo | auth: token | roles: owner, admin | rate_limit: none

# POST /api/org/public-profile/logo

Загружает логотип для публичной страницы организации. Принимаются только PNG и JPEG — тип определяется по первым байтам файла, а не по заголовку или расширению. Предел размера — 2 МБ.

Прежний логотип удаляется из хранилища после того, как новый записан и привязан к организации. Ответ — полный публичный профиль (как у `GET /api/org/public-profile`) со свежей ссылкой `logoUrl`; ссылка временная, действует 15 минут.

## Запрос

`multipart/form-data`, один файл в поле `file`. Имя файла сохраняется как исходное название (обрезается до 255 знаков).

## Ответ

`201 Created` — профиль целиком, поля те же, что у `GET /api/org/public-profile`.

```json
{
  "name": "Учебный центр «Развитие»",
  "slug": "fgso",
  "description": "",
  "inn": "",
  "website": "",
  "contactEmail": "",
  "contactPhone": "",
  "logoFileId": "8a5f0e1c-2b3d-4e5f-9a6b-7c8d9e0f1a2b",
  "logoUrl": "https://storage.vruchay.ru/…?X-Amz-Signature=…",
  "verifiedIssuer": false,
  "verifiedAt": null,
  "publicPageEnabled": true,
  "publicSearchByName": false,
  "publicIndexable": false,
  "verifyNameMode": "full",
  "trashDays": 7,
  "verifyDomain": ""
}
```

## Ошибки

| Код | Когда |
|---|---|
| 400 | В запросе нет файла: «Файл не передан». |
| 400 | Файл больше 2 МБ: «Файл слишком большой, максимум 2 МБ». |
| 400 | Не PNG и не JPEG по сигнатуре: «Поддерживаются только изображения PNG и JPEG». |
| 401 | Нет действующего токена и нет cookie-сессии. `{"statusCode":401,"message":"Требуется вход в систему","error":"Unauthorized"}` |
| 403 | Роль `member`: «Недостаточно прав для этого действия». |

## Пример

```bash
curl -X POST "https://vruchay.ru/api/org/public-profile/logo" \
  -H "Authorization: Bearer $VRUCHAY_TOKEN" \
  -F "file=@logo.png"
```

---

<!-- endpoints/overview/get-overview -->
> method: GET | path: /api/overview | auth: token | roles: any | rate_limit: none

# GET /api/overview

Сводка для рабочего стола кабинета одним запросом: условия и остаток документов, счётчики выпуска, рассылки и проверок по QR, пять последних материалов и пять последних заданий выпуска. Организация берётся из токена или сессии; единственный параметр — проект. Всё считается агрегатами, поэтому ответ одинаково быстрый у новой организации и у организации с сотнями тысяч документов.

Месяц для `issuedMonth`, `issuedPrevMonth` и `verifiedMonth` считается по московскому времени (UTC+3), с первого числа.

`issuedTotal` — выпущенное за всё время. На плане он не равен `usage.used`: тот считает документы только с даты начала плана.

С `projectId` все счётчики, письма, материалы и задания считаются только по проекту (письма — по материалам проекта и рассылкам без документа, отмеченным проектом). `usage` остаётся общим на организацию: план и остаток у неё одни на все проекты.

## Запрос

| Параметр | Тип | Обязателен | Описание |
|---|---|---|---|
| projectId | string (UUID) \| `none` | нет | Проект ([`GET /api/projects`](../projects/get-projects.md)); `none` — документы без проекта; без параметра — вся организация. |

## Ответ

`200 OK`

| Поле | Тип | Описание |
|---|---|---|
| usage | object | Условия и остаток — ровно тот объект, что отвечает `GET /api/org/usage`, со всеми полями (`plan`, `planName`, `source`, `used`, `limit`, `left`, `bonus`, `warn`, `endsAt`, `expired`, `neverExpires`, `features`). |
| issuedTotal | number | Выпущено документов за всё время. Совпадает с `usage.used` только на пробе и на `legacy-paid`. |
| issuedMonth | number | Выпущено с начала текущего месяца по Москве. |
| issuedPrevMonth | number | Выпущено за прошлый календарный месяц по Москве. |
| emailsSent | number | Писем, принятых почтовым шлюзом, за всё время: статусы `sent`, `delivered`, `opened`. В очереди и отказанные не считаются. |
| mail | object | Судьба писем за всё время. |
| mail.delivered | number | Дошло: статусы `delivered` и `opened`. |
| mail.undelivered | number | Не дошло: статусы `bounced` и `failed`. |
| verifiedMonth | number | Сколько выпущенных документов проверяли по QR с начала месяца. Считаются документы, а не проверки: у файла хранится только дата последней проверки. |
| verificationsTotal | number | Проверок по QR за всё время, по всем выпущенным документам. |
| materials | number | Материалов (документов) в работе — без удалённых в корзину и без шаблонов. |
| documents | array | До 5 последних по времени изменения материалов, без корзины и шаблонов. |
| documents[].id | string (UUID) | Идентификатор материала. |
| documents[].title | string | Название. |
| documents[].eventName | string | Название мероприятия (пустая строка — не задано). |
| documents[].eventDate | string | Дата мероприятия как введена (пустая строка — не задана). |
| documents[].updatedAt | string (ISO 8601) | Когда менялся. |
| jobs | array | До 5 последних заданий выпуска. |
| jobs[].id | string (UUID) | Идентификатор задания. |
| jobs[].documentId | string (UUID) | Материал. |
| jobs[].documentTitle | string | Название материала. |
| jobs[].status | string | `queued`, `running`, `done`, `failed` или `canceled`. |
| jobs[].total | number | Строк в задании. |
| jobs[].done | number | Выпущено. |
| jobs[].failed | number | Не удалось выпустить. |
| jobs[].createdAt | string (ISO 8601) | Когда поставлено. |

```json
{
  "usage": {
    "plan": "free",
    "planName": "Бесплатная проба",
    "source": "trial",
    "used": 37,
    "limit": 100,
    "left": 63,
    "bonus": 50,
    "warn": "none",
    "endsAt": null,
    "expired": false,
    "neverExpires": false,
    "features": null
  },
  "issuedTotal": 37,
  "issuedMonth": 12,
  "issuedPrevMonth": 25,
  "emailsSent": 30,
  "mail": { "delivered": 27, "undelivered": 1 },
  "verifiedMonth": 4,
  "verificationsTotal": 9,
  "materials": 3,
  "documents": [
    {
      "id": "0c9b7a65-4d3e-4f21-8b0a-1e2f3a4b5c6d",
      "title": "Грамоты первенства области",
      "eventName": "Конкурс «Мастер года»",
      "eventDate": "14 сентября 2026",
      "updatedAt": "2026-09-02T14:20:05.000Z"
    }
  ],
  "jobs": [
    {
      "id": "7e6d5c4b-3a29-4180-9f7e-6d5c4b3a2918",
      "documentId": "0c9b7a65-4d3e-4f21-8b0a-1e2f3a4b5c6d",
      "documentTitle": "Грамоты первенства области",
      "status": "done",
      "total": 12,
      "done": 12,
      "failed": 0,
      "createdAt": "2026-09-02T14:21:00.000Z"
    }
  ]
}
```

## Ошибки

| Код | Когда |
|---|---|
| 400 | `projectId` не UUID и не `none`: «Некорректный идентификатор проекта». |
| 401 | Нет действующего токена и нет cookie-сессии. `{"statusCode":401,"message":"Требуется вход в систему","error":"Unauthorized"}` |
| 404 | `projectId` — чужой или несуществующий проект: «Проект не найден». |

## Пример

```bash
curl "https://vruchay.ru/api/overview" \
  -H "Authorization: Bearer $VRUCHAY_TOKEN"
```

---

<!-- endpoints/projects/delete-projects-id -->
> method: DELETE | path: /api/projects/{id} | auth: token | roles: any | rate_limit: none

# DELETE /api/projects/{id}

Удаляет проект. **Документы при этом не удаляются**: они вместе со своим выданным и письмами переходят в «Без проекта» — их видно в `GET /api/documents?projectId=none`. Рассылки без документа, отмеченные этим проектом, тоже остаются, без проекта. Отменить удаление нечем — проект заводят заново и перекладывают в него документы.

## Запрос

| Параметр | Тип | Обязателен | Описание |
|---|---|---|---|
| id | string (UUID) | да | Идентификатор проекта. |

Тела нет.

## Ответ

`200 OK` — удалённый проект: `id`, `name`, `position`.

## Ошибки

| Код | Когда |
|---|---|
| 400 | `id` не UUID: «Некорректный идентификатор». |
| 401 | Нет токена или cookie-сессии. |
| 404 | Проекта с таким `id` в вашей организации нет: «Проект не найден». Чужой проект тоже даёт 404. |

## Пример

```bash
curl -X DELETE "https://vruchay.ru/api/projects/$PROJECT_ID" \
  -H "Authorization: Bearer $TOKEN"
```

---

<!-- endpoints/projects/get-projects -->
> method: GET | path: /api/projects | auth: token | roles: any | rate_limit: none

# GET /api/projects

Проекты организации в том порядке, в каком они стоят в кабинете. Проект — мероприятие или сайт, под которые ведётся работа: у проекта свои документы (их может быть несколько разных), свой реестр выданного, своя аналитика и свои письма. Список плоский: проекта внутри проекта не бывает. Документ лежит либо в одном проекте, либо «Без проекта».

Отбор по проекту принимают `GET /api/documents`, `GET /api/registry` (и выгрузки, сводка, отборы реестра), `GET /api/mailing/log`, `GET /api/overview` — параметром `projectId`: идентификатор проекта или `none` (документы без проекта).

## Запрос

Параметров нет.

## Ответ

`200 OK` — массив проектов.

| Поле | Тип | Описание |
|---|---|---|
| id | string (UUID) | Идентификатор проекта. |
| name | string | Название, как его завела организация. |
| position | number | Порядок в списке; меньше — выше. |
| count | number | Сколько живых документов в проекте. Удалённые в архив и шаблоны не считаются. |

```json
[
  { "id": "9f1c0c8e-0f3c-4a1a-9c1f-1f7d2a3b4c5d", "name": "Кубок города 2026", "position": 0, "count": 3 }
]
```

## Ошибки

| Код | Когда |
|---|---|
| 401 | Нет токена или cookie-сессии. |

## Пример

```bash
curl "https://vruchay.ru/api/projects" -H "Authorization: Bearer $TOKEN"
```

---

<!-- endpoints/projects/patch-projects-id -->
> method: PATCH | path: /api/projects/{id} | auth: token | roles: any | rate_limit: none

# PATCH /api/projects/{id}

Меняет название проекта. Документы внутри не трогает.

## Запрос

| Параметр | Тип | Обязателен | Описание |
|---|---|---|---|
| id | string (UUID) | да | Идентификатор проекта. |
| name | string | да | 1…100 знаков. |

```json
{ "name": "Кубок города — финал" }
```

## Ответ

`200 OK` — проект с новым названием.

## Ошибки

| Код | Когда |
|---|---|
| 400 | Пустое или слишком длинное название; `id` не UUID. |
| 401 | Нет токена или cookie-сессии. |
| 404 | Проекта с таким `id` в вашей организации нет: «Проект не найден». Чужой проект тоже даёт 404. |
| 409 | «Проект с таким названием уже есть». |

## Пример

```bash
curl -X PATCH "https://vruchay.ru/api/projects/$PROJECT_ID" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"name":"Кубок города — финал"}'
```

---

<!-- endpoints/projects/patch-projects-order -->
> method: PATCH | path: /api/projects/order | auth: token | roles: any | rate_limit: none

# PATCH /api/projects/order

Задаёт порядок проектов в кабинете. Место проекта — это его номер в присланном
списке: первый получает позицию 0, второй 1 и так далее.

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

## Запрос

| Параметр | Тип             | Обязателен | Описание                                                             |
| -------- | --------------- | ---------- | -------------------------------------------------------------------- |
| ids      | string[] (UUID) | да         | Все проекты организации, в нужном порядке. 1…100 штук, без повторов. |

```json
{ "ids": ["8f1e…", "2b40…", "c7a9…"] }
```

## Ответ

`200 OK` — список проектов в новом порядке, в том же виде, что у `GET /api/projects`.

```json
[
  { "id": "8f1e…", "name": "Кубок города 2026", "position": 0, "count": 3 },
  { "id": "2b40…", "name": "Сайт школы", "position": 1, "count": 5 }
]
```

## Ошибки

| Код | Когда                                                                                                        |
| --- | ------------------------------------------------------------------------------------------------------------ |
| 400 | Пустой список, больше ста проектов, не UUID или один проект назван дважды.                                   |
| 401 | Нет токена или cookie-сессии.                                                                                |
| 404 | В списке есть чужой проект или перечислены не все свои: «Проект не найден». Порядок при этом не меняется.    |

Неполный список — это тоже 404, а не частичная перестановка: расставить
половину проектов нельзя, остальные встали бы на места, которых никто им
не назначал.

## Пример

```bash
curl -X PATCH "https://vruchay.ru/api/projects/order" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"ids":["8f1e…","2b40…","c7a9…"]}'
```

---

<!-- endpoints/projects/post-projects -->
> method: POST | path: /api/projects | auth: token | roles: any | rate_limit: none

# POST /api/projects

Заводит проект в конце списка. Названия в пределах организации не повторяются: два проекта с одним именем различить нечем.

## Запрос

| Поле | Тип | Обязательно | Описание |
|---|---|---|---|
| name | string | да | 1…100 знаков. Пробелы по краям убираются. |

```json
{ "name": "Кубок города 2026" }
```

## Ответ

`201 Created`

| Поле | Тип | Описание |
|---|---|---|
| id | string (UUID) | Идентификатор нового проекта. |
| name | string | Название. |
| position | number | Порядок в списке. |

## Ошибки

| Код | Когда |
|---|---|
| 400 | Пустое название: «Введите название проекта»; длиннее 100 знаков. |
| 401 | Нет токена или cookie-сессии. |
| 409 | «Проект с таким названием уже есть». |

## Пример

```bash
curl -X POST "https://vruchay.ru/api/projects" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"name":"Кубок города 2026"}'
```

---

<!-- endpoints/public/get-referral-offer -->
> method: GET | path: /api/referral-offer | auth: public | roles: any | rate_limit: «20 запросов за 5 минут с одного IP»

# GET /api/referral-offer

Что получит тот, кто регистрируется по коду приглашения. Вызывается до
регистрации — со страницы `/register?ref=<код>`, — поэтому открыт без входа
и заголовок `Authorization` не нужен.

Наружу уходит только название пригласившей организации: его она и так сообщила
тому, кому дала ссылку. Ни адреса, ни идентификатора организации в ответе нет.
Частота ограничена, иначе адрес превратился бы в способ вычитывать список
клиентов сервиса по одному коду за раз.

Неизвестный код — не ошибка: ответ `200` с `valid: false`. Показать при этом
нужно обычный бесплатный лимит, он приходит в том же ответе.

## Запрос

| Параметр | Тип | Обязателен | Описание |
|---|---|---|---|
| `ref` | string ≤ 16 | да | Код приглашения из ссылки. Регистр не важен. Свой код организация берёт из `GET /api/referral`. |

Код считается годным, если после приведения к строчным подходит под
`^[a-z0-9]{4,16}$` и такая организация нашлась. Всё остальное — включая пустую
строку — даёт `valid: false`.

## Ответ

`200 OK`. Код известен:

| Поле | Тип | Описание |
|---|---|---|
| valid | boolean | `true`. |
| invitedBy | string | Название пригласившей организации. Пустая строка, если организация успела удалиться. |
| freeLimit | number | Обычный бесплатный лимит документов (настройка `FREE_DOCUMENT_LIMIT`, по умолчанию 50). |
| welcomeBonus | number | Надбавка за приход по приглашению (`REFERRAL_WELCOME_BONUS`, по умолчанию 50). |
| total | number | `freeLimit + welcomeBonus` — сколько документов будет у новичка. |

```json
{
  "valid": true,
  "invitedBy": "Учебный центр «Развитие»",
  "freeLimit": 50,
  "welcomeBonus": 50,
  "total": 100
}
```

Код неизвестен или не похож на код — ответ короче, полей о бонусе в нём нет:

| Поле | Тип | Описание |
|---|---|---|
| valid | boolean | `false`. |
| freeLimit | number | Обычный бесплатный лимит документов. |

```json
{
  "valid": false,
  "freeLimit": 50
}
```

## Ошибки

| Код | Когда |
|---|---|
| 400 | `ref` не передан или длиннее 16 знаков — стандартное сообщение Zod. |
| 429 | Больше 20 запросов за 5 минут с одного IP. |

## Пример

```bash
curl "https://vruchay.ru/api/referral-offer?ref=k7m2qp"
```

---

<!-- endpoints/public/get-v1-archive-token -->
> method: GET | path: /api/v1/archive/{token} | auth: public | roles: any | rate_limit: «30 запросов в минуту с одного IP»

# GET /api/v1/archive/{token}

Скачивание пакета по подписанной ссылке — без входа и без заголовка
`Authorization`. Ссылку выдаёт [`POST /api/jobs/{jobId}/archive-link`](../generation/post-jobs-jobId-archive-link.md):
агент или программа получает её по токену и отдаёт человеку, а тот открывает
её в браузере. Ссылка действует час с момента выдачи.

Отдаётся то же, что [`GET /api/jobs/{jobId}/archive`](../generation/get-jobs-jobId-archive.md):
ZIP со всеми документами задания или один PDF на всю пачку для печати —
в зависимости от `format`, выбранного при выдаче ссылки; шаблон имён файлов тоже
зашит в ссылку. Файлы собираются в момент скачивания: ссылка, выданная посреди
выпуска, отдаёт всё, что готово к этому моменту. Скачивать можно сколько угодно
раз, пока ссылка жива; квоту это не трогает.

Предел — по адресу, а не по организации: ссылку пересылают, и перебор подписи
не должен быть дешёвым.

## Запрос

| Параметр | Тип | Обязателен | Описание |
|---|---|---|---|
| `token` | string (путь) | да | подписанный токен из `url` ответа `POST /api/jobs/{jobId}/archive-link` |

Тела нет.

## Ответ

`200 OK`, файл.

Ссылка с `format: "zip"`:

- `Content-Type: application/zip`
- `Content-Disposition` с именем «Сертификаты.zip» (настоящее имя — в `filename*`)
- `Content-Length` нет: архив собирается и отдаётся потоком. Беда посреди отдачи
  обрывает начатый ответ, а не превращается в код ошибки

Ссылка с `format: "pdf"`:

- `Content-Type: application/pdf`
- `Content-Disposition` с именем «Сертификаты на печать.pdf»

В общий PDF попадают только PDF-файлы задания в порядке выпуска; JPEG — нет.

## Ошибки

| Код | Когда |
|---|---|
| 400 | общий PDF собрать нельзя: «В этом выпуске нет ни одного PDF — общий файл собрать не из чего.», «Документов слишком много для одного файла: N при пределе 300. Скачайте архивом — в нём тот же комплект.», «Пачка слишком тяжёлая для одного файла: N МБ при пределе 150 МБ. Скачайте архивом — в нём тот же комплект.», «Не удалось собрать общий файл: документы этого задания не читаются как PDF» |
| 404 | «Ссылка устарела или неверна — попросите новую» — прошёл час, подпись не сходится или токен повреждён |
| 404 | «Задание не найдено» — задание удалено с тех пор, как выдали ссылку |
| 429 | «Слишком много попыток. Повторите через N с.» — больше 30 запросов в минуту с одного адреса |

Ошибки приходят JSON-ом. Браузер в этом случае покажет текст ошибки вместо
скачивания — новую ссылку выдаёт тот, кто выдал прежнюю.

## Пример

```bash
curl -L "https://vruchay.ru/api/v1/archive/$ARCHIVE_TOKEN" -o documents.zip
```

---

<!-- endpoints/public/get-v1-forms-token -->
> method: GET | path: /api/v1/forms/{token} | auth: public | roles: any | rate_limit: «120 запросов за 5 минут с одного IP»

# GET /api/v1/forms/{token}

Что показывает страница формы по ссылке — `https://vruchay.ru/f/{token}`:
чья форма, какой документ, какие поля спросить у участника, текст согласия.
Без входа: страницу открывает участник по ссылке из чата или по QR.

Форма по ссылке — интеграция с `kind: "link"` (см. `POST /api/integrations/tilda`).
Свою страницу для неё делать не нужно, она уже есть на нашем адресе. Заявку
страница отправляет теми же публичными адресами, что и форма на сайте:
`POST /api/v1/tilda/submit`, затем при подтверждении почты
`POST /api/v1/tilda/confirm`, затем опрос `GET /api/v1/tilda/status/{requestId}`
и скачивание `GET /api/v1/tilda/download/{requestId}`. Заявки по ссылке
принимаются только с нашей страницы: источник запроса (`Origin`, без него —
`Referer`) обязан быть адресом сервиса или подтверждённым своим доменом
организации (см. `GET /api/org/domain`), иначе заявка отклоняется. На своём
домене открываются только формы этой организации — чужая форма там отвечает 404. Поля, которых
форма не спрашивает, отбрасываются, а все спрошенные обязательны.

Форма на сайте (`kind: "site"`) здесь не отвечает: у неё своя разметка на странице
организации.

## Запрос

| Параметр | Тип | Обязателен | Описание |
|---|---|---|---|
| `token` | UUID (путь) | да | Токен формы — тот, что в ссылке |

## Ответ

`200 OK`, `cache-control: no-store`:

| Поле | Тип | Описание |
|---|---|---|
| `token` | UUID | Токен формы — его страница кладёт в заявку |
| `documentId` | UUID | Документ, который выдаёт форма |
| `org` | string | Название организации |
| `title` | string | Название документа |
| `event` | string \| null | Мероприятие, если задано у документа |
| `fields` | array | Что спросить: `name` — имя поля для заявки, `title` — подпись, `required` — обязательно ли. Почты здесь нет: её форма спрашивает всегда |
| `authMode` | `none` \| `email_code` | Подтверждать ли почту кодом из письма |
| `consentText` | string | Подпись у галочки согласия на обработку данных |
| `consentVersion` | string | Версия текста согласия — уходит в заявку и в журнал согласий |
| `privacyUrl` | string | Полный текст политики |
| `successMessage` | string | Текст окна успеха |
| `showDownload` | boolean | Показывать ли кнопку скачивания. Когда письмо не ушло, статус заявки разрешает скачивание и без неё |
| `available` | boolean | Выдаст ли форма документ прямо сейчас. `false` — на листе нет ни одного блока, кончилась квота или срок плана: страница сразу пишет «Выдача документов по этой форме сейчас недоступна — напишите организатору», не дожидаясь заявки. Причину участнику не называем — она в журнале действий организации |

```json
{
  "token": "00000000-0000-4000-8000-0000000000cd",
  "documentId": "9e8d7c6b-5a4f-4e3d-2c1b-0a9f8e7d6c5b",
  "org": "Школа № 5",
  "title": "Грамота за олимпиаду",
  "event": "Олимпиада по физике",
  "fields": [
    { "name": "name", "title": "Фамилия, имя, отчество", "required": true },
    { "name": "grade", "title": "Класс", "required": true }
  ],
  "authMode": "email_code",
  "consentText": "Я даю согласие на обработку моих персональных данных — фамилии, имени и адреса почты — чтобы мне изготовили и прислали документ",
  "consentVersion": "2026-08-02",
  "privacyUrl": "https://vruchay.ru/privacy",
  "successMessage": "Спасибо! Документ отправлен на вашу почту",
  "showDownload": true,
  "available": true
}
```

Окно успеха формы по ссылке: «Документ готов», кнопка скачивания и ссылка на
страницу проверки (`verifyUrl` в статусе заявки), если проверка включена у документа.
Статус заявки отдаёт `mailed: true`, когда письмо с документом поставлено в отправку, —
тогда страница пишет «Копия отправлена на …» с адресом, который ввёл участник.

## Ошибки

| Код | Когда |
|---|---|
| 400 | `Некорректный идентификатор` — `token` не UUID |
| 404 | `Форма не найдена` — нет такой, выключена, это форма на сайте, а не по ссылке, документ в корзине или стал шаблоном |
| 429 | Слишком часто — см. [Ограничения частоты](../../rate-limits.md) |

## Пример

```bash
curl "https://vruchay.ru/api/v1/forms/$FORM_TOKEN"
```

---

<!-- endpoints/public/get-v1-public-org-slug-search -->
> method: GET | path: /api/v1/public/org/{slug}/search | auth: public | roles: any | rate_limit: «20 запросов в минуту с одного IP»

# GET /api/v1/public/org/{slug}/search

Поиск в публичном реестре эмитента — без входа. Два разных поиска в одном адресе,
и указывать надо ровно один из параметров:

- `code` — по номеру документа. Работает всегда, отвечает одним лишь путём
  страницы проверки: всё о документе говорит она, а здесь важно только,
  выдан ли такой номер этой организацией;
- `name` — по ФИО. Работает, только если эмитент сам включил такой поиск
  (`publicSearchByName`). Поиск по фамилии без согласия человека — это
  распространение персональных данных (ст. 10.1 152-ФЗ), и согласие собирает
  эмитент, а не сервис.

Предел строже, чем у страницы организации, втрое: поиск по ФИО — это перебор
чужих фамилий, и бесплатным он быть не должен.

## Запрос

| Параметр | Тип | Обязателен | Описание |
|---|---|---|---|
| `slug` | string, `^[a-z0-9-]{1,50}$` | да | Публичный адрес организации (параметр пути). |
| `code` | string 1…64 | ровно один из двух | Номер документа: публичный код или `publicId` (UUID). Регистр, пробелы и дефисы не важны. |
| `name` | string 1…200 | ровно один из двух | ФИО или его часть. Короче трёх знаков — ответ пустой. |

Указать оба параметра сразу или не указать ни одного — 400.

## Ответ

`200 OK`. Форма ответа зависит от того, чем искали.

Поиск по `code`:

| Поле | Тип | Описание |
|---|---|---|
| path | string | Путь страницы проверки: `/c/<код>` или `/verify/<uuid>`. |

```json
{ "path": "/c/K7M2-9QXR-4TVB" }
```

Поиск по `name`:

| Поле | Тип | Описание |
|---|---|---|
| items | array | Совпадения, не больше 20, новые первыми. |

Элемент `items`:

| Поле | Тип | Описание |
|---|---|---|
| code | string | Номер документа: публичный код или `publicId`. |
| path | string | Путь страницы проверки. |
| name | string | Имя получателя так, как разрешил показывать эмитент (`verifyNameMode`). При режиме `none` — пустая строка. |
| title | string | Название материала. |
| eventName | string | Мероприятие. |
| eventDate | string | Дата мероприятия строкой. |
| issuedAt | string (ISO 8601) | Когда выпущен. |
| state | string | `valid`, `revoked`, `replaced` или `expired`. |

```json
{
  "items": [
    {
      "code": "K7M2-9QXR-4TVB",
      "path": "/c/K7M2-9QXR-4TVB",
      "name": "Иванова М. П.",
      "title": "Сертификат участника",
      "eventName": "Городской конкурс «Мастер года»",
      "eventDate": "14.06.2026",
      "issuedAt": "2026-06-15T09:12:44.000Z",
      "state": "valid"
    }
  ]
}
```

Ищется только колонка `name`, по снимку данных на момент выпуска (у документов,
выпущенных до появления снимка — по живой строке таблицы). Совпадение —
вхождение подстроки без учёта регистра. Постраничности нет: сверх двадцати
совпадений ничего не отдаётся, запрос надо уточнять.

Запрос `name` короче трёх знаков после обрезки пробелов отвечает `{"items": []}`
без похода в базу: «Ив» вернуло бы половину реестра.

## Ошибки

| Код | Когда |
|---|---|
| 400 | `slug` не подходит под `^[a-z0-9-]{1,50}$` — «Некорректный адрес». Указаны оба параметра или ни одного — «Укажите номер документа или имя». `code` длиннее 64 знаков или `name` длиннее 200 — стандартное сообщение Zod. |
| 404 | Организации нет или её публичная страница выключена — «Организация не найдена». Поиск по ФИО не включён эмитентом — «Поиск по имени недоступен». Документа с таким номером у этой организации нет, он удалён или проверка для его материала выключена — «Документ не найден». |
| 429 | Больше 20 запросов в минуту с одного IP. |

Чужой и несуществующий номер отвечают одинаково: по ответу нельзя узнать,
существует ли такой документ у кого-то другого.

## Пример

```bash
curl "https://vruchay.ru/api/v1/public/org/fgso/search?code=K7M2-9QXR-4TVB"
```

```bash
curl --get "https://vruchay.ru/api/v1/public/org/fgso/search" \
  --data-urlencode "name=Иванова Мария"
```

---

<!-- endpoints/public/get-v1-public-org-slug -->
> method: GET | path: /api/v1/public/org/{slug} | auth: public | roles: any | rate_limit: «60 запросов в минуту с одного IP»

# GET /api/v1/public/org/{slug}

Публичная страница эмитента: кто это, чем подтверждена его личность и что он
выдаёт. Открыта без входа, заголовок `Authorization` не нужен.

Организация показывается только по своему решению (`publicPageEnabled`) и только
по адресу, который сама выбрала (`slug`). По идентификатору организации из базы
страницы нет — чтобы перебор UUID не давал списка клиентов сервиса. Если страница
выключена или адрес не задан, ответ — 404, такой же, как для несуществующей
организации.

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

## Запрос

| Параметр | Тип | Обязателен | Описание |
|---|---|---|---|
| `slug` | string, `^[a-z0-9-]{1,50}$` | да | Публичный адрес организации. Регистр не важен: приводится к строчным. |

Параметров строки запроса нет.

## Ответ

`200 OK`

| Поле | Тип | Описание |
|---|---|---|
| name | string | Название организации. |
| slug | string \| null | Публичный адрес: `/org/<slug>`. |
| description | string | Описание. Пустая строка — не заполнено. |
| inn | string | ИНН. Пустая строка — не указан. |
| website | string | Сайт. Пустая строка — не указан. |
| contactEmail | string | Почта для связи. Пустая строка — не указана. |
| contactPhone | string | Телефон для связи. Пустая строка — не указан. |
| logoUrl | string \| null | Временная ссылка на логотип, действует 15 минут. `null` — логотипа нет. |
| verified | boolean | Значок «Верифицированный эмитент». |
| verifiedAt | string (ISO 8601) \| null | Когда значок выдан. |
| indexable | boolean | Пускать ли поисковики на страницу. |
| searchByName | boolean | Включён ли поиск документов по ФИО. При `false` запрос к `/search?name=…` отвечает 404. |
| programs | array | Материалы, по которым что-то выдано. Не больше 200, новые первыми. |

Элемент `programs`:

| Поле | Тип | Описание |
|---|---|---|
| id | string (UUID) | Идентификатор материала. |
| title | string | Название. |
| eventName | string | Мероприятие. Пустая строка — не указано. |
| eventDate | string | Дата мероприятия строкой, как её ввела организация. |
| issued | number | Сколько документов выдано по этому материалу. |
| firstIssuedAt | string (ISO 8601) \| null | Первый выпуск. |
| lastIssuedAt | string (ISO 8601) \| null | Последний выпуск. |

```json
{
  "name": "Учебный центр «Развитие»",
  "slug": "fgso",
  "description": "Учебный центр. Программы и аттестации с 2004 года.",
  "inn": "6316123456",
  "website": "https://fgso.vruchay.ru",
  "contactEmail": "info@fgso.vruchay.ru",
  "contactPhone": "+7 846 000-00-00",
  "logoUrl": "https://storage.vruchay.ru/…?X-Amz-Signature=…",
  "verified": true,
  "verifiedAt": "2026-03-11T08:00:00.000Z",
  "indexable": true,
  "searchByName": false,
  "programs": [
    {
      "id": "b1c2d3e4-f5a6-4b7c-8d9e-0f1a2b3c4d5e",
      "title": "Сертификат участника",
      "eventName": "Городской конкурс «Мастер года»",
      "eventDate": "14.06.2026",
      "issued": 248,
      "firstIssuedAt": "2026-06-15T09:12:44.000Z",
      "lastIssuedAt": "2026-06-15T09:41:02.000Z"
    }
  ]
}
```

Постраничности у `programs` нет: отдаётся не больше 200 материалов, остальные
в ответ не попадают.

## Ошибки

| Код | Когда |
|---|---|
| 400 | `slug` не подходит под `^[a-z0-9-]{1,50}$` — «Некорректный адрес». |
| 404 | Организации с таким адресом нет или её публичная страница выключена: `{"statusCode":404,"message":"Организация не найдена","error":"Not Found"}`. |
| 429 | Больше 60 запросов в минуту с одного IP. |

## Пример

```bash
curl "https://vruchay.ru/api/v1/public/org/fgso"
```

---

<!-- endpoints/public/get-v1-reviews -->
> method: GET | path: /api/v1/reviews | auth: public | roles: any | rate_limit: none

# GET /api/v1/reviews

Отзывы заказчиков для посадочной страницы сервиса. Без входа, заголовок
`Authorization` не нужен.

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

Свой отзыв организация оставляет через `PATCH /api/reviews/mine`.

## Запрос

| Параметр | Тип | Обязателен | Описание |
|---|---|---|---|
| `limit` | integer 1…50 | нет | Сколько отзывов вернуть. По умолчанию 12. |

Постраничности нет: отдаётся первая страница, отсортированная по дате публикации,
новые первыми.

## Ответ

`200 OK`

| Поле | Тип | Описание |
|---|---|---|
| items | array | Отзывы, новые первыми. |

Элемент `items`:

| Поле | Тип | Описание |
|---|---|---|
| authorName | string | Как подписан автор. Заполняет он сам, из профиля не берётся. |
| authorRole | string | Должность автора. |
| orgName | string | Организация, от лица которой написан отзыв. |
| text | string | Текст отзыва, 40…2000 знаков. |
| rating | number \| null | Оценка 1…5. `null` — автор не поставил. |
| publishedAt | string (ISO 8601) | Когда отзыв опубликован. |

```json
{
  "items": [
    {
      "authorName": "Мария Петрова",
      "authorRole": "Ответственный секретарь",
      "orgName": "Учебный центр «Развитие»",
      "text": "Раньше на двести грамот уходило два дня: сверить фамилии, разложить по шаблону, разослать. Теперь загружаем таблицу и через полчаса рассылка уже ушла, а мы смотрим реестр — кто открыл письмо, кто нет.",
      "rating": 5,
      "publishedAt": "2026-05-20T10:03:00.000Z"
    }
  ]
}
```

## Ошибки

| Код | Когда |
|---|---|
| 400 | `limit` не целое число, меньше 1 или больше 50 — стандартное сообщение Zod. |

## Пример

```bash
curl "https://vruchay.ru/api/v1/reviews?limit=6"
```

---

<!-- endpoints/public/get-v1-verify-id -->
> method: GET | path: /api/v1/verify/{id} | auth: public | roles: any | rate_limit: «30 запросов в минуту с одного IP»

# GET /api/v1/verify/{id}

Страница проверки выданного документа. Открыта без входа намеренно: её открывает
посторонний — работодатель, приёмная комиссия, жюри конкурса, — у которого
нет и не должно быть учётной записи. Заголовка `Authorization` здесь не нужно.

Отвечает скупо: только то, что и так напечатано на бумаге, которую человек держит
в руках. Почта, телефон, дата рождения и прочие колонки таблицы получателей не
показываются ни при каких настройках. Какие поля вообще попадут в ответ, решают две
вещи: список `verifyFields` материала (что отметила организация) и режим
`verifyNameMode` организации (как показывать имя) — см. ниже.

Каждый успешный ответ увеличивает обезличенный счётчик проверок документа
(`verifyCount` в реестре). Ни адрес, ни устройство проверяющего не сохраняются.

Действующий документ отдаётся с `cache-control: public, max-age=60,
stale-while-revalidate=60`; отозванный, заменённый и истёкший — с `no-store`,
чтобы отзыв доезжал до страницы сразу.

## Запрос

| Параметр | Тип | Обязателен | Описание |
|---|---|---|---|
| `id` | string 1…64 | да | Публичный код документа (`K7M2-9QXR-4TVB`) или `publicId` (UUID) у документов, выпущенных до появления кода. |

Код принимается как угодно набранным: строчными буквами, с пробелами и дефисами
в любых местах, с `O` вместо нуля и `I`/`L` вместо единицы. Готовый путь страницы
для каждого документа отдаёт реестр в поле `verifyPath`.

## Ответ

`200 OK`

| Поле | Тип | Описание |
|---|---|---|
| state | string | `valid` — действителен; `revoked` — отозван организацией; `replaced` — выдан новый взамен; `expired` — срок вышел. |
| valid | boolean | `state === "valid"`. |
| revoked | boolean | `state === "revoked"`. |
| replaced | boolean | `state === "replaced"`. |
| expired | boolean | `state === "expired"`. |
| code | string | Код, напечатанный на бумаге: публичный код или `publicId`. |
| title | string | Название материала. |
| event.name | string | Мероприятие (пустая строка — не указано). |
| event.date | string | Дата мероприятия как её ввела организация, строкой (пустая — не указана). |
| issuedAt | string (ISO 8601) | Когда документ выпущен. |
| expiresAt | string (ISO 8601) \| null | Когда истекает срок. `null` — бессрочный. |
| fields | object | Поля получателя: ключ — имя колонки, значение — строка. Пустой объект, если организация ничего не отметила, режим `none` или документ отозван. |
| issuer.name | string | Название выдавшей организации. |
| issuer.verified | boolean | Значок «Верифицированный эмитент». |
| issuer.publicPath | string \| null | Путь публичной страницы организации `/org/<slug>`. `null` — страница выключена или адрес не задан. |
| issuer.contactEmail | string \| null | Почта для связи («сообщить о проблеме»). `null` — не указана. |
| issuer.website | string \| null | Сайт организации. `null` — не указан. |
| indexable | boolean | Пускать ли поисковики на эту страницу. Решение организации. |
| sha256 | string \| null | Отпечаток выпущенного PDF для сверки. `null` у документов, выпущенных до того, как отпечаток стали сохранять. |
| signed | boolean | Подписан ли файл электронной подписью сервиса. |
| revokedAt | string (ISO 8601) \| null | Когда отозван. Заполнено только при `state: "revoked"`. |
| revokedReason | string \| null | Публичная причина отзыва. Только при `state: "revoked"`, и только если организация её указала. |
| replacedBy.code | string | Код документа-замены. |
| replacedBy.path | string | Путь страницы проверки замены: `/c/<код>` или `/verify/<uuid>`. |
| replacedBy.issuedAt | string (ISO 8601) | Когда выпущена замена. |

`replacedBy` — `null` во всех состояниях, кроме `replaced`, а также если саму замену
успели отозвать или удалить: вести человека на страницу, которая ответит «не найдено»,
хуже, чем отправить его в выдавшую организацию.

Что попадает в `fields`:

- организация отмечает в материале список показываемых колонок (`verifyFields`);
- поля с адресом почты, телефоном, датой рождения, паспортом, СНИЛС и ИНН
  выбрасываются всегда, даже если отмечены;
- дальше действует `verifyNameMode` организации: `full` — как отмечено;
  `initials` — колонки с именем (`name`, `fio`, `full_name`, `name_dat`,
  `name_gen` и т. п.) сворачиваются до фамилии и инициалов, остальные
  (клуб, место, город) остаются; `none` — `fields` пустой, наружу уходит
  только факт подлинности и название организации;
- у отозванного документа `fields` пустой в любом режиме: связывать причину
  отзыва с фамилией на открытой странице сервис не вправе.

Значения берутся из снимка данных на момент выпуска, а не из живой строки таблицы:
страница показывает то, что напечатано на бумаге, даже если строку потом поправили.

```json
{
  "state": "valid",
  "valid": true,
  "replaced": false,
  "expired": false,
  "revoked": false,
  "code": "K7M2-9QXR-4TVB",
  "title": "Сертификат участника",
  "event": { "name": "Городской конкурс «Мастер года»", "date": "14.06.2026" },
  "issuedAt": "2026-06-15T09:12:44.000Z",
  "expiresAt": null,
  "fields": {
    "name": "Иванова М. П.",
    "place": "2 место",
    "club": "СШОР «Олимп»"
  },
  "issuer": {
    "name": "Учебный центр «Развитие»",
    "verified": true,
    "publicPath": "/org/fgso",
    "contactEmail": "info@fgso.vruchay.ru",
    "website": "https://fgso.vruchay.ru"
  },
  "indexable": true,
  "sha256": "9f2c1b0e7a3d4f56b8c9d0e1f2a3b4c5d6e7f8091a2b3c4d5e6f708192a3b4c5",
  "signed": true,
  "revokedAt": null,
  "revokedReason": null,
  "replacedBy": null
}
```

Отозванный документ — тоже `200 OK`, а не «не найдено»: человеку с бумагой в руках
надо сказать прямо, что документ недействителен.

```json
{
  "state": "revoked",
  "valid": false,
  "replaced": false,
  "expired": false,
  "revoked": true,
  "code": "K7M2-9QXR-4TVB",
  "title": "Сертификат участника",
  "event": { "name": "Городской конкурс «Мастер года»", "date": "14.06.2026" },
  "issuedAt": "2026-06-15T09:12:44.000Z",
  "expiresAt": null,
  "fields": {},
  "issuer": {
    "name": "Учебный центр «Развитие»",
    "verified": true,
    "publicPath": "/org/fgso",
    "contactEmail": "info@fgso.vruchay.ru",
    "website": null
  },
  "indexable": true,
  "sha256": null,
  "signed": false,
  "revokedAt": "2026-07-01T11:20:00.000Z",
  "revokedReason": "Ошибка в разряде",
  "replacedBy": null
}
```

## Ошибки

| Код | Когда |
|---|---|
| 400 | `id` длиннее 64 знаков или пустой после обрезки пробелов — стандартное сообщение Zod. |
| 404 | Документ не найден, удалён, или проверка для его материала выключена. Тело нестандартное: `{"message":"Документ не найден","reason":"malformed"}` — без `statusCode` и `error`. |
| 429 | Больше 30 запросов в минуту с одного IP. |

Поле `reason` в ответе 404 объясняет, что не так с введённым кодом, и ничего
не говорит о чужих документах — все три значения выводятся из самого кода:

| `reason` | Что показать человеку |
|---|---|
| `malformed` | Это не похоже ни на код, ни на UUID: не те знаки или не та длина. |
| `checksum` | Похоже на код, но контрольный хвост не сходится — почти наверняка опечатка при переписывании с бумаги. Стоит перепроверить пары `0`/`O`, `1`/`I`, `1`/`L`, `5`/`S`, `8`/`B`, `2`/`Z`. |
| `unknown` | Форма верна, но такого документа нет или он закрыт для проверки. |

Отключённая проверка и несуществующий код отвечают одинаково: разные ответы
позволяли бы отличать «такого не было» от «был, но закрыт».

## Пример

```bash
curl "https://vruchay.ru/api/v1/verify/K7M2-9QXR-4TVB"
```

Старый выпуск — по тому же адресу с UUID:

```bash
curl "https://vruchay.ru/api/v1/verify/3f9a2b1c-5d6e-4f70-8a9b-0c1d2e3f4a5b"
```

---

<!-- endpoints/public/post-v1-leads -->
> method: POST | path: /api/v1/leads | auth: public | roles: any | rate_limit: «5 запросов за 10 минут с одного IP»

# POST /api/v1/leads

Заявка с посадочной страницы: организация просит связаться и обсудить условия. Адрес открыт без входа, заголовок `Authorization` не нужен.

Заявка только сохраняется, и владельцу сервиса уходит уведомление. Ни счёта, ни плана она не создаёт: условия обсуждают в разговоре, после него владелец сервиса выставляет счёт по реквизитам организации (см. `GET /api/org/invoices`) и назначает план. Полей `inn` и `tariff` в заявке больше нет; если их прислать, они молча отбрасываются вместе с любыми другими незнакомыми полями.

Обязательны четыре поля: название организации, как обращаться, почта и согласие на обработку персональных данных. Вместе с заявкой сохраняются редакция текста согласия, IP-адрес отправителя и заголовки `Referer` и `User-Agent`.

## Запрос

Тело (`application/json`):

| Поле | Тип | Обязательно | Описание |
|---|---|---|---|
| orgName | string | да | Название организации, от 2 до 200 знаков. |
| contact | string | да | Как обращаться, от 2 до 120 знаков. |
| email | string | да | Почта для ответа, до 254 знаков. Приводится к нижнему регистру. |
| consent | boolean | да | Только `true`: согласие на обработку персональных данных. |
| phone | string | нет | Телефон, до 40 знаков. |
| volume | string | нет | Объём документов в год: `to1k`, `to5k`, `to20k`, `to60k`, `more` или `unknown`. |
| eventKinds | string[] | нет | Типы мероприятий: `competitions`, `education`, `conferences`, `corporate`, `contests`, `other`. |
| callTime | string | нет | Когда удобно связаться: `morning`, `day`, `evening` или `mail` («не звоните, напишите на почту»). |
| comment | string | нет | Комментарий, до 2000 знаков. |

```json
{
  "orgName": "Учебный центр «Развитие»",
  "contact": "Мария",
  "email": "maria@vruchay.ru",
  "volume": "to5k",
  "eventKinds": ["education"],
  "callTime": "day",
  "consent": true
}
```

## Ответ

`201 Created`

```json
{ "ok": true }
```

## Ошибки

| Код | Когда |
|---|---|
| 400 | `orgName` короче двух знаков: «Укажите название организации»; `contact` короче двух: «Как к вам обращаться?»; почта не адрес: «Проверьте адрес электронной почты». |
| 400 | Нет согласия: «Без согласия на обработку данных заявку принять нельзя». |
| 400 | Значение `volume`, `eventKinds` или `callTime` не из списка, превышена длина — сообщение Zod. |
| 413 | Тело больше 1 МБ. |
| 429 | Больше 5 заявок за 10 минут с одного IP. |

## Пример

```bash
curl -X POST "https://vruchay.ru/api/v1/leads" \
  -H "Content-Type: application/json" \
  -d '{"orgName":"Учебный центр «Развитие»","contact":"Мария","email":"maria@vruchay.ru","consent":true}'
```

---

<!-- endpoints/public/post-v1-uploads-link -->
> method: POST | path: /api/v1/uploads/{link} | auth: public | roles: any | rate_limit: «30 запросов в минуту с одного IP»

# POST /api/v1/uploads/{link}

Принимает файл по одноразовой ссылке из [`POST /api/uploads`](../documents/post-uploads.md)
(в MCP — `get_upload_link`). Заголовок `Authorization` не нужен: ссылка и есть
пропуск. Так агент с оболочкой загружает бланк, логотип или таблицу с диска
одной командой `curl -F "file=@…"`.

Файл проходит те же проверки, что в кабинете, теми же словами: тип картинки
определяется по первым байтам (PNG или JPEG до 20 МБ), таблица — по расширению
(`.xlsx`, `.csv`, `.tsv`, `.txt`, до 10 МБ). Дальше — по цели ссылки:

- `background` — картинка ставится фоном листа, как
  [`POST …/sheets/{sheetId}/background`](../documents/post-documents-id-sheets-sheetId-background.md);
  прежний фон уходит, если больше нигде не стоит;
- `element` — картинка для блока, как [`POST /api/documents/{id}/assets`](../documents/post-documents-id-assets.md):
  её `fileId` ставят в `props.fileId` блока `image`;
- `recipients` — таблица разбирается, как [`POST …/recipients/parse`](../recipients/post-documents-id-recipients-parse.md),
  и сразу загружается, как [`POST …/recipients/import`](../recipients/post-documents-id-recipients-import.md),
  с режимом из ссылки. Колонки называются так, как их предложил разбор
  («ФИО» → `name`, «Почта» → `email`; Фамилия, Имя и Отчество отдельными колонками
  дополнительно собираются в `name`), заголовки из шапки файла сохраняются.
  Имя колонки можно поправить потом —
  [`PATCH …/recipients/columns/{columnId}`](../recipients/patch-documents-id-recipients-columns-columnId.md).

Ссылка одноразовая: первый принятый файл её гасит, повтор отвечает `410`.
Файл, не прошедший проверку (`400`), ссылку не тратит — исправьте путь
и повторите. Права проверяются в момент приёма: если токен, выдавший ссылку,
отозван, или агент, подключённый через OAuth, потерял права, ответ — `404`.

Загрузка пишется в [журнал действий](../audit/get-audit.md) организации
с автором — токеном или подключённым агентом (`upload.background`,
`upload.element`, `upload.recipients`). Имени файла и строк таблицы
в журнале нет, в ответе строк тоже нет — их читают
[`GET …/recipients`](../recipients/get-documents-id-recipients.md).

## Запрос

| Параметр | Тип | Обязателен | Описание |
|---|---|---|---|
| `link` | string (путь) | да | подписанный токен из `url` ответа `POST /api/uploads` |

Тело — `multipart/form-data`, одно поле-файл `file`.

## Ответ

`200 OK`

Фон и картинка для блока:

| Поле | Тип | Описание |
|---|---|---|
| `target` | string | `background` или `element` |
| `fileId` | UUID | файл; у фона это уже `backgroundFileId` листа |
| `sheetId` | UUID | лист — только у фона |
| `widthPx`, `heightPx` | integer | размер картинки в пикселях; полей нет, если заголовок не прочитался |

```json
{ "target": "background", "fileId": "d4e5f6a7-b8c9-4d0e-9f1a-2b3c4d5e6f70", "sheetId": "b2c3d4e5-f6a7-4b8c-9d0e-1f2a3b4c5d6e", "widthPx": 3508, "heightPx": 2480 }
```

Таблица:

| Поле | Тип | Описание |
|---|---|---|
| `target` | string | `recipients` |
| `imported` | integer | сколько строк загружено |
| `mode` | string | `append` или `replace` |
| `columns` | array | колонки файла: `name` — имя для `%name` и других подстановок, `title` — заголовок из шапки |
| `skippedEmptyRows` | integer | пропущено пустых строк |
| `warnings` | string[] | замечания разбора: где нашлась шапка, что собрано ФИО и т. п. |
| `groups` | integer | групп протокола соревнований — только если файл прочитан как протокол |

```json
{
  "target": "recipients",
  "imported": 120,
  "mode": "replace",
  "columns": [
    { "name": "surname", "title": "Фамилия" },
    { "name": "firstname", "title": "Имя" },
    { "name": "email", "title": "Почта" },
    { "name": "name", "title": "ФИО" }
  ],
  "skippedEmptyRows": 0,
  "warnings": ["ФИО собрано из колонок «Фамилия», «Имя» и «Отчество» — в таблице добавлена колонка «ФИО»"]
}
```

## Ошибки

| Код | Когда |
|---|---|
| 400 | «Файл не передан»; «Файл слишком большой, максимум 20 МБ» (у таблицы — 10 МБ); «Поддерживаются только изображения PNG и JPEG»; «Поддерживаются файлы Excel (.xlsx) и CSV»; «Не удалось прочитать файл…»; ошибки схемы импорта — как у [`…/import`](../recipients/post-documents-id-recipients-import.md). Ссылка остаётся рабочей |
| 404 | «Ссылка на загрузку неверна — получите новую (get_upload_link)» — подпись не сходится, токен отозван или потерял права; «Документ не найден», «Лист не найден» — документ удалён с тех пор, как выдали ссылку |
| 406 | тело не `multipart/form-data` |
| 410 | «Срок ссылки истёк — получите новую (get_upload_link)»; «По этой ссылке файл уже загружен — для следующего получите новую (get_upload_link)» |
| 429 | «Слишком много попыток. Повторите через N с.» — больше 30 запросов в минуту с одного адреса |

## Пример

```bash
curl -sS -F "file=@бланк.png" "https://vruchay.ru/api/v1/uploads/u.eyJvcmdJZCI6….Qm9v…"
```

---

<!-- endpoints/recipients/delete-documents-id-recipients-columns-columnId -->
> method: DELETE | path: /api/documents/{id}/recipients/columns/{columnId} | auth: token | roles: any | rate_limit: none

# DELETE /api/documents/{id}/recipients/columns/{columnId}

Удаляет колонку и пересчитывает `position` у оставшихся, чтобы нумерация шла подряд. Значения в `data` строк под именем удалённой колонки не вычищаются, макеты листов не правятся — поле макета, ссылавшееся на колонку, останется без источника данных.

## Запрос

| Параметр | Тип | Обязателен | Описание |
|---|---|---|---|
| `id` | UUID (путь) | да | Идентификатор документа |
| `columnId` | UUID (путь) | да | Идентификатор колонки |

Тела нет.

## Ответ

`200 OK`

| Поле | Тип | Описание |
|---|---|---|
| `ok` | boolean | Всегда `true` |

```json
{ "ok": true }
```

## Ошибки

| Код | Когда |
|---|---|
| 400 | `Некорректный идентификатор` — `id` или `columnId` не UUID |
| 401 | Нет токена или сессии |
| 404 | `Документ не найден` |
| 404 | `Колонка не найдена` — колонки нет или она из другого документа |

## Пример

```bash
curl -X DELETE "https://vruchay.ru/api/documents/$DOCUMENT_ID/recipients/columns/$COLUMN_ID" \
  -H "Authorization: Bearer $VRUCHAY_TOKEN"
```

---

<!-- endpoints/recipients/delete-documents-id-recipients-rows-rowId -->
> method: DELETE | path: /api/documents/{id}/recipients/rows/{rowId} | auth: token | roles: any | rate_limit: none

# DELETE /api/documents/{id}/recipients/rows/{rowId}

Удаляет строку из таблицы получателей. `position` остальных строк не пересчитывается. Выпущенные по строке файлы остаются; если по ней шёл выпуск, строка попадёт в список неудач задания как «Строки больше нет в таблице».

Если строку нужно лишь исключить из выпуска, снимите отметку: `PATCH …/rows/{rowId}` с `{"checked": false}` или `POST /api/documents/{id}/validation/exclude`.

## Запрос

| Параметр | Тип | Обязателен | Описание |
|---|---|---|---|
| `id` | UUID (путь) | да | Идентификатор документа |
| `rowId` | UUID (путь) | да | Идентификатор строки |

Тела нет.

## Ответ

`200 OK`

```json
{ "ok": true }
```

## Ошибки

| Код | Когда |
|---|---|
| 400 | `Некорректный идентификатор` — `id` или `rowId` не UUID |
| 401 | Нет токена или сессии |
| 404 | `Документ не найден` |
| 404 | `Строка не найдена` — строки нет или она из другого документа |

## Пример

```bash
curl -X DELETE "https://vruchay.ru/api/documents/$DOCUMENT_ID/recipients/rows/$ROW_ID" \
  -H "Authorization: Bearer $VRUCHAY_TOKEN"
```

---

<!-- endpoints/recipients/get-documents-id-recipients -->
> method: GET | path: /api/documents/{id}/recipients | auth: token | roles: any | rate_limit: none

# GET /api/documents/{id}/recipients

Возвращает таблицу получателей документа целиком: колонки по порядку, строки по порядку (не больше 10 000) и число отмеченных строк. Отмеченные строки (`checked: true`) — это те, что попадут в выпуск. Пагинации нет; строки сверх 10 000 в ответ не входят.

У нового документа уже есть две колонки — `name` и `email`: по ним работает выдача документов и рассылка.

## Запрос

| Параметр | Тип | Обязателен | Описание |
|---|---|---|---|
| `id` | UUID (путь) | да | Идентификатор документа |

Тела нет.

## Ответ

`200 OK`

| Поле | Тип | Описание |
|---|---|---|
| `columns` | array | Колонки, отсортированы по `position` |
| `columns[].id` | UUID | Идентификатор колонки |
| `columns[].documentId` | UUID | Документ |
| `columns[].position` | number | Порядковый номер, от нуля |
| `columns[].name` | string | Имя переменной для макета без знака процента (`name`, `email`, `course`) |
| `columns[].title` | string \| null | Заголовок из загруженного файла («Год рождения»); `null` — колонка заведена руками |
| `rows` | array | Строки, отсортированы по `position`, не больше 10 000 |
| `rows[].id` | UUID | Идентификатор строки |
| `rows[].documentId` | UUID | Документ |
| `rows[].position` | number | Порядковый номер, от нуля |
| `rows[].data` | object | Значения по именам колонок: `{"name": "…", "email": "…"}` |
| `rows[].checked` | boolean | Отмечена к выпуску |
| `rows[].lastFileId` | UUID \| null | Последний выпущенный по строке файл |
| `rows[].source` | string | Откуда строка: `table` — таблица организатора (загрузка, вставка, добавление руками); `site` — выдача по одному с формы на сайте; `link` и `api` зарезервированы под выдачу по ссылке и по API. Строки не из таблицы заводятся неотмеченными, «отметить все» и замена таблицы при загрузке их не трогают |
| `rows[].mailStatus` | string \| null | Последнее письмо о нынешнем файле строки: `queued`, `sent`, `delivered`, `opened`, `bounced`, `failed`; `null` — не отправлялось. Письмо о прежнем, перевыпущенном файле не учитывается. Письмо на адрес из строки важнее письма на другой адрес: копия организатору по заявке с формы не закрывает собой письмо участнику |
| `rows[].changedSinceIssue` | boolean | Данные строки поправили после выпуска её файла — на руках у получателя документ со старыми данными. У файлов, выпущенных без снимка данных, всегда `false` |
| `rows[].createdAt`, `rows[].updatedAt` | string (ISO 8601) | Даты создания и изменения |
| `checkedCount` | number | Сколько строк отмечено (считается по всей таблице, без предела в 10 000) |

```json
{
  "columns": [
    { "id": "3f1c…", "documentId": "9a2b…", "position": 0, "name": "name", "title": "ФИО" },
    { "id": "5d7e…", "documentId": "9a2b…", "position": 1, "name": "email", "title": null }
  ],
  "rows": [
    {
      "id": "b1c2…",
      "documentId": "9a2b…",
      "position": 0,
      "data": { "name": "Иванова Мария Сергеевна", "email": "ivanova@vruchay.ru" },
      "checked": true,
      "lastFileId": null,
      "source": "table",
      "mailStatus": null,
      "changedSinceIssue": false,
      "createdAt": "2026-09-03T10:15:00.000Z",
      "updatedAt": "2026-09-03T10:15:00.000Z"
    }
  ],
  "checkedCount": 1
}
```

## Ошибки

| Код | Когда |
|---|---|
| 400 | `Некорректный идентификатор` — `id` не UUID |
| 401 | Нет токена или сессии |
| 404 | `Документ не найден` — документа нет, он удалён или принадлежит другой организации |

## Пример

```bash
curl "https://vruchay.ru/api/documents/$DOCUMENT_ID/recipients" \
  -H "Authorization: Bearer $VRUCHAY_TOKEN"
```

---

<!-- endpoints/recipients/patch-documents-id-recipients-columns-columnId -->
> method: PATCH | path: /api/documents/{id}/recipients/columns/{columnId} | auth: token | roles: any | rate_limit: none

# PATCH /api/documents/{id}/recipients/columns/{columnId}

Переименовывает колонку и одной транзакцией переносит значения во всех строках под новый ключ (значения хранятся по имени, а не по идентификатору колонки), а также переписывает макеты листов документа: поля, ссылавшиеся на прежнее имя, начинают ссылаться на новое. Заголовок `title` не меняется.

## Запрос

| Параметр | Тип | Обязателен | Описание |
|---|---|---|---|
| `id` | UUID (путь) | да | Идентификатор документа |
| `columnId` | UUID (путь) | да | Идентификатор колонки |

Тело:

| Поле | Тип | Обязательно | Описание |
|---|---|---|---|
| `name` | string | да | Новое имя: 1–64 символа, латинские буквы, цифры и `_`, первым символом — буква |

```json
{ "name": "full_name" }
```

## Ответ

`200 OK` — обновлённая колонка.

| Поле | Тип | Описание |
|---|---|---|
| `id` | UUID | Идентификатор колонки |
| `documentId` | UUID | Документ |
| `position` | number | Порядковый номер |
| `name` | string | Новое имя |
| `title` | string \| null | Заголовок из файла, без изменений |

```json
{ "id": "3f1c…", "documentId": "9a2b…", "position": 0, "name": "full_name", "title": "ФИО" }
```

## Ошибки

| Код | Когда |
|---|---|
| 400 | `Некорректный идентификатор` — `id` или `columnId` не UUID |
| 400 | `Введите название колонки` / `Латинские буквы, цифры и подчёркивание; первым символом — буква` / длиннее 64 символов |
| 401 | Нет токена или сессии |
| 404 | `Документ не найден` |
| 404 | `Колонка не найдена` — колонки нет или она из другого документа |
| 500 | Новое имя занято другой колонкой документа (нарушение уникальности не перехватывается) |

## Пример

```bash
curl -X PATCH "https://vruchay.ru/api/documents/$DOCUMENT_ID/recipients/columns/$COLUMN_ID" \
  -H "Authorization: Bearer $VRUCHAY_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name":"full_name"}'
```

---

<!-- endpoints/recipients/patch-documents-id-recipients-rows-rowId -->
> method: PATCH | path: /api/documents/{id}/recipients/rows/{rowId} | auth: token | roles: any | rate_limit: none

# PATCH /api/documents/{id}/recipients/rows/{rowId}

Правит одну строку: значения ячеек и/или отметку к выпуску. `data` объединяется с уже сохранёнными значениями — передавать нужно только изменившиеся ячейки, остальные не затираются. Удалить ключ из `data` этим запросом нельзя, только записать пустую строку.

## Запрос

| Параметр | Тип | Обязателен | Описание |
|---|---|---|---|
| `id` | UUID (путь) | да | Идентификатор документа |
| `rowId` | UUID (путь) | да | Идентификатор строки |

Тело — хотя бы одно из полей:

| Поле | Тип | Обязательно | Описание |
|---|---|---|---|
| `data` | object | нет | Изменяемые ячейки: ключ — латинское имя колонки, значение — строка до 1000 символов |
| `checked` | boolean | нет | Отметка к выпуску |

```json
{ "data": { "email": "ivanova@vruchay.ru" }, "checked": true }
```

## Ответ

`200 OK` — обновлённая строка, той же формы, что в `GET /api/documents/{id}/recipients`.

```json
{
  "id": "b1c2…",
  "documentId": "9a2b…",
  "position": 0,
  "data": { "name": "Иванова Мария Сергеевна", "email": "ivanova@vruchay.ru" },
  "checked": true,
  "lastFileId": null,
  "createdAt": "2026-09-03T10:15:00.000Z",
  "updatedAt": "2026-09-03T10:20:00.000Z"
}
```

## Ошибки

| Код | Когда |
|---|---|
| 400 | `Некорректный идентификатор` — `id` или `rowId` не UUID |
| 400 | `Нечего обновлять` — пустое тело `{}` |
| 400 | Ключ `data` не по шаблону: `Латинские буквы, цифры и подчёркивание; первым символом — буква`; значение длиннее 1000 символов |
| 401 | Нет токена или сессии |
| 404 | `Документ не найден` |
| 404 | `Строка не найдена` — строки нет или она из другого документа |

## Пример

```bash
curl -X PATCH "https://vruchay.ru/api/documents/$DOCUMENT_ID/recipients/rows/$ROW_ID" \
  -H "Authorization: Bearer $VRUCHAY_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"data":{"email":"ivanova@vruchay.ru"}}'
```

---

<!-- endpoints/recipients/post-documents-id-recipients-checked -->
> method: POST | path: /api/documents/{id}/recipients/checked | auth: token | roles: any | rate_limit: none

# POST /api/documents/{id}/recipients/checked

Ставит или снимает отметку к выпуску сразу у многих строк. Без `rowIds` (или с пустым массивом) отметка применяется ко всем строкам таблицы — к строкам с `source: "table"`. Строки, выданные по одному (`source: "site"` — заявки с формы на сайте), под «все» не попадают: иначе следующий выпуск перепечатал бы документ каждому, кто когда-либо заполнял форму, и списал бы за это квоту. Отметить такую строку можно — перечислив её в `rowIds`. Идентификаторы чужих или несуществующих строк молча пропускаются — по `updated` видно, сколько строк реально изменено.

В выпуск (`POST /api/documents/{id}/generate`) попадают только строки с `checked: true`.

## Запрос

| Параметр | Тип | Обязателен | Описание |
|---|---|---|---|
| `id` | UUID (путь) | да | Идентификатор документа |

Тело:

| Поле | Тип | Обязательно | Описание |
|---|---|---|---|
| `checked` | boolean | да | Что поставить |
| `rowIds` | UUID[] | нет | Каких строк это касается, до 10 000. Пусто — всех строк таблицы (`source: "table"`) |

```json
{ "checked": false, "rowIds": ["b1c2…", "c3d4…"] }
```

## Ответ

`201 Created`

| Поле | Тип | Описание |
|---|---|---|
| `updated` | number | Сколько строк обновлено |

```json
{ "updated": 2 }
```

## Ошибки

| Код | Когда |
|---|---|
| 400 | `Некорректный идентификатор` — `id` не UUID |
| 400 | `checked` не boolean; элемент `rowIds` не UUID; больше 10 000 элементов (сообщения Zod) |
| 401 | Нет токена или сессии |
| 404 | `Документ не найден` |

## Пример

```bash
curl -X POST "https://vruchay.ru/api/documents/$DOCUMENT_ID/recipients/checked" \
  -H "Authorization: Bearer $VRUCHAY_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"checked":true}'
```

---

<!-- endpoints/recipients/post-documents-id-recipients-columns-order -->
> method: POST | path: /api/documents/{id}/recipients/columns/order | auth: token | roles: any | rate_limit: none

# POST /api/documents/{id}/recipients/columns/order

Задаёт порядок колонок в таблице получателей материала. В `order` перечисляются идентификаторы колонок в нужном порядке; перечисленные встают первыми, остальные — следом, сохраняя прежний порядок между собой. Поэтому, чтобы поднять одну колонку в начало, достаточно передать её одну. Повтор идентификатора в `order` не ошибка: берётся первое вхождение.

Меняется только порядковый номер (`position`) — имена, заголовки, значения в строках и макеты листов не трогаются. Все позиции переставляются одной транзакцией. Порядок колонок виден в `GET /api/documents/{id}/recipients`.

## Запрос

| Параметр | Тип | Обязателен | Описание |
|---|---|---|---|
| `id` | UUID (путь) | да | Идентификатор документа |

Тело:

| Поле | Тип | Обязательно | Описание |
|---|---|---|---|
| `order` | string[] (UUID) | да | Идентификаторы колонок этого документа в нужном порядке, 1–200 штук |

```json
{
  "order": [
    "3f1c2b4a-5d6e-4f70-8a91-b2c3d4e5f601",
    "7a8b9c0d-1e2f-4a3b-8c4d-5e6f7a8b9c02"
  ]
}
```

## Ответ

`201 Created`

| Поле | Тип | Описание |
|---|---|---|
| `order` | string[] (UUID) | Итоговый порядок всех колонок документа — и перечисленных, и остальных |

```json
{
  "order": [
    "3f1c2b4a-5d6e-4f70-8a91-b2c3d4e5f601",
    "7a8b9c0d-1e2f-4a3b-8c4d-5e6f7a8b9c02",
    "c4d5e6f7-0a1b-4c2d-9e3f-4a5b6c7d8e03"
  ]
}
```

## Ошибки

| Код | Когда |
|---|---|
| 400 | `Некорректный идентификатор` — `id` не UUID |
| 400 | `order` не передан: «Invalid input: expected array, received undefined»; пустой: «Too small: expected array to have >=1 items»; больше 200: «Too big: expected array to have <=200 items»; элемент не UUID: «Invalid UUID» |
| 401 | Нет токена или сессии |
| 404 | `Документ не найден` — документа нет, он удалён или чужой |
| 404 | `Колонка не найдена` — в `order` есть идентификатор, которого нет среди колонок этого документа; порядок при этом не меняется |

## Пример

```bash
curl -X POST "https://vruchay.ru/api/documents/$DOCUMENT_ID/recipients/columns/order" \
  -H "Authorization: Bearer $VRUCHAY_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"order":["'"$COLUMN_ID"'"]}'
```

---

<!-- endpoints/recipients/post-documents-id-recipients-columns -->
> method: POST | path: /api/documents/{id}/recipients/columns | auth: token | roles: any | rate_limit: none

# POST /api/documents/{id}/recipients/columns

Добавляет колонку в конец таблицы получателей. Имя колонки — это имя переменной в макете (`%name`), поэтому оно должно быть латинским. Колонка создаётся без заголовка (`title: null`); заголовок появляется только при импорте файла с шапкой. Существующие строки значения в новой колонке не получают — в `data` ключ появится после правки строки.

## Запрос

| Параметр | Тип | Обязателен | Описание |
|---|---|---|---|
| `id` | UUID (путь) | да | Идентификатор документа |

Тело:

| Поле | Тип | Обязательно | Описание |
|---|---|---|---|
| `name` | string | да | 1–64 символа после обрезки пробелов; латинские буквы, цифры и `_`, первым символом — буква |

```json
{ "name": "course" }
```

## Ответ

`201 Created` — созданная колонка.

| Поле | Тип | Описание |
|---|---|---|
| `id` | UUID | Идентификатор колонки |
| `documentId` | UUID | Документ |
| `position` | number | Порядковый номер, от нуля (равен числу колонок до добавления) |
| `name` | string | Имя переменной |
| `title` | null | Заголовка у колонки, заведённой руками, нет |

```json
{ "id": "7c0d…", "documentId": "9a2b…", "position": 2, "name": "course", "title": null }
```

## Ошибки

| Код | Когда |
|---|---|
| 400 | `Некорректный идентификатор` — `id` не UUID |
| 400 | `Введите название колонки` — пустое имя |
| 400 | `Латинские буквы, цифры и подчёркивание; первым символом — буква` — имя не по шаблону |
| 400 | Имя длиннее 64 символов (сообщение Zod для `max`) |
| 400 | `Колонка «<name>» уже есть` — имя занято в этом документе |
| 401 | Нет токена или сессии |
| 404 | `Документ не найден` |

## Пример

```bash
curl -X POST "https://vruchay.ru/api/documents/$DOCUMENT_ID/recipients/columns" \
  -H "Authorization: Bearer $VRUCHAY_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name":"course"}'
```

---

<!-- endpoints/recipients/post-documents-id-recipients-import -->
> method: POST | path: /api/documents/{id}/recipients/import | auth: token | roles: any | rate_limit: none

# POST /api/documents/{id}/recipients/import

Записывает таблицу получателей в документ: заводит недостающие колонки и добавляет строки. Обычно сюда передают результат `POST …/recipients/parse` после проверки сопоставления колонок, но можно прислать и данные, собранные самостоятельно (например, из CRM). Всё идёт одной транзакцией (таймаут 60 с): либо записывается всё, либо ничего.

Как обрабатываются колонки: имя из `columns[i]` сопоставляется с существующими колонками документа по `name`. Новые колонки создаются в конец с заголовком из `titles[i]` (пустой заголовок не записывается). У существующей колонки заголовок проставляется, только если его ещё не было; готовый заголовок не трогается. Колонки `name` и `email` есть у документа изначально.

Как обрабатываются строки: `rows[i][j]` — значение колонки `columns[j]`; недостающие значения в строке становятся пустой строкой. В режиме `append` (по умолчанию) строки дописываются после существующих; в режиме `replace` прежние строки таблицы сначала удаляются (колонки остаются). Строки, выданные по одному (`source: "site"` — заявки с формы на сайте), замена не трогает: в вашем файле их не было, и стирать их значило бы терять выданное. Они остаются в прежнем порядке на первых местах, новые строки встают следом. Новые строки создаются отмеченными к выпуску.

Ограничения: до 30 колонок и до 10 000 строк за запрос (`importSchema`). Предел тела запроса у этого маршрута — `MAX_IMPORT_BODY_BYTES` = 16 МБ (16 777 216 байт), а не 1 МБ как у остальных.

## Запрос

| Параметр | Тип | Обязателен | Описание |
|---|---|---|---|
| `id` | UUID (путь) | да | Идентификатор документа |

Тело:

| Поле | Тип | Обязательно | Описание |
|---|---|---|---|
| `columns` | string[] | да | Имена колонок, 1–30: латинские буквы, цифры, `_`, первый символ буква, до 64 символов (это `suggested` из ответа parse) |
| `titles` | string[] | нет | Заголовки тех же колонок из файла (это `source` из ответа parse), каждый до 200 символов; длина массива обязана совпадать с `columns` |
| `rows` | string[][] | да | Строки, до 10 000; в каждой до 30 значений, каждое до 1000 символов |
| `mode` | `append` \| `replace` | нет | Дописать (по умолчанию) или заменить строки целиком |

```json
{
  "columns": ["name", "email", "place"],
  "titles": ["ФИО", "Электронная почта", "Место"],
  "rows": [
    ["Иванова Мария Сергеевна", "ivanova@vruchay.ru", "1"],
    ["Петров Олег Иванович", "petrov@vruchay.ru", "2"]
  ],
  "mode": "replace"
}
```

## Ответ

`201 Created`

| Поле | Тип | Описание |
|---|---|---|
| `imported` | number | Сколько строк добавлено |
| `columns` | string[] | Имена всех колонок документа после импорта (прежние и созданные) |

```json
{ "imported": 2, "columns": ["name", "email", "place"] }
```

## Ошибки

| Код | Когда |
|---|---|
| 400 | `Некорректный идентификатор` — `id` не UUID |
| 400 | `Введите название колонки` / `Латинские буквы, цифры и подчёркивание; первым символом — буква` — имя в `columns` не по шаблону |
| 400 | `Заголовков столько же, сколько колонок` — длина `titles` не равна длине `columns` |
| 400 | `columns` пуст или больше 30; `rows` больше 10 000; строка длиннее 30 значений; значение длиннее 1000 символов; заголовок длиннее 200; `mode` не из списка (сообщения Zod) |
| 401 | Нет токена или сессии |
| 404 | `Документ не найден` |
| 413 | Тело больше 16 МБ |

## Пример

```bash
curl -X POST "https://vruchay.ru/api/documents/$DOCUMENT_ID/recipients/import" \
  -H "Authorization: Bearer $VRUCHAY_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"columns":["name","email"],"titles":["ФИО","Почта"],"rows":[["Иванова Мария Сергеевна","ivanova@vruchay.ru"]],"mode":"append"}'
```

---

<!-- endpoints/recipients/post-documents-id-recipients-parse -->
> method: POST | path: /api/documents/{id}/recipients/parse | auth: token | roles: any | rate_limit: none

# POST /api/documents/{id}/recipients/parse

Читает загруженный файл (Excel `.xlsx` или CSV) и возвращает, что в нём распознано: имя листа, строку с шапкой, колонки с подобранными латинскими именами переменных и сами строки. В базу ничего не записывается — это предварительный просмотр. Импорт подтверждается отдельным запросом `POST /api/documents/{id}/recipients/import`, в который клиент передаёт уже проверенные колонки и строки.

Разбор берёт не больше 10 000 строк и 30 колонок; если строк больше, в `warnings` появится предупреждение «Взяты первые 10000 строк — остальные не поместились». Файл сначала пробуется прочитать как протокол мероприятия (группы, места, двухуровневая шапка); если в нём нашлись графа места или группы, ответ содержит поле `protocol`. Если в файле фамилия, имя и отчество лежат отдельными колонками, а колонки `name` нет, разбор добавляет собранную колонку «ФИО» (`suggested: "name"`) в конец и сообщает об этом в `warnings`.

## Запрос

Тело — `multipart/form-data`, одно поле `file`. Лимит размера файла — 10 МБ (`MAX_TABLE_BYTES` = 10 485 760 байт). Принимаются расширения `.xlsx`, `.csv`, `.txt`, `.tsv`.

| Параметр | Тип | Обязателен | Описание |
|---|---|---|---|
| `id` | UUID (путь) | да | Идентификатор документа, к которому будет привязана таблица |
| `headers` | `auto` \| `headers` \| `none` (строка запроса) | нет | Как читать первую строку данных: `auto` — решает разбор (по умолчанию); `headers` — первая строка точно шапка; `none` — шапки нет, все строки данные. Для файла, распознанного как протокол, параметр не действует |

## Ответ

`201 Created`

| Поле | Тип | Описание |
|---|---|---|
| `sheetName` | string | Имя листа книги; для CSV — имя файла без расширения |
| `headerRowIndex` | number | Номер строки с шапкой, от нуля, в координатах исходного файла |
| `columns` | array | Колонки файла, по порядку |
| `columns[].source` | string | Заголовок из файла («ФИО», «Электронная почта») |
| `columns[].suggested` | string | Подобранное латинское имя переменной (`name`, `email`, `place` и т. п. либо транслитерация заголовка) |
| `columns[].guessed` | boolean | `true`, если имя подобрано по значениям колонки, а не по заголовку, — такое стоит проверить глазами |
| `rows` | string[][] | Строки данных, значения по порядку колонок |
| `skippedEmptyRows` | number | Сколько строк отброшено как полностью пустые |
| `headerMode` | `headers` \| `none` | Как в итоге разобрана первая строка |
| `firstRowLooksLikeData` | boolean | Первая строка похожа на данные, а не на заголовки — вероятно, вставлен диапазон без шапки |
| `suggestions` | array | Предлагаемые правки текста; применяет их клиент, а не разбор |
| `suggestions[].kind` | `uppercase` \| `email-homoglyph` | ФИО прописными буквами / кириллица, похожая на латиницу, в адресе почты |
| `suggestions[].column` | number | Индекс колонки в `columns` |
| `suggestions[].columnTitle` | string | Заголовок колонки |
| `suggestions[].count` | number | Сколько ячеек изменится |
| `suggestions[].before`, `suggestions[].after` | string | Пример «было → стало» из первой затронутой ячейки |
| `suggestions[].values` | string[] | Готовые значения колонки по строкам — можно подставить в `rows` как есть |
| `warnings` | string[] | Предупреждения для человека (другой лист, обрезанные строки, собранное ФИО и т. п.) |
| `protocol` | object \| отсутствует | Есть, только если файл прочитан как протокол мероприятия |
| `protocol.groupColumn` | string | Имя колонки, по которой строки делятся на группы; пусто — групп нет |
| `protocol.groups` | array | Группы: `{ title: string, rowCount: number }` |
| `protocol.headerRowCount` | number | Сколько строк заняла шапка: 1 или 2 |

```json
{
  "sheetName": "Лист1",
  "headerRowIndex": 2,
  "columns": [
    { "source": "ФИО", "suggested": "name", "guessed": false },
    { "source": "Электронная почта", "suggested": "email", "guessed": false },
    { "source": "Место", "suggested": "place", "guessed": false }
  ],
  "rows": [
    ["Иванова Мария Сергеевна", "ivanova@vruchay.ru", "1"],
    ["Петров Олег Иванович", "petrov@vruchay.ru", "2"]
  ],
  "skippedEmptyRows": 1,
  "headerMode": "headers",
  "firstRowLooksLikeData": false,
  "suggestions": [],
  "warnings": []
}
```

## Ошибки

| Код | Когда |
|---|---|
| 400 | `Некорректный идентификатор` — `id` не UUID |
| 400 | Значение `headers` не из списка `auto`, `headers`, `none` (сообщение Zod для enum) |
| 400 | `Файл не передан` — в multipart нет части `file` |
| 400 | `Поддерживаются файлы Excel (.xlsx) и CSV` — расширение не `.xlsx`, `.csv`, `.txt`, `.tsv` |
| 400 | `Файл слишком большой, максимум 10 МБ` |
| 400 | `Не удалось прочитать файл. Проверьте, что это таблица Excel или CSV и в ней есть строка с названиями колонок` — любая ошибка разбора (в том числе старый `.xls` или HTML с расширением `.xls`) |
| 401 | Нет токена или сессии |
| 404 | `Документ не найден` — документа нет, он удалён или принадлежит другой организации |
| 406 | Запрос отправлен не как `multipart/form-data` (ответ @fastify/multipart) |

## Пример

```bash
curl -X POST "https://vruchay.ru/api/documents/$DOCUMENT_ID/recipients/parse?headers=auto" \
  -H "Authorization: Bearer $VRUCHAY_TOKEN" \
  -F "file=@table.xlsx"
```

---

<!-- endpoints/recipients/post-documents-id-recipients-rows -->
> method: POST | path: /api/documents/{id}/recipients/rows | auth: token | roles: any | rate_limit: none

# POST /api/documents/{id}/recipients/rows

Добавляет одну строку в конец таблицы получателей. Строка создаётся отмеченной к выпуску (`checked: true`). Ключи `data` — имена колонок; сервер не проверяет, что такие колонки существуют в документе: значение под неизвестным ключом просто сохранится и в макет не попадёт.

Для загрузки списка целиком используйте `POST /api/documents/{id}/recipients/import`.

## Запрос

| Параметр | Тип | Обязателен | Описание |
|---|---|---|---|
| `id` | UUID (путь) | да | Идентификатор документа |

Тело:

| Поле | Тип | Обязательно | Описание |
|---|---|---|---|
| `data` | object | нет | Значения по именам колонок; по умолчанию `{}`. Ключ — латинское имя (буквы, цифры, `_`, первый символ буква, до 64 символов); значение — строка до 1000 символов |

```json
{ "data": { "name": "Иванова Мария Сергеевна", "email": "ivanova@vruchay.ru" } }
```

## Ответ

`201 Created` — созданная строка.

| Поле | Тип | Описание |
|---|---|---|
| `id` | UUID | Идентификатор строки |
| `documentId` | UUID | Документ |
| `position` | number | Порядковый номер, от нуля |
| `data` | object | Сохранённые значения |
| `checked` | boolean | `true` |
| `lastFileId` | null | Файлов по строке ещё нет |
| `createdAt`, `updatedAt` | string (ISO 8601) | Даты |

```json
{
  "id": "b1c2…",
  "documentId": "9a2b…",
  "position": 12,
  "data": { "name": "Иванова Мария Сергеевна", "email": "ivanova@vruchay.ru" },
  "checked": true,
  "lastFileId": null,
  "createdAt": "2026-09-03T10:15:00.000Z",
  "updatedAt": "2026-09-03T10:15:00.000Z"
}
```

## Ошибки

| Код | Когда |
|---|---|
| 400 | `Некорректный идентификатор` — `id` не UUID |
| 400 | Ключ `data` не по шаблону имени колонки: `Латинские буквы, цифры и подчёркивание; первым символом — буква` |
| 400 | Значение длиннее 1000 символов или не строка (сообщение Zod) |
| 401 | Нет токена или сессии |
| 404 | `Документ не найден` |

## Пример

```bash
curl -X POST "https://vruchay.ru/api/documents/$DOCUMENT_ID/recipients/rows" \
  -H "Authorization: Bearer $VRUCHAY_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"data":{"name":"Иванова Мария Сергеевна","email":"ivanova@vruchay.ru"}}'
```

---

<!-- endpoints/referral/get-referral -->
> method: GET | path: /api/referral | auth: token | roles: any | rate_limit: none

# GET /api/referral

Всё, что показывает раздел «Пригласить друга»: код приглашения организации,
готовая ссылка, готовый текст для мессенджера, список приглашённых и сколько
бонусных документов уже заработано.

Раздел открыт любому сотруднику организации: звать коллег из соседней организации
будет тот, кто с ними знаком, а не обязательно владелец учётной записи.

Первый вызов имеет побочное действие: если у организации ещё нет кода
приглашения, он выдаётся именно здесь и закрепляется навсегда. Дальше запрос
только читает.

Начисленное нигде не хранится, а считается по фактам при каждом запросе: сколько
приглашённых организаций выпустили свои первые документы. Приглашённая считается
работающей, когда выпустила не меньше `qualifyDocuments` документов — просто
регистрации мало, иначе бонусы зарабатывались бы заведением пустых организаций.
Вознаграждение платится не больше чем за `maxRewarded` приглашённых.

Бонусные документы прибавляются только к бесплатной пробе; сколько их сейчас
и сколько израсходовано, показывает `GET /api/org/usage`. Как только организация уходит
с пробы — на план или на оплаченный тариф, — бонус к её пределу больше не прибавляется: в `GET /api/org/usage`
`bonus` становится 0, а `bonusEarned` здесь продолжает показывать заработанное.
При оплате картой неизрасходованный остаток пробы вместе с бонусом переносится
в новый план.

## Запрос

Параметров нет.

## Ответ

`200 OK`

| Поле | Тип | Описание |
|---|---|---|
| code | string | Код приглашения организации: 6 знаков из алфавита без похожих друг на друга (`0`/`o`, `1`/`l`/`i` в нём нет) — код диктуют по телефону. |
| link | string | Готовая ссылка на регистрацию: `<публичный адрес>/register?ref=<code>`. |
| message | string | Готовый текст приглашения для мессенджера — с названием организации и ссылкой. Собирается на сервере, чтобы человеку не пришлось придумывать его самому. |
| invitedTotal | number | Сколько организаций зарегистрировалось по ссылке. |
| invitedWorking | number | Сколько из них выпустили не меньше `qualifyDocuments` документов. |
| bonusEarned | number | Заработано бонусных документов: `min(invitedWorking, maxRewarded) × rewardPerFriend`. |
| welcomeBonus | number | Сколько документов получает пришедший по ссылке (`REFERRAL_WELCOME_BONUS`, по умолчанию 50). |
| rewardPerFriend | number | Сколько документов даётся за одного заработавшего приглашённого (`REFERRAL_REWARD`, по умолчанию 50). |
| qualifyDocuments | number | Сколько документов должен выпустить приглашённый, чтобы считаться работающим (`REFERRAL_QUALIFY_DOCUMENTS`, по умолчанию 10). |
| maxRewarded | number | За скольких приглашённых максимум платится вознаграждение (`REFERRAL_MAX_REWARDED`, по умолчанию 20). |
| invited | array | Приглашённые организации, старые первыми. |

Элемент `invited`:

| Поле | Тип | Описание |
|---|---|---|
| name | string | Название приглашённой организации. |
| joinedAt | string (ISO 8601) | Когда зарегистрировалась. |
| working | boolean | Выпустила ли она не меньше `qualifyDocuments` документов. |

```json
{
  "code": "k7m2qp",
  "link": "https://vruchay.ru/register?ref=k7m2qp",
  "message": "Мы в «Учебный центр «Развитие»» выдаём грамоты и сертификаты через сервис «Вручай»: загружаешь список участников — он сам делает именные файлы и рассылает их по почте. Вручную это занимало два дня, теперь — полчаса.\n\nВот приглашение, по нему дают 100 бесплатных документов вместо 50:\nhttps://vruchay.ru/register?ref=k7m2qp",
  "invitedTotal": 3,
  "invitedWorking": 2,
  "bonusEarned": 100,
  "welcomeBonus": 50,
  "rewardPerFriend": 50,
  "qualifyDocuments": 10,
  "maxRewarded": 20,
  "invited": [
    {
      "name": "СШОР «Олимп»",
      "joinedAt": "2026-04-02T07:15:00.000Z",
      "working": true
    },
    {
      "name": "Гимназия № 3",
      "joinedAt": "2026-05-11T13:44:20.000Z",
      "working": false
    }
  ]
}
```

Название приглашённой организации видно целиком — она сама зарегистрировалась
по вашей ссылке. Ни её адресов, ни идентификаторов, ни того, что именно она
выпускает, в ответе нет.

## Ошибки

| Код | Когда |
|---|---|
| 401 | Нет действующего токена и нет cookie-сессии. |
| 500 | Не удалось выдать код приглашения при первом вызове: десять сгенерированных кодов подряд оказались заняты. Случай крайне редкий, помогает повтор запроса. |

## Пример

```bash
curl "https://vruchay.ru/api/referral" \
  -H "Authorization: Bearer $VRUCHAY_TOKEN"
```

Что обещано по этому коду пришедшему — `GET /api/referral-offer?ref=<code>`,
он открыт без входа.

---

<!-- endpoints/registry/get-registry-analytics -->
> method: GET | path: /api/registry/analytics | auth: token | roles: any | rate_limit: none

# GET /api/registry/analytics

Цифры под таблицей реестра: сколько выдано, отозвано, заменено, истекло,
как дошли письма, сколько раз документы проверяли и скачивали, и разбивка по
материалам. Считается по тому же отбору, что и `GET /api/registry`, — сводка
относится ровно к тому, что видно в таблице.

Ничего о проверяющих сервис не хранит: только обезличенные счётчики на
документе.

## Запрос

Параметры строки запроса — общий отбор реестра
([reference/registry-filter.md](../../reference/registry-filter.md)).

| Параметр | Тип | Обязателен | Описание |
|---|---|---|---|
| `search` | string ≤ 200 | нет | Имя, почта или проверочный код целиком. |
| `documentId` | UUID | нет | Материал. |
| `event` | string ≤ 200 | нет | Мероприятие, точное совпадение. |
| `state` | `valid` \| `revoked` \| `replaced` \| `expired` | нет | Состояние документа. |
| `mail` | `none` \| `queued` \| `sent` \| `delivered` \| `opened` \| `bounced` \| `failed` | нет | Состояние письма. |
| `from` | дата | нет | Выпущен не раньше этого дня (по Москве). |
| `to` | дата | нет | Выпущен не позже этого дня включительно. |

## Ответ

`200 OK`

| Поле | Тип | Описание |
|---|---|---|
| `issued` | number | Документов в отборе. |
| `revoked` | number | С отозванной проверкой. |
| `replaced` | number | С выданной заменой. Считается независимо от отзыва: документ, который заменили и затем отозвали, входит в оба числа. |
| `expired` | number | С истёкшим сроком среди не отозванных и не заменённых. |
| `mail` | object | Воронка писем нарастающим итогом, а не по текущему состоянию: прочитанное письмо входит и в `sent`, и в `delivered`. |
| `mail.queued` | number | В очереди. |
| `mail.sent` | number | Ушло с сервера: `sent` + `delivered` + `opened` + `bounced`. |
| `mail.delivered` | number | Доставлено: `delivered` + `opened`. |
| `mail.opened` | number | Прочитано. |
| `mail.bounced` | number | Не доставлено (вернулось). |
| `mail.failed` | number | Не отправилось. |
| `downloads.total` | number | Сумма скачиваний по всем документам отбора. |
| `downloads.files` | number | Сколько документов скачивали хотя бы раз. |
| `verifications.total` | number | Сумма проверок по QR/коду. |
| `verifications.files` | number | Сколько документов проверяли хотя бы раз. |
| `documents` | array | До 10 материалов с наибольшим числом выданных документов. |
| `documents[].documentId` | string (UUID) \| null | Материал; null — документы без материала («Без материала»). |
| `documents[].title` | string | Название; «Материал удалён», если его больше нет. |
| `documents[].eventName` | string | Мероприятие. |
| `documents[].issued` | number | Выдано по материалу. |
| `documents[].verifications` | number | Проверок по документам материала. |

```json
{
  "issued": 1284,
  "revoked": 3,
  "replaced": 12,
  "expired": 0,
  "mail": { "queued": 0, "sent": 1270, "delivered": 1251, "opened": 904, "bounced": 19, "failed": 2 },
  "downloads": { "total": 316, "files": 288 },
  "verifications": { "total": 2410, "files": 913 },
  "documents": [
    {
      "documentId": "b1c2d3e4-f5a6-4b7c-8d9e-0f1a2b3c4d5e",
      "title": "Сертификат участника",
      "eventName": "Городской конкурс «Мастер года»",
      "issued": 640,
      "verifications": 1502
    }
  ]
}
```

## Ошибки

| Код | Когда |
|---|---|
| 400 | Те же ошибки отбора, что у `GET /api/registry`: «Некорректный идентификатор материала», «Неизвестное состояние документа», «Неизвестное состояние письма», «Не удалось разобрать дату начала периода», «Не удалось разобрать дату конца периода». |
| 401 | Нет токена или сессии. |
| 404 | `projectId` — чужой или несуществующий проект: «Проект не найден». |

## Пример

```bash
curl "https://vruchay.ru/api/registry/analytics?from=2026-06-01&to=2026-06-30" \
  -H "Authorization: Bearer $VRUCHAY_TOKEN"
```

---

<!-- endpoints/registry/get-registry-archive -->
> method: GET | path: /api/registry/archive | auth: token | roles: any | rate_limit: none

# GET /api/registry/archive

Скачать пачку выданных документов одним ZIP-архивом: либо отмеченные по
идентификаторам, либо всё, что нашлось по отбору. Архив собирается потоком
прямо в ответ, поэтому размер ограничен числом файлов — не больше 2000 за
раз. У каждого попавшего в архив документа увеличивается `downloadCount`.

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

## Запрос

Параметры строки запроса — `ids` плюс общий отбор реестра
([reference/registry-filter.md](../../reference/registry-filter.md)). Если
передано и то и другое, условия складываются: берутся только те из `ids`,
которые проходят отбор.

| Параметр | Тип | Обязателен | Описание |
|---|---|---|---|
| `ids` | string ≤ 18 500 знаков | нет | UUID файлов через запятую. Не похожие на UUID куски молча отбрасываются, дубли схлопываются, берутся первые 500. |
| `search` | string ≤ 200 | нет | Имя, почта или проверочный код целиком. |
| `documentId` | UUID | нет | Материал. |
| `event` | string ≤ 200 | нет | Мероприятие, точное совпадение. |
| `state` | `valid` \| `revoked` \| `replaced` \| `expired` | нет | Состояние документа. |
| `mail` | `none` \| `queued` \| `sent` \| `delivered` \| `opened` \| `bounced` \| `failed` | нет | Состояние письма. |
| `from` | дата | нет | Выпущен не раньше этого дня (по Москве). |
| `to` | дата | нет | Выпущен не позже этого дня включительно. |

Без параметров вовсе — «всё выданное организацией», если оно умещается
в 2000 файлов.

## Ответ

`200 OK`, файл. Тело передаётся потоком, `Content-Length` не выставляется.

| Заголовок | Значение |
|---|---|
| `Content-Type` | `application/zip` |
| `Content-Disposition` | `attachment; filename="..."; filename*=UTF-8''...` (имя «Реестр — документы.zip») |

Внутри архива файлы лежат плоско, под своими исходными именами (или
`Документ.pdf` / `Документ.jpg`, если имени нет); повторяющиеся имена
получают числовой суффикс. Порядок — по дате выпуска, старые первыми.

## Ошибки

| Код | Когда |
|---|---|
| 400 | По отбору ничего не нашлось — «По этому отбору нечего скачивать». |
| 400 | Нашлось больше 2000 — «Документов слишком много для одного архива: больше 2000. Сузьте отбор — например, по мероприятию или по периоду.» |
| 400 | Ошибки отбора: «Некорректный идентификатор материала», «Неизвестное состояние документа», «Неизвестное состояние письма», «Не удалось разобрать дату начала периода», «Не удалось разобрать дату конца периода»; `ids` длиннее 18 500 знаков — стандартное сообщение Zod. |
| 401 | Нет токена или сессии. |
| 404 | `projectId` — чужой или несуществующий проект: «Проект не найден». |

Чужие идентификаторы в `ids` не ошибка: они просто не находятся, и если не
нашлось ничего — ответ «По этому отбору нечего скачивать».

## Пример

```bash
curl "https://vruchay.ru/api/registry/archive?documentId=$DOCUMENT_ID&state=valid" \
  -H "Authorization: Bearer $VRUCHAY_TOKEN" \
  -o documents.zip
```

По отмеченным:

```bash
curl "https://vruchay.ru/api/registry/archive?ids=$FILE_ID_1,$FILE_ID_2" \
  -H "Authorization: Bearer $VRUCHAY_TOKEN" \
  -o documents.zip
```

---

<!-- endpoints/registry/get-registry-export-csv -->
> method: GET | path: /api/registry/export.csv | auth: token | roles: any | rate_limit: none

# GET /api/registry/export.csv

Тот же реестр таблицей для Excel — по тому же отбору, что и
`GET /api/registry`. Отдаётся не больше 20 000 строк, новые первыми;
если документов в отборе больше, остальные в файл не попадают — сузьте отбор
периодом или материалом. Ответ — файл, а не JSON.

## Запрос

Параметры строки запроса — общий отбор реестра
([reference/registry-filter.md](../../reference/registry-filter.md)).

| Параметр | Тип | Обязателен | Описание |
|---|---|---|---|
| `search` | string ≤ 200 | нет | Имя, почта или проверочный код целиком. |
| `documentId` | UUID | нет | Материал. |
| `event` | string ≤ 200 | нет | Мероприятие, точное совпадение. |
| `state` | `valid` \| `revoked` \| `replaced` \| `expired` | нет | Состояние документа. |
| `mail` | `none` \| `queued` \| `sent` \| `delivered` \| `opened` \| `bounced` \| `failed` | нет | Состояние письма. |
| `from` | дата | нет | Выпущен не раньше этого дня (по Москве). |
| `to` | дата | нет | Выпущен не позже этого дня включительно. |

## Ответ

`200 OK`, файл.

| Заголовок | Значение |
|---|---|
| `Content-Type` | `text/csv; charset=utf-8` |
| `Content-Disposition` | `attachment; filename="..."; filename*=UTF-8''%D0%A0%D0%B5%D0%B5%D1%81%D1%82%D1%80%20%D0%B2%D1%8B%D0%B4%D0%B0%D0%BD%D0%BD%D0%BE%D0%B3%D0%BE.csv` (имя «Реестр выданного.csv») |

Формат: метка порядка байтов (BOM) в начале, разделитель `;`, строки через
`\r\n`, каждая ячейка в двойных кавычках (кавычки внутри удвоены). Значения,
начинающиеся с `=`, `+`, `-`, `@`, табуляции или перевода строки, получают
апостроф спереди — чтобы Excel не выполнил их как формулу. Даты — `ДД.ММ.ГГГГ`
по Москве.

Колонки по порядку:

| Колонка | Откуда |
|---|---|
| № | Порядковый номер в выгрузке. |
| ФИО | `name` строки реестра. |
| Адрес почты | `email`. |
| Материал | `documentTitle`. |
| Мероприятие | `eventName`. |
| Выдан | `issuedAt`. |
| Действителен до | `expiresAt`, пусто у бессрочных. |
| Проверочный код | `code`. |
| Состояние | `действителен`, `перевыпускается`, `отозван`, `заменён`, `срок истёк`. |
| Письмо | `не отправлялось`, `в очереди`, `отправлено`, `доставлено`, `прочитано`, `не доставлено`, `ошибка`. |
| Проверок по QR | `verifyCount`. |
| Удаление по сроку | Дата `retention.purgeAt`, пусто, если материал не в корзине. |

```
"№";"ФИО";"Адрес почты";"Материал";"Мероприятие";"Выдан";"Действителен до";"Проверочный код";"Состояние";"Письмо";"Проверок по QR";"Удаление по сроку"
"1";"Иванова Мария Петровна";"ivanova@vruchay.ru";"Сертификат участника";"Городской конкурс «Мастер года»";"15.06.2026";"";"K7M2-9QXR-4TVB";"действителен";"доставлено";"3";""
```

## Ошибки

| Код | Когда |
|---|---|
| 400 | Те же ошибки отбора, что у `GET /api/registry`: «Некорректный идентификатор материала», «Неизвестное состояние документа», «Неизвестное состояние письма», «Не удалось разобрать дату начала периода», «Не удалось разобрать дату конца периода». |
| 401 | Нет токена или сессии. |
| 404 | `projectId` — чужой или несуществующий проект: «Проект не найден». |

## Пример

```bash
curl "https://vruchay.ru/api/registry/export.csv?documentId=$DOCUMENT_ID" \
  -H "Authorization: Bearer $VRUCHAY_TOKEN" \
  -o registry.csv
```

---

<!-- endpoints/registry/get-registry-facets -->
> method: GET | path: /api/registry/facets | auth: token | roles: any | rate_limit: none

# GET /api/registry/facets

Что подставлять в отбор реестра: материалы и мероприятия, по которым
что-то выдано. Материалы без выпущенных документов сюда не попадают —
фильтр по ним дал бы пустую таблицу. Материалы в корзине попадают (у них
заполнен `deletedAt`): их документы всё ещё в реестре, пока не истёк срок
хранения.

С `projectId` — только материалы и мероприятия этого проекта: отбор по
материалу соседнего проекта дал бы пустую таблицу.

## Запрос

| Параметр | Тип | Обязателен | Описание |
|---|---|---|---|
| `projectId` | string (UUID) \| `none` | нет | Проект ([`GET /api/projects`](../projects/get-projects.md)); `none` — материалы без проекта; без параметра — вся организация. |

## Ответ

`200 OK`

| Поле | Тип | Описание |
|---|---|---|
| `documents` | array | До 300 материалов с выпущенными документами, новые первыми. |
| `documents[].id` | string (UUID) | Значение для параметра `documentId`. |
| `documents[].title` | string | Название материала. |
| `documents[].eventName` | string | Мероприятие. |
| `documents[].eventDate` | string | Дата мероприятия — строкой, как её ввели. |
| `documents[].deletedAt` | string (дата) \| null | Когда материал отправили в корзину; null — материал жив. |
| `events` | string[] | Уникальные непустые названия мероприятий из этих материалов — значения для параметра `event`. |
| `trashDays` | number | Срок корзины организации в днях — от 1 до 90, по умолчанию 7 (настройка «Об организации → Хранение данных», `trashDays` в `GET /api/org/public-profile`). По этому сроку ночная уборка удаляет материал из корзины вместе с выданными документами. |

```json
{
  "documents": [
    {
      "id": "b1c2d3e4-f5a6-4b7c-8d9e-0f1a2b3c4d5e",
      "title": "Сертификат участника",
      "eventName": "Городской конкурс «Мастер года»",
      "eventDate": "14.06.2026",
      "deletedAt": null
    }
  ],
  "events": ["Городской конкурс «Мастер года»"],
  "trashDays": 7
}
```

## Ошибки

| Код | Когда |
|---|---|
| 400 | `projectId` не UUID и не `none`: «Некорректный идентификатор проекта». |
| 401 | Нет токена или сессии. |
| 404 | «Проект не найден» — чужой или несуществующий `projectId`. |

## Пример

```bash
curl "https://vruchay.ru/api/registry/facets" \
  -H "Authorization: Bearer $VRUCHAY_TOKEN"
```

---

<!-- endpoints/registry/get-registry-files-fileId-download -->
> method: GET | path: /api/registry/files/{fileId}/download | auth: token | roles: any | rate_limit: none

# GET /api/registry/files/{fileId}/download

Отдать файл одного выданного документа — посмотреть или переслать вручную.
Ответ — сам PDF или JPEG, не JSON. У документа увеличивается `downloadCount`.

## Запрос

| Параметр | Тип | Обязателен | Описание |
|---|---|---|---|
| `fileId` | UUID (путь) | да | Идентификатор файла из реестра (`fileId` строки). |

## Ответ

`200 OK`, файл потоком.

| Заголовок | Значение |
|---|---|
| `Content-Type` | MIME выпущенного файла: `application/pdf` или `image/jpeg`. |
| `Content-Disposition` | `attachment; filename="..."; filename*=UTF-8''...` — исходное имя файла либо `Документ.pdf` / `Документ.jpg`. |

## Ошибки

| Код | Когда |
|---|---|
| 400 | `fileId` не UUID — «Некорректный идентификатор». |
| 400 | Документа нет, он удалён, не выпущен или принадлежит другой организации — «Документ не найден». Именно 400, а не 404. |
| 401 | Нет токена или сессии. |

## Пример

```bash
curl "https://vruchay.ru/api/registry/files/$FILE_ID/download" \
  -H "Authorization: Bearer $VRUCHAY_TOKEN" \
  -o certificate.pdf
```

---

<!-- endpoints/registry/get-registry-files-fileId -->
> method: GET | path: /api/registry/files/{fileId} | auth: token | roles: any | rate_limit: none

# GET /api/registry/files/{fileId}

Что с документом было и что с ним стало: строка реестра, письма с ним и
лента событий, сведённая из трёх источников — выпуска, событий писем и
журнала действий организации (по самому файлу и по его материалу).

Имена сотрудников в ленте (`actor`) видят только владелец и управляющий;
для роли `member` поле всегда пустое. Внутренняя причина отзыва в `row`
тоже отдаётся только им.

## Запрос

| Параметр | Тип | Обязателен | Описание |
|---|---|---|---|
| `fileId` | UUID (путь) | да | Идентификатор файла из реестра. |

## Ответ

`200 OK`

| Поле | Тип | Описание |
|---|---|---|
| `row` | object | Строка реестра — см. [reference/registry-row.md](../../reference/registry-row.md). |
| `verifyCount` | number | Сколько раз документ проверяли. |
| `verifyLastAt` | string (дата) \| null | Последняя проверка. |
| `emails` | array | Все письма с этим документом, старые первыми. |
| `emails[].id` | string (UUID) | Идентификатор письма. |
| `emails[].toEmail` | string | Адрес получателя. |
| `emails[].status` | string | `queued`, `sent`, `delivered`, `opened`, `bounced`, `failed`. |
| `emails[].error` | string \| null | Текст ошибки отправки, если была. |
| `emails[].queuedAt` | string (дата) | Когда поставлено в очередь. |
| `history` | array | До 100 событий, новые первыми. |
| `history[].at` | string (дата) | Когда произошло. |
| `history[].kind` | `issued` \| `mail` \| `action` | Выпуск, событие письма, действие сотрудника. |
| `history[].title` | string | «Документ выпущен»; «Письмо отправлено» / «Письмо доставлено» / «Письмо прочитано» / «Письмо не доставлено» / «Письмо не отправилось»; для действий — текст записи журнала. |
| `history[].detail` | string | Для событий письма — адрес получателя; иначе пустая строка. |
| `history[].actor` | string | Имя или почта сотрудника для `action` (только владельцу и управляющему); иначе пустая строка. |

```json
{
  "row": {
    "fileId": "7d4c6e0a-1b2f-4c3d-9e8f-0a1b2c3d4e5f",
    "code": "K7M2-9QXR-4TVB",
    "state": "valid",
    "name": "Иванова Мария Петровна",
    "…": "остальные поля строки реестра"
  },
  "verifyCount": 3,
  "verifyLastAt": "2026-08-02T17:40:01.000Z",
  "emails": [
    {
      "id": "0c9d8e7f-6a5b-4c3d-2e1f-0a9b8c7d6e5f",
      "toEmail": "ivanova@vruchay.ru",
      "status": "delivered",
      "error": null,
      "queuedAt": "2026-06-15T09:13:02.000Z"
    }
  ],
  "history": [
    {
      "at": "2026-06-15T09:13:31.000Z",
      "kind": "mail",
      "title": "Письмо доставлено",
      "detail": "ivanova@vruchay.ru",
      "actor": ""
    },
    {
      "at": "2026-06-15T09:12:44.000Z",
      "kind": "issued",
      "title": "Документ выпущен",
      "detail": "",
      "actor": ""
    }
  ]
}
```

## Ошибки

| Код | Когда |
|---|---|
| 400 | `fileId` не UUID — «Некорректный идентификатор». |
| 401 | Нет токена или сессии. |
| 404 | Документа нет, он удалён или принадлежит другой организации — «Документ не найден». |

## Пример

```bash
curl "https://vruchay.ru/api/registry/files/$FILE_ID" \
  -H "Authorization: Bearer $VRUCHAY_TOKEN"
```

---

<!-- endpoints/registry/get-registry -->
> method: GET | path: /api/registry | auth: token | roles: any | rate_limit: none

# GET /api/registry

Страница реестра выданного по всей организации: каждый выпущенный документ
одной строкой — кому, по какому материалу, когда, в каком состоянии, что с
письмом и сколько раз проверяли. В отличие от реестра внутри материала, здесь
можно найти «грамоту Ивановой», не помня, в каком материале она выпускалась.

Выборка постранична: не больше 100 строк за запрос, сортировка — по дате
выпуска, новые первыми. Внутренняя причина отзыва (`revokedReasonInternal`)
отдаётся только владельцу и управляющему; сотруднику с ролью `member` в этом
поле всегда `null`.

## Запрос

Параметры строки запроса — общий отбор реестра (подробно:
[reference/registry-filter.md](../../reference/registry-filter.md)) плюс
постраничность.

| Параметр | Тип | Обязателен | Описание |
|---|---|---|---|
| `search` | string ≤ 200 | нет | Имя, почта или проверочный код целиком. |
| `documentId` | UUID | нет | Материал. |
| `event` | string ≤ 200 | нет | Мероприятие, точное совпадение. |
| `state` | `valid` \| `revoked` \| `replaced` \| `expired` | нет | Состояние документа. |
| `mail` | `none` \| `queued` \| `sent` \| `delivered` \| `opened` \| `bounced` \| `failed` | нет | Состояние письма. |
| `from` | дата | нет | Выпущен не раньше этого дня (по Москве). |
| `to` | дата | нет | Выпущен не позже этого дня включительно. |
| `limit` | integer 1…100 | нет | Размер страницы. По умолчанию 50. |
| `offset` | integer ≥ 0 | нет | Смещение. По умолчанию 0. |

## Ответ

`200 OK`

| Поле | Тип | Описание |
|---|---|---|
| `items` | array | Строки реестра — см. [reference/registry-row.md](../../reference/registry-row.md). |
| `total` | number | Сколько всего строк подходит под отбор (без учёта `limit`/`offset`). |
| `limit` | number | Применённый размер страницы. |
| `offset` | number | Применённое смещение. |

```json
{
  "items": [
    {
      "fileId": "7d4c6e0a-1b2f-4c3d-9e8f-0a1b2c3d4e5f",
      "publicId": "3f9a2b1c-5d6e-4f70-8a9b-0c1d2e3f4a5b",
      "code": "K7M2-9QXR-4TVB",
      "verifyPath": "/c/K7M2-9QXR-4TVB",
      "name": "Иванова Мария Петровна",
      "email": "ivanova@vruchay.ru",
      "documentId": "b1c2d3e4-f5a6-4b7c-8d9e-0f1a2b3c4d5e",
      "documentTitle": "Сертификат участника",
      "eventName": "Городской конкурс «Мастер года»",
      "eventDate": "14.06.2026",
      "issuedAt": "2026-06-15T09:12:44.000Z",
      "expiresAt": null,
      "printedName": null,
      "revokedAt": null,
      "revokedReasonPublic": null,
      "revokedReasonInternal": null,
      "signedAt": "2026-06-15T09:12:45.000Z",
      "state": "valid",
      "reissuePending": false,
      "replacedBy": null,
      "mail": { "status": "delivered", "sentAt": "2026-06-15T09:13:10.000Z", "error": null },
      "verifyCount": 3,
      "verifyLastAt": "2026-08-02T17:40:01.000Z",
      "downloadCount": 1,
      "retention": null
    }
  ],
  "total": 1284,
  "limit": 50,
  "offset": 0
}
```

## Ошибки

| Код | Когда |
|---|---|
| 400 | `documentId` не UUID — «Некорректный идентификатор материала»; неизвестный `state` — «Неизвестное состояние документа»; неизвестный `mail` — «Неизвестное состояние письма»; нечитаемые `from`/`to` — «Не удалось разобрать дату начала периода» / «Не удалось разобрать дату конца периода»; `limit` вне 1…100, `offset` < 0 или не целое, `search`/`event` длиннее 200 знаков — стандартное сообщение Zod. |
| 401 | Нет токена или сессии. |
| 404 | `projectId` — чужой или несуществующий проект: «Проект не найден». |

## Пример

```bash
curl "https://vruchay.ru/api/registry?state=valid&event=%D0%9E%D1%82%D0%BA%D1%80%D1%8B%D1%82%D1%8B%D0%B9%20%D0%BA%D1%83%D0%B1%D0%BE%D0%BA&limit=100&offset=0" \
  -H "Authorization: Bearer $VRUCHAY_TOKEN"
```

Следующая страница — тот же запрос с `offset=100`; останавливайтесь, когда
`offset + items.length >= total`.

---

<!-- endpoints/registry/post-registry-reissue -->
> method: POST | path: /api/registry/reissue | auth: token | roles: owner, admin | rate_limit: none

# POST /api/registry/reissue

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

Асинхронно. Ответ лишь подтверждает, что задания на выпуск поставлены в ту
же очередь, что и обычный выпуск, — по одному заданию на каждый материал
из выбранных документов. Пока задание идёт, у старого документа
`reissuePending: true`, состояние остаётся `valid`; когда новый файл
выпущен, старый получает `state: "replaced"` и `replacedBy`. Если выпуск
упал или его отменили, обещание замены снимается, и перевыпуск можно
заказать снова.

Перевыпуск создаёт новые файлы и списывается с той же квоты, что обычный
выпуск: на пробе — с базового лимита и бонуса за приглашённых, на плане —
с его объёма. Проверка та же: выпущенное вместе с бронью активных заданий
плюс перевыпускаемые строки не должны превышать предел, а при истёкшем плане
перевыпуск не начинается вовсе. Остаток и срок заранее показывает
`GET /api/org/usage`.
Формат нового файла (PDF или JPEG) берётся у первого документа пачки по
каждому материалу. В журнал пишется запись `registry.reissue`, если хоть
что-то перевыпущено.

## Запрос

Тело — JSON.

| Поле | Тип | Обязательно | Описание |
|---|---|---|---|
| `fileIds` | UUID[] 1…500 | да | Документы, которые нужно заменить. |

```json
{ "fileIds": ["7d4c6e0a-1b2f-4c3d-9e8f-0a1b2c3d4e5f"] }
```

## Ответ

`201 Created`

| Поле | Тип | Описание |
|---|---|---|
| `reissued` | number | Сколько документов принято на перевыпуск. |
| `jobs` | array | Задания на выпуск, по одному на материал. |
| `jobs[].jobId` | string (UUID) | Идентификатор задания выпуска. |
| `jobs[].documentId` | string (UUID) | Материал. |
| `jobs[].total` | number | Сколько строк печатается в задании. |
| `skipped` | array | Документы, которые не перевыпускаются, с причиной. |
| `skipped[].fileId` | string (UUID) | Файл. |
| `skipped[].name` | string | Имя получателя. |
| `skipped[].reason` | string | Причина, см. ниже. |

Причины в `skipped[].reason`:

- «материал удалён — перевыпускать не из чего»;
- «материал в корзине — сначала восстановите его»;
- «строки больше нет в таблице получателей»;
- «документ уже заменён — перевыпускайте тот, что его заменил»;
- «перевыпуск уже заказан и ещё идёт»;
- «По этому материалу уже идёт выпуск — дождитесь его окончания» (вся пачка по материалу);
- отказ по квоте (вся пачка по материалу), см. ниже.

Отказы по квоте — те же тексты, что у [запуска выпуска](../generation/post-documents-id-generate.md). К отказу по остатку в конце дописано «Перевыпуск создаёт новые документы и считается наравне с обычным выпуском.», к отказу по сроку плана — нет:

- «Бесплатная проба закончилась: выпущено X документов из Y. Напишите нам — обсудим условия и добавим документов. Или пригласите коллегу в разделе «Пригласить друга» — за каждого, кто начнёт работать, добавим ещё документов. Перевыпуск создаёт новые документы…»;
- «На бесплатной пробе осталось N документов из Y, а отмечено K. Снимите лишние отметки — или напишите нам, обсудим условия. Или пригласите коллегу… Перевыпуск создаёт новые документы…»;
- «План «Название» израсходован: выпущено X документов из Y. Уже начатый выпуск дойдёт до конца, а новый не начнётся. Напишите нам — обсудим условия и добавим документов. Перевыпуск создаёт новые документы…»;
- «По плану «Название» осталось N документов из Y, а отмечено K. Снимите лишние отметки — или напишите нам, обсудим условия. Перевыпуск создаёт новые документы…»;
- «Срок плана «Название» закончился ДД.ММ.ГГГГ, новый выпуск не начнётся. Уже выданные документы остаются действительными и проверяются по QR-коду. Напишите нам — обсудим условия и добавим документов.»

Если часть остатка держат незаконченные выпуски, после «из Y» стоит « (из них M держит незаконченный выпуск)». Пачки по разным материалам проверяются по очереди, поэтому первая может пройти, а следующая — получить отказ.

```json
{
  "reissued": 2,
  "jobs": [
    { "jobId": "9a8b7c6d-5e4f-4a3b-2c1d-0e9f8a7b6c5d", "documentId": "b1c2d3e4-f5a6-4b7c-8d9e-0f1a2b3c4d5e", "total": 2 }
  ],
  "skipped": [
    {
      "fileId": "1f2e3d4c-5b6a-4978-8a9b-0c1d2e3f4a5b",
      "name": "Сидоров Пётр Ильич",
      "reason": "документ уже заменён — перевыпускайте тот, что его заменил"
    }
  ]
}
```

`reissued: 0` при непустом `skipped` — тоже `201`: отказ по каждому документу
объяснён в `skipped`, а не ошибкой.

## Ошибки

| Код | Когда |
|---|---|
| 400 | Пустой `fileIds` — «Не отмечено ни одного документа»; больше 500 — «За раз можно перевыпустить не больше 500 документов»; элемент не UUID — «Некорректный идентификатор документа». |
| 400 | Ни один из `fileIds` не найден в организации (чужие, удалённые) — «Ни один из документов не найден». |
| 401 | Нет токена или сессии. |
| 403 | Роль `member` — «Недостаточно прав для этого действия». |

## Пример

```bash
curl -X POST "https://vruchay.ru/api/registry/reissue" \
  -H "Authorization: Bearer $VRUCHAY_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"fileIds":["'"$FILE_ID"'"]}'
```

---

<!-- endpoints/registry/post-registry-resend -->
> method: POST | path: /api/registry/resend | auth: token | roles: any | rate_limit: none

# POST /api/registry/resend

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

Асинхронно: письма ставятся в очередь, ответ говорит лишь, сколько
поставлено. Состояние доставки смотрите потом в `mail` строки реестра или в
`emails` карточки документа. Предел — 50 писем за вызов: переотправка
задумана как «доотправить тем, у кого не дошло», а не как массовая рассылка.
Письма с общего домена считаются в его суточный объём, как и рассылка.
Нужна возможность `mailing` в плане: без неё (или по истёкшему плану) ни одно
письмо не ставится — каждый документ попадает в `skipped` с причиной
«„Рассылка писем участникам“ не входит в ваш план…».
Письмо ставится со строкой получателя, поэтому переменные шаблона (`%name`
и другие) в нём подставляются. В журнал пишется запись `registry.resend`,
если хоть одно письмо поставлено.

Пока прежнее письмо с этим же документом на этот же адрес живо (`queued`,
`sent`, `delivered`, `opened`), второе не ставится — так двойное нажатие
не даёт двух писем. Переотправка работает для писем, которые не дошли
(`bounced`, `failed`).

## Запрос

Тело — JSON.

| Поле | Тип | Обязательно | Описание |
|---|---|---|---|
| `fileIds` | UUID[] 1…50 | да | Документы, письма с которыми нужно отправить заново. |

```json
{ "fileIds": ["7d4c6e0a-1b2f-4c3d-9e8f-0a1b2c3d4e5f"] }
```

## Ответ

`201 Created`

| Поле | Тип | Описание |
|---|---|---|
| `queued` | number | Сколько писем поставлено в очередь. |
| `skipped` | array | Документы, по которым письмо не поставлено, с причиной. |
| `skipped[].fileId` | string (UUID) | Файл. |
| `skipped[].name` | string | Имя получателя. |
| `skipped[].reason` | string | Причина, см. ниже. |

Причины в `skipped[].reason`:

- «материал удалён или в корзине — письмо слать не от чего»;
- «у получателя не указан адрес почты»;
- «Некорректный адрес»;
- «Отправка писем не настроена — обратитесь в поддержку»;
- «Домен отправителя не подтверждён — отправка невозможна»;
- «Это письмо уже отправлено на этот адрес» — прежнее письмо с этим документом ещё живо;
- отказ по суточному объёму общего домена — тот же текст, что у рассылки.

```json
{
  "queued": 1,
  "skipped": [
    {
      "fileId": "1f2e3d4c-5b6a-4978-8a9b-0c1d2e3f4a5b",
      "name": "Сидоров Пётр Ильич",
      "reason": "у получателя не указан адрес почты"
    }
  ]
}
```

## Ошибки

| Код | Когда |
|---|---|
| 400 | Пустой `fileIds` — «Не отмечено ни одного документа»; больше 50 — «За раз переотправляем не больше 50 писем»; элемент не UUID — «Некорректный идентификатор документа». |
| 400 | Ни один из `fileIds` не найден в организации — «Ни один из документов не найден». |
| 401 | Нет токена или сессии. |

## Пример

```bash
curl -X POST "https://vruchay.ru/api/registry/resend" \
  -H "Authorization: Bearer $VRUCHAY_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"fileIds":["'"$FILE_ID"'"]}'
```

---

<!-- endpoints/registry/post-registry-revoke-preview -->
> method: POST | path: /api/registry/revoke/preview | auth: token | roles: owner, admin | rate_limit: none

# POST /api/registry/revoke/preview

Что будет отозвано, до того как это станет необратимым: число документов по
цели, сколько из них уже отозваны и первые двадцать имён. Ничего не меняет.
Полученное `count` передаётся в `expectedCount` при вызове
`POST /api/registry/revoke`.

## Запрос

Тело — JSON, та же цель, что у отзыва.

| Поле | Тип | Обязательно | Описание |
|---|---|---|---|
| `fileIds` | UUID[] 1…500 | либо оно, либо `filter` | Отмеченные документы. |
| `filter` | object | либо оно, либо `fileIds` | Отбор реестра ([reference/registry-filter.md](../../reference/registry-filter.md)); должен содержать хотя бы одно из `projectId` (идентификатор проекта, не `none`), `documentId`, `event`, `from`, `to`. |

```json
{ "filter": { "event": "Городской конкурс «Мастер года»", "from": "2026-06-01", "to": "2026-06-30" } }
```

## Ответ

`201 Created`

| Поле | Тип | Описание |
|---|---|---|
| `count` | number | Сколько документов попадёт под действие. |
| `alreadyRevoked` | number | Сколько из них уже отозваны. |
| `sample` | array | До 20 документов, новые выпуски первыми. |
| `sample[].fileId` | string (UUID) | Файл. |
| `sample[].name` | string | Имя получателя (может быть пустым). |
| `sample[].code` | string | Проверочный код с бумаги. |
| `sample[].documentTitle` | string | Название материала или «Материал удалён». |
| `sample[].state` | `valid` \| `revoked` \| `replaced` \| `expired` | Текущее состояние. |

```json
{
  "count": 640,
  "alreadyRevoked": 2,
  "sample": [
    {
      "fileId": "7d4c6e0a-1b2f-4c3d-9e8f-0a1b2c3d4e5f",
      "name": "Иванова Мария Петровна",
      "code": "K7M2-9QXR-4TVB",
      "documentTitle": "Сертификат участника",
      "state": "valid"
    }
  ]
}
```

Если по цели ничего не нашлось, ответ `{ "count": 0, "alreadyRevoked": 0, "sample": [] }` — это не ошибка.

## Ошибки

| Код | Когда |
|---|---|
| 400 | Переданы и `fileIds`, и `filter`, или ни одно — «Укажите либо документы, либо отбор». |
| 400 | Отбор без `projectId`/`documentId`/`event`/`from`/`to` (или только с `projectId=none`) — «Отбор для массового отзыва должен быть сужен проектом, материалом, мероприятием или периодом». |
| 404 | `filter.projectId` — чужой или несуществующий проект: «Проект не найден». |
| 400 | Пустой `fileIds` — «Не отмечено ни одного документа»; больше 500 — «За раз можно обработать не больше 500 документов»; элемент не UUID — «Некорректный идентификатор документа». |
| 400 | Ошибки внутри `filter`: «Некорректный идентификатор материала», «Неизвестное состояние документа», «Неизвестное состояние письма», «Не удалось разобрать дату начала периода», «Не удалось разобрать дату конца периода». |
| 401 | Нет токена или сессии. |
| 403 | Роль `member` — «Недостаточно прав для этого действия». |

## Пример

```bash
curl -X POST "https://vruchay.ru/api/registry/revoke/preview" \
  -H "Authorization: Bearer $VRUCHAY_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"filter":{"documentId":"'"$DOCUMENT_ID"'"}}'
```

---

<!-- endpoints/registry/post-registry-revoke -->
> method: POST | path: /api/registry/revoke | auth: token | roles: owner, admin | rate_limit: none

# POST /api/registry/revoke

Отозвать проверку у пачки документов или вернуть её. Сами файлы остаются —
их могли скачать и распечатать; меняется ответ страницы проверки: отозванный
документ показывает красную страницу с публичной причиной. Действие
синхронное: после ответа состояние уже изменено. В журнал организации
пишется запись `verify.revoke` или `verify.restore` (внутренняя причина
в журнал не попадает).

Цель задаётся одним из двух способов: список `fileIds` (отмеченные
документы) или `filter` — всё, что находится по отбору реестра. Отбор
обязан быть сужен хотя бы проектом, материалом, мероприятием или периодом: пустой
отбор означал бы «всё выданное организацией за всё время».

Отзыв подтверждается числом: в `expectedCount` клиент присылает, сколько
документов он показал человеку, и сервер отказывает, если по цели сейчас
находится другое число. Перед вызовом получите это число через
`POST /api/registry/revoke/preview`.

Возврат проверки (`revoked: false`) возможен только по `fileIds`. Он снимает
лишь отметку: `revokedAt` и причины остаются историей документа.

## Запрос

Тело — JSON.

| Поле | Тип | Обязательно | Описание |
|---|---|---|---|
| `fileIds` | UUID[] 1…500 | либо оно, либо `filter` | Отмеченные документы. |
| `filter` | object | либо оно, либо `fileIds` | Отбор реестра ([reference/registry-filter.md](../../reference/registry-filter.md)); должен содержать хотя бы одно из `projectId` (идентификатор проекта, не `none`), `documentId`, `event`, `from`, `to`. |
| `revoked` | boolean | да | `true` — отозвать, `false` — вернуть проверку. |
| `expectedCount` | integer 1…1 000 000 | да | Сколько документов подтверждено; должно совпасть с тем, что найдётся сейчас. |
| `reasonPublic` | string ≤ 300 | нет | Причина, которую увидит человек на странице проверки. Пишется только при `revoked: true` и только если поле передано; пустая строка стирает прежнюю. |
| `reasonInternal` | string ≤ 1000 | нет | Причина для владельца и управляющего. Правила те же. |

Повторный отзыв уже отозванного документа без причин прежние причины не
стирает.

```json
{
  "filter": { "documentId": "b1c2d3e4-f5a6-4b7c-8d9e-0f1a2b3c4d5e" },
  "revoked": true,
  "expectedCount": 640,
  "reasonPublic": "Протокол мероприятия пересмотрен",
  "reasonInternal": "Ошибка судейской бригады, письмо от 20.06"
}
```

## Ответ

`201 Created`

| Поле | Тип | Описание |
|---|---|---|
| `changed` | number | Сколько документов изменено. |
| `names` | string[] | Имена получателей изменённых документов (пустые пропущены), новые выпуски первыми. |

```json
{ "changed": 640, "names": ["Иванова Мария Петровна", "Петров Олег Игоревич"] }
```

## Ошибки

| Код | Когда |
|---|---|
| 400 | Переданы и `fileIds`, и `filter`, или ни одно — «Укажите либо документы, либо отбор». |
| 400 | Отбор без `projectId`/`documentId`/`event`/`from`/`to` (или только с `projectId=none`) — «Отбор для массового отзыва должен быть сужен проектом, материалом, мероприятием или периодом». |
| 404 | `filter.projectId` — чужой или несуществующий проект: «Проект не найден». |
| 400 | `revoked: false` с `filter` — «Возврат проверки — только по отмеченным документам». |
| 400 | Нет `revoked` — «Не указано, отзывать проверку или возвращать». |
| 400 | `expectedCount` < 1 или отсутствует — «Подтвердите число документов»; > 1 000 000 — стандартное сообщение Zod. |
| 400 | Пустой `fileIds` — «Не отмечено ни одного документа»; больше 500 — «За раз можно обработать не больше 500 документов»; элемент не UUID — «Некорректный идентификатор документа». |
| 400 | Длинные причины — «Публичная причина — не длиннее 300 знаков», «Внутренняя причина — не длиннее 1000 знаков». |
| 400 | Ошибки внутри `filter`: «Некорректный идентификатор материала», «Неизвестное состояние документа», «Неизвестное состояние письма», «Не удалось разобрать дату начала периода», «Не удалось разобрать дату конца периода». |
| 400 | По цели ничего не нашлось (в том числе все `fileIds` чужие или удалены) — «Ни один из документов не найден». |
| 400 | Число не сошлось — «Список изменился: сейчас по этому отбору N документов, а подтверждено M. Обновите список и подтвердите снова.» |
| 401 | Нет токена или сессии. |
| 403 | Роль `member` — «Недостаточно прав для этого действия». |

## Пример

```bash
curl -X POST "https://vruchay.ru/api/registry/revoke" \
  -H "Authorization: Bearer $VRUCHAY_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"fileIds":["'"$FILE_ID"'"],"revoked":true,"expectedCount":1,"reasonPublic":"Выдан по ошибке"}'
```

---

<!-- endpoints/reviews/delete-reviews-id -->
> method: DELETE | path: /api/reviews/{id} | auth: token | roles: any | rate_limit: none

# DELETE /api/reviews/{id}

Убрать отзыв своей организации. Удаление окончательное: записи не остаётся,
и опубликованный отзыв пропадает с посадочной страницы. Чтобы просто переписать
текст, удалять ничего не нужно — достаточно `PATCH /api/reviews/mine`.

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

## Запрос

| Параметр | Тип | Обязателен | Описание |
|---|---|---|---|
| `id` | UUID | да | Идентификатор отзыва из `GET /api/reviews/mine`. |

Тела нет.

## Ответ

`200 OK`

| Поле | Тип | Описание |
|---|---|---|
| ok | boolean | Всегда `true`. |

```json
{ "ok": true }
```

## Ошибки

| Код | Когда |
|---|---|
| 400 | `id` не UUID — «Некорректный идентификатор». |
| 401 | Нет действующего токена и нет cookie-сессии. |
| 404 | Отзыва нет или он принадлежит другой организации — «Отзыв не найден». Чужой отзыв отвечает 404, а не 403: иначе по ответу можно было бы узнавать, какие отзывы вообще существуют. |

## Пример

```bash
curl -X DELETE "https://vruchay.ru/api/reviews/$REVIEW_ID" \
  -H "Authorization: Bearer $VRUCHAY_TOKEN"
```

---

<!-- endpoints/reviews/get-reviews-mine -->
> method: GET | path: /api/reviews/mine | auth: token | roles: any | rate_limit: none

# GET /api/reviews/mine

Отзыв, который оставила ваша организация: текст, состояние проверки и замечание
модератора, если отзыв отклонили. Отзыв один на организацию — он говорит от её
лица, а не от лица сотрудника, — поэтому и ответ здесь один объект, а не список.

Читать и править отзыв может любой сотрудник, роль не проверяется.

## Запрос

Параметров нет.

## Ответ

`200 OK`. Отзыва ещё нет — в теле `null`.

| Поле | Тип | Описание |
|---|---|---|
| id | string (UUID) | Идентификатор отзыва. Нужен для `DELETE /api/reviews/{id}`. |
| authorName | string | Как подписан автор. |
| authorRole | string | Должность автора. |
| orgName | string | Организация, от лица которой написан отзыв. |
| text | string | Текст отзыва. |
| rating | number \| null | Оценка 1…5. `null` — не поставлена. |
| status | string | `pending` — на проверке; `published` — опубликован на сайте; `rejected` — отклонён. |
| moderatorNote | string \| null | Что не так с отзывом. Заполняется при отклонении, чтобы автор знал, что править. |
| createdAt | string (ISO 8601) | Когда отзыв создан. |
| publishedAt | string (ISO 8601) \| null | Когда опубликован. `null` — пока нет. |

```json
{
  "id": "c3d4e5f6-a7b8-4c9d-8e0f-1a2b3c4d5e6f",
  "authorName": "Мария Петрова",
  "authorRole": "Ответственный секретарь",
  "orgName": "Учебный центр «Развитие»",
  "text": "Раньше на двести грамот уходило два дня. Теперь загружаем таблицу и через полчаса рассылка уже ушла.",
  "rating": 5,
  "status": "published",
  "moderatorNote": null,
  "createdAt": "2026-05-18T12:40:11.000Z",
  "publishedAt": "2026-05-20T10:03:00.000Z"
}
```

## Ошибки

| Код | Когда |
|---|---|
| 401 | Нет действующего токена и нет cookie-сессии. |

## Пример

```bash
curl "https://vruchay.ru/api/reviews/mine" \
  -H "Authorization: Bearer $VRUCHAY_TOKEN"
```

---

<!-- endpoints/reviews/patch-reviews-mine -->
> method: PATCH | path: /api/reviews/mine | auth: humans-only | roles: any | rate_limit: none

# PATCH /api/reviews/mine

Оставить отзыв о сервисе или переписать уже оставленный. Один вызов на оба случая:
если отзыв у организации есть, он заменяется, если нет — заводится.

Отзыв всегда уходит на проверку человеком: сразу после этого запроса `status`
становится `pending`, `moderatorNote` и `publishedAt` очищаются. Правка снимает
публикацию — на витрине не должно оказаться непроверенного текста только потому,
что прежний вариант когда-то одобрили. Обратная сторона удобна: исправленный
после замечания отзыв не остаётся отклонённым навсегда.

Опубликовать отзыв через API нельзя: решение принимает оператор сервиса.

**По токену API недоступно, только из кабинета (cookie-сессия).** Отзыв — слова человека под его именем; по токену его автором записался бы сотрудник, выдавший токен.

## Запрос

Тело — JSON.

| Поле | Тип | Обязательно | Описание |
|---|---|---|---|
| authorName | string 2…120 | да | Как подписать отзыв. Из профиля не берётся — автор пишет сам. |
| authorRole | string 2…120 | да | Должность автора. |
| orgName | string 2…200 | да | Название организации так, как её назвать на витрине. |
| text | string 40…2000 | да | Текст отзыва. Нижняя граница не придирка: по «всё ок» невозможно понять, чем сервис пригодился. |
| rating | integer 1…5 | нет | Оценка. Без неё в отзыве останется `null`. |

Пробелы по краям всех строк обрезаются до проверки длины.

```json
{
  "authorName": "Мария Петрова",
  "authorRole": "Ответственный секретарь",
  "orgName": "Учебный центр «Развитие»",
  "text": "Раньше на двести грамот уходило два дня: сверить фамилии, разложить по шаблону, разослать. Теперь загружаем таблицу и через полчаса рассылка уже ушла.",
  "rating": 5
}
```

## Ответ

`200 OK` — сохранённая запись отзыва целиком.

| Поле | Тип | Описание |
|---|---|---|
| id | string (UUID) | Идентификатор отзыва. |
| orgId | string (UUID) | Организация автора. |
| userId | string (UUID) | Сотрудник, впервые оставивший отзыв. При правке не меняется. |
| authorName | string | Как подписан автор. |
| authorRole | string | Должность автора. |
| orgName | string | Организация. |
| text | string | Текст отзыва. |
| rating | number \| null | Оценка. |
| status | string | Всегда `pending` сразу после этого запроса. |
| moderatorNote | string \| null | Всегда `null` сразу после этого запроса. |
| createdAt | string (ISO 8601) | Когда отзыв заведён. При правке не меняется. |
| publishedAt | string (ISO 8601) \| null | Всегда `null` сразу после этого запроса. |

Ответ шире, чем у `GET /api/reviews/mine`: здесь отдаётся вся запись, включая
`orgId` и `userId`.

```json
{
  "id": "c3d4e5f6-a7b8-4c9d-8e0f-1a2b3c4d5e6f",
  "orgId": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
  "userId": "f0e1d2c3-b4a5-4968-9788-6a5b4c3d2e1f",
  "authorName": "Мария Петрова",
  "authorRole": "Ответственный секретарь",
  "orgName": "Учебный центр «Развитие»",
  "text": "Раньше на двести грамот уходило два дня: сверить фамилии, разложить по шаблону, разослать. Теперь загружаем таблицу и через полчаса рассылка уже ушла.",
  "rating": 5,
  "status": "pending",
  "moderatorNote": null,
  "createdAt": "2026-05-18T12:40:11.000Z",
  "publishedAt": null
}
```

## Ошибки

| Код | Когда |
|---|---|
| 400 | `authorName` короче 2 знаков или отсутствует — «Укажите, как вас зовут»; `authorRole` — «Укажите вашу должность»; `orgName` — «Укажите организацию»; `text` короче 40 знаков — «Напишите хотя бы пару предложений». Превышение верхних границ и `rating` вне 1…5 — стандартное сообщение Zod. |
| 401 | Нет cookie-сессии. |
| 403 | Запрос пришёл по токену API: «Это действие доступно только человеку, вошедшему в кабинет. По токену API его выполнить нельзя.» |
| 413 | Тело больше 1 МБ. |

## Пример

```bash
curl -X PATCH "https://vruchay.ru/api/reviews/mine" \
  -b cookies.txt \
  -H "Content-Type: application/json" \
  -d '{
    "authorName": "Мария Петрова",
    "authorRole": "Ответственный секретарь",
    "orgName": "Учебный центр «Развитие»",
    "text": "Раньше на двести грамот уходило два дня. Теперь загружаем таблицу и через полчаса рассылка уже ушла.",
    "rating": 5
  }'
```

---

<!-- endpoints/tokens/delete-tokens-id -->
> method: DELETE | path: /api/tokens/{id} | auth: humans-only | roles: owner, admin | rate_limit: none

# DELETE /api/tokens/{id}

Отзывает токен: с этого момента запросы с ним получают 401. Запись о токене не удаляется, а помечается временем отзыва (`revokedAt`) — чтобы позже можно было ответить, кто и когда завёл этот доступ. Из списка `GET /api/tokens` отозванный токен пропадает; повторный отзыв того же токена отвечает 404. Отзыв записывается в журнал действий событием `token.revoke`.

**По токену API недоступно, только из кабинета (cookie-сессия).**

## Запрос

| Параметр | Тип | Обязателен | Описание |
|---|---|---|---|
| id | string (UUID) | да | Идентификатор токена из `GET /api/tokens` или ответа на выдачу. |

Тела нет.

## Ответ

`200 OK`

| Поле | Тип | Описание |
|---|---|---|
| ok | boolean | Всегда `true`. |
| name | string | Название отозванного токена. |

```json
{
  "ok": true,
  "name": "выгрузка из CRM"
}
```

## Ошибки

| Код | Когда |
|---|---|
| 400 | `id` не UUID: «Некорректный идентификатор». |
| 401 | Нет cookie-сессии. `{"statusCode":401,"message":"Требуется вход в систему","error":"Unauthorized"}` |
| 403 | Запрос пришёл по токену API: «Это действие доступно только человеку, вошедшему в кабинет. По токену API его выполнить нельзя.» |
| 403 | Роль `member`: «Недостаточно прав для этого действия». |
| 404 | Токена с таким `id` в вашей организации нет, либо он уже отозван: «Токен не найден». Чужой токен тоже даёт 404. |

## Пример

```bash
curl -X DELETE "https://vruchay.ru/api/tokens/$TOKEN_ID" \
  -b cookies.txt
```

---

<!-- endpoints/tokens/get-tokens -->
> method: GET | path: /api/tokens | auth: humans-only | roles: owner, admin | rate_limit: none

# GET /api/tokens

Список действующих (не отозванных) токенов API организации, новые сверху. Сам токен в списке не показывается — только его начало (`prefix`, первые 10 знаков: `vru_` и шесть символов), по которому владелец узнаёт свой токен среди нескольких. Полный токен выдаётся один раз в ответе `POST /api/tokens` и нигде больше не хранится.

**По токену API недоступно, только из кабинета (cookie-сессия).** Токен не может ни видеть, ни выдавать, ни отзывать другие токены — иначе утёкший токен нельзя было бы отозвать: он тут же выдал бы себе следующий.

## Запрос

Параметров нет.

## Ответ

`200 OK` — массив.

| Поле | Тип | Описание |
|---|---|---|
| id | string (UUID) | Идентификатор токена — нужен для отзыва. |
| name | string | Название, заданное при выдаче. |
| prefix | string | Первые 10 знаков токена (`vru_` + 6 символов). |
| role | string | `member` или `admin`. |
| createdAt | string (ISO 8601) | Когда выдан. |
| lastUsedAt | string (ISO 8601) \| null | Когда токеном пользовались в последний раз. Отметка обновляется не чаще раза в час; `null` — не использовался ни разу. |

```json
[
  {
    "id": "5f1c2e6a-9d0b-4c3e-8a7f-1b2c3d4e5f60",
    "name": "выгрузка из CRM",
    "prefix": "vru_Qm9yaX",
    "role": "member",
    "createdAt": "2026-08-30T09:12:44.000Z",
    "lastUsedAt": "2026-09-02T18:03:10.000Z"
  }
]
```

## Ошибки

| Код | Когда |
|---|---|
| 401 | Нет cookie-сессии. `{"statusCode":401,"message":"Требуется вход в систему","error":"Unauthorized"}` |
| 403 | Запрос пришёл по токену API: «Это действие доступно только человеку, вошедшему в кабинет. По токену API его выполнить нельзя.» |
| 403 | Роль `member`: «Недостаточно прав для этого действия». |

## Пример

```bash
curl "https://vruchay.ru/api/tokens" \
  -b cookies.txt
```

---

<!-- endpoints/tokens/post-tokens -->
> method: POST | path: /api/tokens | auth: humans-only | roles: owner, admin | rate_limit: none

# POST /api/tokens

Выдаёт новый токен API организации. Полный токен возвращается **только в этом ответе** — сервер хранит лишь его хеш (sha256), восстановить или показать повторно его нельзя. Потеряли — отзовите и выдайте новый.

Формат токена: `vru_` и 43 символа base64url (32 случайных байта). Токен не истекает сам; действует, пока его не отозвали через `DELETE /api/tokens/{id}`. У организации не может быть больше десяти действующих токенов одновременно.

Роль токена определяет, что ему можно: `member` работает с материалами, `admin` вдобавок удаляет. Управлять сотрудниками и токенами не может ни один токен (HumansOnlyGuard). Выдача записывается в журнал действий событием `token.create`.

**По токену API недоступно, только из кабинета (cookie-сессия).**

## Запрос

Тело (`application/json`):

| Поле | Тип | Обязательно | Описание |
|---|---|---|---|
| name | string | да | Название токена: 1–100 знаков после обрезки пробелов. |
| role | string | нет | `member` или `admin`. По умолчанию `member`. |

```json
{
  "name": "выгрузка из CRM",
  "role": "member"
}
```

## Ответ

`201 Created`

| Поле | Тип | Описание |
|---|---|---|
| id | string (UUID) | Идентификатор токена — нужен для отзыва. |
| name | string | Название. |
| token | string | Полный токен. Показывается один раз. |

```json
{
  "id": "5f1c2e6a-9d0b-4c3e-8a7f-1b2c3d4e5f60",
  "name": "выгрузка из CRM",
  "token": "vru_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"
}
```

## Ошибки

| Код | Когда |
|---|---|
| 400 | Пустое `name`: «Назовите токен — потом не разберётесь». |
| 400 | `name` длиннее 100 знаков или `role` не из списка `member`/`admin` — стандартное сообщение Zod в `errors[].message`. |
| 400 | Уже десять действующих токенов: «Больше десяти действующих токенов не бывает. Отзовите ненужные.» |
| 401 | Нет cookie-сессии. `{"statusCode":401,"message":"Требуется вход в систему","error":"Unauthorized"}` |
| 403 | Запрос пришёл по токену API: «Это действие доступно только человеку, вошедшему в кабинет. По токену API его выполнить нельзя.» |
| 403 | Роль `member`: «Недостаточно прав для этого действия». |
| 403 | Возможности `api` нет в плане: ««Доступ по API-токену» не входит в ваш план. Напишите нам — добавим.» Срок плана вышел: «Срок плана «…» закончился, поэтому раздел «Доступ по API-токену» сейчас недоступен. Уже выданные документы остаются действительными. Напишите нам — обсудим продление.» См. [Аутентификация](../../authentication.md) |
| 413 | Тело больше 1 МБ. |

## Пример

```bash
curl -X POST "https://vruchay.ru/api/tokens" \
  -b cookies.txt \
  -H "Content-Type: application/json" \
  -d '{"name":"выгрузка из CRM","role":"member"}'
```

---

<!-- endpoints/validation/post-documents-id-validation-exclude -->
> method: POST | path: /api/documents/{id}/validation/exclude | auth: token | roles: any | rate_limit: «80 запросов за 5 минут: токен — на организацию, вход в кабинет — с одного IP»

# POST /api/documents/{id}/validation/exclude

Снимает отметку к выпуску (`checked: false`) с перечисленных строк — «выпустить только чистые». Строки остаются в таблице со своими данными; вернуть их в выпуск можно через `POST …/recipients/checked` или `PATCH …/recipients/rows/{rowId}`. Идентификаторы чужих или несуществующих строк молча пропускаются.

Действие записывается в журнал аудита организации (`validation.exclude`).

## Запрос

| Параметр | Тип | Обязателен | Описание |
|---|---|---|---|
| `id` | UUID (путь) | да | Идентификатор документа |

Тело:

| Поле | Тип | Обязательно | Описание |
|---|---|---|---|
| `rowIds` | UUID[] | да | 1–5000 идентификаторов строк (например, `rows[].rowId` из отчёта проверки) |

```json
{ "rowIds": ["b1c2…", "c3d4…"] }
```

## Ответ

`201 Created`

| Поле | Тип | Описание |
|---|---|---|
| `excluded` | number | Со скольких строк снята отметка |

```json
{ "excluded": 2 }
```

## Ошибки

| Код | Когда |
|---|---|
| 400 | `Некорректный идентификатор` — `id` не UUID |
| 400 | `rowIds` пуст или больше 5000; элемент не UUID (сообщения Zod) |
| 401 | Нет токена или сессии |
| 404 | `Документ не найден` |
| 429 | `Слишком много попыток. Повторите через N с.` — больше 80 запросов за 5 минут: по токену — от организации, из кабинета — с одного IP |

## Пример

```bash
curl -X POST "https://vruchay.ru/api/documents/$DOCUMENT_ID/validation/exclude" \
  -H "Authorization: Bearer $VRUCHAY_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"rowIds":["'$ROW_ID'"]}'
```

---

<!-- endpoints/validation/post-documents-id-validation-fix -->
> method: POST | path: /api/documents/{id}/validation/fix | auth: token | roles: any | rate_limit: «80 запросов за 5 минут: токен — на организацию, вход в кабинет — с одного IP»

# POST /api/documents/{id}/validation/fix

Записывает новые значения в отдельные ячейки — по одной или сразу во многих строках (например, «ФИО прописными» во всех трёхстах строках протокола). Все правки идут одной транзакцией. Идентификаторы строк из другого документа или несуществующие молча пропускаются; по `updated` видно, сколько строк реально изменено. Несколько правок одной строки объединяются, повторная правка одной ячейки перекрывает предыдущую. Существование колонки не проверяется.

Действие записывается в журнал аудита организации (`validation.fix`): число строк и имена колонок, без значений.

## Запрос

| Параметр | Тип | Обязателен | Описание |
|---|---|---|---|
| `id` | UUID (путь) | да | Идентификатор документа |

Тело:

| Поле | Тип | Обязательно | Описание |
|---|---|---|---|
| `fixes` | array | да | 1–5000 правок |
| `fixes[].rowId` | UUID | да | Строка |
| `fixes[].column` | string | да | Имя колонки: латинские буквы, цифры, `_`, первый символ буква |
| `fixes[].value` | string | да | Новое значение, до 1000 символов |

```json
{
  "fixes": [
    { "rowId": "b1c2…", "column": "name", "value": "Иванова Мария Сергеевна" },
    { "rowId": "c3d4…", "column": "email", "value": "petrov@vruchay.ru" }
  ]
}
```

## Ответ

`201 Created`

| Поле | Тип | Описание |
|---|---|---|
| `updated` | number | Сколько строк изменено |

```json
{ "updated": 2 }
```

## Ошибки

| Код | Когда |
|---|---|
| 400 | `Некорректный идентификатор` — `id` не UUID |
| 400 | `Недопустимое имя колонки` — `column` не по шаблону |
| 400 | `fixes` пуст или больше 5000; `rowId` не UUID; `value` длиннее 1000 символов (сообщения Zod) |
| 401 | Нет токена или сессии |
| 404 | `Документ не найден` |
| 429 | `Слишком много попыток. Повторите через N с.` — больше 80 запросов за 5 минут: по токену — от организации, из кабинета — с одного IP |

## Пример

```bash
curl -X POST "https://vruchay.ru/api/documents/$DOCUMENT_ID/validation/fix" \
  -H "Authorization: Bearer $VRUCHAY_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"fixes":[{"rowId":"'$ROW_ID'","column":"name","value":"Иванова Мария Сергеевна"}]}'
```

---

<!-- endpoints/validation/post-documents-id-validation -->
> method: POST | path: /api/documents/{id}/validation | auth: token | roles: any | rate_limit: «40 запросов за 5 минут: токен — на организацию, вход в кабинет — с одного IP»

# POST /api/documents/{id}/validation

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

За один запрос разбирается не больше 5000 строк (`MAX_VALIDATED_ROWS`). Если в выбранной области строк больше, отчёт содержит оговорку в `caveats` о непроверенном хвосте, а `quota.adding` при этом считается по всем строкам области — именно столько уйдёт в выпуск. Коды проблем и их серьёзность описаны в [справочнике кодов проверки](../../reference/validation-problem-codes.md). Правила награждения (места, группы) проверка не разбирает и говорит об этом в `caveats`.

## Запрос

| Параметр | Тип | Обязателен | Описание |
|---|---|---|---|
| `id` | UUID (путь) | да | Идентификатор документа |

Тело:

| Поле | Тип | Обязательно | Описание |
|---|---|---|---|
| `scope` | `checked` \| `all` | нет | Какие строки смотреть: только отмеченные к выпуску (по умолчанию) или всю таблицу |

```json
{ "scope": "checked" }
```

## Ответ

`201 Created`

| Поле | Тип | Описание |
|---|---|---|
| `total` | number | Сколько строк проверено (не больше 5000) |
| `clean` | number | Строк без единой проблемы |
| `blocked` | number | Строк с блокирующей проблемой (`overflow`, `required_empty`, `email_invalid`) — их выпускать нельзя |
| `rows` | array | Только строки с проблемами; чистые в ответ не входят |
| `rows[].rowId` | UUID | Строка таблицы |
| `rows[].position` | number | Номер строки в таблице, с единицы |
| `rows[].title` | string | ФИО из колонки `name` или «Строка N» |
| `rows[].problems[].code` | string | Код проблемы, см. справочник |
| `rows[].problems[].column` | string \| null | Колонка, к которой относится проблема, если она одна |
| `rows[].problems[].detail` | string | Что именно не так, с подставленными числами и значениями |
| `rows[].problems[].suggestion` | string (необязательно) | Чем заменить значение — подходит для `POST …/validation/fix` |
| `quota.plan` | `free` \| `paid` | `free` на бесплатной пробе, `paid` на плане и на оплаченном тарифе старого образца — как в `GET /api/org/usage` |
| `quota.used` | number | Уже выпущено документов: на плане — с даты его начала, иначе за всё время |
| `quota.limit` | number \| null | Предел: объём плана или бесплатная проба с бонусами; `null` — предела нет (оплаченный тариф старого образца) |
| `quota.left` | number \| null | Остаток `max(0, limit − used)`; `null` — предела нет |
| `quota.adding` | number | Сколько строк в выбранной области уйдёт в выпуск (без предела в 5000) |
| `caveats` | string[] | Чего проверка не знает: непроверенный хвост, нечитаемые листы, правила награждения, автомасштаб и т. п. |

В `quota` нет ни срока плана, ни брони незаконченных выпусков. Хватит ли остатка, выпуск решает сравнением `used + бронь + adding ≤ limit`, а истёкший план (`expired: true` в `GET /api/org/usage`) отказывает при любом остатке — проверьте это там.

```json
{
  "total": 120,
  "clean": 117,
  "blocked": 1,
  "rows": [
    {
      "rowId": "b1c2…",
      "position": 7,
      "title": "ИВАНОВА МАРИЯ СЕРГЕЕВНА",
      "problems": [
        {
          "code": "name_uppercase",
          "column": "name",
          "detail": "«ИВАНОВА МАРИЯ СЕРГЕЕВНА» записано прописными",
          "suggestion": "Иванова Мария Сергеевна"
        }
      ]
    },
    {
      "rowId": "c3d4…",
      "position": 15,
      "title": "Петров Олег Иванович",
      "problems": [
        { "code": "email_invalid", "column": "email", "detail": "«petrov@example» не похож на адрес почты" }
      ]
    }
  ],
  "quota": { "plan": "free", "used": 10, "limit": 50, "left": 40, "adding": 120 },
  "caveats": [
    "Занятые места и правила награждения проверка не смотрела: набор правил на вкладке «Правила» она не разбирает"
  ]
}
```

## Ошибки

| Код | Когда |
|---|---|
| 400 | `Некорректный идентификатор` — `id` не UUID |
| 400 | `scope` не из списка `checked`, `all` (сообщение Zod для enum) |
| 401 | Нет токена или сессии |
| 404 | `Документ не найден` — документа нет, он удалён или принадлежит другой организации |
| 429 | `Слишком много попыток. Повторите через N с.` — больше 40 запросов за 5 минут: по токену — от организации, из кабинета — с одного IP |

## Пример

```bash
curl -X POST "https://vruchay.ru/api/documents/$DOCUMENT_ID/validation" \
  -H "Authorization: Bearer $VRUCHAY_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"scope":"checked"}'
```

---

<!-- reference/award-rules -->
# Набор правил награждения

Контракт живёт в `packages/shared/src/awards/rules.ts` (схемы `awardRuleSet`, `awardRule`,
`awardCondition`, `awardOutput`) и `plan.ts` (форма раскладки `AwardPlan`). Его читают
конструктор в кабинете, движок раскладки на сервере и превью. Версия формата —
`schemaVersion: 1` (`AWARD_RULES_SCHEMA_VERSION`).

Набор принадлежит организации, а не документу: один набор применяется к каждому
следующему протоколу через привязку `POST /api/documents/{id}/awards/rule-set`.

Правила сейчас считают раскладку, но не выпускают. `POST …/awards/preview`
показывает, кому какой шаблон полагается, а выпуск (`POST …/generate`) печатает
отмеченные строки по листам своего документа и `outputs[]` не читает. Документы
по шаблонам из раскладки выпускают в самих шаблонах: строки, которым шаблон
полагается, загружают в его таблицу получателей.

## Набор правил на входе (`POST /api/award-rules`, `PATCH`, `preview.ruleSet`)

| Поле | Тип | Обязательно | Описание |
|---|---|---|---|
| `name` | string | да | 1–200 символов после обрезки пробелов. Ошибка: «Введите название набора» |
| `groupColumn` | string | нет | имя колонки таблицы получателей (`^[a-zA-Z][a-zA-Z0-9_]*$`, до 64) или `""` — весь протокол одна группа. По умолчанию `""`. Группа задаёт область дедупликации, подпись строки в отчёте и круг строк для `withinGroup` |
| `statusColumn` | string | нет | имя колонки со статусом (DSQ/DNS/«снят») или `""`. По умолчанию `""` |
| `isDefault` | boolean | нет | предлагать по умолчанию для нового мероприятия. По умолчанию `false`. Набор по умолчанию у организации один: при сохранении с `true` признак снимается с остальных |
| `rules` | Rule[] | нет | до 50 правил; **порядок в массиве — приоритет**, `position` сервер проставляет сам. По умолчанию `[]` |

### Правило (`Rule`)

| Поле | Тип | Обязательно | Описание |
|---|---|---|---|
| `id` | UUID | да | задаёт клиент; сервер пересоздаёт правила набора целиком, поэтому идентификатор живёт только внутри своего набора |
| `enabled` | boolean | нет | по умолчанию `true` |
| `label` | string | нет | до 120 символов, подпись в превью («Победители»). По умолчанию `""` |
| `match` | `all` \| `any` | нет | как соединять условия. По умолчанию `all` |
| `conditions` | Condition[] | да | до 20; **пустой список — правило «иначе», срабатывает всегда** |
| `action` | `issue` \| `skip` | нет | `issue` — в раскладке строке полагаются `outputs`; `skip` — ничего не полагается, дальше правила не смотрятся. По умолчанию `issue` |
| `outputs` | Output[] | нет | до 10. По умолчанию `[]` |

Выигрывает первое совпавшее правило сверху вниз.

### Условие (`Condition`) — различается по `op`

Во всех вариантах `field` — имя колонки (`^[a-zA-Z][a-zA-Z0-9_]*$`, 1–64; ошибки «Выберите колонку», «Некорректное имя колонки»). Сравнение строк — без учёта регистра, ё = е, лишние пробелы схлопываются.

| `op` | `value` | Дополнительно | Смысл |
|---|---|---|---|
| `equals` | string ≤ 200 | | значение равно |
| `notEquals` | string ≤ 200 | | значение не равно |
| `oneOf` | string[] 1…50, каждая ≤ 200 | | одно из. Ошибка: «Добавьте хотя бы одно значение» |
| `contains` | string 1…200 | | содержит подстроку |
| `filled` | — | | ячейка не пуста |
| `empty` | — | | ячейка пуста |
| `placeEquals` | integer 1…300 | `withinGroup: boolean` (по умолчанию `false`) | место равно числу; делёжка «2-3» равна и 2, и 3 |
| `placeBetween` | `{ from: 1…300, to: 1…300 }`, `from ≤ to` («Начало интервала больше конца») | `withinGroup: boolean` | место в интервале включительно |
| `statusIn` | массив 1…6 из `ok`, `dsq`, `dns`, `dnf`, `dnq`, `withdrawn` | | статус из словаря; написания из файла («дисквалифицирован», «DQ») приводятся к коду |

`withinGroup: true` — считать место внутри группы по порядку строк, а не брать из ячейки.
Нужно только для протоколов со сквозной нумерацией; в обычном протоколе места уже
расставлены по группам.

### Выход (`Output`)

| Поле | Тип | Обязательно | Описание |
|---|---|---|---|
| `templateDocumentId` | UUID | да | документ-шаблон **той же организации**, не в корзине. Ошибка: «Выберите шаблон»; чужой или удалённый — 400 «Выбранный шаблон недоступен: он удалён или лежит в корзине» |
| `subjectColumn` | string | нет | `""` (по умолчанию) — документ самому участнику; иначе имя колонки, откуда брать получателя (`coach`, `team`). По значению этой колонки идёт дедупликация |
| `dedupeScope` | `all` \| `group` | нет | где искать повторы получателя. По умолчанию `all` |
| `label` | string | нет | до 120, подпись для превью («Благодарность наставнику»). По умолчанию `""` |

## Набор правил в ответе (`AwardRuleSet`)

Так его отдают `GET /api/award-rules/{id}`, `POST`, `PATCH`, `duplicate` и `suggest`.

| Поле | Тип | Описание |
|---|---|---|
| `id` | UUID | |
| `name` | string | |
| `schemaVersion` | integer | версия формата, сейчас 1 |
| `groupColumn` | string | `""` — не выбрана |
| `statusColumn` | string | `""` — не выбрана |
| `rules` | Rule[] | каждое правило с полями `id`, `position` (0…200, по порядку), `enabled`, `label`, `match`, `conditions`, `action`, `outputs` — уже с подставленными значениями по умолчанию |

Пример:

```json
{
  "id": "7c1f7d1e-2b5e-4c3a-9c1a-8d2f0a6b1e11",
  "name": "Награждение по протоколу",
  "schemaVersion": 1,
  "groupColumn": "category",
  "statusColumn": "status",
  "rules": [
    {
      "id": "0b6a1d2e-1111-4a5b-8c9d-0e1f2a3b4c5d",
      "position": 0,
      "enabled": true,
      "label": "Снятые и не стартовавшие",
      "match": "any",
      "conditions": [{ "field": "status", "op": "statusIn", "value": ["dsq", "dns", "dnf", "dnq", "withdrawn"] }],
      "action": "skip",
      "outputs": []
    },
    {
      "id": "1c7b2e3f-2222-4b6c-9d0e-1f2a3b4c5d6e",
      "position": 1,
      "enabled": true,
      "label": "Победители",
      "match": "all",
      "conditions": [{ "field": "place", "op": "placeEquals", "value": 1, "withinGroup": false }],
      "action": "issue",
      "outputs": [
        { "templateDocumentId": "3a9e0c4d-3333-4c7d-8e1f-2a3b4c5d6e7f", "subjectColumn": "", "dedupeScope": "all", "label": "" }
      ]
    },
    {
      "id": "2d8c3f40-4444-4c8e-9f20-3b4c5d6e7f80",
      "position": 2,
      "enabled": true,
      "label": "Все остальные участники",
      "match": "all",
      "conditions": [],
      "action": "issue",
      "outputs": [
        { "templateDocumentId": "4b0f1d5e-5555-4d8e-9f21-4c5d6e7f8091", "subjectColumn": "", "dedupeScope": "all", "label": "" }
      ]
    }
  ]
}
```

## Раскладка (`AwardPlan`) — ответ `POST /api/documents/{id}/awards/preview`

Считается по строкам получателей документа с `checked: true` (не больше 5000), в порядке
`position`. План самодостаточен: в нём подписи правил и названия шаблонов на момент расчёта.

| Поле | Тип | Описание |
|---|---|---|
| `schemaVersion` | integer | версия формата правил |
| `items` | PlanItem[] | запланированные документы |
| `summary` | `{ templateDocumentId, templateTitle, ruleLabels: string[], count }[]` | сводка «шаблон — сколько» |
| `issues` | Issue[] | замечания по строкам |
| `totals.rows` | integer | строк рассмотрено |
| `totals.issuingRows` | integer | строк, по которым выдаётся хоть один документ |
| `totals.excludedRows` | integer | снято правилом `skip` |
| `totals.unmatchedRows` | integer | не подошло ни под одно правило |
| `totals.blockedRows` | integer | остановлено ошибкой до правил (например, неизвестный статус) |
| `totals.documents` | integer | документов после дедупликации |
| `totals.deduplicated` | integer | сколько документов дедупликация убрала |

`PlanItem`: `rowId`, `rowNumber` (с единицы), `group` (string \| null), `subject` (кому —
ФИО или значение колонки получателя), `templateDocumentId`, `templateTitle`, `ruleId`,
`ruleLabel`, `outputLabel`, `mergedRowIds` (строки, слитые дедупликацией в этот документ).

`Issue`: `code`, `severity` (`error` мешает выпуску, `warning` — нет), `rowId`, `rowNumber`,
`group`, `subject`, `message` (готовое пояснение по-русски), `ruleId?`.

Коды `Issue.code`: `no-rule` (ни одно правило не совпало), `excluded-by-rule` (сработал
`skip`), `missing-group` (колонка группы выбрана, а в строке пуста), `unparsable-place`,
`unknown-status`, `no-template` (шаблон удалён или недоступен), `empty-subject` (колонка
получателя в строке пуста), `duplicate-subject` (несколько строк дали одного получателя).

Серьёзность у кода всегда одна и та же: `error` — `no-rule`, `unknown-status`, `no-template`,
`empty-subject` (по такой строке документ не выйдет); `warning` — `excluded-by-rule`,
`missing-group`, `unparsable-place`, `duplicate-subject` (выпуску не мешают, но человек должен
их увидеть).

## Замечания к набору (`problems`) — второе поле ответа превью

Рядом с планом `POST /api/documents/{id}/awards/preview` отдаёт `problems: string[]` —
готовые к показу претензии к самому набору, а не к строкам (функция `lintRuleSet`):

- «Правило «…» срабатывает всегда, поэтому правила под ним никогда не проверятся. Перенесите его в конец» — включённое правило без условий стоит не последним;
- «У правила «…» не выбран ни один шаблон» — `action: issue` без `outputs`;
- «В правиле «…» повторы ищутся внутри группы, но колонка группы не выбрана» — `dedupeScope: group` при пустом `groupColumn`;
- «Правило «…» проверяет статус, но колонка статуса не выбрана» — условие `statusIn` при пустом `statusColumn`;
- «Правило «…» считает место внутри группы, но колонка группы не выбрана — место посчитается по всему протоколу» — `withinGroup: true` при пустом `groupColumn`;
- «Отмечено строк: N, в раскладку взяты первые 5000…» — протокол обрезан пределом раскладки.

Повторы в списке убираются, порядок — по правилам сверху вниз.

---

<!-- reference/generation-job -->
# Задание на выпуск

Форма записи о выпуске, которую возвращают `POST /api/documents/{id}/generate`,
`POST /api/jobs/{jobId}/resume`, `POST /api/jobs/{jobId}/cancel`,
`GET /api/jobs/{jobId}` и `GET /api/documents/{id}/jobs` (массивом). Это запись
таблицы `generation_jobs` целиком, без отбора полей
(`apps/server/prisma/schema.prisma`, модель `GenerationJob`).

Все даты — строки ISO 8601 в UTC.

| Поле | Тип | Описание |
|---|---|---|
| `id` | string (UUID) | Идентификатор задания. Им адресуются продолжение, отмена, состояние, отчёт о неудачах и скачивание. |
| `orgId` | string (UUID) | Организация, которой принадлежит выпуск. |
| `documentId` | string (UUID) | Материал, по макету которого идёт выпуск. |
| `format` | `pdf` \| `jpg` | В чём выпускаются документы. Задаётся при запуске и потом не меняется. |
| `status` | `queued` \| `running` \| `done` \| `failed` \| `canceled` | Состояние задания, см. ниже. |
| `kind` | `batch` \| `single` | Вид задания, см. ниже. Список выпусков отдаёт только `batch`; `single` видно по `jobId` из выдачи по API. |
| `idempotencyKey` | string \| null | Ключ повтора из заголовка `Idempotency-Key` выдачи по API. У пакетов и заданий формы — null. |
| `sendEmail` | boolean | Выдача по API с письмом (`send: true`). У пакетов и заданий формы — false: письма там решаются иначе. |
| `total` | number | Сколько строк обещано выпустить. При продолжении пересчитывается под сегодняшний список отмеченных строк. |
| `done` | number | Сколько документов уже создано. Считается по фактам — по файлам с байтами в хранилище, а не приращением счётчика. |
| `failed` | number | По скольким строкам записана причина неудачи. Поимённо — `GET /api/jobs/{jobId}/failures`. |
| `error` | string \| null | Что случилось со всем выпуском, по-русски. Заполняется, когда выпуск остановлен общей бедой, не поставился в очередь или закончился с неудачными строками (`Не удалось создать файлов: N`). Null — сказать нечего. |
| `chunks` | number | На сколько частей пакет разрезан в очереди (по 50 строк). Служебное: по нему части снимаются с очереди при отмене. |
| `attempt` | number | Номер попытки, с единицы. Растёт на каждое продолжение — номер входит в идентификаторы частей в очереди. |
| `createdAt` | string (дата) | Когда задание создано. От него же считается признак «зависло». |
| `startedAt` | string (дата) \| null | Когда воркер взял первую часть. Null — за заданием ещё никто не пришёл. |
| `finishedAt` | string (дата) \| null | Когда задание закрылось — успехом, неудачей или отменой. |

## Вид задания

- `batch` — пакет: выпуск по отмеченным строкам, запущенный из кабинета или
  `POST /api/documents/{id}/generate`. Только такие задания показывает
  `GET /api/documents/{id}/jobs`, только они занимают место в пределе
  одновременных пакетов организации и не дают запустить второй выпуск
  по тому же материалу.
- `single` — один документ по заявке с формы (на сайте или по ссылке) или
  по API (`POST /api/documents/{id}/issue`). Заводится вместе со строкой
  получателя (`source: "site"`, `"link"` или `"api"`) и бронирует один документ квоты,
  как пакет — свои строки. Выпуск материала оно не держит: пакет по тому же
  материалу запускается, пока заявки идут. Продолжить или отменить его
  нельзя (`resume` и `cancel` отвечают 400): заявку повторяет сам сервис,
  а после трёх неудачных попыток закрывает и задание, и заявку. Состояние
  выдачи по API в удобном виде — `GET /api/documents/{id}/issue/{jobId}`.

## Состояния

Перечисление `JobStatus` в схеме Prisma:

- `queued` — задание создано, части лежат в очереди, воркер к ним не приступал;
- `running` — воркер взял первую часть; документы создаются;
- `done` — по всем `total` строкам есть исход и хотя бы один документ создан;
- `failed` — выпуск закрыт, но не дал ни одного документа: либо все строки
  упали, либо выпуск остановлен общей бедой (умершее хранилище, упавший
  браузер), либо пакет не удалось поставить в очередь;
- `canceled` — выпуск отменили вручную. Уже созданные документы остаются.

Закрытые состояния — `done`, `failed`, `canceled`; у них заполнено `finishedAt`.

## Признак «зависло»

`GET /api/jobs/{jobId}` добавляет к записи вычисляемое поле `stuck` (boolean).
Оно истинно, когда все четыре условия выполнены разом: `status` — `queued`,
`startedAt` — null, `done` и `failed` — нули, а с `createdAt` прошло 15 минут
и больше (`STUCK_AFTER_MS` в `generation.service.ts`).

Такое задание не идёт медленно, а стоит навсегда: запись о задании коммитится
в базу раньше, чем пакет кладётся в очередь, и моргнувший Redis между этими
шагами оставлял бы вечный прогресс. Зависшее задание принимается
`POST /api/jobs/{jobId}/resume` наравне с упавшим и отменённым. Сервер и сам
раз в пять минут ищет такие задания и поднимает их тем же путём, поэтому
`stuck: true` может рассосаться без вашего участия.

## Что можно делать в каком состоянии

| Состояние | `resume` | `cancel` |
|---|---|---|
| `queued`, `stuck: false` | нет — «Этот выпуск и так идёт» | да |
| `queued`, `stuck: true` | да | да |
| `running` | нет — «Этот выпуск и так идёт» | да |
| `done` | нет — «Этот выпуск уже завершён — продолжать нечего» | нет — «Это задание уже завершено — отменять нечего» |
| `failed` | да | нет |
| `canceled` | да | нет |

Скачивать (`download-options`, `archive`) можно в любом состоянии: отдаются
файлы, созданные к этому моменту.

---

<!-- reference/layout -->
# Макет листа (`layout`)

Контракт живёт в `packages/shared/src/schema/layout.ts` и `rich-text.ts`; им же
пользуются редактор, превью и серверный PDF-рендер. Здесь описано, что принимают
`PATCH /api/documents/{id}/sheets/{sheetId}` в поле `layout` и правка по частям
`PATCH /api/documents/{id}/sheets/{sheetId}/elements` — по Zod-схеме `sheetLayout`.
Текущая версия схемы — `schemaVersion: 3`; сервер проставляет её сам при каждом
сохранении. Версия 3 отличается от второй не формой данных, а печатью: толщина
контура и штрих пунктира фигур (`strokeWidth`, `dash`) печатаются ровно
в миллиметрах. Во второй версии печать считала их пикселями — в 3,78 раза
тоньше; сохранённые фигуры пересчитаны так, что напечатанное не изменилось.

Как этим верстать — арифметика раскладки, кегли, шрифты, полные примеры
и рецепты — в [руководстве «Как свёрстать лист»](../layout-guide.md). Умолчания
каждого вида блока, форматы, шрифты и служебные поля одним ответом отдаёт
`GET /api/layout/catalog`.

Макет — **массив элементов** (не больше 500). Вид элемента задаёт поле `type`:
`text`, `image`, `qr`, `link`, `shape`. Поля со значением по умолчанию можно
не передавать — сервер подставит их и сохранит нормализованный объект.

## Единицы

| Величина | Единица |
|---|---|
| `x`, `y`, `w`, `h`, `padding`, `borderWidth`, `strokeWidth`, `radius`, `dash` | мм; начало координат — левый верхний угол листа, `x` вправо, `y` вниз |
| `fontSize` (блока и марки `textStyle`) | pt; 1 pt = 25,4 / 72 ≈ 0,3528 мм |
| `letterSpacing`, `wordSpacing` | pt |
| `lineHeight` | множитель кегля (1,2 — строка высотой 1,2 кегля) |
| `rotation` | градусы, по часовой стрелке вокруг центра блока |
| цвета | `#RRGGBB` |

## Общие поля любого элемента

| Поле | Тип | Обязательно | Описание |
|---|---|---|---|
| `id` | string | да | 1–100 символов. Схема повторов не запрещает, но правка по частям на листе с повторами отказывает («На листе есть блоки с одинаковым id…»), а проверка листа сообщает `duplicate_id` |
| `type` | `text` \| `image` \| `qr` \| `link` \| `shape` | да | Вид элемента |
| `x`, `y` | number | да | −5000…5000 |
| `w`, `h` | number | да | > 0, не больше 5000 |
| `rotation` | number | нет | −360…360, по умолчанию 0; поворот вокруг центра `(x + w/2, y + h/2)` по часовой стрелке |
| `z` | integer | нет | порядок наложения, −10000…10000, по умолчанию 0. Больший рисуется поверх; при равных `z` поверх тот, кто позже в массиве |
| `opacity` | number | нет | 0…1, по умолчанию 1; прозрачность блока целиком |
| `locked` | boolean | нет | замок от сдвига в редакторе; печатается и правится через API как обычный. По умолчанию `false` |
| `hidden` | boolean | нет | не печатается и не виден на холсте, проверка листа его пропускает — кроме повтора `id`. По умолчанию `false` |
| `groupId` | string \| null | нет | до 100 символов; блоки с одним `groupId` двигаются в редакторе вместе. По умолчанию `null` |
| `name` | string \| null | нет | до 100 символов, подпись в панели слоёв. По умолчанию `null` |
| `props` | object | да | Свойства вида — ниже |

## Как лист печатается

- Лист — белый прямоугольник размера документа. Фон листа (`backgroundFileId`)
  заполняет его целиком **с обрезкой**: картинка масштабируется, пока не закроет
  лист, лишнее по краям срезается поровну. Фон — не элемент макета.
- Видимые элементы рисуются по возрастанию `z`, при равных `z` — в порядке
  массива. `hidden` не рисуются.
- Всё, что за краем листа, обрезается. Текст, не влезший в свой блок,
  обрезается по краю блока.
- `opacity` применяется к блоку целиком, `rotation` — CSS-поворотом вокруг
  центра блока.

## `type: "text"` — текстовый блок

Текст — дерево `props.doc` (см. «Дерево текста» ниже). На вход принимается и строка
`props.text` с полями `%name`: сервер превращает её в дерево (каждая строка через
`\n` — отдельный абзац) и сохраняет в новом виде. Нужно передать **хотя бы одно**
из `text` и `doc`, иначе 400 «У текстового блока нет ни текста, ни дерева». Текст
без оформления — не длиннее 5000 символов («Текст блока длиннее 5000 символов»).

Стили ниже — оформление всего блока; марки внутри дерева накладываются поверх
и сильнее блока.

| Поле | Тип | Обязательно | Описание |
|---|---|---|---|
| `doc` | RichDoc | да* | дерево текста; *либо `text` |
| `text` | string | да* | до 5000 символов, только на входе; *либо `doc` |
| `fontFamily` | string | нет | 1–100 символов из букв, цифр, пробела, `' , _ -`. По умолчанию `PT Sans`. Печатаются только гарнитуры набора (см. ниже) |
| `fontSize` | number | нет | кегль в pt, > 0, не больше 500. По умолчанию 16 |
| `color` | string | нет | `#RRGGBB`, по умолчанию `#000000` |
| `align` | `left` \| `center` \| `right` \| `justify` | нет | по умолчанию `center` |
| `verticalAlign` | `top` \| `middle` \| `bottom` | нет | положение текста по высоте блока, по умолчанию `middle` |
| `lineHeight` | number | нет | **множитель** кегля, > 0, по умолчанию 1.2 |
| `letterSpacing` | number | нет | разрядка **в pt**, добавляется после каждого знака; может быть отрицательной. По умолчанию 0 |
| `bold`, `italic`, `underline`, `strike` | boolean | нет | по умолчанию `false`; полужирный — вес 700, `strike` — зачёркнутый |
| `uppercase` | boolean | нет | прописные оформлением, а не правкой данных. По умолчанию `false` |
| `strokeWidth` | number | нет | обводка букв, мм, 0…2. По умолчанию 0 |
| `strokeColor` | string | нет | `#RRGGBB`, по умолчанию `#ffffff` |
| `autoFit` | boolean | нет | ужимать текст, если не влезает. По умолчанию `false` |
| `padding` | number | нет | отступ от краёв блока, мм, 0…50. По умолчанию 0 |
| `background` | string \| null | нет | заливка блока `#RRGGBB`; `null` — прозрачный (по умолчанию) |
| `borderWidth` | number | нет | рамка блока, мм, 0…5. По умолчанию 0 |
| `borderColor` | string | нет | `#RRGGBB`, по умолчанию `#000000` |
| `shadow` | boolean | нет | мягкая тень под текстом. По умолчанию `false` |

Тексту остаётся `w − 2 × (padding + borderWidth)` в ширину и столько же меньше
в высоту. Высота строки — `fontSize × lineHeight × 0,3528` мм; при `lineHeight`
меньше 1,3 измеритель добавляет выступ выносных `(1,3 − lineHeight) × fontSize ×
0,3528` мм (`CONTENT_AREA_EM` в `packages/shared/src/fonts/measure.ts`).

`autoFit` только уменьшает, ступенями (`packages/shared/src/schema/autofit.ts`):
кегль 100 → 65 %, межстрочный до 1,0 (не больше чем на пятую часть), кегль
до 50 %, разрядка до −3 % кегля. Слова при этом не рвутся: слишком длинное
слово («Константинопольская») уменьшает кегль, пока не встанет в ширину целиком,
перенос допускается только после дефиса. Что не влезло на последней ступени,
обрезается, а слово, не вставшее в ширину и там, рвётся посреди; проверка листа
в обоих случаях сообщает `text_overflow`. Без `autoFit` длинное слово сразу
рвётся по буквам.

### Шрифты

Список — `packages/shared/src/schema/font-families.ts`, он же в
`GET /api/layout/catalog` (`fonts`, по группам `sans`, `serif`, `display`,
`script`, с флагами `bold` и `italic`). Файлы есть только у начертаний 400
и 700. Имя вне набора проходит схему, но печатается системным шрифтом, а проверка
листа отвечает `unknown_font`. Таблица гарнитур — в
[руководстве](../layout-guide.md), раздел 6.

### Дерево текста (`RichDoc`)

Формат совпадает с деревом ProseMirror/TipTap: `{type, attrs, content, marks}`.
Пределы на всё дерево: не больше 1000 узлов («В блоке больше 1000 узлов») и не глубже
6 уровней списков («Списки вложены глубже 6 уровней»).

```
doc
└── content: RichBlock[]          — paragraph | bulletList | orderedList
    paragraph
      attrs: { align: left|center|right|justify|null, indent: 0..8 }
      content: RichInline[]      — text | mergeField | hardBreak
    bulletList
      attrs: { marker: disc|dash|circle|square }      (по умолчанию disc)
      content: listItem[]
    orderedList
      attrs: { numbering: decimal-dot|decimal-paren|upper-roman|lower-alpha, start: 1..9999 }
      content: listItem[]
    listItem
      attrs: { checked: boolean|null }               (null — обычный пункт)
      content: RichBlock[]                            (снова абзацы и списки)
```

Абзацы печатаются друг под другом без отбивки; пустой абзац — пустая строка.
`paragraph.attrs.align: null` — как у блока; `indent` — ступени по 6 мм; маркер
списка занимает 6 мм. Нумерация: `decimal-dot` «1.», `decimal-paren` «1)»,
`upper-roman` «I.», `lower-alpha` «а)»; `checked: true/false` рисует ☑/☐.

Строчные узлы:

| Узел | Поля | Описание |
|---|---|---|
| `text` | `text` (1–5000 символов), `marks?` | обычный прогон текста |
| `mergeField` | `attrs.source` (обязательно, `^[a-zA-Z][a-zA-Z0-9_]*$`, до 100), `attrs.fieldId?` (string \| null, до 100), `attrs.fallback?` (string \| null, до 200), `attrs.format` (`none` \| `upper` \| `lower` \| `title`, по умолчанию `none`), `marks?` | подставляемое поле: `source` — ключ в данных (колонка, постоянное или служебное поле), `fieldId` — стабильный идентификатор колонки (у полей из строки `text` его нет), `fallback` — что напечатать, если значения нет; `format` — регистр значения, `title` — каждое слово с прописной |
| `hardBreak` | `marks?` | перевод строки внутри абзаца |

`attrs` у `paragraph`, `bulletList`, `orderedList`, `listItem` можно не передавать —
подставятся значения по умолчанию.

Марки (`marks`, до 8 на прогон), различаются по `type`:

| Марка | Поля |
|---|---|
| `bold`, `italic`, `underline`, `strike`, `superscript`, `subscript` | только `type` |
| `textStyle` | `attrs`: `color?`, `background?` (`#RRGGBB`, заливка под буквами), `fontFamily?`, `fontSize?` (> 0, ≤ 500 pt), `fontWeight?` (integer 100…900; файлы есть у 400 и 700, прочие берут ближайший), `letterSpacing?`, `wordSpacing?` (−20…100 pt), `transform?` (`uppercase` \| `lowercase` \| `smallcaps`). Все поля необязательны |
| `link` | `attrs.href` (обязательно, до 2000, `http(s)://…`; поля `{{name}}` или `%name` подставляются при печати и кодируются как часть адреса), `attrs.underline` (по умолчанию `true`), `attrs.color?` (`#RRGGBB`) |

Ошибка адреса ссылки: «Ссылка должна начинаться с http:// или https://». Если после
подстановки адрес перестал быть http(s)-адресом (пустое поле в имени сайта), текст
печатается без ссылки.

Минимальный текстовый блок с подстановкой имени:

```json
{
  "id": "name",
  "type": "text",
  "x": 20, "y": 90, "w": 257, "h": 20,
  "props": {
    "fontSize": 32,
    "bold": true,
    "doc": {
      "type": "doc",
      "content": [
        {
          "type": "paragraph",
          "content": [
            { "type": "text", "text": "Награждается " },
            { "type": "mergeField", "attrs": { "source": "name_dat", "format": "none" } }
          ]
        }
      ]
    }
  }
}
```

То же строкой (сервер сохранит как дерево):

```json
{ "id": "name", "type": "text", "x": 20, "y": 90, "w": 257, "h": 20,
  "props": { "text": "Награждается %name_dat", "fontSize": 32, "bold": true } }
```

## `type: "image"` — картинка

| Поле | Тип | Обязательно | Описание |
|---|---|---|---|
| `props.fileId` | UUID | да | Картинка блока, загруженная в документ: `POST /api/documents/{id}/assets` (multipart, поле `file`) или `POST /api/documents/{id}/assets/from-url` с `{"url": "https://…"}`. PNG или JPEG до 20 МБ; ответ — `fileId`, `url`, `widthPx`, `heightPx` |

- `fileId` фона листа (`…/sheets/{sheetId}/background`) **не подходит**: при
  сохранении макета каждая новая картинка проверяется как картинка блока этой
  организации, и фон отвечает 404 «Картинка не найдена». То же — у чужого или
  удалённого файла.
- Картинка вписывается в блок целиком (`object-fit: contain`), без обрезки
  и искажения; лишнее место остаётся пустым. Пропорции блока —
  `h = w × heightPx / widthPx`.
- Картинка, которую печать не смогла загрузить, останавливает выпуск этого
  документа; проверка листа сообщает `missing_image`.

## `type: "qr"` — QR-код

| Поле | Тип | Обязательно | Описание |
|---|---|---|---|
| `props.template` | string | нет | содержимое, до 5000 символов. Пустая строка (по умолчанию) — адрес страницы проверки экземпляра (`https://vruchay.ru/c/<код>`); если проверка у документа выключена (`verifyEnabled: false`), такой QR не печатается вовсе |
| `props.color` | string | нет | цвет точек, `#RRGGBB`, по умолчанию `#000000` |
| `props.background` | string \| null | нет | подложка `#RRGGBB`, по умолчанию `#ffffff`; `null` — прозрачная, код ложится прямо на бланк |
| `props.dotStyle` | `square` \| `rounded` \| `dots` | нет | форма точек, по умолчанию `square` |
| `props.cornerStyle` | `square` \| `rounded` \| `circle` | нет | форма трёх угловых «глазков», по умолчанию `square` |
| `props.cornerColor` | string \| null | нет | цвет «глазков» `#RRGGBB`; `null` (по умолчанию) — как у точек |
| `props.margin` | integer | нет | поле вокруг кода в клетках узора, 0…4, по умолчанию 0; поле внутри того же блока, код становится мельче |
| `props.level` | `L` \| `M` \| `Q` \| `H` | нет | коррекция ошибок: какую долю узора можно закрыть или затереть — 7, 15, 25 или 30 %; по умолчанию `M` |

- В `template` поля пишутся **только** как `%name` (запись `{{name}}` здесь
  не работает) и подставляются как есть, без кодирования для адреса.
  Формы по роду в шаблоне не раскрываются.
- Если содержимое начинается с `http://` или `https://`, QR в PDF ещё
  и кликабелен.
- Код рисуется квадратом по меньшей стороне блока. Проверка листа предупреждает,
  если сторона меньше 15 мм (`small_qr`) и если `w` и `h` различаются больше
  чем на 0,5 мм (`qr_not_square`).
- Если проверка подлинности у документа включена, а QR с пустым шаблоном нет
  ни на одном листе, проверка листа предупреждает `verify_without_qr`: получателю
  нечем проверить документ.

## `type: "link"` — кликабельная область

Невидимый прямоугольник, в PDF становится аннотацией-ссылкой.

| Поле | Тип | Обязательно | Описание |
|---|---|---|---|
| `props.url` | string | да | до 2000 символов, `http://` или `https://`; поля `{{name}}` или `%name` подставляются при печати и кодируются как часть адреса. Ошибка: «Ссылка должна начинаться с http:// или https://». Если после подстановки адреса не вышло, область печатается без ссылки |

## `type: "shape"` — фигура

| Поле | Тип | Обязательно | Описание |
|---|---|---|---|
| `props.kind` | `rect` \| `ellipse` \| `line` | нет | по умолчанию `rect` |
| `props.fill` | string \| null | нет | заливка `#RRGGBB`; `null` (по умолчанию) — без заливки |
| `props.stroke` | string | нет | `#RRGGBB`, по умолчанию `#000000` |
| `props.strokeWidth` | number | нет | мм, 0…20, по умолчанию 0.5; 0 — без обводки |
| `props.radius` | number | нет | скругление углов прямоугольника, мм, 0…100, по умолчанию 0 |
| `props.dash` | number | нет | длина штриха и пробела пунктира, мм, 0…50; 0 (по умолчанию) — сплошная |

- `rect` и `ellipse` вписаны в блок, обводка лежит внутри него.
- `line` — горизонтальная линия через середину блока по высоте: от
  `(x, y + h/2)` до `(x + w, y + h/2)`. Толщину задаёт `strokeWidth`, а не `h`;
  наклонная или вертикальная линия — та же линия с `rotation`.

## Переменные подстановки

Источник — `packages/shared/src/variables.ts`, `constants.ts`, `paired-forms.ts`
и `schema/rich-text-resolve.ts`. Все поля документа и поля его листов, которых
взять неоткуда (`unknown`), перечисляет `GET /api/documents/{id}/fields`.

Синтаксис: в дереве текста — узел `mergeField` с `source: "name"`; в строке `text`
и в `qr.template` — `%name` (латиница, цифры, подчёркивание, начинается с буквы);
в адресах ссылок — `{{name}}` или `%name`.

**Откуда значения**, от слабого к сильному: служебные переменные → постоянные
поля документа → колонки строки таблицы. Колонка перекрывает одноимённое
постоянное поле, постоянное — служебную переменную.

- **Колонки** таблицы получателей: `name` и `email` есть всегда, остальные
  заводятся импортом.
- **Постоянные поля документа** — `PATCH /api/documents/{id}` с
  `constants: [{key, title, value}]` (список целиком). Ключ —
  `^[a-z][a-z0-9_]{0,39}$`, не совпадает со служебными, не больше 30 полей,
  `title` 1–100, `value` до 1000 символов. Готовые раскладки заводят
  `signer_role` и `signer_name` с образцами, если их нет.
- **Мероприятие** — поля документа `eventName`, `eventDate`, `eventPlace`,
  `eventHours`, на листе — `event`, `event_date`, `event_place`, `hours`.

Служебные переменные:

| Имя | Что подставляется |
|---|---|
| `date` | дата выдачи, `04.08.2026` |
| `date_long` | `4 августа 2026 г.` |
| `date_iso` | `2026-08-04` |
| `date_en` | `4 August 2026` |
| `year` | год выдачи |
| `number` | номер строки по списку, с единицы |
| `total` | всего строк в списке |
| `reg_number` | регистрационный номер экземпляра, `142/2026` |
| `code` | проверочный код экземпляра, `K7M2-9QXR-4TVB` (тот же, что в адресе QR) |
| `org` | название организации из настроек кабинета |
| `valid_until` | дата окончания срока, если у материала задан срок |
| `event`, `event_date`, `event_place`, `hours` | поля мероприятия из карточки документа |
| `name_dat`, `name_gen`, `name_short` | ФИО в дательном, в родительном, «Фамилия И. О.» |
| `name_lat_gost`, `name_lat_icao` | ФИО латиницей по ГОСТ 7.79-2000 и по ICAO |
| `place_word` | место словом («первое») из колонки `place`: понимает 1–10 (`1`, `1 место`, `1-е`), остальное — как в ячейке |
| `hours_word` | объём часов прописью («семьдесят два часа») из `hours`, только если там чистое число; иначе — значение как есть |

- ФИО для `name_*` берётся из колонки `name`, а если она пуста — собирается из
  колонок `surname`, `firstname`, `patronymic`. Сам `%name` печатает только
  колонку `name`.
- Своя колонка с именем производного поля (`name_dat`, `place_word`,
  `hours_word`…) сильнее вычисленного значения.
- Дата выдачи берётся из `issueDate` документа, если задана, иначе — момент
  выпуска; время московское.

### Формы по роду

Пишутся в тексте макета (в узлах `text`, не в данных) и раскрываются по полу
строки:

- скобочная форма вплотную к слову, 1–5 букв в скобках: `награждён(а)`,
  `занял(а)`, `участник(-ца)`, `победитель(-ница)`, `занявший(ая)`,
  `прошёл(ла)`. Раскрываются только знакомые сочетания; незнакомое
  («студент(ка)») печатается как есть;
- пара через черту для любых слов: `принял|приняла`, `студент|студентка`.

Пол — из колонки `пол`, `pol`, `gender` или `sex` (`м`, `муж`, `мужской`, `m`,
`male`; `ж`, `жен`, `женский`, `f`, `female`, без учёта регистра, точек
и пробелов), иначе — по ФИО. Пол не определён: скобочная форма остаётся
как написана, пара печатается через косую черту («принял/приняла»).

### Как печатается значение

- **Типографика** подставленных значений (`packages/shared/src/typography.ts`):
  машинописные кавычки → «ёлочки» (вложенные — „лапки“), дефис, отбитый
  пробелами, → тире, дефис между числами → «–», неразрывные пробелы в инициалах,
  у сокращений «г.», «ул.», «д.», «руб.» и др., после «№», перед «%»,
  после однобуквенных предлогов и союзов. Поля `code` и `reg_number`
  печатаются буква в букву. Текст самого макета и `fallback` не обрабатываются.
- `format` у `mergeField` применяется к значению и к `fallback`.
- Поле без значения печатается пустой строкой (или `fallback`).
- Абзац, в котором есть поля и который после подстановки состоит только из
  пустых полей и разделителей (пробелы, `,` `;` `:` `·` `•` `–` `—` `-`),
  на печати исчезает. Разделитель, стоящий ровно между двумя полями, убирается,
  если одно из них пусто: `%event_place, %event_date` без даты даёт «г. Москва».
  Абзац с набранными словами («Тренер: %coach») остаётся.

## Правка по частям и версии

- `PATCH /api/documents/{id}/sheets/{sheetId}/elements` с `add`, `update`,
  `remove` (до 500 каждого) — порядок удалить → поменять → добавить; `update`
  заменяет верхние поля и сливает `props` на один уровень (новый `props.text`
  заменяет всё дерево); `type` и `id` правкой не меняются. Новые блоки без `id`,
  `z`, `w`/`h`, `x`/`y` получают UUID, место поверх всех, размер по умолчанию
  и место по центру листа. Ответ — `{sheet, problems}`.
- У листа есть `version`, она растёт с каждым сохранением. `baseVersion`
  в правке по частям и в `PATCH …/sheets/{sheetId}` требует, чтобы лист был
  ровно этой версии, иначе 409 с полем `version` — текущей версией.
- `POST /api/documents/{id}/sheets/{sheetId}/check` — проверка листа (коды
  замечаний — в [руководстве](../layout-guide.md), раздел 12),
  `POST …/preview` — JPEG листа, `POST …/preset` — готовая раскладка.

## Размер листа

Задаётся не в макете, а у документа: `pageWidthMm` × `pageHeightMm` (50…600 мм
каждая сторона; по умолчанию 297 × 210 — A4 альбомный). `POST /api/documents`
принимает и `format` (`a3`, `a4`, `a5`, `a6`, `letter`, `legal`, `square`, `wide`)
с `orientation` (`portrait` | `landscape`, без неё — альбомная); формат сильнее
миллиметров. Готовые форматы (`page-sizes.ts`, книжные): A3 297×420, A4 210×297,
A5 148×210, A6 105×148, US Letter 216×279, US Legal 216×356, квадрат 210×210,
экран 16:9 167×297; альбомный — переворот сторон.

Смена размера — `PATCH /api/documents/{id}` с `pageWidthMm`, `pageHeightMm`
и `resize`: `keep` (по умолчанию, блоки не трогаются), `scale` (всё
пропорционально, по центру) или `reposition` (центры блоков на тех же долях
листа). Безопасные поля (`page-fit.ts`): обрез 3 мм, не печатается ближе 5 мм
к краю.

---

<!-- reference/registry-filter -->
# Отбор в реестре выданного

Один и тот же набор параметров принимают `GET /api/registry`,
`GET /api/registry/analytics`, `GET /api/registry/export.csv`,
`GET /api/registry/archive` (строкой запроса) и `POST /api/registry/revoke`,
`POST /api/registry/revoke/preview` (объектом `filter` в теле). Условие
выборки одно на всех (`apps/server/src/registry/registry-filter.ts`), поэтому
таблица, сводка, выгрузка и архив по одному отбору всегда содержат одни и те же
документы.

Схема — `registryFilterSchema` в `apps/server/src/registry/registry.dto.ts`.
Все параметры необязательны.

| Параметр | Тип | Описание |
|---|---|---|
| `search` | string, до 200 знаков | Поиск по имени и адресу почты получателя («содержит», без учёта регистра). Если строка целиком — UUID или короткий проверочный код (в любом написании: строчные, без дефисов, `O` вместо нуля), ищется точное совпадение по коду. |
| `documentId` | UUID | Материал, по макету которого выпущен документ. Ошибка: «Некорректный идентификатор материала». |
| `event` | string, до 200 знаков | Мероприятие — точное совпадение со строкой `eventName` в карточке материала. Значения берите из `GET /api/registry/facets`. |
| `projectId` | UUID \| `none` | Проект материала ([`GET /api/projects`](../endpoints/projects/get-projects.md)); `none` — материалы без проекта. Сочетается с `event` и `documentId`: оба условия на материал действуют вместе. Проект другой организации или несуществующий — 404 «Проект не найден». Ошибка формата: «Некорректный идентификатор проекта». |
| `state` | `valid` \| `revoked` \| `replaced` \| `expired` | Состояние документа. Ошибка: «Неизвестное состояние документа». |
| `mail` | `none` \| `queued` \| `sent` \| `delivered` \| `opened` \| `bounced` \| `failed` | Состояние письма. `none` — писем не было. Остальные значения — «есть хотя бы одно письмо в таком состоянии», а не «последнее письмо в таком состоянии»: у тех, кому переотправляли, отбор может разойтись с колонкой `mail` в строке. Ошибка: «Неизвестное состояние письма». |
| `from` | дата (`YYYY-MM-DD` или ISO 8601) | Начало периода выпуска, включительно. Ошибка: «Не удалось разобрать дату начала периода». |
| `to` | дата | Конец периода выпуска, включительно: «по 31-е» означает всё 31-е число целиком. Ошибка: «Не удалось разобрать дату конца периода». |

Границы периода считаются по календарным дням Москвы (UTC+3): дата
`2026-08-01` в `from` означает `2026-07-31T21:00:00Z`.

Всегда, независимо от параметров, выбираются только выпущенные документы
текущей организации (`kind = generated`), не удалённые и с загруженным файлом.
Документы материалов, лежащих в корзине, в реестре остаются — у них заполнено
поле `retention`.

---

<!-- reference/registry-row -->
# Строка реестра выданного

Форма одной строки, которую возвращают `GET /api/registry` (в `items[]`) и
`GET /api/registry/files/{fileId}` (в `row`). Собирается в
`apps/server/src/registry/registry.service.ts` (`RegistryRow`).

Все даты — строки ISO 8601 в UTC.

| Поле | Тип | Описание |
|---|---|---|
| `fileId` | string (UUID) | Идентификатор выданного файла. Им же адресуются скачивание, карточка, отзыв, перевыпуск и переотправка. |
| `publicId` | string (UUID) | Прежний публичный идентификатор; напечатан в QR у документов, выпущенных до появления короткого кода. |
| `code` | string | Код, напечатанный на бумаге: короткий вида `K7M2-9QXR-4TVB` у новых выпусков, `publicId` у старых. |
| `verifyPath` | string | Путь страницы проверки — тот же, что закодирован в QR: `/c/<код>` или `/verify/<uuid>`. |
| `name` | string | Имя получателя из строки таблицы (`data.name`); пустая строка, если нет. |
| `email` | string | Адрес почты из строки таблицы (`data.email`); пустая строка, если нет. |
| `documentId` | string (UUID) \| null | Материал, по макету которого выпущен документ. Null, если материал удалён окончательно. |
| `documentTitle` | string | Название материала; «Материал удалён», если его больше нет. |
| `eventName` | string | Мероприятие из карточки материала. |
| `eventDate` | string | Дата мероприятия из карточки материала — строкой, как её ввели. |
| `issuedAt` | string (дата) | Когда документ выпущен. |
| `expiresAt` | string (дата) \| null | Когда документ перестаёт действовать. Null — бессрочный. |
| `printedName` | string \| null | Имя, напечатанное на документе, если оно отличается от текущей строки таблицы (строку могли поправить после выпуска). Null — совпадает или снимка нет. |
| `revokedAt` | string (дата) \| null | Когда проверку отозвали. Остаётся и после возврата проверки. |
| `revokedReasonPublic` | string \| null | Причина отзыва, которую видит человек на странице проверки. |
| `revokedReasonInternal` | string \| null | Внутренняя причина. Только владельцу и управляющему; сотруднику с ролью `member` всегда null. |
| `signedAt` | string (дата) \| null | Когда PDF подписан электронной подписью сервиса. Null — без подписи. |
| `state` | `valid` \| `revoked` \| `replaced` \| `expired` | Состояние документа. Выводится из фактов, а не хранится: отзыв сильнее замены, замена сильнее срока. |
| `reissuePending` | boolean | Перевыпуск заказан, но новый документ ещё не выпущен. |
| `replacedBy` | object \| null | Документ, выданный вместо этого: `{ fileId, publicId, code, verifyPath, issuedAt }`. |
| `mail` | object \| null | Последнее письмо с этим документом: `{ status, sentAt, error }`. Null — письмо не отправлялось. `status`: `queued`, `sent`, `delivered`, `opened`, `bounced`, `failed`. |
| `verifyCount` | number | Сколько раз документ проверяли по QR/коду. Обезличенный счётчик. |
| `verifyLastAt` | string (дата) \| null | Когда проверяли в последний раз. |
| `downloadCount` | number | Сколько раз документ скачивали из реестра (по одному или в архиве). |
| `retention` | object \| null | Срок хранения, если материал в корзине: `{ trashedAt, purgeAt, daysLeft }`. Через `trashDays` дней после `trashedAt` (срок корзины организации, от 1 до 90, по умолчанию 7; задаётся в `PATCH /api/org/public-profile`) файлы и записи удаляются. `purgeAt` и `daysLeft` посчитаны по этому сроку. Null — материал не в корзине. |

## Состояния

- `valid` — документ действителен;
- `revoked` — организация отозвала проверку; замены нет;
- `replaced` — выдан новый документ вместо этого (`replacedBy`);
- `expired` — `expiresAt` наступил; документ был настоящим, но подтверждает прошлое.

Документ действителен до самого момента `expiresAt` и перестаёт быть действительным с него.

## Пример

```json
{
  "fileId": "7d4c6e0a-1b2f-4c3d-9e8f-0a1b2c3d4e5f",
  "publicId": "3f9a2b1c-5d6e-4f70-8a9b-0c1d2e3f4a5b",
  "code": "K7M2-9QXR-4TVB",
  "verifyPath": "/c/K7M2-9QXR-4TVB",
  "name": "Иванова Мария Петровна",
  "email": "ivanova@vruchay.ru",
  "documentId": "b1c2d3e4-f5a6-4b7c-8d9e-0f1a2b3c4d5e",
  "documentTitle": "Сертификат участника",
  "eventName": "Городской конкурс «Мастер года»",
  "eventDate": "14.06.2026",
  "issuedAt": "2026-06-15T09:12:44.000Z",
  "expiresAt": null,
  "printedName": null,
  "revokedAt": null,
  "revokedReasonPublic": null,
  "revokedReasonInternal": null,
  "signedAt": "2026-06-15T09:12:45.000Z",
  "state": "valid",
  "reissuePending": false,
  "replacedBy": null,
  "mail": { "status": "delivered", "sentAt": "2026-06-15T09:13:10.000Z", "error": null },
  "verifyCount": 3,
  "verifyLastAt": "2026-08-02T17:40:01.000Z",
  "downloadCount": 1,
  "retention": null
}
```

---

<!-- reference/validation-problem-codes -->
# Коды проблем проверки списка

Возвращаются в `rows[].problems[].code` ответа `POST /api/documents/{id}/validation`. Источник — `packages/shared/src/validation/problems.ts` (`PROBLEM_KINDS`).

Серьёзность: `blocker` — печатать нельзя, получится брак или письмо не уйдёт; `warning` — печатать можно, но человек, скорее всего, ошибся. В `blocked` отчёта считаются строки хотя бы с одной проблемой уровня `blocker`.

| Код | Серьёзность | Название | Чем кончится, если не исправить |
|---|---|---|---|
| `overflow` | blocker | Не влезает в блок | Текст обрежется на печати — часть имени пропадёт с грамоты |
| `required_empty` | blocker | Пустое обязательное поле | На грамоте останется пустое место там, где должно стоять значение |
| `duplicate_name` | warning | Повтор ФИО | Один человек получит две грамоты, если это не тёзки |
| `duplicate_email` | warning | Повтор адреса почты | На один адрес уйдёт несколько писем |
| `email_invalid` | blocker | Неверный адрес почты | Письмо не уйдёт — документ придётся передавать вручную |
| `email_mixed_script` | warning | Русские буквы в адресе | Скорее всего, набрано в русской раскладке и письмо вернётся. У некоторых российских провайдеров такой адрес рабочий — проверьте |
| `date_invalid` | warning | Непонятная дата | На грамоте напечатается ровно то, что в ячейке |
| `name_uppercase` | warning | ФИО прописными | Прописные попадут на грамоту как есть — обычно так выгружают из протокола |
| `name_not_declined` | warning | Падеж не подобрался | В макете имя стоит в дательном падеже, а на этой грамоте останется как в таблице |
| `already_issued` | warning | Уже выдавалось | Этому получателю по этому документу уже выпускали грамоту |

Поле `suggestion` у проблемы есть не всегда: `name_uppercase` и `email_mixed_script` чинятся однозначно и приходят с готовой заменой, `overflow` — правкой макета или руками.

Правила награждения (занятые места, группы) проверка пока не разбирает: `checkAwardingRules` возвращает пустой список, а в `caveats` отчёта всегда есть оговорка «Занятые места и правила награждения проверка не смотрела…».

---
