# Fraim.ru Tenders API V2 — документация для ИИ-агентов

> HTTP API по тендерам и контрактам России (44-ФЗ, 223-ФЗ, коммерческие торги): поиск закупок, живые ленты изменений по сохранённому фильтру, карточки тендеров и контрактов, история по ИНН заказчика и поставщика. Только server-to-server, авторизация Bearer-токеном, оплата — за доставленный объект, а не за запрос.

Документ самодостаточен: его можно целиком положить в контекст модели. Справочник
эндпоинтов, параметры и схемы объектов сгенерированы из OpenAPI-схемы API. Всё, что
схемой не выражается — модель данных, биллинг, формы ошибок, лимиты — описано ниже
по боевому поведению; расхождения между схемой и API оговорены там, где они есть.

- **Base URL:** `https://public.fraim.ru/api/v2`
- **Версия API:** 2.0.0 (OpenAPI 3.1.0, снапшот схемы от 2026-09-10)
- **Этот документ одним файлом:** https://fraim.ru/ai.md
- **Машинная схема (OpenAPI 3.1):** https://public.fraim.ru/api/v2/openapi.json
- **Документация для людей:** https://fraim.ru/docs
- **Личный кабинет (токены, баланс, пополнение и счёт, песочница):** https://lk.fraim.ru
- **Поддержка (ошибки, интеграция, доступы):** support@fraim.ru
- **Любые вопросы (цены, объёмы, договор):** info@fraim.ru

## Кратко: правила интеграции

1. Ходите в API **только с сервера**. Токен — это доступ к балансу; CORS отключён намеренно, из браузера API не работает.
2. Авторизация — заголовок `Authorization: Bearer <token>`. Легаси-заголовок `X-API-Token` не используйте: с ним не работает песочница.
3. Поиск — это `POST /tenders/query` с JSON-телом, а не GET со строкой запроса. Отдельного `/search` нет.
4. Множество задаётся **ровно одним** способом: критерии | `filter_id` | `list_id` (у контрактов ещё `tender_ids`). Смешение → `400 validation_error`.
5. Пагинация только курсорная. Возьмите `next_cursor` из ответа и пришлите `{"cursor": "cur_…"}` — остальные поля повторять не нужно, курсор несёт фильтр внутри. Параметров `page`/`offset` не существует.
6. Выдача — **живой список**, а не снапшот: каждая страница отражает состояние тендеров на момент запроса. Повторный запрос с тем же курсором может вернуть меньше объектов или те же объекты с обновлёнными данными; пустой `results` с `next_cursor` — валидный ответ, а не конец ленты.
7. `total` — **справочное** поле: сколько объектов сейчас подходит под фильтр, а не размер вашей выборки. Считается фоном (пересчёт раз в несколько минут), поэтому меняется между запросами, в том числе прямо во время листания: гарантии «показали 693 — вытащите ровно 693» нет. Конец выборки определяйте по пустому `results`, а не по `total` и не по `next_cursor` (он не бывает пустым — лента живая). `total: null` — нормальный ответ, а не ошибка. Точное число приходит только на запросе по `ids` или `list_id`: это размер списка.
8. Потоки выбираются **один раз, в первом запросе**: `events` попадает в курсор, при чтении с курсором значение из тела игнорируется. Нужны изменения — сразу шлите `events: ["created","updated"]`.
9. `202 preparing` — не ошибка: идёт фоновый сбор. Повторите **тот же** запрос через `retry_after` секунд. `state: "partial"` в 200-ответе значит «читать уже можно, лента ещё дособирается».
10. Опрашивайте фильтр **не реже раза в сутки**: подписка живёт 14 дней от последнего чтения, а новая — первые сутки в пробном режиме. Умершая подписка означает `409` и потерю событий за период молчания.
11. Тарифицируется первая доставка версии объекта, а не запрос. Лишняя пагинация «на всякий случай» стоит денег; пустые ответы и повторы бесплатны.
12. На мутациях (`POST`/`DELETE`) заголовок `Idempotency-Key` обязателен — иначе `400 idempotency_key_required`. Кладите UUID v4 и повторяйте его при ретрае; тот же ключ с ДРУГИМ телом — `409 idempotency_key_reused_with_different_payload`.
13. При `429` дождитесь `Retry-After` и повторите — заголовок есть всегда. Ограничителя два: минутный на аккаунт (`Retry-After: 60`; считается на аккаунт, а не на токен — заводить новые токены ради частоты бессмысленно) и лимит **всплесков по IP** (`Retry-After: 1`). Параллельная выгрузка в несколько потоков упирается во второй раньше: держите 2–3 потока, а не 20.
14. Другого способа аутентификации, кроме вечного API-токена (`fraim_live_…`), нет: возьмите его в кабинете или создайте через `POST /auth/tokens`. Сессия браузера для интеграции не годится.
15. **Объект приходит целиком прямо в ленте** — в `results[].tender` (и `results[].contract`) лежит весь списочный объект, а не заглушка из пары полей. Не делайте `GET /tenders/{id}` на каждый элемент выдачи: это лишние запросы и лишние деньги. Карточка нужна только ради позиций, документов, лотов и полной организации.
16. Тело ошибки вложено в `detail`: `{"detail": {"error": {…}}}` — у всех кодов без исключений, включая `422` и `429`. Особый случай один: у `409 cursor_expired` внутри `detail` рядом с `error` лежит свежий срез ленты (`detail.results`, `detail.next_cursor`).
17. Документы качайте по `download_url` из карточки, а не по `link`. `link` — адрес у первоисточника (ЕИС, площадка, сайт заказчика), он может стать недоступен или потребовать входа на площадку; `download_url` ведёт на наш `downloads.fraim.ru`, работает без заголовка `Authorization`, срок действия у неё не ограничен. Но файл всё равно берётся у первоисточника (недавно скачанный — из нашего кеша), поэтому удалённый у источника документ не отдаст ни одна из ссылок: `410 document_gone`. Документы уже доставленного объекта бесплатны.
18. Слова короче **3 символов** в ключевых и минус-словах игнорируются — как и годы (2000–2099) и предлоги. `["насос жд"]` ищется как `["насос"]`, а `["жд"]` даёт `400`. Отброшенное перечислено в `ignored_keywords` ответа.
19. `keywords` **обязателен** — фильтр без ключевых слов не примут (`400`), кроме случаев, когда множество задано узко через `ids`, `list_id`, `supplier` или `customer`. Одиночные слишком общие слова (`поставка`, `услуга`, `работа`, `лот`, `номер`…) отбрасываются, внутри фразы остаются, в `exception_keywords` не трогаются; если после отбрасывания искать нечего — `400`.
20. Дедуплицируйте у себя по паре `id` + `version`. Поле `billed` отвечает на вопрос «списали ли деньги», а не «видел ли я этот объект».
21. Даты — ISO-8601. По умолчанию `status: ["active"]` и `events: ["created"]`; ленту изменений включайте явно.
22. `regions`, `platforms`, `placement_types`, `purchase_types` принимают **внутренние id Fraim.ru** — это не ОКАТО, не коды регионов на автомобильных номерах и не коды ЕИС. Москва — `1`, Санкт-Петербург — `34`, а `77` — Республика Бурятия. Коды всех четырёх справочников напечатаны ниже, в разделе «Справочники кодов» — берите оттуда. Не угадывайте номер: ошибки не будет, придёт выдача по другому региону.
23. Лишние поля **верхнего уровня** тела запроса игнорируются молча (`page`, `offset`, `status` у контрактов). Опечатка в имени (`region` вместо `regions`) не даст ошибки, а тихо изменит фильтр — сверяйте `applied` в ответе с тем, что вы посылали. Строго проверяются только вложенные `customer` и `supplier`: незнакомое поле там → `422`.

## Аутентификация

Все маршруты требуют токен:

```http
Authorization: Bearer fraim_live_…
```

API-токен вечный — живёт до явного отзыва. Создаётся в личном кабинете или запросом
`POST /auth/tokens`; секрет показывается один раз, дальше виден только маскированный
`token_preview` — короткий hex-хвост вида `064c...3b80`, без префикса `fraim_`.
Активных токенов — до 20. У только что созданного токена `last_used_at` приходит
`null`, хотя схема помечает поле обязательным.

Два окружения различаются **только префиксом токена**, адрес и тело запроса те же:

- `fraim_test_…` — песочница на демо-данных, баланс не списывается;
- `fraim_live_…` — боевые данные, тарифицируется.

Переключение теста и боя определяется по заголовку `Authorization`. Легаси-заголовок
`X-API-Token` ещё принимается для боевых токенов, но с ним тестовый токен уйдёт на
боевой бэкенд и получит 401 — используйте `Bearer`.

## Модель данных

Четыре понятия, без которых ответы API читаются неправильно.

**Фильтр** — сохранённый запрос, определяющий множество закупок. Создаётся
неявно любым QUERY-запросом; `filter_id` — детерминированный хеш
нормализованных параметров, поэтому одинаковые по смыслу запросы (другой
порядок ключей, регистр, дубли) дают один и тот же фильтр.

**Лента (feed)** — поток событий по фильтру. У фильтра две ленты: тендерная и
контрактная. Событие бывает `created` (объект **впервые появился в вашем
фильтре** — неважно, только что опубликован или изменился и стал подходить под
критерии) и `updated` (изменился объект, который уже был в фильтре); потоки
читаются независимо, каждый своим курсором. Поле `event` описывает вашу
историю, а не историю объекта: изменившийся тендер, которого вы раньше не
получали, приедет как `created`.

**Подписка** — связка «вы ↔ фильтр» с TTL: она хранит вашу позицию в ленте и
продлевается любым чтением.

**Курсор** — непрозрачная подписанная строка `cur_<payload>.<sig>`: позиция в
конкретной ленте для конкретного набора событий. Возвращайте её как есть.
Курсор идёт по оси **индексации** (когда объект обнаружен нами), а не по датам
публикации, поэтому «отставшая» закупка не проваливается за курсор, а доезжает
следующим запросом. Хронологию сортируйте у себя.

**Версия** — счётчик изменений объекта. Пара `id` + `version` — ключ
дедупликации на вашей стороне.

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

### Как работают ключевые слова

Поиск идёт не по подстроке, а по **нормализованным словам**: текст разбивается
на слова и приводится к начальной форме, поэтому «поставка насосов» находит
«поставку насоса». Многословный элемент `keywords` — это фраза (слова должны
встретиться вместе), разные элементы списка — ИЛИ.

**Слово короче 3 символов в поиске не участвует** — оно выбрасывается вместе с
остальным «шумом»:

- слова из 1–2 символов (`жд`, `ip`, `а`);
- годы (`2024`, `2025` и любой год 2000–2099);
- служебные части речи — предлоги, союзы, частицы (`для`, `и`, `с`).

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

- коды с буквами: `ГОСТ 12345-78`, `ГОСТ Р 52948-2008`, `38-09/зк`,
  `М-24-10`, `Ф10.5-2`, `5w-40`, `10w-30`, `20w-50`;
