Fraim Tenders API v2
Публичный API тендеров и контрактов РФ для сервер-сервер интеграций. Один эндпоинт закрывает поиск, подписку на изменения и выгрузку; остальное — детали реализации, которые сервис берёт на себя.
Модель в одном абзаце
Фильтр определяет множество закупок; тендеры и контракты — две ленты (проекции) одного фильтра. Фильтр создаётся неявно любым запросом и идентифицируется хешем нормализованных параметров. У каждой ленты — потоки событий created и updated, читаемые независимо через курсоры. Курсоры идут по оси индексации, а не по доменным датам — поэтому «отставшие» объекты не теряются. Биллинг привязан к первой доставке версии объекта, а не к запросам.
Базовый адрес
https://api-v2.fraim.ru/api/v2
Что дальше
- Быстрый старт — первый запрос за 5 минут.
- Фильтры и ленты — как устроена выдача.
- Справочник эндпоинтов — все 17 маршрутов.
Быстрый старт
От тестового токена до боевого — без переписывания кода. Соберите запрос в песочнице тестовым токеном, затем подставьте боевой: тело и путь те же.
1. Получите токен
В личном кабинете или через логин/пароль:
# вернёт вечный токен доступа
curl -X POST "https://api-v2.fraim.ru/api/v2/auth/login?login=you@company.ru&password=…&token_name=Prod"2. Найдите активные тендеры
curl -X POST https://api-v2.fraim.ru/api/v2/tenders/query \ -H "X-API-Token: $FRAIM_TOKEN" \ -H "Content-Type: application/json" \ -d '{"keywords":["ремонт кровли","асфальт"], "regions":[77],"status":["active"],"limit":50}'
Ответ несёт filter_id, страницу results и next_cursor. Каждый элемент — конверт события с объектом внутри:
{
"filter_id": "flt_8a3c9d12",
"state": "ready",
"results": [
{ "event": "created", "version": 1, "billed": true,
"tender": { "id": 4821337, "etp_id": "0173…" } }
],
"next_cursor": "cur_eyJ…",
"billed": { "created": 50, "free": 0 }
}3. Продолжите ленту курсором
Следующую страницу берите тем же запросом, добавив cursor. Курсор несёт filter_id внутри — параметры повторять не нужно:
curl -X POST https://api-v2.fraim.ru/api/v2/tenders/query \ -H "X-API-Token: $FRAIM_TOKEN" \ -d '{"cursor":"cur_eyJ…","limit":50}'
frm_test_… → frm_live_…. Код остаётся прежним.Аутентификация
Все маршруты, кроме /auth/login, требуют токен. Передавайте его одним из двух способов:
X-API-Token: frm_live_…
# или
Authorization: Bearer frm_live_…Токен вечный — живёт до явного удаления. Создать можно логином/паролем (POST /auth/login) или уже имеющимся токеном (POST /auth/tokens). Секрет показывается один раз при создании — в списке токенов вы видите только маскированный token_preview.
Тестовый и боевой
Тестовый токен (frm_test_…) работает в песочнице на демо-данных и не списывает баланс. Боевой (frm_live_…) обращается к реальным данным и тарифицируется. Держите токен только на сервере.
Фильтры, ленты и подписки
Фильтр — сохранённый запрос, определяющий множество закупок. Создаётся неявно любым QUERY-запросом; filter_id = детерминированный хеш нормализованных параметров. Одинаковые по смыслу параметры (другой порядок ключей, регистр, дубли) → всегда один и тот же фильтр.
Лента (feed) — append-only поток событий по фильтру. У фильтра две ленты: тендерная и контрактная. Каждое событие — created (объект вошёл в множество) или updated (объект множества изменился), несёт версию объекта.
Подписка — связка пользователь↔фильтр с TTL. Продлевается любым чтением; параметр events выбирает читаемые потоки.
Потоки created и updated
Потоки читаются раздельно — под разные пайплайны (новые → скоринг/уведомления, изменения → апдейт своей БД). Дефолт — ["created"]; платная подписка на изменения включается явно. Поле event в конверте говорит правду про вас: created — версия доставляется впервые, updated — у вас была более старая. Изменившийся объект, который вы никогда не получали, приедет из updated-потока с event: "created".
Ось порядка
Курсоры идут по оси индексации (момент обнаружения у нас), а не по доменным датам. Тендер, опубликованный неделю назад, но проиндексированный сегодня, получает свежую позицию и доезжает следующим опросом — «отставший» объект не проваливается за курсор. Порядок живой ленты — порядок обнаружения; хронологию сортируйте у себя.
Снапшот и живая лента
Первый запрос возвращает снапшот — всё, что уже подходит под фильтр на этот момент. Дальше идёт живая лента — всё новое, что появится позже. Граница между ними фиксируется один раз, поэтому объекты не теряются и не приходят дважды.
filter_id и параметры не нужно — достаточно последнего курсора: он несёт фильтр внутри себя. Следующую страницу можно запросить телом { "cursor": "cur_…" } вообще без других полей.Как работают контракты
Контракты ищутся через закупки (тендеры): сначала определяется множество тендеров (критериями, filter_id, list_id или tender_ids), затем по ним выдаются связанные контракты.
У критериев закупок в QUERY /contracts нет поля status: множество всегда строится по завершённым закупкам — контракты существуют только у них. Присланный status игнорируется.
Первое обращение может вернуть preparing
Контракты производит фоновый опросчик. Первое обращение к контрактной проекции фильтра активирует её и может вернуть 202 preparing, пока идёт первичный обход. Повторите тот же запрос до готовности — другого протокола ожидания нет.
Свежесть
В ответе контрактной ленты — поле freshness.last_polled_at: честная нижняя граница момента, когда мы последний раз опрашивали источник по этому множеству.
Поиск по поставщику
Поставщик существует только в контракте. Есть два режима по ИНН:
- Пост-фильтр
supplier— сужает выдачу уже заданного множества; вfilter_idне входит, повторяйте в каждом запросе. - Supplier-подписка —
{"supplier": {...}}без других способов: контрактный фильтр «мои контракты» / «контракты конкурента». Тендерной проекции у него нет.
Заказчик контракта — это организация закупки, поэтому «контракты по заказчику» = обычные критерии customer.inn.
Биллинг
Модель — чистый баланс: доступ даёт токен, оплата пообъектная. Балансы раздельные по типам: tenders и contracts. Лицензий и суточных лимитов нет.
Правила
- Тарифицируется первая доставка версии объекта, а не запрос. Пустые ответы и поллинг бесплатны.
- Платны оба события:
createdиupdated(каждая новая версия). - Оплата постранично: нашли 5000, забрали 2 страницы по 50 → заплатили за 100. Глубину пагинации контролируете вы.
- Ретрай той же страницы бесплатен — версии уже в
delivered. - Дедуп между фильтрами: объект под двумя вашими фильтрами тарифицируется один раз.
- Карточка
GET /{id}: списание только если версия ещё не доставлялась (общий учёт с лентами).
Поле 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.
Идемпотентность
QUERY идемпотентен по построению — ретрайте свободно. На мутациях (создание токена, операции со списками, снятие подписки) заголовок Idempotency-Key обязателен; без него — 400 idempotency_key_required. Повтор с тем же ключом возвращает сохранённый ответ первого выполнения байт-в-байт.
Пересборка ленты
Протухание подписки — не ошибка: следующий запрос вернёт preparing → пересборка → продолжение. Если продолжение без потерь невозможно, придёт 409 cursor_expired со свежим срезом и новым курсором в теле (повторные объекты бесплатны).
Деградация под нагрузкой
Конкурентность тяжёлых поисков ограничена, но отказов нет: под пиком новый фильтр вернёт 202 preparing, а total/freshness могут отсутствовать (null). Правило одно: получил preparing — повтори тот же запрос. Ошибок 429/5xx из-за конкуренции поисков не бывает.
Эндпоинты
17 публичных маршрутов. Все, кроме /auth/login, требуют токен. QUERY — это POST /…/query с телом JSON.
Auth токены доступа
POST/auth/loginтокен по логину/паролю
Авторизация email/паролем для получения вечного токена. Единственный маршрут без токена.
login query, stringpassword query, stringtoken_name query, stringPOST /auth/login?login=you@company.ru&password=…&token_name=Prod
→ 200
{ "id": 12, "name": "Prod",
"token": "frm_live_…", // показывается один раз
"created_at": "2026-07-24T09:00:00Z" }GET/auth/tokensсписок токенов
Все токены пользователя с маскированным превью — без секретов.
GET /auth/tokens
→ 200
{ "tokens": [ { "id": 12, "name": "Prod",
"token_preview": "frm_…k9x1", "is_active": true,
"created_at": "…", "last_used_at": "…" } ],
"total": 1 }POST/auth/tokensсоздать токен
Создаёт токен уже имеющимся токеном. Требует Idempotency-Key. Секрет — в ответе один раз.
Idempotency-Key header, stringname body, stringPOST /auth/tokens
Idempotency-Key: 3f2a…
{ "name": "CRM интеграция" }
→ 200 { "id": 13, "token": "frm_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 с начала.
keywords string[]exception_keywords string[]regions integer[]platforms integer[]okpd2 string[]price {from,to}publish_date {from,to}customer {inn[],ogrn,name}status string[]ids integer[]filter_id / list_id stringevents string[]cursor stringlimit integerQUERY /tenders/query
{ "keywords": ["асфальт","укладка асфальта"],
"exception_keywords": ["ямочный"],
"regions": [77], "okpd2": ["42.11"],
"price": {"from": 100000, "to": 5000000},
"customer": {"inn": ["7707083893"]},
"status": ["active"], "events": ["created"], "limit": 100 }
→ 200 { "filter_id","state":"ready","total":5000,
"results":[…], "next_cursor":"cur_…",
"billed":{"created":100,"free":0} }GET/tenders/{id}карточка тендера
Полная карточка: лоты, позиции (постранично, с ОКПД2/КТРУ), документы, организация. Списание — только если версия ещё не доставлялась.
id pathid_type querypositions_page querypositions_limit queryGET /tenders/4821337?positions_page=1&positions_limit=20
→ 200 { "id":4821337, "lots":[…], "positions":[…],
"positions_total":54, "documents":[…],
"organization":{…} }Contracts контракты по закупкам
QUERY/contracts/queryконтракты по множеству
Контракты ищутся через закупки (тендеры). Ровно один способ задать множество: критерии закупок | filter_id | list_id | tender_ids | supplier. У критериев закупок НЕТ status (всегда завершённые). 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": [77] }
→ 202 preparing → повтор → 200 ready { "results":[…] }GET/contracts/{id}карточка контракта
Полная карточка: позиции (постранично), поставщики, исполнение. id_type: internal | reestr. Списание — только для недоставленной версии.
id pathid_type querypositions_page / positions_limit queryGET /contracts/55123?id_type=internal
→ 200 { "id":55123, "suppliers":[…],
"execution":{…}, "positions":[…] }Lists изменяемые коллекции закупок
POST/listsсоздать список
Персональная изменяемая коллекция тендеров без TTL. name 1–255, description ≤2000, metadata ≤2048 байт. После создания поля неизменяемы.
name body, stringdescription body, stringmetadata body, objectIdempotency-Key headerPOST /lists
Idempotency-Key: a1b2…
{ "name": "Проект Юг", "metadata": {"project_id": 42} }
→ 200 { "list_id": "lst_9f2a", … }GET/listsмои списки
Курсорная пагинация. Конверт коллекции: { data, has_more, next_cursor }.
limit querystarting_after queryGET /lists?limit=20
→ 200 { "data":[{"list_id":"lst_9f2a","name":"…"}],
"has_more": false, "next_cursor": null }GET/lists/{list_id}детали и состав
Метаданные списка + состав (tender_id, курсорная пагинация).
list_id pathlimit querystarting_after queryGET /lists/lst_9f2a
→ 200 { "list_id":"lst_9f2a", "name":"…",
"data":[4821337, 4821001], "has_more":false }POST/lists/{list_id}/itemsдобавить тендеры
Идемпотентно: повторное добавление не создаёт дублей. Добавленный тендер сразу виден через QUERY {list_id}, его контракты доливаются в контрактную ленту.
list_id pathids body, integer[]Idempotency-Key headerPOST /lists/lst_9f2a/items
Idempotency-Key: c3d4…
{ "ids": [4821337, 4899210] }DELETE/lists/{list_id}/itemsубрать тендеры
Идемпотентно. Доставленное остаётся доставленным; будущие события убранного в ленты не попадают.
list_id pathids body, integer[]Idempotency-Key headerDELETE /lists/lst_9f2a/items
Idempotency-Key: e5f6…
{ "ids": [4821337] }Filters обвязка для отладки
GET/filtersмои подписки
Живые подписки: параметры, expires_at, флаги проекций. Фильтры создаются только неявно любым QUERY — это отладочная обвязка, не happy path.
limit querystarting_after queryGET /filters
→ 200 { "data":[{ "filter_id":"flt_8a3c9d12",
"params":{…}, "expires_at":"…",
"contracts_projection": true }], "has_more":false }DELETE/filters/{filter_id}погасить подписку
Гасит вашу персональную подписку досрочно. Сам фильтр (общий кеш параметров) не удаляется.
filter_id pathIdempotency-Key headerDELETE /filters/flt_8a3c9d12 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":"led_…","balance_type":"tenders",
"amount":1, "object_id":4821337, "version":1,
"filter_id":"flt_…", "request_id":"req_…" }],
"has_more": true, "next_cursor": 987 }Коды ошибок
Формат тела ошибки — {"error": {"code", "message", "request_id"}}. request_id есть в каждом ответе и в журнале списаний.