Fraim.ru Tenders API V2 — документация для ИИ-агентов
HTTP API по тендерам и контрактам России (44-ФЗ, 223-ФЗ, коммерческие торги): поиск закупок, живые ленты изменений по сохранённому фильтру, карточки тендеров и контрактов, история по ИНН заказчика и поставщика. Только server-to-server, авторизация Bearer-токеном, оплата — за доставленный объект, а не за запрос.
Документ самодостаточен: его можно целиком положить в контекст модели. Справочник эндпоинтов, параметры и схемы объектов сгенерированы из OpenAPI-схемы API. Всё, что схемой не выражается — модель данных, биллинг, формы ошибок, лимиты — описано ниже по боевому поведению; расхождения между схемой и API оговорены там, где они есть.
- Base URL:
https://public.fraim.ru/api/v2 - Версия API: 2.0.0 (OpenAPI 3.1.0, снапшот схемы от 2026-09-10)
- Этот документ одним файлом: https://fraim.ru/ai.md
- Машинная схема (OpenAPI 3.1): https://public.fraim.ru/api/v2/openapi.json
- Документация для людей: https://fraim.ru/docs
- Личный кабинет (токены, баланс, пополнение и счёт, песочница): https://lk.fraim.ru
- Поддержка (ошибки, интеграция, доступы): support@fraim.ru
- Любые вопросы (цены, объёмы, договор): info@fraim.ru
Кратко: правила интеграции
- Ходите в API только с сервера. Токен — это доступ к балансу; CORS отключён намеренно, из браузера API не работает.
- Авторизация — заголовок
Authorization: Bearer <token>. Легаси-заголовокX-API-Tokenне используйте: с ним не работает песочница. - Поиск — это
POST /tenders/queryс JSON-телом, а не GET со строкой запроса. Отдельного/searchнет. - Множество задаётся ровно одним способом: критерии |
filter_id|list_id(у контрактов ещёtender_ids). Смешение →400 validation_error. - Пагинация только курсорная. Возьмите
next_cursorиз ответа и пришлите{"cursor": "cur_…"}— остальные поля повторять не нужно, курсор несёт фильтр внутри. Параметровpage/offsetне существует. - Выдача — живой список, а не снапшот: каждая страница отражает состояние тендеров на момент запроса. Повторный запрос с тем же курсором может вернуть меньше объектов или те же объекты с обновлёнными данными; пустой
resultsсnext_cursor— валидный ответ, а не конец ленты. total— справочное поле: сколько объектов сейчас подходит под фильтр, а не размер вашей выборки. Считается фоном (пересчёт раз в несколько минут), поэтому меняется между запросами, в том числе прямо во время листания: гарантии «показали 693 — вытащите ровно 693» нет. Конец выборки определяйте по пустомуresults, а не поtotalи не поnext_cursor(он не бывает пустым — лента живая).total: null— нормальный ответ, а не ошибка. Точное число приходит только на запросе поidsилиlist_id: это размер списка.- Потоки выбираются один раз, в первом запросе:
eventsпопадает в курсор, при чтении с курсором значение из тела игнорируется. Нужны изменения — сразу шлитеevents: ["created","updated"]. 202 preparing— не ошибка: идёт фоновый сбор. Повторите тот же запрос черезretry_afterсекунд.state: "partial"в 200-ответе значит «читать уже можно, лента ещё дособирается».- Опрашивайте фильтр не реже раза в сутки: подписка живёт 14 дней от последнего чтения, а новая — первые сутки в пробном режиме. Умершая подписка означает
409и потерю событий за период молчания. - Тарифицируется первая доставка версии объекта, а не запрос. Лишняя пагинация «на всякий случай» стоит денег; пустые ответы и повторы бесплатны.
- На мутациях (
POST/DELETE) заголовокIdempotency-Keyобязателен — иначе400 idempotency_key_required. Кладите UUID v4 и повторяйте его при ретрае; тот же ключ с ДРУГИМ телом —409 idempotency_key_reused_with_different_payload. - При
429дождитесьRetry-Afterи повторите — заголовок есть всегда. Ограничителя два: минутный на аккаунт (Retry-After: 60; считается на аккаунт, а не на токен — заводить новые токены ради частоты бессмысленно) и лимит всплесков по IP (Retry-After: 1). Параллельная выгрузка в несколько потоков упирается во второй раньше: держите 2–3 потока, а не 20. - Другого способа аутентификации, кроме вечного API-токена (
fraim_live_…), нет: возьмите его в кабинете или создайте черезPOST /auth/tokens. Сессия браузера для интеграции не годится. - Объект приходит целиком прямо в ленте — в
results[].tender(иresults[].contract) лежит весь списочный объект, а не заглушка из пары полей. Не делайтеGET /tenders/{id}на каждый элемент выдачи: это лишние запросы и лишние деньги. Карточка нужна только ради позиций, документов, лотов и полной организации. - Тело ошибки вложено в
detail:{"detail": {"error": {…}}}— у всех кодов без исключений, включая422и429. Особый случай один: у409 cursor_expiredвнутриdetailрядом сerrorлежит свежий срез ленты (detail.results,detail.next_cursor). - Документы качайте по
download_urlиз карточки, а не поlink.link— адрес у первоисточника (ЕИС, площадка, сайт заказчика), он может стать недоступен или потребовать входа на площадку;download_urlведёт на нашdownloads.fraim.ru, работает без заголовкаAuthorization, срок действия у неё не ограничен. Но файл всё равно берётся у первоисточника (недавно скачанный — из нашего кеша), поэтому удалённый у источника документ не отдаст ни одна из ссылок:410 document_gone. Документы уже доставленного объекта бесплатны. - Слова короче 3 символов в ключевых и минус-словах игнорируются — как и годы (2000–2099) и предлоги.
["насос жд"]ищется как["насос"], а["жд"]даёт400. Отброшенное перечислено вignored_keywordsответа. keywordsобязателен — фильтр без ключевых слов не примут (400), кроме случаев, когда множество задано узко черезids,list_id,supplierилиcustomer. Одиночные слишком общие слова (поставка,услуга,работа,лот,номер…) отбрасываются, внутри фразы остаются, вexception_keywordsне трогаются; если после отбрасывания искать нечего —400.- Дедуплицируйте у себя по паре
id+version. Полеbilledотвечает на вопрос «списали ли деньги», а не «видел ли я этот объект». - Даты — ISO-8601. По умолчанию
status: ["active"]иevents: ["created"]; ленту изменений включайте явно. regions,platforms,placement_types,purchase_typesпринимают внутренние id Fraim.ru — это не ОКАТО, не коды регионов на автомобильных номерах и не коды ЕИС. Москва —1, Санкт-Петербург —34, а77— Республика Бурятия. Коды всех четырёх справочников напечатаны ниже, в разделе «Справочники кодов» — берите оттуда. Не угадывайте номер: ошибки не будет, придёт выдача по другому региону.- Лишние поля верхнего уровня тела запроса игнорируются молча (
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, хотя схема помечает поле обязательным.
Два окружения различаются только префиксом токена, адрес и тело запроса те же:
fraim_test_…— песочница на демо-данных, баланс не списывается;fraim_live_…— боевые данные, тарифицируется.
Переключение теста и боя определяется по заголовку Authorization. Легаси-заголовок X-API-Token ещё принимается для боевых токенов, но с ним тестовый токен уйдёт на боевой бэкенд и получит 401 — используйте Bearer.
Модель данных
Четыре понятия, без которых ответы API читаются неправильно.
Фильтр — сохранённый запрос, определяющий множество закупок. Создаётся неявно любым QUERY-запросом; filter_id — детерминированный хеш нормализованных параметров, поэтому одинаковые по смыслу запросы (другой порядок ключей, регистр, дубли) дают один и тот же фильтр.
Лента (feed) — поток событий по фильтру. У фильтра две ленты: тендерная и контрактная. Событие бывает created (объект впервые появился в вашем фильтре — неважно, только что опубликован или изменился и стал подходить под критерии) и updated (изменился объект, который уже был в фильтре); потоки читаются независимо, каждый своим курсором. Поле event описывает вашу историю, а не историю объекта: изменившийся тендер, которого вы раньше не получали, приедет как created.
Подписка — связка «вы ↔ фильтр» с TTL: она хранит вашу позицию в ленте и продлевается любым чтением.
Курсор — непрозрачная подписанная строка cur_<payload>.<sig>: позиция в конкретной ленте для конкретного набора событий. Возвращайте её как есть. Курсор идёт по оси индексации (когда объект обнаружен нами), а не по датам публикации, поэтому «отставшая» закупка не проваливается за курсор, а доезжает следующим запросом. Хронологию сортируйте у себя.
Версия — счётчик изменений объекта. Пара id + version — ключ дедупликации на вашей стороне.
Первый запрос отдаёт всё, что подходит под фильтр сейчас, дальше идёт поток того, что появится позже. Читается это одним и тем же способом — курсором.
Как работают ключевые слова
Поиск идёт не по подстроке, а по нормализованным словам: текст разбивается на слова и приводится к начальной форме, поэтому «поставка насосов» находит «поставку насоса». Многословный элемент keywords — это фраза (слова должны встретиться вместе), разные элементы списка — ИЛИ.
Слово короче 3 символов в поиске не участвует — оно выбрасывается вместе с остальным «шумом»:
- слова из 1–2 символов (
жд,ip,а); - годы (
2024,2025и любой год 2000–2099); - служебные части речи — предлоги, союзы, частицы (
для,и,с).
Игнорируется только «голое» число — само по себе, без соседних букв или символов. Составные обозначения, где число сцеплено с буквами, дефисом, слэшем, подчёркиванием или точкой, поиском не игнорируются и ищутся как есть, даже если это год или короткий фрагмент:
- коды с буквами:
ГОСТ 12345-78,ГОСТ Р 52948-2008,38-09/зк,М-24-10,Ф10.5-2,5w-40,10w-30,20w-50; - число + дефис/слэш + число:
123-456,2024-2025,123/456,220401/123; - артикулы с несколькими дефисами:
12312-12312-выв,123-4567-890,ABC-123-XYZ; - размеры с
x/х:12.5x20,10x20x30 мм,12х20; - обозначения с подчёркиванием:
abc_123,файл_2024; - смешанные буквенно-цифровые обозначения:
ДИТ-2024-ГП,приказ-123,SN123456,S/N12345678,abc123,123asd,asd123; - дефисные цепочки:
8-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 обязателен. Запрос без них — 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 означает «на этом отрезке ленты сейчас нет подходящих объектов, продолжайте с нового курсора», а не «лента кончилась».
Гарантии курсора
- Каждое событие ленты доставляется не более одного раза. Повтор запроса со старым курсором не приводит ни к дублям, ни к повторным списаниям.
- События не теряются: всё, что появилось в ленте после вашей позиции, будет выдано при следующих чтениях — независимо от того, как часто вы опрашиваете (в пределах срока жизни подписки).
- Тендер, который вошёл в фильтр и вышел из него между вашими опросами (например, успел завершиться), не доставляется — на него уже нельзя отреагировать.
409 cursor_expired— лента была пересобрана (подписка истекала). Тело ответа содержит свежий срез и новыйnext_cursor— под ключомdetail: продолжайте с него. История за период отсутствия недоступна.402 balance_exhausted— пополните баланс и повторите запрос с тем же курсором: выдача продолжится с первого неоплаченного объекта.
Подписки и TTL
- Подписка на фильтр продлевается любым чтением и живёт 14 дней от последнего запроса.
- Новая подписка первые сутки живёт в пробном режиме: если вы не повторили запрос в течение 24 часов, она удаляется. Первый же запрос спустя 3+ часа после создания переводит её на полный срок (14 дней).
- Рекомендация: опрашивайте фильтр не реже раза в сутки. Если подписка умерла, ваш курсор вернёт
409со свежим срезом — события за пропущенный период будут недоступны.
Состояния ответа
state | HTTP | Что делать |
|---|---|---|
ready | 200 | Обычный ответ, лента собрана. |
partial | 200 | Часть ленты уже доступна, читайте курсором — продолжение доедет; total может отсутствовать. |
preparing | 202 | Читать нечего, идёт сбор. Повторите тот же запрос через 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"
}
Про поля конверта, о которые чаще всего спотыкаются:
applied— что API реально применил после нормализации. Форма непостоянна: у запроса критериями там естьstatus, у запроса сlist_idилиids— нет, а у контрактной ленты появляетсяstatus: ["completed"], хотя параметраstatusу контрактов не существует. Полезен как проверка «меня правильно поняли»: лишнее поле верхнего уровня в запросе игнорируется молча, и разойтись с ожиданием можно тихо.total— справочное число: сколько закупок сейчас подходит под фильтр («столько в базе под эти условия»), а не размер вашей выборки. Считается фоном, не в момент запроса: отдельная задача пересчитывает каждый фильтр раз в несколько минут, а запрос отдаёт последнее посчитанное значение — поэтомуtotalне замедляет ответ и поэтому же он живой. Между двумя запросами число меняется, в том числе прямо во время листания: гарантии «показали 693 — вытащите ровно 693» нет. Конец выборки поtotalне определяют — конец это пустойresults(next_cursorпустым не бывает никогда: лента живая, и по тому же курсору позже приедут новые тендеры). Может бытьnull, и это нормальный ответ, а не ошибка — клиент обязан его переживать: фильтр слишком тяжёлый, чтобы его считать (например, «все завершённые» — миллионы записей), лента ещё собирается (preparingилиpartial), потокupdated(конечного числа там не существует) или значение ещё ни разу не посчитано. Исключение — запрос поidsили по сохранённому списку (list_id): тамtotalточный, это размер списка.freshness.last_polled_at(только контрактная лента) — честная нижняя граница момента, когда мы последний раз опрашивали источник по этому множеству.request_idв теле успешного ответа не приходит — он отдаётся заголовкомX-Request-Id. В теле ошибок он дополнительно лежит вerror.request_id, и по нему же сходятся строкиGET /balance/ledger.
Что лежит в 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.
- Тарифицируется первая доставка версии объекта, а не запрос: пустые ответы, поллинг и ретраи той же страницы бесплатны. Повторное получение того же объекта (тот же фильтр, другой фильтр, карточка, повтор курсора) — бесплатно,
billed: false. - Платны оба события —
createdиupdated(каждая новая версия). - Тендеры, исключённые из выдачи из-за смены статуса, не списываются.
- Поле
total— справочная фоновая оценка «сколько сейчас подходит под фильтр», а не счётчик оплаченного и не размер выборки; бываетnull. Планировать расходы по нему нельзя: платите вы за то, что реально пролистали. - Оплата постранично: нашли 5000, забрали две страницы по 50 — заплатили за 100. Глубину пагинации контролирует клиент.
- Дедуп между фильтрами: объект под двумя вашими фильтрами тарифицируется один раз.
GET /tenders/{id}иGET /contracts/{id}списывают, только если эта версия вам ещё не доставлялась.- При нехватке баланса страница отдаётся частично:
billed.balance_exhausted: true,next_cursorуказывает перед первым неоплаченным объектом, HTTP 200. Если доставить не удалось ничего —402 balance_exhausted. После пополнения чтение продолжается с того же места без потерь и дублей. - Документы объекта, который вам уже доставлен, скачиваются бесплатно — сколько угодно раз. Если объект ещё не доставлялся, первое скачивание его документа тарифицируется как доставка самого объекта (одна единица), после чего остальные его документы бесплатны.
- Каждое списание объяснимо построчно:
GET /balance/ledger(объект, версия, фильтр,request_id).
Документы закупок и контрактов
У тендера и контракта есть прикреплённые файлы — документация, ТЗ, проекты контрактов, акты. Они приходят в карточке объекта (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.
Когда файла нет
Документы хранятся у первоисточника, и он их иногда убирает. Мы отвечаем разными кодами, чтобы клиент понимал, стоит ли повторять:
| HTTP | code | Что делать |
|---|---|---|
| 410 | document_gone | Файл удалён у источника. Повторять бессмысленно. |
| 409 | document_restricted_at_source | Площадка отдаёт файл только своим участникам. |
| 409 | document_not_a_file | По ссылке источника сейчас страница, а не файл. |
| 503 | document_temporarily_unavailable | Источник недоступен. Повторить по Retry-After. |
| 413 | document_too_large | Документ больше допустимого размера. |
Ни один из этих ответов не списывает баланс.
Ограничения
- Скачивание тестовым токеном (
fraim_test_…) недоступно:409 document_unavailable_in_sandbox— в песочнице демо-данные, а файлы лежат у боевых источников. - Формат ошибок тот же, что у API: тело вложено в
detail. idдокумента стабилен, пока у объекта не изменился состав документов; надёжный порядок работы — брать свежую карточку иdownload_urlиз неё.
Типовые сценарии
1. Разовый поиск закупок
POST /tenders/query с критериями → читайте results, при необходимости следующая страница по next_cursor. Ограничивайте выдачу критериями, а не пагинацией: каждая страница платная.
2. Мониторинг новых закупок (основной сценарий)
- Первый запрос с критериями — получаете первый срез и
next_cursor. - Сохраняете только
next_cursor. - По расписанию (например, раз в 5–15 минут) шлёте
{"cursor": "<сохранённый>"}. Реже раза в сутки опрашивать нельзя — умрёт подписка. - Пустой
results— нормально и бесплатно;next_cursorобновляете всегда. 409 cursor_expired— не ошибка: свежий срез и новый курсор уже в теле, под ключомdetail(detail.results,detail.next_cursor).
3. Отслеживание изменений
То же самое, но events задаётся в первом запросе: "events": ["created","updated"], если нужны и новые, и изменившиеся, или ["updated"] — только изменения. Дальше значение зашито в курсор и в теле игнорируется; сменить набор потоков можно только начав ленту заново. Потоки независимы — держите по курсору на каждый.
4. Контракты поставщика или заказчика по ИНН
- Контракты конкретного поставщика:
POST /contracts/queryс{"supplier": {"inn": ["…"]}}. - Контракты заказчика: те же критерии закупок —
{"customer": {"inn": ["…"]}}. - Можно переиспользовать
filter_idтендерного фильтра: передайте его вPOST /contracts/queryявно — контрактная проекция того же множества соберётся.statusна контрактную выдачу не влияет, отдельный фильтр «со status completed» заводить не нужно. - Но
filter_idв ответах двух лент совпадает не всегда. В контрактный хеш всегда входитstatus: ["completed"], а тендерный по умолчанию берёт["active"]— поэтому одно и то же тело даёт разные id. Пошлите в/tenders/queryявныйstatus: ["completed"]— и id совпадут. Не сравнивайте id двух ответов вслепую: идентификатором множества служит тот, что вы передали сами. - Первое обращение к контрактной проекции может вернуть
202 preparing— повторите.
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-Key | header | string | да | Ключ идемпотентности (UUID v4), один на логическую операцию. Без него — 400 idempotency_key_required; тот же ключ с другим телом — 409 idempotency_key_reused_with_different_payload. |
Тело запроса — application/json, схема TokenCreateRequest:
| Поле | Тип | Обяз. | Описание и ограничения |
|---|---|---|---|
name | string | да | Название токена; длина 1–100 |
DELETE /auth/tokens/{token_id} — Удалить API токен
Деактивирует API токен. Удалять можно только свои токены; на чужой или несуществующий id — 404. token_id = 0 — заглушечный id сессии кабинета. Успех и no-op он даёт только когда запрос сделан САМОЙ сессией (отзывать нечего). Тот же запрос с API-токеном получит 404: строки с таким id в реестре ключей нет.
| Параметр | Где | Тип | Обяз. | Описание и ограничения |
|---|---|---|---|---|
token_id | path | integer | да | — |
Idempotency-Key | header | string | да | Ключ идемпотентности (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:
| Поле | Тип | Обяз. | Описание и ограничения |
|---|---|---|---|
events | string[] | нет | Какие потоки читать. created — объект ВПЕРВЫЕ появился в вашем фильтре (неважно, только что опубликован или изменился и стал подходить под критерии); updated — изменился объект, который уже был в фильтре. По умолчанию ["created"] — «новое по моей теме». Задаётся в ПЕРВОМ запросе: набор потоков запоминает курсор, при чтении с курсором значение из тела игнорируется. Поле event в конверте результата считается относительно ВАС: created — вы видите объект впервые, updated — уже получали его раньше (возможно, через другой фильтр или карточку).; значения: created | updated |
cursor | string | нет | Продолжение ленты. Непрозрачная подписанная строка из next_cursor предыдущего ответа — возвращайте как есть, не разбирайте. Несёт фильтр и набор потоков внутри: остальные поля тела повторять не нужно. |
limit | integer | нет | диапазон 1–100; по умолчанию 100 |
keywords | string[] | нет | Ключевые слова/фразы (ИЛИ: закупка подходит при совпадении любого элемента; многословный элемент ищется как фраза) |
exception_keywords | string[] | нет | Минус-слова: исключить закупки с этими словами |
sections | integer[] | нет | Где искать keywords и exception_keywords — части тендера: 1 наименование закупки, 2 наименование лота, 3 наименование позиции, 4 характеристики позиции. По умолчанию (поле опущено или пустой список) — во всех четырёх. Типовое применение: [1,2,3] — не искать по характеристикам товаров, где часто срабатывают ложные совпадения. Действует только вместе с keywords/exception_keywords; сами по себе секции множество не задают — иначе 400 validation_error |
regions | integer[] | нет | ID регионов. Нумерация ВНУТРЕННЯЯ (не ОКАТО, не коды на номерах): Москва — 1, Воронежская область — 3, а 77 — Республика Бурятия. Справочник: https://fraim.ru/refs.json (раздел «Справочники кодов» в https://fraim.ru/docs) |
platforms | integer[] | нет | ID площадок (ЭТП). Нумерация внутренняя. Ходовые площадки — в https://fraim.ru/refs.json; там их не все, остальные видны в поле platform {id, name} выдачи |
placement_types | integer[] | нет | ID способов размещения закупки. Нумерация внутренняя, справочник — https://fraim.ru/refs.json |
purchase_types | integer[] | нет | ID типов закупки. Нумерация внутренняя, справочник — https://fraim.ru/refs.json |
price | RangeFilter | нет | Начальная цена ЗАКУПКИ (НМЦК), не цена контракта |
advance | RangeFilter | нет | Размер аванса по контракту, % от цены (0–100) — поле «Размер аванса, %» извещения в ЕИС. Это НЕ обеспечение заявки и НЕ обеспечение контракта. 0 — аванс не предусмотрен или не указан; заполняется только у закупок из ЕИС (44-ФЗ/223-ФЗ), у коммерческих всегда 0. {"from": 1} — только закупки с авансом, {"to": 0} — без аванса |
publish_date | DateRangeFilter | нет | Дата публикации ЗАКУПКИ |
start_date | DateRangeFilter | нет | Дата начала подачи заявок по ЗАКУПКЕ |
end_date | DateRangeFilter | нет | Дата окончания подачи заявок по ЗАКУПКЕ |
customer | CustomerFilter | нет | Заказчик закупки: inn[] / ogrn / name |
status | string[] | нет | Статусы закупок, по умолчанию ["active"]. Выдача живая: тендер, сменивший статус и переставший подходить под фильтр, исчезает из неё сам и НЕ тарифицируется. На контрактную выдачу (QUERY /contracts) этот параметр не влияет; значения: active | completed | cancelled |
ids | integer[] | нет | — |
filter_id | string | нет | — |
list_id | string | нет | — |
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_id | path | string | да | — |
id_type | query | string | нет | по умолчанию "internal" |
positions_page | query | integer | нет | по умолчанию 1 |
positions_limit | query | integer | нет | по умолчанию 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:
| Поле | Тип | Обяз. | Описание и ограничения |
|---|---|---|---|
events | string[] | нет | Какие потоки читать. created — объект ВПЕРВЫЕ появился в вашем фильтре (неважно, только что опубликован или изменился и стал подходить под критерии); updated — изменился объект, который уже был в фильтре. По умолчанию ["created"] — «новое по моей теме». Задаётся в ПЕРВОМ запросе: набор потоков запоминает курсор, при чтении с курсором значение из тела игнорируется. Поле event в конверте результата считается относительно ВАС: created — вы видите объект впервые, updated — уже получали его раньше (возможно, через другой фильтр или карточку).; значения: created | updated |
cursor | string | нет | Продолжение ленты. Непрозрачная подписанная строка из next_cursor предыдущего ответа — возвращайте как есть, не разбирайте. Несёт фильтр и набор потоков внутри: остальные поля тела повторять не нужно. |
limit | integer | нет | диапазон 1–100; по умолчанию 100 |
keywords | string[] | нет | Ключевые слова/фразы (ИЛИ: закупка подходит при совпадении любого элемента; многословный элемент ищется как фраза) |
exception_keywords | string[] | нет | Минус-слова: исключить закупки с этими словами |
sections | integer[] | нет | Где искать keywords и exception_keywords — части тендера: 1 наименование закупки, 2 наименование лота, 3 наименование позиции, 4 характеристики позиции. По умолчанию (поле опущено или пустой список) — во всех четырёх. Типовое применение: [1,2,3] — не искать по характеристикам товаров, где часто срабатывают ложные совпадения. Действует только вместе с keywords/exception_keywords; сами по себе секции множество не задают — иначе 400 validation_error |
regions | integer[] | нет | ID регионов. Нумерация ВНУТРЕННЯЯ (не ОКАТО, не коды на номерах): Москва — 1, Воронежская область — 3, а 77 — Республика Бурятия. Справочник: https://fraim.ru/refs.json (раздел «Справочники кодов» в https://fraim.ru/docs) |
platforms | integer[] | нет | ID площадок (ЭТП). Нумерация внутренняя. Ходовые площадки — в https://fraim.ru/refs.json; там их не все, остальные видны в поле platform {id, name} выдачи |
placement_types | integer[] | нет | ID способов размещения закупки. Нумерация внутренняя, справочник — https://fraim.ru/refs.json |
purchase_types | integer[] | нет | ID типов закупки. Нумерация внутренняя, справочник — https://fraim.ru/refs.json |
price | RangeFilter | нет | Начальная цена ЗАКУПКИ (НМЦК), не цена контракта |
advance | RangeFilter | нет | Размер аванса по контракту, % от цены (0–100) — поле «Размер аванса, %» извещения в ЕИС. Это НЕ обеспечение заявки и НЕ обеспечение контракта. 0 — аванс не предусмотрен или не указан; заполняется только у закупок из ЕИС (44-ФЗ/223-ФЗ), у коммерческих всегда 0. {"from": 1} — только закупки с авансом, {"to": 0} — без аванса |
publish_date | DateRangeFilter | нет | Дата публикации ЗАКУПКИ |
start_date | DateRangeFilter | нет | Дата начала подачи заявок по ЗАКУПКЕ |
end_date | DateRangeFilter | нет | Дата окончания подачи заявок по ЗАКУПКЕ |
customer | CustomerFilter | нет | Заказчик закупки: inn[] / ogrn / name |
filter_id | string | нет | — |
list_id | string | нет | — |
tender_ids | integer[] | нет | — |
supplier | SupplierFilter | нет | — |
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_id | path | string | да | — |
id_type | query | string | нет | по умолчанию "internal" |
positions_page | query | integer | нет | по умолчанию 1 |
positions_limit | query | integer | нет | по умолчанию 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 — Мои списки
Курсорная пагинация по спискам пользователя.
| Параметр | Где | Тип | Обяз. | Описание и ограничения |
|---|---|---|---|---|
limit | query | integer | нет | по умолчанию 20 |
starting_after | query | string | нет | — |
POST /lists — Создать список
Персональная изменяемая коллекция тендеров без TTL — живёт до явного удаления через DELETE /lists/{list_id}. Поля после создания неизменяемы (PATCH нет); состав меняется через POST/DELETE /lists/{list_id}/items.
| Параметр | Где | Тип | Обяз. | Описание и ограничения |
|---|---|---|---|---|
Idempotency-Key | header | string | да | Ключ идемпотентности (UUID v4), один на логическую операцию. Без него — 400 idempotency_key_required; тот же ключ с другим телом — 409 idempotency_key_reused_with_different_payload. |
Тело запроса — application/json, схема ListCreateRequest:
| Поле | Тип | Обяз. | Описание и ограничения |
|---|---|---|---|
name | string | да | длина 1–255 |
description | string | нет | длина 0–2000 |
metadata | object | нет | — |
GET /lists/{list_id} — Детали и состав списка
Метаданные списка + состав. Состав лежит во ВЛОЖЕННОМ конверте коллекции items (data/has_more/next_cursor), а не полями верхнего уровня; items.data — просто массив tender_id (числа), момент добавления не отдаётся. Продолжение — starting_after = последний tender_id страницы.
| Параметр | Где | Тип | Обяз. | Описание и ограничения |
|---|---|---|---|---|
list_id | path | string | да | — |
limit | query | integer | нет | по умолчанию 100 |
starting_after | query | integer | нет | — |
DELETE /lists/{list_id} — Удалить список
Удаляет список целиком вместе с его составом. Гасит и вашу подписку на ленту фильтра-над-списком, если она была. Сам фильтр (общий кеш параметров) остаётся — его чистит TTL-пруннинг, как и у DELETE /filters/{filter_id}. Уже доставленные объекты остаются доставленными: повторно за них деньги не списываются, а история списаний в /balance/ledger не переписывается.
| Параметр | Где | Тип | Обяз. | Описание и ограничения |
|---|---|---|---|---|
list_id | path | string | да | — |
Idempotency-Key | header | string | да | Ключ идемпотентности (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_id | path | string | да | — |
Idempotency-Key | header | string | да | Ключ идемпотентности (UUID v4), один на логическую операцию. Без него — 400 idempotency_key_required; тот же ключ с другим телом — 409 idempotency_key_reused_with_different_payload. |
Тело запроса — application/json, схема ListItemsRequest:
| Поле | Тип | Обяз. | Описание и ограничения |
|---|---|---|---|
ids | integer[] | да | элементов 1–10000 |
DELETE /lists/{list_id}/items — Убрать тендеры из списка
Идемпотентно. Уже доставленное остаётся доставленным (не отзывается); будущие события убранного тендера в ленты списка не попадают.
| Параметр | Где | Тип | Обяз. | Описание и ограничения |
|---|---|---|---|---|
list_id | path | string | да | — |
Idempotency-Key | header | string | да | Ключ идемпотентности (UUID v4), один на логическую операцию. Без него — 400 idempotency_key_required; тот же ключ с другим телом — 409 idempotency_key_reused_with_different_payload. |
Тело запроса — application/json, схема ListItemsRequest:
| Поле | Тип | Обяз. | Описание и ограничения |
|---|---|---|---|
ids | integer[] | да | элементов 1–10000 |
Filters
| Метод и путь | Назначение |
|---|---|
GET /filters | Мои живые подписки |
DELETE /filters/{filter_id} | Погасить подписку досрочно |
GET /filters — Мои живые подписки
Отладочная обвязка, не happy path — фильтры создаются только неявно любым QUERY. Срок жизни подписки. Подписка продлевается ЛЮБЫМ чтением и живёт 14 дней от последнего запроса (expires_at в ответе). Новая подписка первые сутки живёт в пробном режиме: если запрос не повторить в течение 24 часов, она удаляется; первый же запрос спустя 3+ часа после создания переводит её на полный срок. Рекомендация — опрашивать фильтр не реже раза в сутки: если подписка умерла, курсор вернёт 409 со свежим срезом, а события за пропущенный период будут недоступны.
| Параметр | Где | Тип | Обяз. | Описание и ограничения |
|---|---|---|---|---|
limit | query | integer | нет | по умолчанию 20 |
starting_after | query | string | нет | — |
DELETE /filters/{filter_id} — Погасить подписку досрочно
Сам фильтр (общий кеш параметров) не удаляется — гасится только ваша персональная подписка на него.
| Параметр | Где | Тип | Обяз. | Описание и ограничения |
|---|---|---|---|---|
filter_id | path | string | да | — |
Idempotency-Key | header | string | да | Ключ идемпотентности (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).
| Параметр | Где | Тип | Обяз. | Описание и ограничения |
|---|---|---|---|---|
limit | query | integer | нет | по умолчанию 100 |
starting_after | query | integer | нет | — |
Схемы объектов
Вложенные объекты фильтров и модели ответов, на которые ссылается справочник выше. Тела ответов QUERY-эндпоинтов схемой не описаны — их структура показана в разделе «Конверт ответа QUERY».
CustomerFilter
Заказчик/организатор ЗАКУПКИ (единый стиль с supplier — вложенный объект). inn — список (мониторинг нескольких заказчиков — обычный кейс). extra=forbid: незнакомое поле (напр. kpp) — явный 422, не молчаливое игнорирование.
| Поле | Тип | Обяз. | Описание и ограничения |
|---|---|---|---|
inn | string[] | нет | — |
ogrn | string | нет | длина 13–15 |
name | string | нет | длина 2–300 |
DateRangeFilter
| Поле | Тип | Обяз. | Описание и ограничения |
|---|---|---|---|
from | string | нет | — |
to | string | нет | — |
ListCreated
Ответ POST /lists.
| Поле | Тип | Обяз. | Описание и ограничения |
|---|---|---|---|
list_id | string | да | — |
name | string | да | — |
description | string | нет | — |
metadata | object | нет | — |
created_at | string | да | — |
ListDeleted
Ответ DELETE /lists/{list_id}.
| Поле | Тип | Обяз. | Описание и ограничения |
|---|---|---|---|
list_id | string | да | — |
deleted | boolean | нет | по умолчанию true |
ListItemsAdded
Ответ POST /lists/{list_id}/items.
| Поле | Тип | Обяз. | Описание и ограничения |
|---|---|---|---|
list_id | string | да | — |
added | integer | да | Сколько id добавлено (без учёта уже бывших в списке) |
total | integer | да | Размер списка после операции |
ListItemsRemoved
Ответ DELETE /lists/{list_id}/items.
| Поле | Тип | Обяз. | Описание и ограничения |
|---|---|---|---|
list_id | string | да | — |
removed | integer | да | Сколько id убрано (отсутствовавшие не считаются) |
RangeFilter
| Поле | Тип | Обяз. | Описание и ограничения |
|---|---|---|---|
from | number | нет | — |
to | number | нет | — |
SupplierFilter
Поставщик контракта (единый стиль с customer — вложенный объект, inn — список, name — подстрока названия). Поставщик существует только в контракте — на множество ЗАКУПОК не влияет и в filter_id обычных фильтров не входит (ТЗ §6); сам по себе — supplier-режим (§6.1). ogrn (в отличие от customer) отсутствует: у поставщиков в БД нет ОГРН — матчить не по чему (резолв через справочник организаций покрыл бы ~2% поставщиков). kpp удалён 20.07.2026 ради симметрии с customer. Требуется хотя бы одно из inn/name (иначе 400).
| Поле | Тип | Обяз. | Описание и ограничения |
|---|---|---|---|
inn | string[] | нет | Список ИНН поставщиков (10 или 12 цифр каждый) |
name | string | нет | длина 2–300 |
TokenDeleteResponse
Ответ при удалении токена
| Поле | Тип | Обяз. | Описание и ограничения |
|---|---|---|---|
success | boolean | да | — |
message | string | да | — |
TokenInfo
Информация о токене с маскированным превью
| Поле | Тип | Обяз. | Описание и ограничения |
|---|---|---|---|
id | integer | да | — |
name | string | да | — |
token_preview | string | нет | Маскированный токен (например: abc...xyz) |
is_active | boolean | да | — |
created_at | string | да | — |
last_used_at | string | да, бывает null | — |
TokenListResponse
Список токенов
| Поле | Тип | Обяз. | Описание и ограничения |
|---|---|---|---|
tokens | TokenInfo[] | да | — |
total | integer | да | — |
TokenResponse
Ответ с секретом — и при создании токена, и при входе (сессия)
| Поле | Тип | Обяз. | Описание и ограничения |
|---|---|---|---|
id | integer | да | ID токена; для сессии из POST /auth/login всегда 0 и ничего не идентифицирует |
name | string | да | — |
token | string | да | Секрет, показывается ОДИН раз. Для POST /auth/tokens — бессрочный API-ключ fraim_live_…/fraim_test_…; для POST /auth/login — сессия со сроком жизни 30 дней |
created_at | string | да | — |
Справочники кодов
Значения для 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 | ЕИС ЗАКУПКИ |
98 | B2B-Center |
16 | 223ETP 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 из ответа.
| HTTP | code | Когда |
|---|---|---|
| 400 | validation_error | Невалидные параметры; ids>1000; >1 способа задать множество |
| 400 | invalid_cursor | Подпись курсора не сошлась |
| 400 | idempotency_key_required | Мутирующий запрос без заголовка Idempotency-Key |
| 401 | api_key_required | Нет заголовка Authorization |
| 401 | invalid_api_key | Токен неизвестен, отозван или не похож на ключ Fraim.ru |
| 401 | api_key_mode_mismatch | Префикс токена не совпал с его режимом (тестовый против боевого) |
| 401 | account_inactive | Аккаунт заблокирован |
| 401 | invalid_session | Сессия кабинета истекла (30 дней) — войдите заново; к API-токенам не относится |
| 402 | balance_exhausted | Баланс 0 и ни один объект не доставлен |
| 404 | not_found | Объект/фильтр/список не существует или чужой список |
| 405 | method_not_allowed | Метод не поддержан маршрутом — напр. DELETE /lists (удаления списка нет) |
| 409 | cursor_expired | Лента пересобрана; в теле — свежий срез и новый курсор |
| 409 | api_key_limit_reached | Уже 20 активных токенов — отзовите ненужный и повторите |
| 409 | idempotency_key_reused_with_different_payload | Тот же Idempotency-Key прислан с другим телом. Тип ошибки — idempotency_error |
| 409 | document_restricted_at_source | Документ есть, но площадка-первоисточник отдаёт его только своим участникам |
| 409 | document_not_a_file | По ссылке первоисточника сейчас страница, а не файл |
| 409 | document_unavailable_in_sandbox | Скачивание документов тестовым токеном недоступно |
| 410 | document_gone | Документ удалён у первоисточника — повторять бессмысленно |
| 413 | document_too_large | Документ больше допустимого размера для скачивания через сервис |
| 503 | document_temporarily_unavailable | Первоисточник недоступен; в ответе Retry-After — повторите позже |
| 422 | validation_error | Запрос не сошёлся со схемой: limit вне 1–100, metadata > 2048 байт, неверный тип поля |
| 422 | extra_forbidden | Незнакомое поле во вложенном фильтре customer или supplier |
| 429 | rate_limit_exceeded | Лимит частоты: минутный на аккаунт (Retry-After: 60), всплеск по IP (Retry-After: 1), попытки входа или выпуска токенов. Подождите указанное время и повторите |
| 202 | preparing | Не ошибка: идёт фоновый сбор, повторите тот же запрос |
Лимиты
| Что | Сколько | Подробности |
|---|---|---|
| Запросы к API | 100 в минуту | Считается на аккаунт, а не на токен. Это гарантированный минимум: счётчик живёт в памяти процесса, поэтому на практике может пропустить и больше — не стройте на этом расчёт. Сверх — 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 без keywords | 400. Раньше запрос принимался, но минус-слова молча не применялись — исключения работают только поверх непустого набора ключевых слов. |
Считать, что keywords ушли в поиск как есть | Часть могла быть отброшена. Сверяйтесь с ignored_keywords в ответе: там keyword и причина — too_common или no_searchable_words. |
| Год в ключевых словах ради «закупок 2025 года» | Годы (2000–2099) игнорируются наравне с короткими словами. Период задаётся датами фильтра, а не текстом. |
keywords: ["ГОСТ 32144-2013"], ["ТУ 27.32.13-001-12345678-2022"] | Такие обозначения не выбрасываются (в отличие от годов и коротких слов), фильтр примут — но найдёт он единицы объектов: точный номер стандарта обычно есть только в приложенной документации, не в тексте карточки. Ищите широким словом (["кабель силовой"]) и отсеивайте результат сами — вручную, через ИИ или exception_keywords. |
| Токен в браузерном коде или в репозитории | Токен = доступ к балансу. Только серверное окружение, только переменная окружения. |
Ссылки
- Этот документ в Markdown: https://fraim.ru/ai.md
- Карта документации для ИИ: https://fraim.ru/llms.txt
- Справочники кодов одним JSON: https://fraim.ru/refs.json
- OpenAPI 3.1: https://public.fraim.ru/api/v2/openapi.json
- Документация для людей: https://fraim.ru/docs
- Регистрация и токены: https://lk.fraim.ru
- Поддержка: support@fraim.ru (в письме укажите
request_idиз ответа) - Любые вопросы: info@fraim.ru