- число + дефис/слэш + число: `123-456`, `2024-2025`, `123/456`,
  `220401/123`;
- артикулы с несколькими дефисами: `12312-12312-выв`, `123-4567-890`,
  `ABC-123-XYZ`;
- размеры с `x`/`х`: `12.5x20`, `10x20x30 мм`, `12х20`;
- обозначения с подчёркиванием: `abc_123`, `файл_2024`;
- смешанные буквенно-цифровые обозначения: `ДИТ-2024-ГП`, `приказ-123`,
  `SN123456`, `S/N12345678`, `abc123`, `123asd`, `asd123`;
- дефисные цепочки: `8-800-555-35-35`.

**Из того, что эти обозначения не игнорируются, не следует, что ими стоит
сужать поиск.** Номер ГОСТа, ТУ или другой точный код почти всегда есть
только в приложенной документации к закупке, а не в тексте самой карточки —
такой запрос находит единицы объектов вместо всей номенклатуры. Правильный
паттерн: широкое ключевое слово (`кабель силовой`, а не `ГОСТ 32144-2013`;
`электротехническая продукция`, а не `ТУ 27.32.13-001-12345678-2022`) плюс
последующий отбор найденного — вручную, через ИИ-анализ карточек или через
`exception_keywords`. См. также таблицу антипаттернов ниже.

Что из этого следует на практике:

| Запрос | Что реально ищется |
|---|---|
| `"keywords": ["насос жд"]` | `насос` — короткое слово из фразы выпадает |
| `"keywords": ["насос 2025"]` | `насос` — год выпадает |
| `"keywords": ["насос", "жд"]` | `насос` — второй элемент пустой и игнорируется |
| `"keywords": ["жд"]` | ничего не остаётся → `400` «no searchable words» |
| `"exception_keywords": ["жд"]` | ничего не исключается |

Всё выброшенное перечислено в поле `ignored_keywords` ответа
(`[{keyword, reason}]`) — по нему видно, что именно осталось от запроса.
Короткие обозначения (`жд`, `кс`, `ip`) в фильтр закладывать бессмысленно:
ищите по полному слову (`железнодорожный`) или отбирайте такие закупки у себя
после выдачи.

### Слишком общий запрос не принимается

Фильтр — это подписка на ленту, а не разовая выгрузка реестра. Запрос, под
который подходит половина закупок страны, даёт нечитаемую ленту, поэтому
действуют четыре ограничения.

**`keywords` обязателен.** Запрос без них — `400`. Исключение: множество
задано узко другим способом — `ids`, `list_id`, `supplier` или любое поле
`customer`. «Все активные закупки Москвы» фильтром быть не может, «все закупки
заказчика по ИНН» — может.

**Слишком общие слова выбрасываются.** `поставка` встречается у 41.8% активных
закупок, `лот` — у 19.6%, `услуга` — у 17.1%: как фильтр они не сужают
ничего. В том же списке `работа`, `оказание`, `выполнение`, `вид`, `тип`,
`номер`, `заявка`, `нужда`, `закупка` и ещё около двадцати служебных слов.
Отбрасывается только элемент **из одного слова**: внутри фразы такое слово
остаётся.

Список целиком, сверка идёт по начальной форме (`поставки`,
`поставку`, `поставок` — одно и то же слово):

```
адрес, вид, выполнение, год, закупка, заявка, использование, количество, лот, назначение, наличие, номер, нужда, обеспечение, область, общий, объем, оказание, описание, определение, поставка, применение, проведение, работа, размер, соответствие, тип, услуга, форма, часть, являться
```

**В минус-словах не выбрасывается ничего.** `exception_keywords: ["поставка"]`
работает как написано: «услуги, но не поставки» — осмысленный запрос.

**Если после этого искать нечего — `400`,** а не пустая выдача.

| Запрос | Результат |
|---|---|
| `{"keywords": ["поставка", "насос"]}` | ищется `["насос"]`, `ignored_keywords` содержит `поставка` / `too_common` |
| `{"keywords": ["поставка труб стальных"]}` | ищется целиком — это фраза, а не одно слово |
| `{"keywords": ["поставка"]}` | `400` «query is too general» |
| `{"status": ["active"], "regions": [1]}` | `400` «keywords: required» |
| `{"customer": {"inn": ["7707083893"]}}` | принимается: фильтр узкий и без слов |
| `{"exception_keywords": ["ремонт"]}` | `400`: минус-слова только вместе с `keywords` |

Чистка происходит **до** вычисления `filter_id`, поэтому
`["поставка","насос"]` и `["насос"]` — один и тот же фильтр: одна подписка
вместо двух. На стоимость это не влияет — тарифицируется первая доставка версии
объекта, и один и тот же тендер не списывается дважды, даже если подходит под
несколько ваших фильтров.

### Как читать выдачу: это живой список

**Выдача всегда актуальна.** Каждая страница отражает текущее состояние тендеров
на момент запроса. Если фильтр задан со `status: ["active"]`, в выдаче будут
только тендеры, активные прямо сейчас: тендер, который завершился после
попадания в вашу подписку, из выдачи исчезает автоматически. За такие тендеры
баланс не списывается.

**Страницы не повторяются байт-в-байт.** Повторный запрос с тем же курсором
может вернуть меньше объектов (часть перестала подходить под фильтр) или те же
объекты с обновлёнными данными. Это нормальное поведение живой ленты, а не
ошибка. Не стройте логику на том, что страница воспроизводится.

**Пустая страница — валидный ответ.** `results: []` с `next_cursor` означает
«на этом отрезке ленты сейчас нет подходящих объектов, продолжайте с нового
курсора», а не «лента кончилась».

### Гарантии курсора

- Каждое событие ленты доставляется **не более одного раза**. Повтор запроса со
  старым курсором не приводит ни к дублям, ни к повторным списаниям.
- События **не теряются**: всё, что появилось в ленте после вашей позиции, будет
  выдано при следующих чтениях — независимо от того, как часто вы опрашиваете
  (в пределах срока жизни подписки).
- Тендер, который вошёл в фильтр и вышел из него **между вашими опросами**
  (например, успел завершиться), не доставляется — на него уже нельзя
  отреагировать.
- `409 cursor_expired` — лента была пересобрана (подписка истекала). Тело ответа
  содержит свежий срез и новый `next_cursor` — под ключом `detail`:
  продолжайте с него. История за период отсутствия недоступна.
- `402 balance_exhausted` — пополните баланс и повторите запрос **с тем же
  курсором**: выдача продолжится с первого неоплаченного объекта.

### Подписки и TTL

- Подписка на фильтр продлевается **любым чтением** и живёт 14 дней от
  последнего запроса.
- **Новая** подписка первые сутки живёт в пробном режиме: если вы не повторили
  запрос в течение 24 часов, она удаляется. Первый же запрос спустя 3+ часа
  после создания переводит её на полный срок (14 дней).
- Рекомендация: опрашивайте фильтр **не реже раза в сутки**. Если подписка
  умерла, ваш курсор вернёт `409` со свежим срезом — события за пропущенный
  период будут недоступны.

### Состояния ответа

| `state` | HTTP | Что делать |
|---|---|---|
| `ready` | 200 | Обычный ответ, лента собрана. |
| `partial` | 200 | Часть ленты уже доступна, читайте курсором — продолжение доедет; `total` может отсутствовать. |
| `preparing` | 202 | Читать нечего, идёт сбор. Повторите тот же запрос через `retry_after` секунд; `progress` — доля собранной ленты 0..1. |

Под пиковой нагрузкой на **поиск** API не отказывает, а отвечает `202 preparing`.
С частотой запросов это не связано: `429` приходит от ограничителей частоты — вашего
минутного лимита на аккаунт (`Retry-After: 60`) или лимита всплесков по IP
(`Retry-After: 1`). Параллельная выгрузка в несколько потоков упирается во второй
раньше. Оба отвечают обычным JSON-конвертом ошибки.

### Конверт ответа QUERY

```json
{
  "filter_id": "flt_6a40ebde3711b9b2",
  "state": "ready",
  "applied": { "events": ["created"], "status": ["active"] },
  "results": [
    { "event": "created", "version": 1, "billed": true,
      "tender": {
        "id": 4821337,
        "etp_id": "0173200001425000456",
        "name": "Ремонт кровли школы №12",
        "price": 4850000.0,
        "publish_date": "2026-08-01T09:00:00Z",
        "start_date": "2026-08-01T09:00:00Z",
        "end_date": "2026-08-14T23:59:00Z",
        "update_date": "2026-08-05T12:30:00Z",
        "end_date_computed": false,
        "positions_inserted": 10,
        "source_url": "https://zakupki.gov.ru/…",
        "region": { "id": 1, "name": "Москва" },
        "platform": { "id": 3, "name": "РТС-тендер" },
        "placement_type": { "id": 7, "name": "Электронный аукцион" },
        "purchase_type": { "id": 1, "name": "Закупка по 44ФЗ" },
        "status": { "id": 1, "name": "Подача заявок" },
        "organization": { "name": "ГБОУ Школа №12", "inn": "7707083893",
                          "kpp": "770701001", "ogrn": "1037739…" }
      } }
  ],
  "next_cursor": "cur_eyJ…",
  "total": 5000,
  "billed": { "created": 50, "updated": 0, "free": 0,
              "period_total": 50, "balance_exhausted": false },
  "expires_at": "2026-08-12T09:00:00Z"
}
```

Про поля конверта, о которые чаще всего спотыкаются:

- `applied` — что API реально применил после нормализации. Форма **непостоянна**:
  у запроса критериями там есть `status`, у запроса с `list_id` или `ids` — нет, а
  у контрактной ленты появляется `status: ["completed"]`, хотя параметра `status` у
  контрактов не существует. Полезен как проверка «меня правильно поняли»: лишнее поле
  верхнего уровня в запросе игнорируется молча, и разойтись с ожиданием можно тихо.
- `total` — **справочное** число: сколько закупок сейчас подходит под фильтр
  («столько в базе под эти условия»), а не размер вашей выборки. Считается **фоном**,
  не в момент запроса: отдельная задача пересчитывает каждый фильтр раз в несколько
  минут, а запрос отдаёт последнее посчитанное значение — поэтому `total` не
  замедляет ответ и поэтому же он живой. Между двумя запросами число меняется, в том
  числе прямо во время листания: гарантии «показали 693 — вытащите ровно 693» нет.
  **Конец выборки по `total` не определяют** — конец это пустой `results`
  (`next_cursor` пустым не бывает никогда: лента живая, и по тому же курсору позже
  приедут новые тендеры). Может быть `null`, и это нормальный ответ, а не ошибка —
  клиент обязан его переживать: фильтр слишком тяжёлый, чтобы его считать (например,
  «все завершённые» — миллионы записей), лента ещё собирается (`preparing` или
  `partial`), поток `updated` (конечного числа там не существует) или значение ещё
  ни разу не посчитано. Исключение — запрос по `ids` или по сохранённому списку
  (`list_id`): там `total` точный, это размер списка.
