Fraim.ru Tenders API v2
Публичный API тендеров и контрактов РФ для сервер-сервер интеграций. Один эндпоинт закрывает поиск, подписку на изменения и выгрузку; остальное — детали реализации, которые сервис берёт на себя.
Модель в одном абзаце
Фильтр определяет множество закупок; тендеры и контракты — две ленты (проекции) одного фильтра. Фильтр создаётся неявно любым запросом и идентифицируется хешем нормализованных параметров. У каждой ленты — потоки событий created и updated, читаемые независимо через курсоры. Курсоры идут по оси индексации, а не по доменным датам — поэтому «отставшие» объекты не теряются. Биллинг привязан к первой доставке версии объекта, а не к запросам.
Базовый адрес
https://public.fraim.ru/api/v2
Что дальше
- Быстрый старт — первый запрос за 5 минут.
- Фильтры и ленты — как устроена выдача.
- Как читать выдачу — почему страницы не повторяются и пустой ответ — норма.
- Справочник эндпоинтов — все 17 маршрутов OpenAPI-схемы.
Быстрый старт
От тестового токена до боевого — без переписывания кода. Соберите запрос в песочнице тестовым токеном, затем подставьте боевой: тело и путь те же.
1. Получите токен
В личном кабинете, на странице «API-токены». Секрет показывается один раз — сразу положите его в переменную окружения на сервере.
POST /auth/tokens или кнопкой в кабинете.2. Найдите активные тендеры
curl -X POST https://public.fraim.ru/api/v2/tenders/query \ -H "Authorization: Bearer $FRAIM_TOKEN" \ -H "Content-Type: application/json" \ -d '{"keywords":["ремонт кровли","асфальт"], "regions":[1],"status":["active"],"limit":50}'
regions: это внутренние id Fraim.ru, а не ОКАТО и не коды на автомобильных номерах. Москва — 1, Воронежская область — 3, а 77 — Республика Бурятия. Коды всех четырёх параметров — в разделе Справочники кодов.Ответ несёт filter_id, страницу results и next_cursor. Каждый элемент — конверт события с целым объектом внутри:
{
"filter_id": "flt_6a40ebde3711b9b2",
"state": "ready",
"results": [
{ "event": "created", "version": 1, "billed": true,
"tender": {
"id": 4821337,
"etp_id": "0173200001425000456", // бывает null
"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, // справочно, бывает null
"billed": { "created": 50, "updated": 0, "free": 0,
"period_total": 50, "balance_exhausted": false }
}GET /tenders/{id} на каждый элемент ленты не нужно — и не нужно платить за это. В results[].tender объект уже целиком. Карточка добавляет только то, чего в списке нет: позиции, документы, лоты и полные данные организации (адрес, контакты). У контрактов так же: в results[].contract — целый объект с поставщиками и родительской закупкой.3. Продолжите ленту курсором
Следующую страницу берите тем же запросом, добавив cursor. Курсор несёт filter_id внутри — параметры повторять не нужно:
curl -X POST https://public.fraim.ru/api/v2/tenders/query \ -H "Authorization: Bearer $FRAIM_TOKEN" \ -d '{"cursor":"cur_eyJ…","limit":50}'
fraim_test_… → fraim_live_…. Код остаётся прежним.Аутентификация
Все маршруты требуют токен в заголовке Authorization:
Authorization: Bearer fraim_live_…
X-API-Token: заголовок ещё принимается для боевых токенов, но с ним не работает песочница — переключение тест/бой определяется только по Authorization, и тестовый токен уйдёт на боевой бэкенд и получит 401. Используйте Bearer.API-токен вечный — живёт до явного удаления. Создаётся в кабинете или запросом POST /auth/tokens. Секрет показывается один раз при создании — в списке токенов вы видите только маскированный token_preview. Активных токенов может быть до 20; на 21-м приходит 409 api_key_limit_reached.
Вход в кабинет — это не токен
Вход в личный кабинет даёт не API-токен, а сессию: JWT на 30 дней, для браузера. В списке /auth/tokens её нет, отзывать её нечем, и по истечении срока она перестаёт работать — 401 invalid_session. Для интеграции нужен именно API-токен: он не привязан к вашему входу, живёт до явного отзыва и не зависит от того, кто и когда заходил в кабинет.
Лимит запросов считается на аккаунт, а не на токен, — выпуск дополнительных токенов частоту не увеличивает. Токены нужны, чтобы разделять доступ по местам использования и отзывать их поштучно.
Тестовый и боевой
Тестовый токен (fraim_test_…) работает в песочнице на демо-данных и не списывает баланс. Боевой (fraim_live_…) обращается к реальным данным и тарифицируется. Держите токен только на сервере.
Фильтры, ленты и подписки
Фильтр — сохранённый запрос, определяющий множество закупок. Создаётся неявно любым QUERY-запросом; filter_id = детерминированный хеш нормализованных параметров. Одинаковые по смыслу параметры (другой порядок ключей, регистр, дубли) → всегда один и тот же фильтр.
Лента (feed) — поток событий по фильтру. У фильтра две ленты: тендерная и контрактная. Каждое событие — created или updated, несёт версию объекта.
Подписка — связка пользователь↔фильтр с TTL. Продлевается любым чтением; параметр events выбирает читаемые потоки. Подробнее — Подписки и TTL.
Как работают ключевые слова
Поиск идёт не по подстроке, а по нормализованным словам: текст разбивается на слова и приводится к начальной форме, поэтому «поставка насосов» находит «поставку насоса». Многословный элемент keywords — это фраза (слова должны встретиться вместе), разные элементы списка — ИЛИ.
Слово короче 3 символов в поиске не участвует. Вместе с ним выбрасываются годы (2024, 2025 — любой год 2000–2099) и служебные части речи (предлоги, союзы, частицы).
Игнорируется только «голое» число — само по себе, без соседних букв или символов. Составное обозначение, где число сцеплено с буквами, дефисом, слэшем, подчёркиванием или точкой, поиском не игнорируется и ищется как есть — даже если это год или короткий фрагмент:
ГОСТ 12345-78ГОСТ Р 52948-200838-09/зкМ-24-10Ф10.5-25w-40123-4562024-2025123/456220401/12312312-12312-вывABC-123-XYZ12.5x2010x20x30 ммabc_123файл_2024ДИТ-2024-ГПприказ-123SN123456S/N12345678abc123123asd8-800-555-35-35Из этого не следует, что стоит сужать поиск по конкретным номерам и обозначениям (ГОСТ 32144-2013, ТУ 27.32.13-001-12345678-2022) — такие коды обычно встречаются только в приложенной документации к закупке, а не в тексте самой карточки, и по ним почти ничего не найдётся. Лучше работают широкие ключевые слова (кабель силовой, электротехническая продукция) с последующим анализом найденных тендеров (например, через ИИ) и, при необходимости, exception_keywords для отсева лишнего.
"keywords": ["насос жд"]→ ищетсянасос: короткое слово из фразы выпадает."keywords": ["насос 2025"]→ ищетсянасос: год выпадает."keywords": ["насос", "жд"]→ ищетсянасос: второй элемент пустой и игнорируется."keywords": ["жд"]→ искать нечего,400«no searchable words»."exception_keywords": ["жд"]→ ничего не исключается.
Всё отброшенное перечислено в поле ignored_keywords ответа ([{keyword, reason}]) — по нему видно, что осталось от запроса. Короткие обозначения (жд, кс, ip) в фильтр закладывать бессмысленно: ищите по полному слову (железнодорожный) или отбирайте такие закупки у себя после выдачи.
Слишком общий запрос не принимается
Фильтр — это подписка на ленту, а не разовая выгрузка реестра. Запрос, под который подходит половина закупок страны, даёт нечитаемую ленту, поэтому действуют четыре ограничения.
keywordsобязателен.{"status":["active"],"regions":[1]}→400. Исключение — множество, заданное узко другим способом:ids,list_id,supplierили любое полеcustomer. «Все активные закупки Москвы» фильтром быть не может, «все закупки заказчика по ИНН» — может.- Слишком общие слова отбрасываются.
поставкавстречается у 41.8% активных закупок,лот— у 19.6%,услуга— у 17.1%: как фильтр они не сужают ничего. Отбрасывается только элемент из одного слова:["поставка труб стальных"]ищется целиком. - В минус-словах не отбрасывается ничего.
exception_keywords: ["поставка"]работает как написано: «услуги, но не поставки» — осмысленный запрос. - Если после этого искать нечего —
400«query is too general», а не пустая выдача.
Сверяется по начальной форме: «поставки», «поставку», «поставок» — это одно слово.
адрес, вид, выполнение, год, закупка, заявка, использование, количество, лот, назначение, наличие, номер, нужда, обеспечение, область, общий, объем, оказание, описание, определение, поставка, применение, проведение, работа, размер, соответствие, тип, услуга, форма, часть, являться
Чистка происходит до вычисления filter_id, поэтому ["поставка","насос"] и ["насос"] — один и тот же фильтр: одна подписка вместо двух. На стоимость это не влияет: платите вы за первую доставку версии объекта, и один и тот же тендер не тарифицируется дважды, даже если подходит под несколько ваших фильтров. И ещё: exception_keywords без непустых keywords → 400; раньше такой запрос принимался, но минус-слова молча не применялись.
Потоки created и updated
created— тендер впервые появился в вашем фильтре. Неважно, как это произошло: он был только что опубликован или изменился и стал подходить под критерии — в обоих случаях вы получите его в потокеcreated.updated— изменился тендер, который уже был в фильтре.
По умолчанию (без параметра events) вы получаете только created — поток «новые тендеры по моей теме». Чтобы следить и за изменениями, укажите events: ["created","updated"] в первом запросе: курсор запоминает выбранные потоки, и менять events при чтении с курсором нельзя — значение из тела игнорируется. Потоки читаются раздельно, под разные пайплайны (новые → скоринг/уведомления, изменения → апдейт своей БД), и у каждого свой курсор.
Поле event в конверте каждого результата считается относительно вас: created — вы видите объект впервые, updated — вы уже получали его раньше (возможно, через другой фильтр или карточку).
Ось порядка
Курсоры идут по оси индексации (момент обнаружения у нас), а не по доменным датам. Тендер, опубликованный неделю назад, но проиндексированный сегодня, получает свежую позицию и доезжает следующим опросом — «отставший» объект не проваливается за курсор. Порядок живой ленты — порядок обнаружения; хронологию сортируйте у себя.
Первый запрос и продолжение
Первый запрос возвращает всё, что уже подходит под фильтр на этот момент. Дальше по курсору идёт всё новое, что появится позже, — читается это одним и тем же способом. Как ведёт себя выдача при повторных чтениях, описано в разделе Как читать выдачу.
filter_id и параметры не нужно — достаточно последнего курсора: он несёт фильтр внутри себя. Следующую страницу можно запросить телом { "cursor": "cur_…" } вообще без других полей.Как читать выдачу
Выдача — живой список, а не зафиксированный снимок. Ниже — следствия, из-за которых интеграции чаще всего считают нормальное поведение багом.
Выдача всегда актуальна
Каждая страница отражает текущее состояние тендеров на момент запроса. Если фильтр задан со status: ["active"], в выдаче будут только тендеры, активные прямо сейчас: тендер, который завершился после попадания в вашу подписку, из выдачи исчезает автоматически. За такие тендеры баланс не списывается.
Страницы не повторяются байт-в-байт
Повторный запрос с тем же курсором может вернуть меньше объектов (часть перестала подходить под фильтр) или те же объекты с обновлёнными данными. Это нормальное поведение живой ленты, а не ошибка. Дедупликацию стройте на паре id + version, а не на составе страницы.
Пустая страница — валидный ответ
results: [] с next_cursor означает «на этом отрезке ленты сейчас нет подходящих объектов, продолжайте с нового курсора». Это не конец ленты и не повод останавливать поллинг: у живой ленты конца нет. Пустые ответы бесплатны.
total — справочное поле
total отвечает на вопрос «сколько закупок сейчас подходит под эти условия», а не «сколько объектов в вашей выборке». Считается он в фоне, а не в момент запроса: отдельная задача пересчитывает каждый фильтр раз в несколько минут, а запрос отдаёт последнее посчитанное число — поэтому total приходит мгновенно и ничего не замедляет.
Число живое: оно может измениться между двумя вашими запросами, в том числе прямо во время листания. Гарантии «показали 693 — вытащите ровно 693» нет.
total: null — нормальный ответ, а не ошибка, и клиент должен его переживать. Так бывает в четырёх случаях:
Исключение — точное число. Если множество задано перечислением ids или сохранённым списком (list_id), total точный: это размер списка.
total не определяют. Конец — это пустой results в ответе по курсору. И не пустой next_cursor: он не бывает пустым никогда, потому что лента живая и по тому же курсору позже придут новые тендеры.Подписки и TTL
Подписка — это ваша позиция в ленте фильтра. Она живёт, пока вы её читаете.
- Подписка на фильтр продлевается любым чтением и живёт 14 дней от последнего запроса.
- Новая подписка первые сутки живёт в пробном режиме: если вы не повторили запрос в течение 24 часов, она удаляется. Первый же запрос спустя 3+ часа после создания переводит её на полный срок (14 дней).
409 со свежим срезом — события за пропущенный период будут недоступны.Досрочно погасить подписку можно через DELETE /filters/{filter_id}; список живых — GET /filters с полем expires_at.
Как работают контракты
Контракты ищутся через закупки (тендеры), по тематике вашего фильтра: ключевые слова, регионы, заказчик и т.д. Сначала определяется множество закупок (критериями, filter_id, list_id или tender_ids), затем по ним выдаются связанные контракты.
status на контракты не влияет
Параметр status фильтра контрактную выдачу не меняет: можно использовать один и тот же filter_id и для мониторинга активных тендеров, и для получения контрактов по завершившимся закупкам той же темы. Заводить отдельный фильтр «со status: completed» ради контрактов не нужно.
filter_id в ответах двух лент разный. Если послать одни и те же критерии в /tenders/query и в /contracts/query, вернутся два разных идентификатора: в контрактный хеш входит status: ["completed"]. Передать тендерный filter_id в контрактный запрос можно и нужно — проекция построится. А вот сравнивать filter_id из двух ответов или использовать его как общий ключ дедупликации нельзя.Когда появляется контракт
Контракт публикуется площадкой через несколько дней после завершения закупки — до этого он не существует нигде. Как только он появляется у источника, он приезжает в вашу ленту. Мгновенной связки «закупка завершилась → контракт в ленте» не бывает: это ограничение источника, а не выдачи.
Первое обращение может вернуть preparing
Контракты производит фоновый опросчик. Первое обращение к контрактной проекции фильтра активирует её и может вернуть 202 preparing, пока идёт первичный обход. Повторите тот же запрос до готовности — другого протокола ожидания нет.
Свежесть
В ответе контрактной ленты — поле freshness.last_polled_at: честная нижняя граница момента, когда мы последний раз опрашивали источник по этому множеству.
Поиск по поставщику
Поставщик существует только в контракте. Есть два режима по ИНН:
- Пост-фильтр
supplier— сужает выдачу уже заданного множества; вfilter_idне входит, повторяйте в каждом запросе. - Supplier-подписка —
{"supplier": {...}}без других способов: контрактный фильтр «мои контракты» / «контракты конкурента». Тендерной проекции у него нет.
Заказчик контракта — это организация закупки, поэтому «контракты по заказчику» = обычные критерии customer.inn.
Документы
Файлы закупки и контракта — документация, ТЗ, проект контракта, акты — приходят в карточке объекта (GET /tenders/{id}, GET /contracts/{id}) в массиве documents[]. В ленте (QUERY) документов нет.
{
"ordinal": 1,
"title": "Приложение 3. Обоснование НМЦК",
"extension": "xlsx",
"link": "https://zakupki.gov.ru/44fz/filestore/…",
"id": "d6e9f213bc379a60315d1",
"download_url": "https://downloads.fraim.ru/f/MzQ6ZDZlOWYyMTNiYzM3…"
}Качайте по download_url, а не по link
link — адрес у первоисточника (ЕИС, электронная площадка, сайт заказчика). Он может быть недоступен, отдать HTML-страницу вместо файла или потребовать входа на площадку. download_url ведёт на наш сервер: файл отдаётся с корректным именем, а недавно скачанный — из нашего кеша, без обращения к источнику. Но сам файл живёт у первоисточника: если он его убрал, скачать не получится ни по одной из ссылок — см. «Когда документа нет».
Два способа скачать
- Готовая ссылка
download_url— постоянная, персональная, без срока годности и без заголовкаAuthorization. Её можно открыть в браузере, отправить письмом, положить в задачу или в CRM. Ссылка подписана для вашего аккаунта, и срок действия у неё не ограничен — но она ведёт за файлом к первоисточнику, а он свои файлы иногда убирает. Если документ нужен вам надолго, сохраните сам файл, а не ссылку на него. - Из кода по токену:
GET https://downloads.fraim.ru/d/{document_id}с обычнымAuthorization: Bearer. Рядом —GET https://downloads.fraim.ru/d/{document_id}/meta: состояние документа (state,filename,size,sha256) без скачивания.
Сколько стоит
Документы объекта, который вам уже доставлен, — бесплатны, сколько угодно раз. Если объект ещё не доставлялся, первое скачивание его документа тарифицируется как доставка объекта (одна единица баланса), дальше все его документы бесплатны. При нулевом балансе — 402 balance_exhausted. Подробнее — Биллинг.
Когда документа нет
Файлы хранятся у первоисточника, и он их иногда убирает. Чтобы вы понимали, стоит ли повторять, ответы разные:
410 document_gone— файл удалён у источника, повторять бессмысленно;409 document_restricted_at_source— площадка отдаёт файл только своим участникам;409 document_not_a_file— по ссылке источника сейчас страница, а не файл;503 document_temporarily_unavailable— источник недоступен, повторите поRetry-After;413 document_too_large— документ больше допустимого размера.
Ни один из этих ответов не списывает баланс. В песочнице скачивание недоступно (409 document_unavailable_in_sandbox): там демо-данные, а файлы лежат у боевых источников.
Биллинг
Модель — чистый баланс: доступ даёт токен, оплата пообъектная. Балансы раздельные по типам: tenders и contracts. Лицензий и суточных лимитов нет.
Правила
- Тарифицируется первая доставка версии объекта, а не запрос. Пустые ответы и поллинг бесплатны.
- Платны оба события:
createdиupdated(каждая новая версия). - Оплата постранично: нашли 5000, забрали 2 страницы по 50 → заплатили за 100. Глубину пагинации контролируете вы.
- Повторное получение того же объекта — бесплатно (
billed: false): тот же фильтр, другой фильтр, карточка, повтор курсора. - Тендеры, исключённые из выдачи из-за смены статуса, не списываются.
- Дедуп между фильтрами: объект под двумя вашими фильтрами тарифицируется один раз.
- Карточка
GET /{id}: списание только если версия ещё не доставлялась (общий учёт с лентами). - Поле
total— справочная фоновая оценка «сколько сейчас подходит под фильтр», а не счётчик оплаченного и не размер выборки; бываетnull. Планировать по нему расходы нельзя: платите вы за то, что реально пролистали. Подробнее — Как читать выдачу.
Поле billed
billed — это про деньги, не про «видел ли я объект». Видел ли — определяйте по своей базе (id + version). Повторная доставка той же версии в любом потоке — billed: false, бесплатно.
Недостаточный баланс
Страница отдаётся частично: доставлено и списано то, на что хватило, billed.balance_exhausted: true, а next_cursor указывает перед первым неоплаченным. HTTP 200 при частичной выдаче, 402 при нуле доставленных. После пополнения — продолжение с того же места без потерь и дублей.
Журнал
GET /balance/ledger — по строке на тарифицированную доставку: объект, версия, фильтр, request_id. Любое списание объяснимо построчно.
Курсоры и надёжность
Курсоры
Курсор — непрозрачная подписанная строка cur_<payload>.<sig>: позиция в конкретной ленте для конкретного набора событий. Возвращайте как есть. В ответе возвращается только next_cursor (одно поле = одна инструкция «сохрани это»). Невалидная подпись → 400 invalid_cursor.
Только курсоры — никаких page/page_size. До двух курсоров на ленту: по одному на поток created/updated.
Что гарантировано
- Каждое событие ленты доставляется не более одного раза. Повтор запроса со старым курсором не приводит ни к дублям, ни к повторным списаниям.
- События не теряются: всё, что появилось в ленте после вашей позиции, будет выдано при следующих чтениях — независимо от того, как часто вы опрашиваете (в пределах срока жизни подписки).
- Тендер, который вошёл в фильтр и вышел из него между вашими опросами (например, успел завершиться), не доставляется — на него уже нельзя отреагировать.
402 balance_exhausted— пополните баланс и повторите запрос с тем же курсором: выдача продолжится с первого неоплаченного объекта.
Идемпотентность
QUERY идемпотентен по построению — ретрайте свободно. На мутациях (создание токена, операции со списками, снятие подписки) заголовок Idempotency-Key обязателен; без него — 400 idempotency_key_required. Повтор с тем же ключом возвращает сохранённый ответ первого выполнения — те же поля и значения. Побайтового совпадения не ждите: порядок ключей в JSON может отличаться, поэтому сверять ответ по хешу тела или по подписи нельзя.
Обратная ситуация — тот же Idempotency-Key с другим телом — это 409 idempotency_key_reused_with_different_payload (тип idempotency_error). Ключ генерируется один раз на логическую операцию и не переиспользуется для следующей.
Пересборка ленты
Истечение подписки — не ошибка: следующий запрос вернёт preparing → пересборка → продолжение. Если продолжение без потерь невозможно, придёт 409 cursor_expired: лента была пересобрана, в теле уже лежит свежий срез и новый курсор — продолжайте с него, повторный запрос не нужен (повторные объекты бесплатны). Читайте их из detail: detail.results и detail.next_cursor, а не с верхнего уровня — см. формат ошибок. История за период вашего отсутствия недоступна.
Деградация под нагрузкой
Конкурентность тяжёлых поисков ограничена, но отказов нет: под пиком новый фильтр вернёт 202 preparing, а total/freshness могут отсутствовать (null). Правило одно: получил preparing — повтори тот же запрос. Ошибок 429/5xx из-за конкуренции поисков не бывает.
В теле 202 preparing есть два поля: retry_after — через сколько секунд повторить запрос, и progress — доля собранной ленты от 0 до 1 (для прогресс-индикатора, логику на нём строить не нужно). Ответ 200 со state: "partial" означает «часть ленты уже доступна, читайте курсором — продолжение доедет».
Эндпоинты
17 публичных маршрутов. Все требуют токен. QUERY — это POST /…/query с телом JSON.
Auth токены доступа
GET/auth/tokensсписок токенов
Все токены пользователя с маскированным превью — без секретов.
GET /auth/tokens
→ 200
{ "tokens": [ { "id": 12, "name": "Prod",
"token_preview": "064c...3b80", "is_active": true,
"created_at": "…", "last_used_at": null } ],
"total": 1 }POST/auth/tokensсоздать токен
Создаёт API-токен — сессией кабинета или уже имеющимся токеном. Требует Idempotency-Key. Секрет — в ответе один раз. Активных токенов не больше 20: на 21-м приходит 409 api_key_limit_reached, отзовите ненужный.
Idempotency-Key header, stringname body, stringPOST /auth/tokens
Idempotency-Key: 3f2a…
{ "name": "CRM интеграция" }
→ 200 { "id": 13, "token": "fraim_live_…" }DELETE/auth/tokens/{token_id}отозвать
Деактивирует токен. Требует Idempotency-Key. Можно удалять только свои токены.
token_id path, integerIdempotency-Key header, stringDELETE /auth/tokens/13
Idempotency-Key: 88ac…
→ 200 { "success": true, "message": "revoked" }Tenders поиск и лента
QUERY/tenders/queryпоиск и лента
Центральный эндпоинт. Ровно один способ задать множество: критерии поиска (включая ids) | filter_id | list_id. Смешение → 400. Продолжение ленты — курсором (несёт filter_id внутри) или тем же filter_id с начала. Выдача всегда актуальна: страница отражает состояние тендеров на момент запроса, поэтому повторный запрос с тем же курсором может вернуть меньше объектов или те же объекты с обновлёнными данными. Пустой results с next_cursor — норма, а не конец ленты. Поле total справочное: фоновая оценка «сколько сейчас подходит под фильтр», меняется между запросами и бывает null — конец выборки определяется пустым results, а не total. Каждое событие доставляется не более одного раза, повтор со старым курсором не даёт ни дублей, ни повторных списаний. Слишком общий запрос не принимается: keywords обязателен (кроме ids/list_id/supplier/customer), одиночные общие слова отбрасываются и перечисляются в ignored_keywords, а если после этого искать нечего — 400.
keywords string[]exception_keywords string[]sections integer[]regions integer[]platforms integer[]price {from,to}advance {from,to}publish_date {from,to}start_date {from,to}end_date {from,to}placement_types integer[]purchase_types integer[]customer {inn[],ogrn,name}status string[]ids integer[]filter_id / list_id stringevents string[]cursor stringlimit integerлишние поля bodyQUERY /tenders/query
{ "keywords": ["асфальт","укладка асфальта"],
"exception_keywords": ["ямочный"],
"regions": [1],
"price": {"from": 100000, "to": 5000000},
"customer": {"inn": ["7707083893"]},
"status": ["active"], "events": ["created"], "limit": 100 }
→ 200 { "filter_id","state":"ready","total":5000,
"results":[{ "event":"created","version":1,"billed":true,
"tender":{ /* объект целиком: name, price,
region, platform, status,
organization, даты */ } }],
"next_cursor":"cur_…",
"billed":{"created":100,"updated":0,"free":0,
"period_total":100,"balance_exhausted":false} }GET/tenders/{id}карточка тендера
Ответ завёрнут в тот же конверт события, что и элемент ленты: { event, version, billed, tender }. Карточка богаче элемента ленты ровно на то, чего в списке нет: лоты, позиции (постранично, с ОКПД2/КТРУ), документы, полная организация (адрес, контакты администратора), placement_type/purchase_type, purchase_plan_id. Всё остальное уже пришло в ленте — за карточкой ради name или price ходить не нужно. Списание — только если версия ещё не доставлялась.
id pathid_type querypositions_page querypositions_limit queryGET /tenders/4821337?positions_page=1&positions_limit=20
→ 200
{ "event":"created", "version":1, "billed":false,
"tender": {
"id":4821337, "etp_id":"0173…", "name":"…", "price":4850000.0,
"region":{…}, "platform":{…}, "status":{…},
"organization":{ "name","inn","kpp","ogrn","address",
"administrator":{"name","phone","email"} }, // бывает null
"lots":[{"ordinal":1,"title":"…","price":4850000.0}],
"positions":[{ "lot_ordinal":1, "position_ordinal":1,
"title":"…", "characteristics":"",
"quantity":1.0, "unit":"шт",
"price":35000.0, "amount":35000.0,
"okpd2":{"code":null,"name":null}, // объект даже без кода
"ktru":null }], // бывает null
"positions_total":54, "positions_page":1,
"positions_page_size":20, "positions_pages":3,
"documents":[{"title","link","extension"}],
"documents_total":7 } }Contracts контракты по закупкам
QUERY/contracts/queryконтракты по множеству
Контракты ищутся через закупки (тендеры) — по тематике вашего фильтра: ключевые слова, регионы, заказчик и т.д. Ровно один способ задать множество: критерии закупок | filter_id | list_id | tender_ids | supplier. Параметр status фильтра на контрактную выдачу НЕ влияет: один и тот же filter_id годится и для мониторинга активных тендеров, и для получения контрактов по завершившимся закупкам той же темы — отдельный фильтр «со status completed» заводить не нужно. supplier — пост-фильтр по поставщику ИЛИ отдельная supplier-подписка.
keywords, regions, … —tender_ids integer[]supplier {inn[],name}filter_id / list_id stringevents, cursor, limit —// контракты поставщика по ИНН (supplier-подписка)
QUERY /contracts/query
{ "supplier": {"inn": ["7701234567"]} }
// контракты по критериям закупок
{ "keywords": ["асфальт"], "regions": [1] }
→ 202 preparing → повтор → 200 ready
{ "results":[{ "event":"created","version":1,"billed":true,
"contract":{ "id":55123, "reestr_number":"…",
"name":"…", "price":4850000.0, "status":"…",
"conclusion_date":"…", "execution_date":"…",
"currency":{"code":"RUB","name":"…"},
"suppliers":[{"name","inn","kpp","country_code",
"address","postal_address","phone",
"email","status"}],
"tender":{"id","etp_id","name"} } }] } // только 3 поляGET/contracts/{id}карточка контракта
Как и у тендеров, ответ завёрнут в конверт события: { event, version, billed, contract }. Карточка добавляет позиции (постранично — positions[] плюс positions_total/positions_page/positions_page_size/positions_pages), документы, исполнение, заказчика и версионные поля ЕИС (ikz, version_number, is_changed, changed_fields). id_type: internal | reestr. Списание — только для недоставленной версии.
id pathid_type querypositions_page / positions_limit queryGET /contracts/55123?id_type=internal
→ 200 { "event":"created", "version":1, "billed":false,
"contract": { "id":55123, "suppliers":[…],
"currency":{"code":"RUB","name":"Российский рубль"},
"customer":{…}, "execution":{…},
"positions":[{ "position_ordinal":1, "title":"…",
"quantity":1.0, "unit":"шт", "price":580.46,
"amount":580.46, "okpd2":{…}, "ktru":null,
"nds":"…", "mnn":null, "is_vital_drug":false }],
"positions_total":2153, "positions_page":1,
"positions_page_size":20, "positions_pages":108,
"documents":[…], "documents_total":12,
"ikz":"…", "plan_position_number":"…",
"result_date":"…", "penalty_withholding":false,
"version_number":3, "is_changed":true,
"changed_fields":{…} } }Lists изменяемые коллекции закупок
POST/listsсоздать список
Персональная изменяемая коллекция тендеров без TTL. name 1–255, description ≤2000, metadata ≤2048 байт. После создания поля неизменяемы. Живёт до явного удаления через DELETE /lists/{list_id}. PATCH нет — после создания меняется только состав.
name body, stringdescription body, stringmetadata body, objectIdempotency-Key headerPOST /lists
Idempotency-Key: a1b2…
{ "name": "Проект Юг", "metadata": {"project_id": 42} }
→ 200 { "list_id": "lst_b20d926e2be5636487", … }DELETE/lists/{list_id}удалить список
Удаляет список вместе с составом и гасит подписку на ленту фильтра-над-списком. Уже доставленные объекты остаются доставленными — повторно за них деньги не списываются. Требует Idempotency-Key.
list_id pathIdempotency-Key headerDELETE /lists/lst_b20d926e2be5636487
Idempotency-Key: 9f8e…
→ 200 { "list_id": "lst_b20d926e2be5636487", "deleted": true }GET/listsмои списки
Курсорная пагинация. Конверт коллекции: { data, has_more, next_cursor }.
limit querystarting_after queryGET /lists?limit=20
→ 200 { "data":[{"list_id":"lst_b20d926e2be5636487","name":"…"}],
"has_more": false, "next_cursor": null }GET/lists/{list_id}детали и состав
Метаданные списка + состав в поле items — это вложенный конверт коллекции, а не поля верхнего уровня. items.data — массив tender_id (просто числа), момент добавления не отдаётся.
list_id pathlimit querystarting_after queryGET /lists/lst_b20d926e2be5636487
→ 200 { "list_id":"lst_b20d926e2be5636487", "name":"Проект Юг",
"description":null, "metadata":{…},
"created_at":"2026-08-01T09:00:00Z",
"items": { "data":[4821001, 4821337],
"has_more":false, "next_cursor":null } }POST/lists/{list_id}/itemsдобавить тендеры
Идемпотентно: повторное добавление не создаёт дублей. Добавленный тендер сразу виден через QUERY {list_id}, его контракты доливаются в контрактную ленту. Существование тендеров проверяется: несуществующий id даёт 400 validation_error, и пакет не применяется частично — либо входят все, либо ни одного.
list_id pathids body, integer[]Idempotency-Key headerPOST /lists/lst_b20d926e2be5636487/items
Idempotency-Key: c3d4…
{ "ids": [4821337, 4899210] }DELETE/lists/{list_id}/itemsубрать тендеры
Идемпотентно. Доставленное остаётся доставленным; будущие события убранного в ленты не попадают.
list_id pathids body, integer[]Idempotency-Key headerDELETE /lists/lst_b20d926e2be5636487/items
Idempotency-Key: e5f6…
{ "ids": [4821337] }Filters обвязка для отладки
GET/filtersмои подписки
Живые подписки: параметры, expires_at, created_at и флаг contracts_projection. Фильтры создаются только неявно любым QUERY — это отладочная обвязка, не happy path. Подписка живёт 14 дней от последнего чтения и продлевается любым запросом; новая первые сутки работает в пробном режиме и удаляется, если её не перечитали в течение 24 часов.
limit querystarting_after queryGET /filters
→ 200 { "data":[{ "filter_id":"flt_6a40ebde3711b9b2",
"params":{…}, "expires_at":"…",
"created_at":"…",
"contracts_projection": true }], "has_more":false }DELETE/filters/{filter_id}погасить подписку
Гасит вашу персональную подписку досрочно. Сам фильтр (общий кеш параметров) не удаляется.
filter_id pathIdempotency-Key headerDELETE /filters/flt_6a40ebde3711b9b2 Idempotency-Key: 77aa…
Balance баланс и списания
GET/balanceбаланс и расход
Баланс по типам (tenders/contracts) и usage_today — тарифицированных доставок за сутки (справочно, не лимит).
GET /balance
→ 200 { "balance": {"tenders": 4312, "contracts": 84},
"usage_today": {"tenders": 218, "contracts": 17},
"last_updated": "2026-07-24T11:42:00Z" }GET/balance/ledgerжурнал списаний
По строке на тарифицированную доставку: объект, версия, фильтр, request_id. Курсорная пагинация.
limit querystarting_after queryGET /balance/ledger?limit=100
→ 200 { "data":[{ "id":98765, "ts":"2026-08-05T12:30:00Z",
"balance_type":"tenders", "amount":-1, "event":"created",
"object_id":4821337, "version":1,
"filter_id":"flt_…", "request_id":"req_…" }],
"has_more": true, "next_cursor": 98765 }Справочники кодов
Значения для regions, platforms, purchase_types и placement_types. Это внутренние id Fraim.ru: не ОКАТО, не коды на автомобильных номерах и не коды ЕИС. Москва — 1, Санкт-Петербург — 34, а 77 — Республика Бурятия.
region, platform и далее, поэтому по ним же можно сверять ответы. Снимок сверен с боевым API 2026-08-09. Те же таблицы с поиском — в личном кабинете, а для скрипта — одним файлом: /refs.json. regionsРегионы · 90
Нумерация внутренняя: это НЕ ОКАТО, не коды на автомобильных номерах и не коды ЕИС. Москва — 1, Санкт-Петербург — 34, а 77 — Республика Бурятия.
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Площадки (ЭТП) · 26 из нескольких сотен
Здесь наиболее используемые площадки; всего их в индексе несколько сотен. Код нужной площадки всегда виден в поле platform {id, name} любой выдачи.
2РТС–тендер20Росэлторг418АО «Сбербанк-АСТ»13Портал Закупай110ЭТП ГПБ8Портал закупок Москвы39Фабрикант67ТЭК-Торг1ЕИС ЗАКУПКИ98B2B-Center16223ETP ZakazRF250БП ZakazRF12Лот-Онлайн113Электронный магазин ГЗ СПб11ТендерПро5Единый агрегатор торговли «Березка»106Бидзаар62Закупки Ленрег108Сбер B2B53Торги ЭТП Регион60Торги82 ЭТП194ЭТП Р-Эст386ЭТП Торги Онлайн421АО «Сбербанк-АСТ» (УТП)445ЭТП «Торги 223»446ЭТП ФЕДЕРАЦИЯ ЗАКУПОК"platforms": [2]
purchase_typesТипы закупки · 5
Закон или режим, по которому проводится закупка.
1Закупка по 44ФЗ2Закупка по 223ФЗ3Коммерческая закупка4Малая закупка5Закупка по 615ПП"purchase_types": [1]
placement_typesСпособы размещения · 23
Форма процедуры. Приходит в карточке тендера полем placement_type.
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]
Если нужного значения в таблице нет, посмотрите его в самой выдаче: объекты несут region {id, name} и platform {id, name}, а карточка тендера — placement_type и purchase_type. Отдельного маршрута справочников у API нет.
Лимиты
Лимиты рассчитаны так, чтобы обычная интеграция их не замечала. Упереться можно в основном при выгрузке в несколько параллельных потоков.
429: подождите Retry-After и повторите тот же запрос — ничего не потеряно, за отклонённый запрос списаний нет. Заголовок есть у обоих ограничителей: у всплеска это 1 секунда, у минутного лимита — 60. Не подставляйте свою константу: пауза в минуту там, где хватило бы секунды, — это потерянная минута на каждой пачке.Под пиковой нагрузкой на поиск API не отказывает, а отвечает 202 preparing — см. Курсоры и деградацию. С частотой запросов это не связано: 429 всегда приходит от ограничителя частоты, а не от загруженности поиска.
Коды ошибок
Тело ошибки вложено в detail — так его отдаёт FastAPI:
{
"detail": {
"error": {
"type": "invalid_request_error",
"code": "validation_error",
"message": "…",
"param": "cursor", // поле, из-за которого ошибка, либо null
"request_id": "req_…"
}
}
}request_id приходит заголовком X-Request-Id в каждом ответе, а в теле ошибок дублируется полем error.request_id. По нему же сходятся строки GET /balance/ledger — указывайте его в обращениях в поддержку (support@fraim.ru). Если пришлёте свой X-Request-Id в запросе, API вернёт именно его: сквозная трассировка работает. Единственный ответ без него — отказ лимитера всплесков (Retry-After: 1): он формируется до приложения, поэтому идентификатора у него нет.
422 и 429. Особый случай один — у 409 cursor_expired внутри detail лежит не только error, но и свежий срез ленты: detail.state, detail.results, detail.next_cursor. 429. Обе разновидности отвечают этой же формой и несут Retry-After: минутный лимит на аккаунт — 60, лимит всплесков по IP — 1. Второй срабатывает раньше при выгрузке в несколько параллельных потоков и отличается сообщением burst limit exceeded; только у него request_id равен null, потому что отказ формируется до приложения.