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

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

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

- **Base URL:** `https://public.fraim.ru/api/v2`
- **Версия API:** 2.0.0 (OpenAPI 3.1.0, снапшот схемы от 2026-08-05)
- **Этот документ одним файлом:** https://api.fraim.ru/ai.md
- **Машинная схема (OpenAPI 3.1):** https://public.fraim.ru/api/v2/openapi.json
- **Документация для людей:** https://api.fraim.ru/docs
- **Личный кабинет (токены, баланс, песочница):** https://lk.api.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. `202 preparing` — не ошибка: идёт фоновый сбор. Повторите **тот же** запрос через `retry_after` секунд. `state: "partial"` в 200-ответе значит «читать уже можно, лента ещё дособирается».
7. Тарифицируется первая доставка версии объекта, а не запрос. Лишняя пагинация «на всякий случай» стоит денег; пустые ответы и повторы бесплатны.
8. На мутациях (`POST`/`DELETE`) заголовок `Idempotency-Key` обязателен — иначе `400 idempotency_key_required`. Кладите UUID v4 и повторяйте его при ретрае.
9. При `429` дождитесь `Retry-After` и повторите. Лимит считается на аккаунт, а не на токен: заводить новые токены ради частоты бессмысленно.
10. Не пытайтесь логиниться по email-коду: `/auth/request-code` и `/auth/login-code` — вход человека в кабинет, они отдают сессию на 30 дней. Агенту нужен вечный API-токен (`fraim_live_…`) из кабинета или `POST /auth/tokens`.
11. Дедуплицируйте у себя по паре `id` + `version`. Поле `billed` отвечает на вопрос «списали ли деньги», а не «видел ли я этот объект».
12. Даты — ISO-8601. По умолчанию `status: ["active"]` и `events: ["created"]`; ленту изменений включайте явно.

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

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

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

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

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

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

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

Маршруты входа человека в кабинет (`/auth/request-code`, `/auth/login-code`) в этой
документации и в OpenAPI-схеме отсутствуют намеренно: они отдают сессию на 30 дней,
для интеграции она не годится.

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

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

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

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

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

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

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

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

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

Под пиковой нагрузкой API не отказывает, а отвечает `202 preparing`; `429`
означает только ваш лимит, но не перегрузку сервиса.

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

```json
{
  "filter_id": "flt_8a3c9d12",
  "state": "ready",
  "applied": { "events": ["created"], "status": ["active"] },
  "results": [
    { "event": "created", "version": 1, "billed": true,
      "tender": { "id": 4821337, "etp_id": "0173…" } }
  ],
  "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"
}
```

Коллекции (`/lists`, `/filters`, `/balance/ledger`) отвечают другим конвертом:
`{ "data": [...], "has_more": false, "next_cursor": null }`, продолжение —
параметром `starting_after`.

## Биллинг

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

- Тарифицируется **первая доставка версии объекта**, а не запрос: пустые
  ответы, поллинг и ретраи той же страницы бесплатны.
- Платны оба события — `created` и `updated` (каждая новая версия).
- Оплата постранично: нашли 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`).

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

### 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` — не ошибка: в теле уже лежит свежий срез и новый
   курсор, продолжайте с него.

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

То же самое, но при первом запросе `"events": ["updated"]`. Потоки `created` и
`updated` независимы — держите по курсору на каждый.

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

- Контракты конкретного поставщика: `POST /contracts/query` с `{"supplier": {"inn": ["…"]}}`.
- Контракты заказчика: те же критерии закупок — `{"customer": {"inn": ["…"]}}`.
- Первое обращение к контрактной проекции может вернуть `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": [77],
    "okpd2": ["42.11"],
    "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": [77], "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:                       # свой лимит частоты
        time.sleep(int(r.headers.get("Retry-After", 60)))
        return [], cursor
    if r.status_code == 409:                       # лента пересобрана: срез уже в теле
        data = r.json()
        return data["results"], data["next_cursor"]

    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}}'
```

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