- `freshness.last_polled_at` (только контрактная лента) — честная нижняя граница
  момента, когда мы последний раз опрашивали источник по этому множеству.
- `request_id` в теле успешного ответа не приходит — он отдаётся заголовком
  `X-Request-Id`. В теле ошибок он дополнительно лежит в `error.request_id`, и по
  нему же сходятся строки `GET /balance/ledger`.

### Что лежит в results[] — объект целиком

Элемент ленты — это конверт события `{event, version, billed}` и **весь
списочный объект** в поле `tender` (у контрактов — `contract`). Отдельный
`GET` на каждый результат делать не нужно: он ничего не добавит, кроме
позиций, документов, лотов и полной организации, зато потратит запросы.

Поля `tender` в ленте: `id`, `etp_id` (**бывает `null`** — у части
агрегированных лотов нет id на исходной площадке, не вешайте NOT NULL),
`name`, `price` (число, валюты нет), `publish_date`, `start_date`,
`end_date`, `update_date`, `end_date_computed`,
`positions_inserted` (сколько у закупки позиций; сами позиции отдаёт
карточка), `source_url`,
`advance` (размер аванса по контракту, % от цены, 0–100; **не обеспечение**
заявки или контракта; `0` — аванса нет или не указан, заполняется только у
закупок из ЕИС),
`region {id, name}`, `platform {id, name}`,
`placement_type {id, name}` (способ размещения),
`purchase_type {id, name}` (закон: 44ФЗ/223ФЗ/…),
`status {id, name}` — объект, не
строка, `organization {name, inn, kpp, ogrn}`.

Поля `contract` в ленте: `id`, `reestr_number`, `contract_id`,
`notice_number`, `name`, `price`, `currency` — **объект**
`{code, name}`, а не строка, `contract_type {id, name}`,
`conclusion_date`, `execution_date`, `registry_date`, `changed_at`,
`status` (здесь как раз строка, в отличие от тендера), `suppliers[]`
(`name, inn, kpp, country_code, address, postal_address, phone, email, status`),
`source_url` и `tender` — **усечённый** блок родительской закупки
`{id, etp_id, name}`, а не закупка целиком: за остальным идите в
`GET /tenders/{id}`.

`GET /tenders/{id}` и `GET /contracts/{id}` отвечают **тем же конвертом**
(`{event, version, billed, tender|contract}`), а не плоским объектом.

Карточка **тендера** добавляет: `lots[] {ordinal, title, price}`,
`positions[]`, `positions_total/positions_page/positions_page_size/positions_pages`,
`documents[] {title, link, extension}`, `documents_total`,
`purchase_plan_id`, `placement_type`, `purchase_type` и организацию с
`address` и `administrator` (последний бывает `null`).
Позиция — `{lot_ordinal, position_ordinal, title, characteristics, quantity,
unit, price, amount, okpd2 {code, name}, ktru}`; `ktru` бывает `null`, а
`okpd2` приходит объектом даже когда кода нет: `{"code": null, "name": null}`.

Карточка **контракта** добавляет: `positions[]` и
`positions_total/positions_page/positions_page_size/positions_pages` (страница
задаётся `positions_page`/`positions_limit`), `documents[]`,
`documents_total`, `execution`, `customer`, а также `ikz`,
`plan_position_number`, `result_date`, `penalty_withholding`,
`version_number`, `is_changed`, `changed_fields`. Позиция контракта —
`{position_ordinal, title, characteristics, quantity, unit, price, amount,
okpd2, ktru, nds, mnn, is_vital_drug}`.

**Список живёт до явного удаления.** `DELETE /lists/{list_id}` убирает его вместе с
составом и гасит подписку на ленту фильтра-над-списком. Уже доставленные объекты
остаются доставленными — повторно за них деньги не списываются. `PATCH` нет: поля
после создания неизменяемы, меняется только состав.

**Состав проверяется.** `POST /lists/{list_id}/items` с несуществующим `tender_id`
отвечает `400 validation_error` («ids: unknown tender ids [...]») и **не применяет
пакет частично** — либо входят все id, либо ни одного.

Коллекции (`/lists`, `/filters`, `/balance/ledger`) отвечают другим конвертом:
`{ "data": [...], "has_more": false, "next_cursor": null }`, продолжение —
параметром `starting_after`. У `GET /lists/{list_id}` этот конверт **вложен**
в поле `items`, а `items.data` — просто массив `tender_id` (числа, без дат
добавления).

## Биллинг

Оплата пообъектная, лицензий и суточных лимитов нет. Балансы раздельные:
`tenders` и `contracts`.

- Тарифицируется **первая доставка версии объекта**, а не запрос: пустые
  ответы, поллинг и ретраи той же страницы бесплатны. Повторное получение того
  же объекта (тот же фильтр, другой фильтр, карточка, повтор курсора) —
  бесплатно, `billed: false`.
- Платны оба события — `created` и `updated` (каждая новая версия).
- Тендеры, исключённые из выдачи из-за смены статуса, **не списываются**.
- Поле `total` — справочная фоновая оценка «сколько сейчас подходит под фильтр»,
  а не счётчик оплаченного и не размер выборки; бывает `null`. Планировать
  расходы по нему нельзя: платите вы за то, что реально пролистали.
- Оплата постранично: нашли 5000, забрали две страницы по 50 — заплатили за 100.
  Глубину пагинации контролирует клиент.
- Дедуп между фильтрами: объект под двумя вашими фильтрами тарифицируется один раз.
- `GET /tenders/{id}` и `GET /contracts/{id}` списывают, только если эта версия
  вам ещё не доставлялась.
- При нехватке баланса страница отдаётся частично: `billed.balance_exhausted: true`,
  `next_cursor` указывает перед первым неоплаченным объектом, HTTP 200. Если
  доставить не удалось ничего — `402 balance_exhausted`. После пополнения
  чтение продолжается с того же места без потерь и дублей.
- **Документы** объекта, который вам уже доставлен, скачиваются бесплатно —
  сколько угодно раз. Если объект ещё не доставлялся, первое скачивание его
  документа тарифицируется как доставка самого объекта (одна единица), после
  чего остальные его документы бесплатны.
- Каждое списание объяснимо построчно: `GET /balance/ledger` (объект, версия,
  фильтр, `request_id`).

## Документы закупок и контрактов

У тендера и контракта есть прикреплённые файлы — документация, ТЗ, проекты
контрактов, акты. Они приходят в **карточке** объекта (`GET /tenders/{id}`,
`GET /contracts/{id}`) в массиве `documents[]`; в ленте (`QUERY`) документов нет.

```json
{
  "ordinal": 1,
  "title": "Приложение 3. Обоснование НМЦК",
  "extension": "xlsx",
  "link": "https://zakupki.gov.ru/44fz/filestore/public/1.0/download/priz/file.html?uid=…",
  "id": "d6e9f213bc379a60315d1",
  "download_url": "https://downloads.fraim.ru/f/MzQ6ZDZlOWYyMTNiYzM3…"
}
```

**Качайте по `download_url`, а не по `link`.** `link` — это адрес у
первоисточника (ЕИС, электронная площадка, сайт заказчика): он может быть
недоступен, отдать HTML-страницу вместо файла или потребовать входа на
площадку. `download_url` ведёт на наш сервер и отдаёт файл с корректным именем, а
недавно скачанный — из нашего кеша, без обращения к источнику. Но сам файл
живёт у первоисточника: если он его убрал, документ не отдаст ни одна из
ссылок (`410 document_gone`).

### Две двери к одному файлу

**1. Готовая ссылка (`download_url`).** Постоянная, персональная, **без
срока годности** и **без заголовка `Authorization`** — её можно открыть в
браузере, положить в письмо, в задачу или в CRM. Сама ссылка бессрочна, но
ведёт за файлом к первоисточнику, а он свои файлы иногда убирает: нужен
документ надолго — храните файл, а не ссылку. Ссылка подписана для вашего
аккаунта: по чужой ссылке ваш документ не скачают, и наоборот.

**2. Bearer (для серверного кода).**

```http
GET https://downloads.fraim.ru/d/{document_id}
Authorization: Bearer fraim_live_…
```

Тот же файл по `id` документа из карточки. Рядом —
`GET https://downloads.fraim.ru/d/{document_id}/meta`: состояние документа (`state`,
`filename`, `size`, `content_type`, `sha256`) **без** скачивания.

### Оплата

Документы объекта, который вам уже доставлен, — **бесплатны**, сколько угодно
раз. Если объект ещё не доставлялся, первое скачивание его документа
тарифицируется как доставка объекта (одна единица соответствующего баланса),
дальше все его документы бесплатны. При нулевом балансе — `402 balance_exhausted`.

### Когда файла нет

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

| HTTP | `code` | Что делать |
|---|---|---|
| 410 | `document_gone` | Файл удалён у источника. Повторять бессмысленно. |
| 409 | `document_restricted_at_source` | Площадка отдаёт файл только своим участникам. |
| 409 | `document_not_a_file` | По ссылке источника сейчас страница, а не файл. |
| 503 | `document_temporarily_unavailable` | Источник недоступен. Повторить по `Retry-After`. |
| 413 | `document_too_large` | Документ больше допустимого размера. |

Ни один из этих ответов не списывает баланс.

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

- Скачивание тестовым токеном (`fraim_test_…`) недоступно:
  `409 document_unavailable_in_sandbox` — в песочнице демо-данные, а файлы
  лежат у боевых источников.
- Формат ошибок тот же, что у API: тело вложено в `detail`.
- `id` документа стабилен, пока у объекта не изменился состав документов;
  надёжный порядок работы — брать свежую карточку и `download_url` из неё.

## Типовые сценарии

### 1. Разовый поиск закупок

`POST /tenders/query` с критериями → читайте `results`, при необходимости
следующая страница по `next_cursor`. Ограничивайте выдачу критериями, а не
пагинацией: каждая страница платная.

### 2. Мониторинг новых закупок (основной сценарий)

1. Первый запрос с критериями — получаете первый срез и `next_cursor`.
2. Сохраняете **только** `next_cursor`.
3. По расписанию (например, раз в 5–15 минут) шлёте `{"cursor": "<сохранённый>"}`.
   Реже раза в сутки опрашивать нельзя — умрёт подписка.
4. Пустой `results` — нормально и бесплатно; `next_cursor` обновляете всегда.
5. `409 cursor_expired` — не ошибка: свежий срез и новый курсор уже в теле,
   под ключом `detail` (`detail.results`, `detail.next_cursor`).

### 3. Отслеживание изменений

То же самое, но `events` задаётся **в первом запросе**: `"events": ["created","updated"]`,
если нужны и новые, и изменившиеся, или `["updated"]` — только изменения. Дальше
значение зашито в курсор и в теле игнорируется; сменить набор потоков можно
только начав ленту заново. Потоки независимы — держите по курсору на каждый.

