Начало

Fraim Tenders API v2

Публичный API тендеров и контрактов РФ для сервер-сервер интеграций. Один эндпоинт закрывает поиск, подписку на изменения и выгрузку; остальное — детали реализации, которые сервис берёт на себя.

Только с сервера. API-токен — это доступ к вашему балансу; в браузерном коде его украдёт первая же XSS. CORS отключён намеренно. Нужен показ данных в браузере — ходите в API со своего бэкенда, токен держите на сервере.

Модель в одном абзаце

Фильтр определяет множество закупок; тендеры и контракты — две ленты (проекции) одного фильтра. Фильтр создаётся неявно любым запросом и идентифицируется хешем нормализованных параметров. У каждой ленты — потоки событий created и updated, читаемые независимо через курсоры. Курсоры идут по оси индексации, а не по доменным датам — поэтому «отставшие» объекты не теряются. Биллинг привязан к первой доставке версии объекта, а не к запросам.

Базовый адрес

https://api-v2.fraim.ru/api/v2

Что дальше

Начало

Быстрый старт

От тестового токена до боевого — без переписывания кода. Соберите запрос в песочнице тестовым токеном, затем подставьте боевой: тело и путь те же.

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, string
Email пользователя
password query, string
Пароль
token_name query, string
Название токена (по умолчанию «API Token»)
POST /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, string
Обязателен на мутациях
name body, string
Название токена, 1–100 символов
POST /auth/tokens
Idempotency-Key: 3f2a…
{ "name": "CRM интеграция" }

→ 200  { "id": 13, "token": "frm_live_…" }
DELETE/auth/tokens/{token_id}отозвать

Деактивирует токен. Требует Idempotency-Key. Можно удалять только свои токены.

Параметры
token_id path, integer
ID токена
Idempotency-Key header, string
Обязателен
DELETE /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[]
ID площадок (ЭТП)
okpd2 string[]
Коды ОКПД2, допустимы префиксы («42.11»)
price {from,to}
Начальная цена закупки (НМЦК)
publish_date {from,to}
Дата публикации, ISO-даты
customer {inn[],ogrn,name}
Заказчик закупки
status string[]
active | completed | cancelled; дефолт ["active"]
ids integer[]
PK-lookup, ≤1000; любые статусы, синхронно
filter_id / list_id string
Альтернативные способы задать множество
events string[]
created | updated; дефолт ["created"]
cursor string
Продолжение ленты; несёт filter_id внутри — можно прислать один, без других полей
limit integer
1–100
QUERY /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 path
Внутренний id или etp_id
id_type query
internal (по умолчанию) | external
positions_page query
Страница позиций, по умолч. 1
positions_limit query
Размер, по умолч. 20
GET /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, …
Те же критерии закупок, что у /tenders, без ids/status
tender_ids integer[]
Разовое множество закупок (заморожено)
supplier {inn[],name}
Пост-фильтр или supplier-подписка по поставщику контракта
filter_id / list_id string
Контрактная проекция существующего множества
events, cursor, limit
Как у /tenders
// контракты поставщика по ИНН (supplier-подписка)
QUERY /contracts/query
{ "supplier": {"inn": ["7701234567"]} }

// контракты по критериям закупок
{ "keywords": ["асфальт"], "regions": [77] }

→ 202 preparing  → повтор → 200 ready { "results":[…] }
GET/contracts/{id}карточка контракта

Полная карточка: позиции (постранично), поставщики, исполнение. id_type: internal | reestr. Списание — только для недоставленной версии.

Параметры
id path
Внутренний id или реестровый номер
id_type query
internal | reestr
positions_page / positions_limit query
Пагинация позиций
GET /contracts/55123?id_type=internal

→ 200 { "id":55123, "suppliers":[…],
        "execution":{…}, "positions":[…] }

Lists изменяемые коллекции закупок

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

Персональная изменяемая коллекция тендеров без TTL. name 1–255, description ≤2000, metadata ≤2048 байт. После создания поля неизменяемы.

Параметры
name body, string
1–255 символов
description body, string
≤2000 символов
metadata body, object
JSON ≤2048 байт, не индексируется
Idempotency-Key header
Обязателен
POST /lists
Idempotency-Key: a1b2…
{ "name": "Проект Юг", "metadata": {"project_id": 42} }

→ 200 { "list_id": "lst_9f2a", … }
GET/listsмои списки

Курсорная пагинация. Конверт коллекции: { data, has_more, next_cursor }.

Параметры
limit query
По умолчанию 20
starting_after query
Курсор
GET /lists?limit=20

→ 200 { "data":[{"list_id":"lst_9f2a","name":"…"}],
        "has_more": false, "next_cursor": null }
GET/lists/{list_id}детали и состав

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

Параметры
list_id path
ID списка
limit query
По умолчанию 100
starting_after query
Курсор по составу
GET /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 path
ID списка
ids body, integer[]
1–10000 tender_id
Idempotency-Key header
Обязателен
POST /lists/lst_9f2a/items
Idempotency-Key: c3d4…
{ "ids": [4821337, 4899210] }
DELETE/lists/{list_id}/itemsубрать тендеры

Идемпотентно. Доставленное остаётся доставленным; будущие события убранного в ленты не попадают.

Параметры
list_id path
ID списка
ids body, integer[]
tender_id к удалению
Idempotency-Key header
Обязателен
DELETE /lists/lst_9f2a/items
Idempotency-Key: e5f6…
{ "ids": [4821337] }

Filters обвязка для отладки

GET/filtersмои подписки

Живые подписки: параметры, expires_at, флаги проекций. Фильтры создаются только неявно любым QUERY — это отладочная обвязка, не happy path.

Параметры
limit query
По умолчанию 20
starting_after query
Курсор
GET /filters

→ 200 { "data":[{ "filter_id":"flt_8a3c9d12",
        "params":{…}, "expires_at":"…",
        "contracts_projection": true }], "has_more":false }
DELETE/filters/{filter_id}погасить подписку

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

Параметры
filter_id path
ID фильтра
Idempotency-Key header
Обязателен
DELETE /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 query
По умолчанию 100
starting_after query
Курсор
GET /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 есть в каждом ответе и в журнале списаний.

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