Всего маршрутов: 16. Сгенерировано из 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` | нет | — |

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

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

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

Деактивирует API токен. Удалять можно только свои токены; на чужой или несуществующий id — `404`. `token_id = 0` соответствует сессии из `POST /auth/login`: отзывать нечего, запрос возвращает успех и ничего не меняет.

| Параметр | Где | Тип | Обяз. | Описание и ограничения |
|---|---|---|---|---|
| `token_id` | path | `integer` | да | — |
| `Idempotency-Key` | header | `string` | нет | — |

### 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 из тела игнорируется, если передан курсор.

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

| Поле | Тип | Обяз. | Описание и ограничения |
|---|---|---|---|
| `events` | `string[]` | нет | значения: created \| updated |
| `cursor` | `string` | нет | — |
| `limit` | `integer` | нет | диапазон 1–100; по умолчанию `100` |
| `wait` | `integer` | нет | диапазон 0–25; по умолчанию `0` |
| `keywords` | `string[]` | нет | Ключевые слова/фразы (ИЛИ: закупка подходит при совпадении любого элемента; многословный элемент ищется как фраза) |
| `exception_keywords` | `string[]` | нет | Минус-слова: исключить закупки с этими словами |
| `regions` | `integer[]` | нет | — |
| `platforms` | `integer[]` | нет | — |
| `placement_types` | `integer[]` | нет | — |
| `purchase_types` | `integer[]` | нет | — |
| `okpd2` | `string[]` | нет | Коды ОКПД2 (допустимы префиксы, напр. '32.50') |
| `price` | `RangeFilter` | нет | Начальная цена ЗАКУПКИ (НМЦК), не цена контракта |
| `publish_date` | `DateRangeFilter` | нет | Дата публикации ЗАКУПКИ |
| `start_date` | `DateRangeFilter` | нет | — |
| `end_date` | `DateRangeFilter` | нет | — |
| `customer` | `CustomerFilter` | нет | — |
| `status` | `string[]` | нет | значения: 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).

| Параметр | Где | Тип | Обяз. | Описание и ограничения |
|---|---|---|---|---|
| `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.

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

| Поле | Тип | Обяз. | Описание и ограничения |
|---|---|---|---|
| `events` | `string[]` | нет | значения: created \| updated |
| `cursor` | `string` | нет | — |
| `limit` | `integer` | нет | диапазон 1–100; по умолчанию `100` |
| `wait` | `integer` | нет | диапазон 0–25; по умолчанию `0` |
| `keywords` | `string[]` | нет | Ключевые слова/фразы (ИЛИ: закупка подходит при совпадении любого элемента; многословный элемент ищется как фраза) |
| `exception_keywords` | `string[]` | нет | Минус-слова: исключить закупки с этими словами |
| `regions` | `integer[]` | нет | — |
| `platforms` | `integer[]` | нет | — |
| `placement_types` | `integer[]` | нет | — |
| `purchase_types` | `integer[]` | нет | — |
| `okpd2` | `string[]` | нет | Коды ОКПД2 (допустимы префиксы, напр. '32.50') |
| `price` | `RangeFilter` | нет | Начальная цена ЗАКУПКИ (НМЦК), не цена контракта |
| `publish_date` | `DateRangeFilter` | нет | Дата публикации ЗАКУПКИ |
| `start_date` | `DateRangeFilter` | нет | — |
| `end_date` | `DateRangeFilter` | нет | — |
| `customer` | `CustomerFilter` | нет | — |
| `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).

| Параметр | Где | Тип | Обяз. | Описание и ограничения |
|---|---|---|---|---|
| `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}` | Детали и состав списка |
| `POST /lists/{list_id}/items` | Добавить тендеры в список |
| `DELETE /lists/{list_id}/items` | Убрать тендеры из списка |

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

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

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

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

Персональная изменяемая коллекция тендеров без TTL — живёт до явного удаления.

| Параметр | Где | Тип | Обяз. | Описание и ограничения |
|---|---|---|---|---|
| `Idempotency-Key` | header | `string` | нет | — |

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

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

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

Метаданные списка + состав (tender_id, курсорная пагинация).

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

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

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

| Параметр | Где | Тип | Обяз. | Описание и ограничения |
|---|---|---|---|---|
| `list_id` | path | `string` | да | — |
| `Idempotency-Key` | header | `string` | нет | — |

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

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

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

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

| Параметр | Где | Тип | Обяз. | Описание и ограничения |
|---|---|---|---|---|
| `list_id` | path | `string` | да | — |
| `Idempotency-Key` | header | `string` | нет | — |

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

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

### Filters

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

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

Отладочная обвязка, не happy path — фильтры создаются только неявно любым QUERY.

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

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

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

| Параметр | Где | Тип | Обяз. | Описание и ограничения |
|---|---|---|---|---|
| `filter_id` | path | `string` | да | — |
| `Idempotency-Key` | header | `string` | нет | — |

### Balance

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

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

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

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

Каждая строка — одна тарифицированная доставка: объект, версия, фильтр, request_id. Курсорная пагинация.

| Параметр | Где | Тип | Обяз. | Описание и ограничения |
|---|---|---|---|---|
| `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` | нет | — |

### 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` | да | — |