### 4. Контракты поставщика или заказчика по ИНН

- Контракты конкретного поставщика: `POST /contracts/query` с `{"supplier": {"inn": ["…"]}}`.
- Контракты заказчика: те же критерии закупок — `{"customer": {"inn": ["…"]}}`.
- Можно переиспользовать `filter_id` тендерного фильтра: передайте его в
  `POST /contracts/query` явно — контрактная проекция того же множества соберётся.
  `status` на контрактную выдачу не влияет, отдельный фильтр «со status completed»
  заводить не нужно.
- Но `filter_id` в **ответах** двух лент совпадает не всегда. В контрактный хеш
  всегда входит `status: ["completed"]`, а тендерный по умолчанию берёт
  `["active"]` — поэтому одно и то же тело даёт **разные** id. Пошлите в
  `/tenders/query` явный `status: ["completed"]` — и id совпадут. Не сравнивайте
  id двух ответов вслепую: идентификатором множества служит тот, что вы передали сами.
- Первое обращение к контрактной проекции может вернуть `202 preparing` — повторите.

### 5. Свой список объектов

`POST /lists` (нужен `Idempotency-Key`) → `POST /lists/{list_id}/items` →
дальше `POST /tenders/query` с `{"list_id": "…"}`: над списком работает такая же
лента, как над критериями.

## Рабочие примеры

### Поиск (curl)

```bash
curl -X POST https://public.fraim.ru/api/v2/tenders/query \
  -H "Authorization: Bearer $FRAIM_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "keywords": ["ремонт кровли", "асфальт"],
    "exception_keywords": ["ямочный"],
    "regions": [1],
    "price": {"from": 100000, "to": 5000000},
    "status": ["active"],
    "limit": 50
  }'
```

### Продолжение ленты (curl)

```bash
curl -X POST https://public.fraim.ru/api/v2/tenders/query \
  -H "Authorization: Bearer $FRAIM_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"cursor": "cur_eyJ…", "limit": 50}'
```

### Цикл мониторинга (Python)

```python
import os, time, requests

API = "https://public.fraim.ru/api/v2"
H = {"Authorization": f"Bearer {os.environ['FRAIM_TOKEN']}"}

def poll(cursor):
    """Один шаг ленты. Возвращает (события, новый курсор)."""
    body = {"cursor": cursor} if cursor else {
        "keywords": ["асфальт"], "regions": [1], "status": ["active"], "limit": 100,
    }
    r = requests.post(f"{API}/tenders/query", json=body, headers=H, timeout=60)

    if r.status_code == 202:                       # идёт сбор — тот же запрос позже
        time.sleep(r.json().get("retry_after", 5))
        return [], cursor
    if r.status_code == 429:                       # лимит частоты: минутный (60)
        time.sleep(int(r.headers.get("Retry-After", 60)))   # или всплеск (1)
        return [], cursor
    if r.status_code == 409:                       # лента пересобрана: срез уже в теле,
        body = r.json()["detail"]                  # но под detail, а не наверху
        return body["results"], body["next_cursor"]
    if r.status_code == 402:                       # баланс кончился
        raise SystemExit(r.json()["detail"]["error"]["message"])

    r.raise_for_status()
    data = r.json()
    if data["billed"]["balance_exhausted"]:
        raise SystemExit("баланс исчерпан — пополните и продолжите с того же курсора")
    return data["results"], data["next_cursor"]

cursor = None
while True:
    events, cursor = poll(cursor)               # cursor сохраняйте в свою БД
    for e in events:
        upsert(e["tender"], version=e["version"])   # дедуп по (id, version)
    time.sleep(300)
```

### Контракты поставщика по ИНН (Node.js)

```js
const res = await fetch("https://public.fraim.ru/api/v2/contracts/query", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.FRAIM_TOKEN}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({ supplier: { inn: ["7701234567"] }, limit: 100 }),
})
if (res.status === 202) {
  // проекция контрактов ещё собирается — повторить тот же запрос
}
const { results, next_cursor } = await res.json()
```

### Создание списка (мутация с Idempotency-Key)

```bash
KEY=$(uuidgen)   # один ключ на операцию, тот же при ретрае
curl -X POST https://public.fraim.ru/api/v2/lists \
  -H "Authorization: Bearer $FRAIM_TOKEN" \
  -H "Idempotency-Key: $KEY" \
  -H "Content-Type: application/json" \
  -d '{"name": "Проект Юг", "metadata": {"project_id": 42}}'
```

## Справочник эндпоинтов

Всего маршрутов: 17 — все ниже, сгенерированы из OpenAPI-схемы.
Ответ `422` (ошибка валидации тела) возможен у любого маршрута с параметрами и ниже не повторяется.

### Auth

| Метод и путь | Назначение |
|---|---|
| `GET /auth/tokens` | Список API токенов |
| `POST /auth/tokens` | Создать API токен (для API V2) |
| `DELETE /auth/tokens/{token_id}` | Удалить API токен |

#### GET /auth/tokens — Список API токенов

Получить список всех API токенов пользователя

#### POST /auth/tokens — Создать API токен (для API V2)

Создаёт **бессрочный** API-токен — его и используют в своём коде. Требует авторизации: подойдёт сессия или уже имеющийся токен. Секрет возвращается **один раз**, повторно его не показать. Активных токенов у пользователя не больше **20**: на следующем приходит `409 api_key_limit_reached`, отзовите ненужный через `DELETE /auth/tokens/{token_id}`. Лимит частоты запросов считается на пользователя, а не на токен, — выпуск дополнительных токенов частоту не увеличивает.

| Параметр | Где | Тип | Обяз. | Описание и ограничения |
|---|---|---|---|---|
| `Idempotency-Key` | header | `string` | да | Ключ идемпотентности (UUID v4), один на логическую операцию. Без него — 400 idempotency_key_required; тот же ключ с другим телом — 409 idempotency_key_reused_with_different_payload. |

Тело запроса — `application/json`, схема `TokenCreateRequest`:

| Поле | Тип | Обяз. | Описание и ограничения |
|---|---|---|---|
| `name` | `string` | да | Название токена; длина 1–100 |

#### DELETE /auth/tokens/{token_id} — Удалить API токен

Деактивирует API токен. Удалять можно только свои токены; на чужой или несуществующий id — `404`. `token_id = 0` — заглушечный id сессии кабинета. Успех и no-op он даёт только когда запрос сделан САМОЙ сессией (отзывать нечего). Тот же запрос с API-токеном получит `404`: строки с таким id в реестре ключей нет.

| Параметр | Где | Тип | Обяз. | Описание и ограничения |
|---|---|---|---|---|
| `token_id` | path | `integer` | да | — |
| `Idempotency-Key` | header | `string` | да | Ключ идемпотентности (UUID v4), один на логическую операцию. Без него — 400 idempotency_key_required; тот же ключ с другим телом — 409 idempotency_key_reused_with_different_payload. |

### Tenders

| Метод и путь | Назначение |
|---|---|
| `POST /tenders/query` | Поиск и лента тендеров |
| `GET /tenders/{tender_id}` | Карточка тендера |

#### POST /tenders/query — Поиск и лента тендеров

Единственный способ найти тендеры и следить за изменениями по ним. Ровно один способ задать множество: критерии поиска (keywords/regions/platforms/price/publish_date/customer_inn/status/ids) | filter_id | list_id — смешение способов даёт 400. Курсор из предыдущего ответа продолжает тот же поток событий (created/updated), заданный при первом запросе; events из тела игнорируется, если передан курсор. **Ключевые слова ищутся по нормализованным словам**, а не по подстроке: «поставка насосов» находит «поставку насоса». Многословный элемент `keywords` — фраза (слова должны встретиться вместе), разные элементы списка — ИЛИ. **Слова короче 3 символов в поиске не участвуют** — вместе с годами (2000–2099) и служебными частями речи они молча выбрасываются: `["насос жд"]` ищется как `["насос"]`, `["жд"]` теперь отвечает 400 (искать нечего), а короткое минус-слово ничего не исключает. Что именно выброшено — в поле `ignored_keywords` ответа. **Слишком общие слова не принимаются.** Однословный элемент `keywords` из стоп-листа выбрасывается — он не сужает выборку; внутри фразы из нескольких слов такое слово остаётся. Список целиком (сверка по начальной форме): адрес, вид, выполнение, год, закупка, заявка, использование, количество, лот, назначение, наличие, номер, нужда, обеспечение, область, общий, объем, оказание, описание, определение, поставка, применение, проведение, работа, размер, соответствие, тип, услуга, форма, часть, являться. Если после этого искать нечего — 400. `keywords` обязателен, если множество не задано через `ids`/`list`/`supplier`/`customer_*`; `exception_keywords` — только вместе с непустым `keywords`. **Выдача — живой список, а не снапшот.** Каждая страница отражает состояние тендеров на момент запроса: при `status: ["active"]` в ней только активные прямо сейчас, а завершившийся тендер исчезает из выдачи сам и баланс за него не списывается. Поэтому повторный запрос с тем же курсором может вернуть меньше объектов или те же объекты с обновлёнными данными — это нормально. Дедупликация на вашей стороне — по паре `id` + `version`, а не по составу страницы. **Пустой `results` с `next_cursor` — валидный ответ**, а не конец ленты: «на этом отрезке сейчас нет подходящих объектов, продолжайте с нового курсора». Пустые ответы бесплатны. **Гарантии.** Каждое событие доставляется не более одного раза: повтор со старым курсором не даёт ни дублей, ни повторных списаний. События не теряются — всё, что появилось после вашей позиции, выдастся при следующих чтениях (в пределах срока жизни подписки, см. GET /filters). Не доставляется только тендер, который вошёл в фильтр и вышел из него между вашими опросами. **В `results[].tender` объект приходит целиком** (name, price, даты, region, platform, status, organization) — `GET /tenders/{id}` на каждый элемент ленты делать не нужно.

Тело запроса — `application/json`, схема `TenderQueryRequest`:

