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

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

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

Документ самодостаточен: его можно целиком положить в контекст модели. Справочник эндпоинтов, параметры и схемы объектов сгенерированы из OpenAPI-схемы API. Всё, что схемой не выражается — модель данных, биллинг, формы ошибок, лимиты — описано ниже по боевому поведению; расхождения между схемой и 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. Выдача — живой список, а не снапшот: каждая страница отражает состояние тендеров на момент запроса. Повторный запрос с тем же курсором может вернуть меньше объектов или те же объекты с обновлёнными данными; пустой 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[].tenderresults[].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.

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

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

Authorization: Bearer fraim_live_…

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Из того, что эти обозначения не игнорируются, не следует, что ими стоит сужать поиск. Номер ГОСТа, ТУ или другой точный код почти всегда есть только в приложенной документации к закупке, а не в тексте самой карточки — такой запрос находит единицы объектов вместо всей номенклатуры. Правильный паттерн: широкое ключевое слово (кабель силовой, а не ГОСТ 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 означает «на этом отрезке ленты сейчас нет подходящих объектов, продолжайте с нового курсора», а не «лента кончилась».

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

Подписки и TTL

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

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

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

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

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

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

Что лежит в 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.

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

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

{
  "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 (для серверного кода).

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.

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

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

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

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

Ограничения

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

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

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": [1],
    "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": [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)

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

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

Всего маршрутов: 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-KeyheaderstringдаКлюч идемпотентности (UUID v4), один на логическую операцию. Без него — 400 idempotency_key_required; тот же ключ с другим телом — 409 idempotency_key_reused_with_different_payload.

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

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

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

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

ПараметрГдеТипОбяз.Описание и ограничения
token_idpathintegerда
Idempotency-KeyheaderstringдаКлюч идемпотентности (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:

ПолеТипОбяз.Описание и ограничения
eventsstring[]нетКакие потоки читать. created — объект ВПЕРВЫЕ появился в вашем фильтре (неважно, только что опубликован или изменился и стал подходить под критерии); updated — изменился объект, который уже был в фильтре. По умолчанию ["created"] — «новое по моей теме». Задаётся в ПЕРВОМ запросе: набор потоков запоминает курсор, при чтении с курсором значение из тела игнорируется. Поле event в конверте результата считается относительно ВАС: created — вы видите объект впервые, updated — уже получали его раньше (возможно, через другой фильтр или карточку).; значения: created | updated
cursorstringнетПродолжение ленты. Непрозрачная подписанная строка из next_cursor предыдущего ответа — возвращайте как есть, не разбирайте. Несёт фильтр и набор потоков внутри: остальные поля тела повторять не нужно.
limitintegerнетдиапазон 1–100; по умолчанию 100
keywordsstring[]нетКлючевые слова/фразы (ИЛИ: закупка подходит при совпадении любого элемента; многословный элемент ищется как фраза)
exception_keywordsstring[]нетМинус-слова: исключить закупки с этими словами
sectionsinteger[]нетГде искать keywords и exception_keywords — части тендера: 1 наименование закупки, 2 наименование лота, 3 наименование позиции, 4 характеристики позиции. По умолчанию (поле опущено или пустой список) — во всех четырёх. Типовое применение: [1,2,3] — не искать по характеристикам товаров, где часто срабатывают ложные совпадения. Действует только вместе с keywords/exception_keywords; сами по себе секции множество не задают — иначе 400 validation_error
regionsinteger[]нетID регионов. Нумерация ВНУТРЕННЯЯ (не ОКАТО, не коды на номерах): Москва — 1, Воронежская область — 3, а 77 — Республика Бурятия. Справочник: https://fraim.ru/refs.json (раздел «Справочники кодов» в https://fraim.ru/docs)
platformsinteger[]нетID площадок (ЭТП). Нумерация внутренняя. Ходовые площадки — в https://fraim.ru/refs.json; там их не все, остальные видны в поле platform {id, name} выдачи
placement_typesinteger[]нетID способов размещения закупки. Нумерация внутренняя, справочник — https://fraim.ru/refs.json
purchase_typesinteger[]нетID типов закупки. Нумерация внутренняя, справочник — https://fraim.ru/refs.json
priceRangeFilterнетНачальная цена ЗАКУПКИ (НМЦК), не цена контракта
advanceRangeFilterнетРазмер аванса по контракту, % от цены (0–100) — поле «Размер аванса, %» извещения в ЕИС. Это НЕ обеспечение заявки и НЕ обеспечение контракта. 0 — аванс не предусмотрен или не указан; заполняется только у закупок из ЕИС (44-ФЗ/223-ФЗ), у коммерческих всегда 0. {"from": 1} — только закупки с авансом, {"to": 0} — без аванса
publish_dateDateRangeFilterнетДата публикации ЗАКУПКИ
start_dateDateRangeFilterнетДата начала подачи заявок по ЗАКУПКЕ
end_dateDateRangeFilterнетДата окончания подачи заявок по ЗАКУПКЕ
customerCustomerFilterнетЗаказчик закупки: inn[] / ogrn / name
statusstring[]нетСтатусы закупок, по умолчанию ["active"]. Выдача живая: тендер, сменивший статус и переставший подходить под фильтр, исчезает из неё сам и НЕ тарифицируется. На контрактную выдачу (QUERY /contracts) этот параметр не влияет; значения: 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). Ответ завёрнут в тот же конверт события, что и элемент ленты: {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_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. Контракты ищутся по тематике фильтра (ключевые слова, регионы, заказчик и т.д.). Параметр status на контрактную выдачу НЕ влияет: один и тот же filter_id годится и для мониторинга активных тендеров, и для получения контрактов по завершившимся закупкам той же темы — отдельный фильтр «со status completed» ради контрактов заводить не нужно. Исключение: у фильтра, заданного ТОЛЬКО статусом (без тематических критериев), контрактная проекция работает по прежним правилам. Контракт публикуется площадкой через несколько дней после завершения закупки и появляется в ленте в течение суток+ после публикации. В results[].contract объект приходит целиком (включая suppliers и блок родительской закупки tender) — GET /contracts/{id} на каждый элемент ленты делать не нужно.Ключевые слова работают так же, как у QUERY /tenders: поиск по нормализованным словам, слова короче 3 символов (а также годы и предлоги) в поиске не участвуют.

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

ПолеТипОбяз.Описание и ограничения
eventsstring[]нетКакие потоки читать. created — объект ВПЕРВЫЕ появился в вашем фильтре (неважно, только что опубликован или изменился и стал подходить под критерии); updated — изменился объект, который уже был в фильтре. По умолчанию ["created"] — «новое по моей теме». Задаётся в ПЕРВОМ запросе: набор потоков запоминает курсор, при чтении с курсором значение из тела игнорируется. Поле event в конверте результата считается относительно ВАС: created — вы видите объект впервые, updated — уже получали его раньше (возможно, через другой фильтр или карточку).; значения: created | updated
cursorstringнетПродолжение ленты. Непрозрачная подписанная строка из next_cursor предыдущего ответа — возвращайте как есть, не разбирайте. Несёт фильтр и набор потоков внутри: остальные поля тела повторять не нужно.
limitintegerнетдиапазон 1–100; по умолчанию 100
keywordsstring[]нетКлючевые слова/фразы (ИЛИ: закупка подходит при совпадении любого элемента; многословный элемент ищется как фраза)
exception_keywordsstring[]нетМинус-слова: исключить закупки с этими словами
sectionsinteger[]нетГде искать keywords и exception_keywords — части тендера: 1 наименование закупки, 2 наименование лота, 3 наименование позиции, 4 характеристики позиции. По умолчанию (поле опущено или пустой список) — во всех четырёх. Типовое применение: [1,2,3] — не искать по характеристикам товаров, где часто срабатывают ложные совпадения. Действует только вместе с keywords/exception_keywords; сами по себе секции множество не задают — иначе 400 validation_error
regionsinteger[]нетID регионов. Нумерация ВНУТРЕННЯЯ (не ОКАТО, не коды на номерах): Москва — 1, Воронежская область — 3, а 77 — Республика Бурятия. Справочник: https://fraim.ru/refs.json (раздел «Справочники кодов» в https://fraim.ru/docs)
platformsinteger[]нетID площадок (ЭТП). Нумерация внутренняя. Ходовые площадки — в https://fraim.ru/refs.json; там их не все, остальные видны в поле platform {id, name} выдачи
placement_typesinteger[]нетID способов размещения закупки. Нумерация внутренняя, справочник — https://fraim.ru/refs.json
purchase_typesinteger[]нетID типов закупки. Нумерация внутренняя, справочник — https://fraim.ru/refs.json
priceRangeFilterнетНачальная цена ЗАКУПКИ (НМЦК), не цена контракта
advanceRangeFilterнетРазмер аванса по контракту, % от цены (0–100) — поле «Размер аванса, %» извещения в ЕИС. Это НЕ обеспечение заявки и НЕ обеспечение контракта. 0 — аванс не предусмотрен или не указан; заполняется только у закупок из ЕИС (44-ФЗ/223-ФЗ), у коммерческих всегда 0. {"from": 1} — только закупки с авансом, {"to": 0} — без аванса
publish_dateDateRangeFilterнетДата публикации ЗАКУПКИ
start_dateDateRangeFilterнетДата начала подачи заявок по ЗАКУПКЕ
end_dateDateRangeFilterнетДата окончания подачи заявок по ЗАКУПКЕ
customerCustomerFilterнетЗаказчик закупки: inn[] / ogrn / name
filter_idstringнет
list_idstringнет
tender_idsinteger[]нет
supplierSupplierFilterнет

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

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

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

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

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

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

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

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

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

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

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

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

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

ПараметрГдеТипОбяз.Описание и ограничения
list_idpathstringда
Idempotency-KeyheaderstringдаКлюч идемпотентности (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_idpathstringда
Idempotency-KeyheaderstringдаКлюч идемпотентности (UUID v4), один на логическую операцию. Без него — 400 idempotency_key_required; тот же ключ с другим телом — 409 idempotency_key_reused_with_different_payload.

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

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

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

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

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

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

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

Filters

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

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

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

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

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

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

ПараметрГдеТипОбяз.Описание и ограничения
filter_idpathstringда
Idempotency-KeyheaderstringдаКлюч идемпотентности (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).

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

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

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

CustomerFilter

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

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

DateRangeFilter

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

ListCreated

Ответ POST /lists.

ПолеТипОбяз.Описание и ограничения
list_idstringда
namestringда
descriptionstringнет
metadataobjectнет
created_atstringда

ListDeleted

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

ПолеТипОбяз.Описание и ограничения
list_idstringда
deletedbooleanнетпо умолчанию true

ListItemsAdded

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

ПолеТипОбяз.Описание и ограничения
list_idstringда
addedintegerдаСколько id добавлено (без учёта уже бывших в списке)
totalintegerдаРазмер списка после операции

ListItemsRemoved

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

ПолеТипОбяз.Описание и ограничения
list_idstringда
removedintegerдаСколько id убрано (отсутствовавшие не считаются)

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да, бывает null

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да

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

Значения для 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ЕИС ЗАКУПКИ
98B2B-Center
16223ETP 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):

{ "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 из ответа.

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

Лимиты

ЧтоСколькоПодробности
Запросы к API100 в минутуСчитается на аккаунт, а не на токен. Это гарантированный минимум: счётчик живёт в памяти процесса, поэтому на практике может пропустить и больше — не стройте на этом расчёт. Сверх — 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 без keywords400. Раньше запрос принимался, но минус-слова молча не применялись — исключения работают только поверх непустого набора ключевых слов.
Считать, что keywords ушли в поиск как естьЧасть могла быть отброшена. Сверяйтесь с ignored_keywords в ответе: там keyword и причина — too_common или no_searchable_words.
Год в ключевых словах ради «закупок 2025 года»Годы (2000–2099) игнорируются наравне с короткими словами. Период задаётся датами фильтра, а не текстом.
keywords: ["ГОСТ 32144-2013"], ["ТУ 27.32.13-001-12345678-2022"]Такие обозначения не выбрасываются (в отличие от годов и коротких слов), фильтр примут — но найдёт он единицы объектов: точный номер стандарта обычно есть только в приложенной документации, не в тексте карточки. Ищите широким словом (["кабель силовой"]) и отсеивайте результат сами — вручную, через ИИ или exception_keywords.
Токен в браузерном коде или в репозиторииТокен = доступ к балансу. Только серверное окружение, только переменная окружения.

Ссылки