### 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` | да | — |

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

Тело ошибки: `{"error": {"code": "…", "message": "…", "request_id": "…"}}`.
`request_id` есть в каждом ответе и в журнале списаний — указывайте его в обращениях в поддержку.

| HTTP | code | Когда |
|---|---|---|
| 400 | `validation_error` | Невалидные параметры; ids>1000; >1 способа задать множество |
| 400 | `invalid_cursor` | Подпись курсора не сошлась |
| 400 | `idempotency_key_required` | Мутирующий запрос без заголовка Idempotency-Key |
| 401 | `unauthorized` | Нет или невалидный токен |
| 401 | `invalid_session` | Сессия кабинета истекла (30 дней) — войдите заново; к API-токенам не относится |
| 402 | `balance_exhausted` | Баланс 0 и ни один объект не доставлен |
| 404 | `not_found` | Объект/фильтр/список не существует или чужой список |
| 409 | `cursor_expired` | Лента пересобрана; в теле — свежий срез и новый курсор |
| 409 | `api_key_limit_reached` | Уже 20 активных токенов — отзовите ненужный и повторите |
| 422 | `—` | Стандарт валидации (в т.ч. превышение лимита metadata) |
| 429 | `rate_limit_exceeded` | Превышен лимит — запросов (100/мин на аккаунт), попыток входа или выпуска токенов. Подождите Retry-After и повторите |
| 202 | `preparing` | Не ошибка: идёт фоновый сбор, повторите тот же запрос |

## Лимиты

| Что | Сколько | Подробности |
|---|---|---|
| Запросы к API | 100 в минуту | Считается на аккаунт, а не на токен. Сверх — 429 с заголовком Retry-After. |
| Объектов в сутки | без лимита | Расход ограничен только балансом. |
| Активных токенов | до 20 | На 21-м — 409 api_key_limit_reached: отзовите ненужный. |
| Выпуск токенов | 200 в час | Повтор с тем же Idempotency-Key за выпуск не считается. |
| Размер страницы | до 100 | Параметр limit. По умолчанию 100 в лентах (QUERY) и 20 в списках подписок и своих списков. |
| ID в запросе | до 10 000 | За раз — в POST /lists и запросах по списку. |
| Новых фильтров | до 50 | Считаются только неподтверждённые: фильтр, который вы продолжаете читать, из счёта уходит. |
| Попытки ввода кода | 5 на код | После пяти неверных вводов код перестаёт действовать — запросите новый через POST /auth/request-code. К API-токенам не относится. |
| Запросы кода | 5 в час | На один email. Код живёт 10 минут; новый запрос гасит предыдущий код. |

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

| Так делать не надо | Почему и как правильно |
|---|---|
| `GET /tenders?keywords=…` | Такого маршрута нет. Поиск — `POST /tenders/query` с JSON-телом. |
| `{"filter_id": "…", "keywords": […]}` | Два способа задать множество сразу → `400`. Оставьте один. |
| `{"page": 2}`, `{"offset": 100}` | Пагинации по номеру страницы нет. Только `cursor` из `next_cursor`. |
| Ретрай `202 preparing` с изменённым телом | Другое тело — другой фильтр, сбор начнётся заново. Повторяйте байт-в-байт тот же запрос. |
| Прогон всей выдачи ради подсчёта | Каждая страница списывает баланс. Общее число — в поле `total`, считать пагинацией не нужно. |
| Новый `Idempotency-Key` на каждом ретрае | Тогда повтор создаст второй объект. Ключ генерируется один раз на логическую операцию. |
| `supplier` в `POST /tenders/query` | Поставщик существует только у контракта. По поставщику ищите в `POST /contracts/query`. |
| `status` в `POST /contracts/query` | Игнорируется: контракты бывают только у завершённых закупок. |
| Хранение `filter_id` и параметров ради продолжения | Достаточно последнего `next_cursor` — он самодостаточен. |
| Токен в браузерном коде или в репозитории | Токен = доступ к балансу. Только серверное окружение, только переменная окружения. |

## Ссылки

- Этот документ в Markdown: https://api.fraim.ru/ai.md
- Карта документации для ИИ: https://api.fraim.ru/llms.txt
- OpenAPI 3.1: https://public.fraim.ru/api/v2/openapi.json
- Документация для людей: https://api.fraim.ru/docs
- Регистрация и токены: https://lk.api.fraim.ru