| Поле | Тип | Обяз. | Описание и ограничения |
|---|---|---|---|
| `events` | `string[]` | нет | Какие потоки читать. `created` — объект ВПЕРВЫЕ появился в вашем фильтре (неважно, только что опубликован или изменился и стал подходить под критерии); `updated` — изменился объект, который уже был в фильтре. По умолчанию `["created"]` — «новое по моей теме». Задаётся в ПЕРВОМ запросе: набор потоков запоминает курсор, при чтении с курсором значение из тела игнорируется. Поле `event` в конверте результата считается относительно ВАС: `created` — вы видите объект впервые, `updated` — уже получали его раньше (возможно, через другой фильтр или карточку).; значения: created \| updated |
| `cursor` | `string` | нет | Продолжение ленты. Непрозрачная подписанная строка из `next_cursor` предыдущего ответа — возвращайте как есть, не разбирайте. Несёт фильтр и набор потоков внутри: остальные поля тела повторять не нужно. |
| `limit` | `integer` | нет | диапазон 1–100; по умолчанию `100` |
| `keywords` | `string[]` | нет | Ключевые слова/фразы (ИЛИ: закупка подходит при совпадении любого элемента; многословный элемент ищется как фраза) |
| `exception_keywords` | `string[]` | нет | Минус-слова: исключить закупки с этими словами |
| `sections` | `integer[]` | нет | Где искать keywords и exception_keywords — части тендера: 1 наименование закупки, 2 наименование лота, 3 наименование позиции, 4 характеристики позиции. По умолчанию (поле опущено или пустой список) — во всех четырёх. Типовое применение: [1,2,3] — не искать по характеристикам товаров, где часто срабатывают ложные совпадения. Действует только вместе с keywords/exception_keywords; сами по себе секции множество не задают — иначе 400 validation_error |
| `regions` | `integer[]` | нет | ID регионов. Нумерация ВНУТРЕННЯЯ (не ОКАТО, не коды на номерах): Москва — 1, Воронежская область — 3, а 77 — Республика Бурятия. Справочник: https://fraim.ru/refs.json (раздел «Справочники кодов» в https://fraim.ru/docs) |
| `platforms` | `integer[]` | нет | ID площадок (ЭТП). Нумерация внутренняя. Ходовые площадки — в https://fraim.ru/refs.json; там их не все, остальные видны в поле platform {id, name} выдачи |
| `placement_types` | `integer[]` | нет | ID способов размещения закупки. Нумерация внутренняя, справочник — https://fraim.ru/refs.json |
| `purchase_types` | `integer[]` | нет | ID типов закупки. Нумерация внутренняя, справочник — https://fraim.ru/refs.json |
| `price` | `RangeFilter` | нет | Начальная цена ЗАКУПКИ (НМЦК), не цена контракта |
| `advance` | `RangeFilter` | нет | Размер аванса по контракту, % от цены (0–100) — поле «Размер аванса, %» извещения в ЕИС. Это НЕ обеспечение заявки и НЕ обеспечение контракта. 0 — аванс не предусмотрен или не указан; заполняется только у закупок из ЕИС (44-ФЗ/223-ФЗ), у коммерческих всегда 0. `{"from": 1}` — только закупки с авансом, `{"to": 0}` — без аванса |
| `publish_date` | `DateRangeFilter` | нет | Дата публикации ЗАКУПКИ |
| `start_date` | `DateRangeFilter` | нет | Дата начала подачи заявок по ЗАКУПКЕ |
| `end_date` | `DateRangeFilter` | нет | Дата окончания подачи заявок по ЗАКУПКЕ |
| `customer` | `CustomerFilter` | нет | Заказчик закупки: inn[] / ogrn / name |
| `status` | `string[]` | нет | Статусы закупок, по умолчанию `["active"]`. Выдача живая: тендер, сменивший статус и переставший подходить под фильтр, исчезает из неё сам и НЕ тарифицируется. На контрактную выдачу (QUERY /contracts) этот параметр не влияет; значения: active \| completed \| cancelled |
| `ids` | `integer[]` | нет | — |
| `filter_id` | `string` | нет | — |
| `list_id` | `string` | нет | — |

#### GET /tenders/{tender_id} — Карточка тендера

Полная информация об одном тендере: описание, лоты, позиции (постранично: `positions_page`/`positions_limit`, с okpd2/ktru), документы. Структура объекта — как в старом API v2 (эталон). `id_type=internal` (по умолчанию) — внутренний id, `id_type=external` — id закупки на площадке (etp_id). Списание — только если версия объекта ещё не доставлялась (общая дедупликация с лентами QUERY /tenders). **Ответ завёрнут в тот же конверт события, что и элемент ленты:** `{event, version, billed, tender}`, а не плоский объект. **Ходить сюда за каждым элементом ленты не нужно.** В `results[].tender` у QUERY /tenders объект уже целиком; карточка добавляет ровно то, чего в ленте нет: `lots`, `positions[]` + `positions_total/page/page_size/pages`, `documents[]` (`ordinal`/`title`/`link`/`id`/`download_url`/`extension`) + `documents_total`, `purchase_plan_id` и организацию с `address` и `administrator` (`placement_type`/`purchase_type` с 12.08.2026 и `advance` с 10.09.2026 есть и в ленте). **Документы качаются через наши сервера.** `download_url` — постоянная персональная ссылка на `downloads.fraim.ru`: работает без заголовка авторизации, срока годности нет. Тот же документ доступен по `GET https://downloads.fraim.ru/d/{id}` с обычным Bearer-токеном, состояние без скачивания — `…/d/{id}/meta`. Документы уже доставленного объекта бесплатны; если объект ещё не доставлялся, первое скачивание тарифицируется как его доставка. Недоступный у источника документ отвечает `document_gone` / `document_restricted_at_source` / `document_temporarily_unavailable`, а не 500.

| Параметр | Где | Тип | Обяз. | Описание и ограничения |
|---|---|---|---|---|
| `tender_id` | path | `string` | да | — |
| `id_type` | query | `string` | нет | по умолчанию `"internal"` |
| `positions_page` | query | `integer` | нет | по умолчанию `1` |
| `positions_limit` | query | `integer` | нет | по умолчанию `20` |

### Contracts

| Метод и путь | Назначение |
|---|---|
| `POST /contracts/query` | Контракты по множеству закупок |
| `GET /contracts/{contract_id}` | Карточка контракта |

#### POST /contracts/query — Контракты по множеству закупок

Ровно один способ задать множество: критерии закупок (те же поля, что у QUERY /tenders, без ids) | filter_id | list_id | tender_ids (разово) | supplier (подписка на контракты поставщика по ИНН/названию — у такого фильтра нет тендерной проекции). Поиска по остальным полям контракта не существует как операции. Критерии закупок дают тот же filter_id, что и QUERY /tenders с теми же параметрами. `supplier` вместе с другим способом — пост-фильтр выдачи по поставщику (в filter_id не входит, повторяйте в каждом запросе). Первое обращение может вернуть preparing, пока не отработает contracts poller. **Контракты ищутся по тематике фильтра** (ключевые слова, регионы, заказчик и т.д.). Параметр `status` на контрактную выдачу НЕ влияет: один и тот же `filter_id` годится и для мониторинга активных тендеров, и для получения контрактов по завершившимся закупкам той же темы — отдельный фильтр «со status completed» ради контрактов заводить не нужно. Исключение: у фильтра, заданного ТОЛЬКО статусом (без тематических критериев), контрактная проекция работает по прежним правилам. Контракт публикуется площадкой через несколько дней после завершения закупки и появляется в ленте в течение суток+ после публикации. В `results[].contract` объект приходит целиком (включая `suppliers` и блок родительской закупки `tender`) — `GET /contracts/{id}` на каждый элемент ленты делать не нужно.Ключевые слова работают так же, как у QUERY /tenders: поиск по нормализованным словам, слова короче 3 символов (а также годы и предлоги) в поиске не участвуют.

Тело запроса — `application/json`, схема `ContractQueryRequest`:

| Поле | Тип | Обяз. | Описание и ограничения |
|---|---|---|---|
| `events` | `string[]` | нет | Какие потоки читать. `created` — объект ВПЕРВЫЕ появился в вашем фильтре (неважно, только что опубликован или изменился и стал подходить под критерии); `updated` — изменился объект, который уже был в фильтре. По умолчанию `["created"]` — «новое по моей теме». Задаётся в ПЕРВОМ запросе: набор потоков запоминает курсор, при чтении с курсором значение из тела игнорируется. Поле `event` в конверте результата считается относительно ВАС: `created` — вы видите объект впервые, `updated` — уже получали его раньше (возможно, через другой фильтр или карточку).; значения: created \| updated |
| `cursor` | `string` | нет | Продолжение ленты. Непрозрачная подписанная строка из `next_cursor` предыдущего ответа — возвращайте как есть, не разбирайте. Несёт фильтр и набор потоков внутри: остальные поля тела повторять не нужно. |
| `limit` | `integer` | нет | диапазон 1–100; по умолчанию `100` |
| `keywords` | `string[]` | нет | Ключевые слова/фразы (ИЛИ: закупка подходит при совпадении любого элемента; многословный элемент ищется как фраза) |
| `exception_keywords` | `string[]` | нет | Минус-слова: исключить закупки с этими словами |
| `sections` | `integer[]` | нет | Где искать keywords и exception_keywords — части тендера: 1 наименование закупки, 2 наименование лота, 3 наименование позиции, 4 характеристики позиции. По умолчанию (поле опущено или пустой список) — во всех четырёх. Типовое применение: [1,2,3] — не искать по характеристикам товаров, где часто срабатывают ложные совпадения. Действует только вместе с keywords/exception_keywords; сами по себе секции множество не задают — иначе 400 validation_error |
| `regions` | `integer[]` | нет | ID регионов. Нумерация ВНУТРЕННЯЯ (не ОКАТО, не коды на номерах): Москва — 1, Воронежская область — 3, а 77 — Республика Бурятия. Справочник: https://fraim.ru/refs.json (раздел «Справочники кодов» в https://fraim.ru/docs) |
| `platforms` | `integer[]` | нет | ID площадок (ЭТП). Нумерация внутренняя. Ходовые площадки — в https://fraim.ru/refs.json; там их не все, остальные видны в поле platform {id, name} выдачи |
| `placement_types` | `integer[]` | нет | ID способов размещения закупки. Нумерация внутренняя, справочник — https://fraim.ru/refs.json |
| `purchase_types` | `integer[]` | нет | ID типов закупки. Нумерация внутренняя, справочник — https://fraim.ru/refs.json |
| `price` | `RangeFilter` | нет | Начальная цена ЗАКУПКИ (НМЦК), не цена контракта |
| `advance` | `RangeFilter` | нет | Размер аванса по контракту, % от цены (0–100) — поле «Размер аванса, %» извещения в ЕИС. Это НЕ обеспечение заявки и НЕ обеспечение контракта. 0 — аванс не предусмотрен или не указан; заполняется только у закупок из ЕИС (44-ФЗ/223-ФЗ), у коммерческих всегда 0. `{"from": 1}` — только закупки с авансом, `{"to": 0}` — без аванса |
| `publish_date` | `DateRangeFilter` | нет | Дата публикации ЗАКУПКИ |
| `start_date` | `DateRangeFilter` | нет | Дата начала подачи заявок по ЗАКУПКЕ |
| `end_date` | `DateRangeFilter` | нет | Дата окончания подачи заявок по ЗАКУПКЕ |
| `customer` | `CustomerFilter` | нет | Заказчик закупки: inn[] / ogrn / name |
| `filter_id` | `string` | нет | — |
| `list_id` | `string` | нет | — |
| `tender_ids` | `integer[]` | нет | — |
| `supplier` | `SupplierFilter` | нет | — |

#### GET /contracts/{contract_id} — Карточка контракта

