Машиночитаемые версии: /ai.md — весь документ одним Markdown-файлом · /llms.txt — карта документации · openapi.json — схема API · /docs — версия для людей · кабинет — токен для https://public.fraim.ru/api/v2

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

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

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

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

  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"]; ленту изменений включайте явно.

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

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

Authorization: Bearer fraim_live_…

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

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

Переключение теста и боя определяется по заголовку 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 — ключ дедупликации на вашей стороне.

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

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

stateHTTPЧто делать
ready200Обычный ответ, лента собрана.
partial200Данные читать можно, снапшот ещё дособирается; total может отсутствовать. Продолжайте курсором.
preparing202Читать нечего, идёт сбор. Повторите тот же запрос через retry_after секунд.

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

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

{
  "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.

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

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. Контракты поставщика или заказчика по ИНН

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

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

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

Поиск (curl)

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)

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)

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)

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)

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-Keyheaderstringнет

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

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

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

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

ПараметрГдеТипОбяз.Описание и ограничения
token_idpathintegerда
Idempotency-Keyheaderstringнет

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:

ПолеТипОбяз.Описание и ограничения
eventsstring[]нетзначения: created | updated
cursorstringнет
limitintegerнетдиапазон 1–100; по умолчанию 100
waitintegerнетдиапазон 0–25; по умолчанию 0
keywordsstring[]нетКлючевые слова/фразы (ИЛИ: закупка подходит при совпадении любого элемента; многословный элемент ищется как фраза)
exception_keywordsstring[]нетМинус-слова: исключить закупки с этими словами
regionsinteger[]нет
platformsinteger[]нет
placement_typesinteger[]нет
purchase_typesinteger[]нет
okpd2string[]нетКоды ОКПД2 (допустимы префиксы, напр. '32.50')
priceRangeFilterнетНачальная цена ЗАКУПКИ (НМЦК), не цена контракта
publish_dateDateRangeFilterнетДата публикации ЗАКУПКИ
start_dateDateRangeFilterнет
end_dateDateRangeFilterнет
customerCustomerFilterнет
statusstring[]нетзначения: active | completed | cancelled
idsinteger[]нет
filter_idstringнет
list_idstringнет

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_idpathstringда
id_typequerystringнетпо умолчанию "internal"
positions_pagequeryintegerнетпо умолчанию 1
positions_limitqueryintegerнетпо умолчанию 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:

ПолеТипОбяз.Описание и ограничения
eventsstring[]нетзначения: created | updated
cursorstringнет
limitintegerнетдиапазон 1–100; по умолчанию 100
waitintegerнетдиапазон 0–25; по умолчанию 0
keywordsstring[]нетКлючевые слова/фразы (ИЛИ: закупка подходит при совпадении любого элемента; многословный элемент ищется как фраза)
exception_keywordsstring[]нетМинус-слова: исключить закупки с этими словами
regionsinteger[]нет
platformsinteger[]нет
placement_typesinteger[]нет
purchase_typesinteger[]нет
okpd2string[]нетКоды ОКПД2 (допустимы префиксы, напр. '32.50')
priceRangeFilterнетНачальная цена ЗАКУПКИ (НМЦК), не цена контракта
publish_dateDateRangeFilterнетДата публикации ЗАКУПКИ
start_dateDateRangeFilterнет
end_dateDateRangeFilterнет
customerCustomerFilterнет
filter_idstringнет
list_idstringнет
tender_idsinteger[]нет
supplierSupplierFilterнет

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

Полная информация об одном контракте, включая позиции (курсор positions_page/positions_limit), поставщиков, исполнение. id_type=internal (по умолчанию) — внутренний id, id_type=reestr — реестровый номер. Списание — только если версия ещё не доставлялась (общая дедупликация с лентой QUERY /contracts).

ПараметрГдеТипОбяз.Описание и ограничения
contract_idpathstringда
id_typequerystringнетпо умолчанию "internal"
positions_pagequeryintegerнетпо умолчанию 1
positions_limitqueryintegerнетпо умолчанию 20

Lists

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

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

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

ПараметрГдеТипОбяз.Описание и ограничения
limitqueryintegerнетпо умолчанию 20
starting_afterquerystringнет

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

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

ПараметрГдеТипОбяз.Описание и ограничения
Idempotency-Keyheaderstringнет

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

ПолеТипОбяз.Описание и ограничения
namestringдадлина 1–255
descriptionstringнетдлина 0–2000
metadataobjectнет

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

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

ПараметрГдеТипОбяз.Описание и ограничения
list_idpathstringда
limitqueryintegerнетпо умолчанию 100
starting_afterqueryintegerнет

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

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

ПараметрГдеТипОбяз.Описание и ограничения
list_idpathstringда
Idempotency-Keyheaderstringнет

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

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

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

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

ПараметрГдеТипОбяз.Описание и ограничения
list_idpathstringда
Idempotency-Keyheaderstringнет

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

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

Filters

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

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

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

ПараметрГдеТипОбяз.Описание и ограничения
limitqueryintegerнетпо умолчанию 20
starting_afterquerystringнет

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

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

ПараметрГдеТипОбяз.Описание и ограничения
filter_idpathstringда
Idempotency-Keyheaderstringнет

Balance

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

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

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

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

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

ПараметрГдеТипОбяз.Описание и ограничения
limitqueryintegerнетпо умолчанию 100
starting_afterqueryintegerнет

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

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

CustomerFilter

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

ПолеТипОбяз.Описание и ограничения
innstring[]нет
ogrnstringнетдлина 13–15
namestringнетдлина 2–300

DateRangeFilter

ПолеТипОбяз.Описание и ограничения
fromstringнет
tostringнет

RangeFilter

ПолеТипОбяз.Описание и ограничения
fromnumberнет
tonumberнет

SupplierFilter

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

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

TokenDeleteResponse

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

ПолеТипОбяз.Описание и ограничения
successbooleanда
messagestringда

TokenInfo

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

ПолеТипОбяз.Описание и ограничения
idintegerда
namestringда
token_previewstringнетМаскированный токен (например: abc...xyz)
is_activebooleanда
created_atstringда
last_used_atstringда

TokenListResponse

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

ПолеТипОбяз.Описание и ограничения
tokensTokenInfo[]да
totalintegerда

TokenResponse

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

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

Коды ошибок

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

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

Лимиты

ЧтоСколькоПодробности
Запросы к API100 в минутуСчитается на аккаунт, а не на токен. Сверх — 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 — он самодостаточен.
Токен в браузерном коде или в репозиторииТокен = доступ к балансу. Только серверное окружение, только переменная окружения.

Ссылки