Полная информация об одном контракте, включая позиции (курсор `positions_page`/`positions_limit`), поставщиков, исполнение. `id_type=internal` (по умолчанию) — внутренний id, `id_type=reestr` — реестровый номер. Списание — только если версия ещё не доставлялась (общая дедупликация с лентой QUERY /contracts). **Ответ завёрнут в конверт события:** `{event, version, billed, contract}`, а не плоский объект. **Документы качаются через наши сервера.** У каждого документа в `documents[]` есть `id` и `download_url` — постоянная персональная ссылка на `downloads.fraim.ru` (без заголовка авторизации и без срока годности); `link` по-прежнему ведёт на источник. Документы уже доставленного контракта бесплатны. Подробности и коды ошибок недоступного документа — в описании карточки тендера.

| Параметр | Где | Тип | Обяз. | Описание и ограничения |
|---|---|---|---|---|
| `contract_id` | path | `string` | да | — |
| `id_type` | query | `string` | нет | по умолчанию `"internal"` |
| `positions_page` | query | `integer` | нет | по умолчанию `1` |
| `positions_limit` | query | `integer` | нет | по умолчанию `20` |

### Lists

| Метод и путь | Назначение |
|---|---|
| `GET /lists` | Мои списки |
| `POST /lists` | Создать список |
| `GET /lists/{list_id}` | Детали и состав списка |
| `DELETE /lists/{list_id}` | Удалить список |
| `POST /lists/{list_id}/items` | Добавить тендеры в список |
| `DELETE /lists/{list_id}/items` | Убрать тендеры из списка |

#### GET /lists — Мои списки

Курсорная пагинация по спискам пользователя.

| Параметр | Где | Тип | Обяз. | Описание и ограничения |
|---|---|---|---|---|
| `limit` | query | `integer` | нет | по умолчанию `20` |
| `starting_after` | query | `string` | нет | — |

#### POST /lists — Создать список

Персональная изменяемая коллекция тендеров без TTL — живёт до явного удаления через `DELETE /lists/{list_id}`. Поля после создания неизменяемы (`PATCH` нет); состав меняется через `POST`/`DELETE /lists/{list_id}/items`.

| Параметр | Где | Тип | Обяз. | Описание и ограничения |
|---|---|---|---|---|
| `Idempotency-Key` | header | `string` | да | Ключ идемпотентности (UUID v4), один на логическую операцию. Без него — 400 idempotency_key_required; тот же ключ с другим телом — 409 idempotency_key_reused_with_different_payload. |

Тело запроса — `application/json`, схема `ListCreateRequest`:

| Поле | Тип | Обяз. | Описание и ограничения |
|---|---|---|---|
| `name` | `string` | да | длина 1–255 |
| `description` | `string` | нет | длина 0–2000 |
| `metadata` | `object` | нет | — |

#### GET /lists/{list_id} — Детали и состав списка

Метаданные списка + состав. Состав лежит во ВЛОЖЕННОМ конверте коллекции `items` (`data`/`has_more`/`next_cursor`), а не полями верхнего уровня; `items.data` — просто массив `tender_id` (числа), момент добавления не отдаётся. Продолжение — `starting_after` = последний `tender_id` страницы.

| Параметр | Где | Тип | Обяз. | Описание и ограничения |
|---|---|---|---|---|
| `list_id` | path | `string` | да | — |
| `limit` | query | `integer` | нет | по умолчанию `100` |
| `starting_after` | query | `integer` | нет | — |

#### DELETE /lists/{list_id} — Удалить список

Удаляет список целиком вместе с его составом. Гасит и вашу подписку на ленту фильтра-над-списком, если она была. Сам фильтр (общий кеш параметров) остаётся — его чистит TTL-пруннинг, как и у `DELETE /filters/{filter_id}`. Уже доставленные объекты остаются доставленными: повторно за них деньги не списываются, а история списаний в `/balance/ledger` не переписывается.

| Параметр | Где | Тип | Обяз. | Описание и ограничения |
|---|---|---|---|---|
| `list_id` | path | `string` | да | — |
| `Idempotency-Key` | header | `string` | да | Ключ идемпотентности (UUID v4), один на логическую операцию. Без него — 400 idempotency_key_required; тот же ключ с другим телом — 409 idempotency_key_reused_with_different_payload. |

#### POST /lists/{list_id}/items — Добавить тендеры в список

Идемпотентно — повторное добавление существующего id не создаёт дублей. Долив в тендерную ленту фильтра-над-списком синхронный: добавленный тендер сразу виден через QUERY /tenders/contracts {list_id}.

| Параметр | Где | Тип | Обяз. | Описание и ограничения |
|---|---|---|---|---|
| `list_id` | path | `string` | да | — |
| `Idempotency-Key` | header | `string` | да | Ключ идемпотентности (UUID v4), один на логическую операцию. Без него — 400 idempotency_key_required; тот же ключ с другим телом — 409 idempotency_key_reused_with_different_payload. |

Тело запроса — `application/json`, схема `ListItemsRequest`:

| Поле | Тип | Обяз. | Описание и ограничения |
|---|---|---|---|
| `ids` | `integer[]` | да | элементов 1–10000 |

#### DELETE /lists/{list_id}/items — Убрать тендеры из списка

Идемпотентно. Уже доставленное остаётся доставленным (не отзывается); будущие события убранного тендера в ленты списка не попадают.

| Параметр | Где | Тип | Обяз. | Описание и ограничения |
|---|---|---|---|---|
| `list_id` | path | `string` | да | — |
| `Idempotency-Key` | header | `string` | да | Ключ идемпотентности (UUID v4), один на логическую операцию. Без него — 400 idempotency_key_required; тот же ключ с другим телом — 409 idempotency_key_reused_with_different_payload. |

Тело запроса — `application/json`, схема `ListItemsRequest`:

| Поле | Тип | Обяз. | Описание и ограничения |
|---|---|---|---|
| `ids` | `integer[]` | да | элементов 1–10000 |

### Filters

| Метод и путь | Назначение |
|---|---|
| `GET /filters` | Мои живые подписки |
| `DELETE /filters/{filter_id}` | Погасить подписку досрочно |

#### GET /filters — Мои живые подписки

Отладочная обвязка, не happy path — фильтры создаются только неявно любым QUERY. **Срок жизни подписки.** Подписка продлевается ЛЮБЫМ чтением и живёт 14 дней от последнего запроса (`expires_at` в ответе). Новая подписка первые сутки живёт в пробном режиме: если запрос не повторить в течение 24 часов, она удаляется; первый же запрос спустя 3+ часа после создания переводит её на полный срок. Рекомендация — опрашивать фильтр не реже раза в сутки: если подписка умерла, курсор вернёт 409 со свежим срезом, а события за пропущенный период будут недоступны.

| Параметр | Где | Тип | Обяз. | Описание и ограничения |
|---|---|---|---|---|
| `limit` | query | `integer` | нет | по умолчанию `20` |
| `starting_after` | query | `string` | нет | — |

#### DELETE /filters/{filter_id} — Погасить подписку досрочно

Сам фильтр (общий кеш параметров) не удаляется — гасится только ваша персональная подписка на него.

| Параметр | Где | Тип | Обяз. | Описание и ограничения |
|---|---|---|---|---|
| `filter_id` | path | `string` | да | — |
| `Idempotency-Key` | header | `string` | да | Ключ идемпотентности (UUID v4), один на логическую операцию. Без него — 400 idempotency_key_required; тот же ключ с другим телом — 409 idempotency_key_reused_with_different_payload. |

### Balance

| Метод и путь | Назначение |
|---|---|
| `GET /balance` | Баланс и расход за сегодня |
| `GET /balance/ledger` | Журнал списаний |

#### GET /balance — Баланс и расход за сегодня

Баланс по типам (tenders/contracts) и количество тарифицированных доставок за текущие сутки (справочно, НЕ лимит). Валюты нет: единица баланса — одна доставленная версия объекта.

#### GET /balance/ledger — Журнал списаний

Каждая строка — одна тарифицированная доставка: объект, версия, фильтр, request_id. Курсорная пагинация: `starting_after` = `id` последней строки (целое число, оно же приходит в `next_cursor`).

| Параметр | Где | Тип | Обяз. | Описание и ограничения |
|---|---|---|---|---|
| `limit` | query | `integer` | нет | по умолчанию `100` |
| `starting_after` | query | `integer` | нет | — |

## Схемы объектов

Вложенные объекты фильтров и модели ответов, на которые ссылается справочник выше.
Тела ответов QUERY-эндпоинтов схемой не описаны — их структура показана в разделе
«Конверт ответа QUERY».

### CustomerFilter

Заказчик/организатор ЗАКУПКИ (единый стиль с supplier — вложенный объект). `inn` — список (мониторинг нескольких заказчиков — обычный кейс). extra=forbid: незнакомое поле (напр. kpp) — явный 422, не молчаливое игнорирование.

| Поле | Тип | Обяз. | Описание и ограничения |
|---|---|---|---|
| `inn` | `string[]` | нет | — |
| `ogrn` | `string` | нет | длина 13–15 |
| `name` | `string` | нет | длина 2–300 |

### DateRangeFilter

| Поле | Тип | Обяз. | Описание и ограничения |
|---|---|---|---|
| `from` | `string` | нет | — |
| `to` | `string` | нет | — |

### ListCreated

Ответ `POST /lists`.

| Поле | Тип | Обяз. | Описание и ограничения |
|---|---|---|---|
| `list_id` | `string` | да | — |
| `name` | `string` | да | — |
| `description` | `string` | нет | — |
| `metadata` | `object` | нет | — |
| `created_at` | `string` | да | — |

### ListDeleted

Ответ `DELETE /lists/{list_id}`.

| Поле | Тип | Обяз. | Описание и ограничения |
|---|---|---|---|
| `list_id` | `string` | да | — |
| `deleted` | `boolean` | нет | по умолчанию `true` |

### ListItemsAdded

Ответ `POST /lists/{list_id}/items`.

| Поле | Тип | Обяз. | Описание и ограничения |
|---|---|---|---|
| `list_id` | `string` | да | — |
| `added` | `integer` | да | Сколько id добавлено (без учёта уже бывших в списке) |
| `total` | `integer` | да | Размер списка после операции |

### ListItemsRemoved

Ответ `DELETE /lists/{list_id}/items`.

| Поле | Тип | Обяз. | Описание и ограничения |
|---|---|---|---|
| `list_id` | `string` | да | — |
| `removed` | `integer` | да | Сколько id убрано (отсутствовавшие не считаются) |

### RangeFilter

| Поле | Тип | Обяз. | Описание и ограничения |
|---|---|---|---|
| `from` | `number` | нет | — |
| `to` | `number` | нет | — |

### SupplierFilter

Поставщик контракта (единый стиль с customer — вложенный объект, `inn` — список, `name` — подстрока названия). Поставщик существует только в контракте — на множество ЗАКУПОК не влияет и в filter_id обычных фильтров не входит (ТЗ §6); сам по себе — supplier-режим (§6.1). `ogrn` (в отличие от customer) отсутствует: у поставщиков в БД нет ОГРН — матчить не по чему (резолв через справочник организаций покрыл бы ~2% поставщиков). `kpp` удалён 20.07.2026 ради симметрии с customer. Требуется хотя бы одно из inn/name (иначе 400).

| Поле | Тип | Обяз. | Описание и ограничения |
|---|---|---|---|
| `inn` | `string[]` | нет | Список ИНН поставщиков (10 или 12 цифр каждый) |
| `name` | `string` | нет | длина 2–300 |

### TokenDeleteResponse

Ответ при удалении токена

| Поле | Тип | Обяз. | Описание и ограничения |
|---|---|---|---|
| `success` | `boolean` | да | — |
| `message` | `string` | да | — |

### TokenInfo

Информация о токене с маскированным превью

| Поле | Тип | Обяз. | Описание и ограничения |
|---|---|---|---|
| `id` | `integer` | да | — |
| `name` | `string` | да | — |
| `token_preview` | `string` | нет | Маскированный токен (например: abc...xyz) |
| `is_active` | `boolean` | да | — |
| `created_at` | `string` | да | — |
| `last_used_at` | `string` | да, бывает `null` | — |

### TokenListResponse

Список токенов

| Поле | Тип | Обяз. | Описание и ограничения |
|---|---|---|---|
| `tokens` | `TokenInfo[]` | да | — |
| `total` | `integer` | да | — |

### TokenResponse

Ответ с секретом — и при создании токена, и при входе (сессия)

| Поле | Тип | Обяз. | Описание и ограничения |
|---|---|---|---|
| `id` | `integer` | да | ID токена; для сессии из POST /auth/login всегда 0 и ничего не идентифицирует |
| `name` | `string` | да | — |
| `token` | `string` | да | Секрет, показывается ОДИН раз. Для POST /auth/tokens — бессрочный API-ключ fraim_live_…/fraim_test_…; для POST /auth/login — сессия со сроком жизни 30 дней |
| `created_at` | `string` | да | — |

## Справочники кодов

Значения для `regions`, `platforms`, `placement_types`, `purchase_types`.
Это **внутренние id Fraim.ru**: не ОКАТО, не коды регионов на автомобильных
номерах, не коды ЕИС. Отдельного маршрута справочников у API нет и не
планируется — коды берите отсюда либо одним файлом: https://fraim.ru/refs.json.
Снимок сверен с боевым API 2026-08-09; те же таблицы с поиском —
в личном кабинете: https://lk.fraim.ru/refs.

Если нужного значения здесь нет, посмотрите его в самой выдаче: объекты
несут `region {id, name}` и `platform {id, name}`, карточка тендера —
`placement_type` и `purchase_type`.

### Регионы — `regions`

Нумерация внутренняя: это НЕ ОКАТО, не коды на автомобильных номерах и не коды ЕИС. Москва — 1, Санкт-Петербург — 34, а 77 — Республика Бурятия.

| id | Название |
|---|---|
| `1` | Москва |
| `2` | Московская область |
| `34` | Санкт-Петербург |
| `35` | Ленинградская область |
| `3` | Воронежская область |
| `4` | Тульская область |
| `5` | Владимирская область |
| `6` | Ярославская область |
| `7` | Рязанская область |
| `8` | Тверская область |
| `9` | Брянская область |
| `10` | Смоленская область |
| `11` | Белгородская область |
| `12` | Курская область |
| `13` | Липецкая область |
| `14` | Калужская область |
| `15` | Ивановская область |
| `16` | Тамбовская область |
| `17` | Костромская область |
| `18` | Орловская область |
| `19` | Республика Башкортостан |
| `20` | Пермский край |
| `21` | Самарская область |
| `22` | Саратовская область |
| `23` | Республика Татарстан |
| `24` | Нижегородская область |
| `25` | Кировская область |
| `26` | Удмуртская Республика |
| `27` | Оренбургская область |
| `28` | Чувашская Республика - Чувашия |
| `29` | Ульяновская область |
| `30` | Пензенская область |
| `31` | Республика Марий Эл |
| `32` | Республика Мордовия |
| `36` | Вологодская область |
| `37` | Республика Коми |
| `38` | Мурманская область |
| `39` | Архангельская область |
| `40` | Калининградская область |
| `41` | Республика Карелия |
| `42` | Новгородская область |
| `43` | Псковская область |
| `44` | Ненецкий АО |
| `45` | Новосибирская область |
| `46` | Красноярский край |
| `47` | Иркутская область |
| `48` | Кемеровская область |
| `49` | Омская область |
| `50` | Томская область |
| `51` | Алтайский край |
| `52` | Республика Хакасия |
| `53` | Республика Алтай |
| `54` | Республика Тыва |
| `57` | Свердловская область |
| `58` | Ханты-Мансийский Автономный округ - Югра АО |
| `59` | Челябинская область |
| `60` | Тюменская область |
| `61` | Ямало-Ненецкий АО |
| `62` | Курганская область |
| `63` | Краснодарский край |
| `64` | Ростовская область |
| `65` | Волгоградская область |
| `66` | Республика Крым |
| `67` | Астраханская область |
| `68` | Севастополь |
| `69` | Республика Адыгея |
| `70` | Республика Калмыкия |
| `71` | Республика Саха (Якутия) |
| `72` | Приморский край |
| `73` | Хабаровский край |
| `74` | Забайкальский край |
| `75` | Сахалинская область |
| `76` | Амурская область |
| `77` | Республика Бурятия |
| `78` | Камчатский край |
| `79` | Магаданская область |
| `80` | Чукотский АО |
| `81` | Еврейская автономная область |
| `84` | Ставропольский край |
| `85` | Республика Дагестан |
| `86` | Чеченская Республика |
| `87` | Кабардино-Балкарская Республика |
| `88` | Республика Северная Осетия - Алания |
| `89` | Карачаево-Черкесская Республика |
| `90` | Республика Ингушетия |
| `93` | Байконур (арендован РФ) |
| `94` | Херсонская область |
| `95` | Луганская Народная Республика |
| `96` | Донецкая Народная Республика |
| `97` | Запорожская область |

Пример: `"regions": [1]`

### Площадки (ЭТП) — `platforms`

Здесь наиболее используемые площадки; всего их в индексе несколько сотен. Код нужной площадки всегда виден в поле platform {id, name} любой выдачи.

> Справочник **неполный**: перечислены ходовые значения, а не все.

| id | Название |
|---|---|
| `2` | РТС–тендер |
| `20` | Росэлторг |
| `418` | АО «Сбербанк-АСТ» |
| `13` | Портал Закупай |
| `110` | ЭТП ГПБ |
| `8` | Портал закупок Москвы |
| `39` | Фабрикант |
| `67` | ТЭК-Торг |
| `1` | ЕИС ЗАКУПКИ |
| `98` | B2B-Center |
| `16` | 223ETP ZakazRF |
| `250` | БП ZakazRF |
| `12` | Лот-Онлайн |
| `113` | Электронный магазин ГЗ СПб |
| `11` | ТендерПро |
| `5` | Единый агрегатор торговли «Березка» |
| `106` | Бидзаар |
| `62` | Закупки Ленрег |
| `108` | Сбер B2B |
| `53` | Торги ЭТП Регион |
| `60` | Торги82 ЭТП |
| `194` | ЭТП Р-Эст |
| `386` | ЭТП Торги Онлайн |
| `421` | АО «Сбербанк-АСТ» (УТП) |
| `445` | ЭТП «Торги 223» |
| `446` | ЭТП ФЕДЕРАЦИЯ ЗАКУПОК |

Пример: `"platforms": [2]`

### Типы закупки — `purchase_types`

Закон или режим, по которому проводится закупка.

| id | Название |
|---|---|
| `1` | Закупка по 44ФЗ |
| `2` | Закупка по 223ФЗ |
| `3` | Коммерческая закупка |
| `4` | Малая закупка |
| `5` | Закупка по 615ПП |

Пример: `"purchase_types": [1]`

### Способы размещения — `placement_types`

Форма процедуры. Приходит в карточке тендера полем placement_type.

| id | Название |
|---|---|
| `7` | Электронный аукцион |
| `4` | Запрос котировок |
| `8` | Открытый конкурс |
| `2` | Запрос предложений |
| `5` | Конкурс |
| `6` | Аукцион |
| `11` | Открытый аукцион |
| `13` | Закупка у единственного поставщика |
| `14` | Мелкая закупка |
| `9` | Упрощенная закупка |
| `15` | Открытый запрос предложений |
| `16` | Открытый запрос котировок |
| `3` | Объявление о покупке |
| `10` | Мониторинг цен |
| `12` | Маркетинговое исследование |
| `17` | Предварительный отбор |
| `18` | Закрытый конкурс |
| `19` | Конкурентные переговоры |
| `20` | Закрытый запрос котировок |
| `101` | Закрытый аукцион |
| `102` | Закрытый запрос предложений |
| `103` | Конкурс с ограниченным участием |
| `1` | Прочее |

Пример: `"placement_types": [7]`

## Коды ошибок

Тело ошибки вложено в `detail` (так его отдаёт FastAPI):

```json
{ "detail": { "error": { "type": "invalid_request_error",
                         "code": "validation_error",
                         "message": "…",
                         "param": "cursor",
                         "request_id": "req_…" } } }
```

Форма одна для **всех** кодов, включая `422` и оба вида `429` — исключений нет.
Особый случай один — `409 cursor_expired`: в его `detail` лежит не только
`error`, но и свежий срез ленты — `detail.state`, `detail.results`,
`detail.next_cursor`.

`request_id` возвращается **заголовком `X-Request-Id` в каждом ответе** — и в
успешном тоже, — а в теле ошибок ещё и полем `error.request_id` (значение то же).
По нему же сходятся строки `GET /balance/ledger`. Если пришлёте свой
`X-Request-Id` в запросе, API вернёт его: сквозная трассировка работает.
Ровно одно место без него — отказ лимитера всплесков: он формируется до
приложения, поэтому там `request_id: null` и заголовка нет. Опознать его можно
по `Retry-After: 1` и сообщению `burst limit exceeded`.

Обращения по ошибкам — на support@fraim.ru, с `request_id` из ответа.

| HTTP | code | Когда |
|---|---|---|
| 400 | `validation_error` | Невалидные параметры; ids>1000; >1 способа задать множество |
| 400 | `invalid_cursor` | Подпись курсора не сошлась |
| 400 | `idempotency_key_required` | Мутирующий запрос без заголовка Idempotency-Key |
| 401 | `api_key_required` | Нет заголовка Authorization |
| 401 | `invalid_api_key` | Токен неизвестен, отозван или не похож на ключ Fraim.ru |
| 401 | `api_key_mode_mismatch` | Префикс токена не совпал с его режимом (тестовый против боевого) |
| 401 | `account_inactive` | Аккаунт заблокирован |
| 401 | `invalid_session` | Сессия кабинета истекла (30 дней) — войдите заново; к API-токенам не относится |
| 402 | `balance_exhausted` | Баланс 0 и ни один объект не доставлен |
| 404 | `not_found` | Объект/фильтр/список не существует или чужой список |
| 405 | `method_not_allowed` | Метод не поддержан маршрутом — напр. DELETE /lists (удаления списка нет) |
| 409 | `cursor_expired` | Лента пересобрана; в теле — свежий срез и новый курсор |
| 409 | `api_key_limit_reached` | Уже 20 активных токенов — отзовите ненужный и повторите |
| 409 | `idempotency_key_reused_with_different_payload` | Тот же Idempotency-Key прислан с другим телом. Тип ошибки — idempotency_error |
| 409 | `document_restricted_at_source` | Документ есть, но площадка-первоисточник отдаёт его только своим участникам |
| 409 | `document_not_a_file` | По ссылке первоисточника сейчас страница, а не файл |
| 409 | `document_unavailable_in_sandbox` | Скачивание документов тестовым токеном недоступно |
| 410 | `document_gone` | Документ удалён у первоисточника — повторять бессмысленно |
| 413 | `document_too_large` | Документ больше допустимого размера для скачивания через сервис |
| 503 | `document_temporarily_unavailable` | Первоисточник недоступен; в ответе Retry-After — повторите позже |
| 422 | `validation_error` | Запрос не сошёлся со схемой: limit вне 1–100, metadata > 2048 байт, неверный тип поля |
| 422 | `extra_forbidden` | Незнакомое поле во вложенном фильтре customer или supplier |
| 429 | `rate_limit_exceeded` | Лимит частоты: минутный на аккаунт (Retry-After: 60), всплеск по IP (Retry-After: 1), попытки входа или выпуска токенов. Подождите указанное время и повторите |
| 202 | `preparing` | Не ошибка: идёт фоновый сбор, повторите тот же запрос |

## Лимиты

| Что | Сколько | Подробности |
|---|---|---|
| Запросы к API | 100 в минуту | Считается на аккаунт, а не на токен. Это гарантированный минимум: счётчик живёт в памяти процесса, поэтому на практике может пропустить и больше — не стройте на этом расчёт. Сверх — 429 rate_limit_exceeded с Retry-After: 60. |
| Всплеск запросов | 20 в секунду | Отдельный лимит по IP, режет всплески (короткая пачка до 40 запросов проходит). Параллельная выгрузка в несколько потоков упирается в него раньше, чем в минутный. Сверх — 429 с Retry-After: 1: хватает паузы в секунду и меньшего числа потоков. |
| Объектов в сутки | без лимита | Расход ограничен только балансом. |
| Активных токенов | до 20 | На 21-м — 409 api_key_limit_reached: отзовите ненужный. |
| Выпуск токенов | 200 в час | Повтор с тем же Idempotency-Key за выпуск не считается. |
| Размер страницы | до 100 | Параметр limit. По умолчанию 100 в лентах (QUERY) и 20 в списках подписок и своих списков. |
| ID в составе списка | до 10 000 | Поле ids в POST и DELETE /lists/{list_id}/items. У POST /lists поля ids нет — там только name/description/metadata. |
| ID в поиске | до 1000 | Поле ids в POST /tenders/query (PK-lookup). Сверх — 400 validation_error. |
| Новых фильтров | до 50 | Считаются только неподтверждённые: фильтр, который вы продолжаете читать, из счёта уходит. |

## Частые ошибки агентов

| Так делать не надо | Почему и как правильно |
|---|---|
| `GET /tenders?keywords=…` | Такого маршрута нет. Поиск — `POST /tenders/query` с JSON-телом. |
| `{"filter_id": "…", "keywords": […]}` | Два способа задать множество сразу → `400`. Оставьте один. |
| `{"page": 2}`, `{"offset": 100}` | Пагинации по номеру страницы нет. Только `cursor` из `next_cursor`. |
| Ретрай `202 preparing` с изменённым телом | Другое тело — другой фильтр, сбор начнётся заново. Повторяйте байт-в-байт тот же запрос. |
| Прогон всей выдачи ради подсчёта | Каждая страница списывает баланс. Порядок величины — в справочном `total`, листать ради счёта не нужно. |
| Листать, «пока не наберётся `total`» | `total` — фоновая справочная оценка, а не размер выборки: он меняется прямо во время листания и бывает `null`. Конец — пустой `results`. |
| `total` как критерий конца выборки или как ошибку при `null` | Ни то, ни другое. `null` — валидное значение (тяжёлый фильтр, `preparing`/`partial`, поток `updated`, ещё не посчитано), и клиент обязан его переживать. |
| Считать пустой `results` концом ленты | Это «сейчас подходящих объектов нет». Сохраните `next_cursor` и вернитесь позже. |
| Сверять страницы на побайтовую повторяемость | Выдача живая: объект мог перестать подходить под фильтр или обновиться. Дедуп — по `id` + `version`, а не по составу страницы. |
| `events` в запросе с `cursor` | Игнорируется: набор потоков зашит в курсор первым запросом. Другой набор — новая лента. |
| Поллинг реже раза в сутки | Подписка живёт 14 дней от последнего чтения, новая — сутки в пробном режиме. Умерла → `409` и потеря событий за период молчания. |
| Новый `Idempotency-Key` на каждом ретрае | Тогда повтор создаст второй объект. Ключ генерируется один раз на логическую операцию. |
| `supplier` в `POST /tenders/query` | Поставщик существует только у контракта. По поставщику ищите в `POST /contracts/query`. |
| Отдельный фильтр «status: completed» ради контрактов | `status` на контрактную выдачу не влияет — берите `filter_id` того же тематического фильтра. |
| `GET /tenders/{id}` на каждый элемент ленты | Объект уже целиком в `results[].tender`. Карточка нужна только ради позиций, документов, лотов и полной организации. |
| `body["error"]["code"]` при разборе ошибки | Ошибка лежит под `detail`: `body["detail"]["error"]` — у всех кодов, включая `402` и `422`. |
| `body["results"]` при `409 cursor_expired` | Свежий срез тоже под `detail`: `body["detail"]["results"]` и `["next_cursor"]`. |
| Ретрай `429` сразу же или с фиксированной паузой | В ответе есть `Retry-After`: у всплеска это 1 секунда, у минутного лимита — 60. Берите паузу оттуда, а не из своей константы. |
| Выгрузка в 10–20 параллельных потоков | Упрётся в лимит всплесков по IP, а не ускорит выгрузку. 2–3 потока проходят целиком; лента всё равно отдаётся курсором последовательно. |
| Минус-слова против мусора из характеристик товаров | Часто дешевле сузить поверхность поиска: `sections: [1,2,3]` — искать в наименованиях закупки, лота и позиции, но не в характеристиках. Секции действуют и на `keywords`, и на `exception_keywords`. |
| `sections: [1,2,3,4]` «чтобы наверняка» | Это то же самое, что не передавать `sections`, — и `filter_id` будет тот же. Отдельного фильтра и повторной оплаты не возникнет, но и смысла в поле нет. |
| `regions: [77]` для Москвы | `77` — Республика Бурятия. Москва — `1`. Нумерация внутренняя, не ОКАТО. |
| Сравнение `filter_id` тендерной и контрактной выдачи | У одних и тех же критериев они **разные** (в контрактный хеш входит `status: ["completed"]`). Как ключ дедупликации не годится. |
| `NOT NULL` на `tender.etp_id` | У части агрегированных лотов id на исходной площадке нет — поле бывает `null`. |
| Ожидание `tender.status` строкой | Это объект `{id, name}`. Строкой `status` приходит только в фильтре запроса и в контракте. |
| Хранение `filter_id` и параметров ради продолжения | Достаточно последнего `next_cursor` — он самодостаточен. |
| Качать документы по `link` из карточки | Это адрес первоисточника: он бывает недоступен, отдаёт HTML вместо файла или требует входа на площадку. Берите `download_url` — тот же файл через наши сервера, с нормальным именем. |
| Считать `404`/`500` на скачивании поломкой API | Недоступность документа объясняется отдельными кодами: `document_gone` (удалён у источника), `document_restricted_at_source` (только участникам площадки), `document_temporarily_unavailable` (повторить по `Retry-After`). |
| Класть `Authorization` в запрос `download_url` | Не нужно: ссылка самодостаточна и подписана. Заголовок нужен только для второго пути — `GET https://downloads.fraim.ru/d/{document_id}`. |
| `keywords: ["жд"]`, `["кс"]`, `["ip"]` | Слова короче 3 символов выбрасываются до поиска: такой фильтр вернёт `400` «no searchable words», а в составе фразы (`"насос жд"`) короткое слово просто выпадает. Ищите по полному слову. |
| Фильтр без `keywords`: `{"status": ["active"], "regions": [1]}` | Такой запрос — подписка на весь регион, её не примут (`400`). Добавьте слова или сузьте множество через `customer.inn`, `ids`, `list_id`, `supplier`. |
| `keywords: ["поставка"]`, `["услуга"]`, `["работа"]` | Слишком общие: `поставка` есть у 41.8% активных закупок. Одиночное такое слово выбрасывается, и если больше искать нечего — `400`. Ищите по номенклатуре: `["насос"]`, `["кровля"]`. |
| `exception_keywords` без `keywords` | `400`. Раньше запрос принимался, но минус-слова молча не применялись — исключения работают только поверх непустого набора ключевых слов. |
| Считать, что `keywords` ушли в поиск как есть | Часть могла быть отброшена. Сверяйтесь с `ignored_keywords` в ответе: там `keyword` и причина — `too_common` или `no_searchable_words`. |
| Год в ключевых словах ради «закупок 2025 года» | Годы (2000–2099) игнорируются наравне с короткими словами. Период задаётся датами фильтра, а не текстом. |
| `keywords: ["ГОСТ 32144-2013"]`, `["ТУ 27.32.13-001-12345678-2022"]` | Такие обозначения не выбрасываются (в отличие от годов и коротких слов), фильтр примут — но найдёт он единицы объектов: точный номер стандарта обычно есть только в приложенной документации, не в тексте карточки. Ищите широким словом (`["кабель силовой"]`) и отсеивайте результат сами — вручную, через ИИ или `exception_keywords`. |
| Токен в браузерном коде или в репозитории | Токен = доступ к балансу. Только серверное окружение, только переменная окружения. |

## Ссылки

- Этот документ в Markdown: https://fraim.ru/ai.md
- Карта документации для ИИ: https://fraim.ru/llms.txt
- Справочники кодов одним JSON: https://fraim.ru/refs.json
- OpenAPI 3.1: https://public.fraim.ru/api/v2/openapi.json
- Документация для людей: https://fraim.ru/docs
- Регистрация и токены: https://lk.fraim.ru
- Поддержка: support@fraim.ru (в письме укажите `request_id` из ответа)
- Любые вопросы: info@fraim.ru
