Kodrio Partner API /v1 — документация для партнёра

Эта страница собрана из того же файла контракта, который мы отправляем партнёру письмом: второго текста не существует, и разойтись им нечем. Обзор возможностей — если вы ещё выбираете. Работает ли сейчас — если уже подключены и что-то пошло не так.

Оптовая выдача цифровых товаров по API: каталог с нашими оптовыми ценами, наличие, заказ, баланс и события. Всё, что нужно, чтобы подключиться, — на этой странице; логин не требуется.

Версия контракта — v1. Журнал изменений — 11-partner-api-changelog.md, там же правило депрекейта.


0. Что открыто сегодня

Мы не пишем в документации того, чего нет. Таблица ниже — состояние на дату последней записи журнала изменений; всё, что в ней помечено «закрыто», описано в §10 отдельным разделом с причиной.

🔴 САМОЕ ГЛАВНОЕ, ЧТО НАДО ЗНАТЬ ПЕРВЫМ: адрес один, и тестового контура нет. Адрес — https://kodrio.com: ключ kodrio_live_… вы выпускаете сами в кабинете после проверки компании; он читает каталог, остаток и события, а приём заказов ещё не открыт (§10.1). Первый заказ пойдёт этим же боевым ключом (§10.2).

ВозможностьСостояние
Каталог с ценами, наличие батчем✅ открыто
Баланс и журнал движений✅ открыто
Подписки на события, подпись, ретраи, повтор✅ открыто
Размещение заказа POST /v1/orders⛔ ещё не открыто — §10.1
Тестовый контур⛔ нет — §10.2
Забрать коды заказа GET /v1/orders/{id}✅ открыто 07.08.2026 — §5.5. Перечитывать можно бессрочно
Список заказов GET /v1/orders✅ открыт 07.09.2026 — §5.6. Курсорная пагинация, заказ без движения денег в списке ЕСТЬ
Проверка получателя /v1/recipients/check⛔ не построено — §10.4

Контракт заказа (§5) описан полностью и меняться не будет. Открытие боевого приёма заказов пойдёт отдельной строкой в журнал изменений.


1. Быстрый старт

Один путь, без ветвлений. Шаги 1–4 описывают построенные ручки и проходятся боевым ключом уже сейчас; шаг 5 ждёт открытия приёма заказов (§10.1), шаг 6 идёт за ним.

Что нужно перед началом

ЧтоЗначение
Базовый адрес APIhttps://kodrio.com
Ключ доступавыпускаете в кабинете, раздел «Ключи доступа» (/cabinet/api), после проверки компании (раздел «Компания»)
Набор скоупов ключавыбираете при выпуске (§3)
Список разрешённых IPзадаёте при выпуске; пустой — замка нет (§3)

Все пути ниже приписываются к базовому адресу: https://kodrio.com/v1/catalog. Ключ показывается один раз (§3). ⚠️ Скоупы и список адресов машинной ручкой «какие у меня права» не проверяются — такой ручки в v1 нет: они видны в кабинете. В примерах ниже адрес и ключ берутся из переменных:

BASE=https://kodrio.com; KODRIO_API_KEY=kodrio_live_…

Порядок шагов

Связь с нами — раздел «Поддержка» в кабинете (/cabinet/support). Пришли туда с экрана заказа — номер заказа подставится в обращение сам. Там же — вопросы по пополнению баланса и по этой странице.

Шаг 1. Получить ключ. Выпустите его в кабинете, раздел «Ключи доступа», после проверки компании. Сохраните ключ сразу: у нас в базе только хеш, второго способа узнать ключ не существует. Потеряли — выпустите новый и отзовите старый в кабинете.

Шаг 2. Проверить, что ключ живой. (Ниже — ручка баланса; если ваш ключ выдан без balance:read, проверяйте GET /v1/catalog. Ответ 403 forbidden_scope тоже означает, что ключ ЖИВОЙ — просто у него нет права именно на эту ручку.)

curl -sS "$BASE/v1/balance" \
  -H "Authorization: Bearer $KODRIO_API_KEY"
{ "data": { "currency_code": "USD", "available_minor": 150000, "overdraft_limit_minor": 0 } }

Деньги счёта — целые в минорных единицах валюты вашей компании (currency_code). Для USD это центы: 150000 = $1,500.00. Цены каталога — не в ней: price, market_price и ступени названы в валюте расчёта контура, в центах доллара, при любом currency_code (§4).

🔴 С 31.08.2026 учётная валюта контура — доллар (USD). До этой даты счета велись в рублях, и *_minor означало копейки. Если ваш currency_code по-прежнему RUB — ваш счёт не переводился, и деньги счёта (available_minor, overdraft_limit_minor, amount_minor, threshold_minor) у вас остаются копейками: единицу денег счёта читайте из currency_code ответа, а не из предположения. Цены (price, market_price, *unit_price_minor) и у такого счёта в центах доллара — единицы разойдутся (§4). Курс между пополнением и тратой исчез: пополнение в USDT зачисляется один к одному к доллару.

Шаг 3. Забрать каталог.

curl -sS "$BASE/v1/catalog?brand=STEAM" \
  -H "Authorization: Bearer $KODRIO_API_KEY"
{
  "data": [
    {
      "sku": "STEAM-TR-100TRY",
      "brand": "STEAM",
      "region": "TR",
      "region_strict": true,
      "denomination": 100,
      "currency": "TRY",
      "delivery_kind": "key_code",
      "min_batch": 1,
      "code_formats": [
        { "mask": "XXXXXXXXXXXXXXXX", "alphabet": "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" },
        { "mask": "XXXXXXXXXXXXXXXX", "alphabet": "0123456789", "prefix": "ALT-" }
      ],
      "code_format": { "mask": "XXXXXXXXXXXXXXXX", "alphabet": "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" },
      "title_ru": "Steam Турция 100 TRY",
      "available": true,
      "price": 31900,
      "market_price": 39900
    }
  ],
  "meta": { "next_cursor": null }
}

Как понять, что каталог дочитан: next_cursor: null. Непустой курсор приходит только на ПОЛНОЙ странице (100 позиций) — неполная страница всегда последняя.

Шаг 4. Подписаться на события — §6. Не обязательный, но полезный: вебхук приносит исход сам, и вам не нужно опрашивать статус заказа в цикле.

Шаг 5. Разместить заказ — контракт в §5. Приём заказов ещё не открыт (§10.1).

🔴 Сколько заказ ждёт в очереди — считайте по двум числам, а не по одному. Заказ, который поставщик не исполнил с первого раза, встаёт в очередь (processing + status_detail: "queued", §5.3), и попытки мы повторяем сами. Попытка НАЗНАЧАЕТСЯ примерно через 30 секунд (разброс ±20 %), но забирает назначенное фоновая петля с шагом в 5 минут. Значит верхняя оценка ожидания одной попытки — около 5,5 минут, и это штатная работа, а не зависший заказ. Дальше пауза удваивается (30 с → 1 мин → 2 мин …) до потолка в час, но пока она короче шага петли, темп задаёт именно шаг. Бюджет ожидания в вашем клиенте стройте от 5,5 минут на попытку, а не от 30 секунд — иначе он объявит провалившимся заказ, который просто ждёт ближайшего тика. Попытки не бесконечны: через сутки либо после 24 попыток, что наступит раньше, заказ становится failed с полным возвратом (§5.3).

Шаг 6. Забрать коды — GET /v1/orders/{id} (§5.5). Это последний шаг: код на руках.


2. Основания контракта

Семь правил, которые действуют на каждой ручке. Если что-то в описании конкретной ручки противоречит этому разделу — верно то, что здесь.

  1. /v1 в пути с первого дня. Ломающее изменение — это новая версия, а не правка v1.

  2. Единый конверт. Успех — {"data": ...}, рядом может стоять meta со служебными данными ответа: next_cursor при пагинации, unknown_skus у GET /v1/stock. Разбирайте meta не только в пагинирующем коде. Ошибка — {"error": {"code", "message", "request_id"}}.

  3. Пагинация только курсорная — ?cursor=, следующий курсор в meta.next_cursor. null в next_cursor значит «страниц больше нет». Ни page, ни offset не поддерживаются.

  4. Деньги — целые в минорных единицах (*_minor), и классов у них два. Деньги счёта (available_minor, overdraft_limit_minor, amount_minor, threshold_minor) — в валюте вашей компании (currency_code); она берётся из профиля, в запросе её не передают. Одна компания — одна валюта. Цены (price, market_price и все *unit_price_minor — ступени, заказ, прайс-файл, отказ price-changed) — в центах доллара, валюте расчёта контура, при любом currency_code. У счёта не в USD эти два класса расходятся — §4.

  5. Идемпотентность обязательна на создании заказа — §5.2.

  6. Наличие отдаётся булевым: available: true|false, без остатка и без «осталось мало». Сколько именно можно взять, вы узнаёте отказом not-enough-stock на конкретном заказе.

  7. Отсутствующее поле — это не null. Необязательные поля (market_price, closed_for_sale, order_id в журнале) появляются, только когда ответ «да». Ни нуля, ни null вместо них не приходит: и то и другое читалось бы как утверждение, которого мы не делали.


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

Заголовок и формат ключа

Authorization: Bearer kodrio_live_<40 hex>

Схема снимается строго: слово Bearer, один пробел, ключ. Формат ключа — kodrio_live_ плюс 40 строчных шестнадцатеричных символов. Регистр значим: ключ, где-нибудь приведённый к верхнему регистру, отвечает 401 без пояснений.

Ключ показывается один раз при выпуске. Ротация — процедура: выпустить новый, отозвать старый; оба действия — в кабинете, раздел «Ключи доступа». Отзыв мгновенный, решение по ключу мы не кэшируем.

🔴 Как хранить. Ключ — предъявитель к деньгам вашей компании: кто им владеет, тот тратит ваш баланс. Держите его в менеджере секретов или переменных окружения — не в репозитории, не в тикете, не в переписке, не в логах. В примерах ниже он подставляется из переменной окружения намеренно: ключ, набранный в командной строке, остаётся в истории оболочки и в логах CI. Тело события и заголовок X-Kodrio-Signature тоже не логируйте.

Заказы и подписки принадлежат компании, а не ключу. Отзыв одного ключа не прерывает ни доставку событий, ни доступ к прежним заказам с остальных ваших ключей.

🔴 Обратная сторона, и она важнее удобства: подписки ПЕРЕЖИВАЮТ отзыв ключа. Если вы отзываете ключ из-за подозрения на компрометацию, одного отзыва НЕДОСТАТОЧНО: подписка, заведённая чужими руками, продолжит слать ваш остаток и все ваши заказы на чужой адрес. Обязательно откройте GET /v1/webhooks и отзовите всё, чего не заводили (§13).

Скоупы

У ключа набор прав; ручка требует своё. Нет права — 403 forbidden_scope.

СкоупЧто открывает
catalog:readGET /v1/catalog, GET /v1/catalog/export, GET /v1/stock, GET /v1/recipients/fields
balance:readGET /v1/balance, GET /v1/balance/entries
orders:createPOST /v1/orders
orders:readчтение заказов (ручка — §5.5)
webhooks:manageреестр подписок /v1/webhooks*

🔴 Подписка на событие требует ещё и права читать его данные. Тело события несёт ровно то же, что и ручки чтения, только уезжает на выбранный вами адрес. Поэтому POST /v1/webhooks сверяет events[] с правами ключа:

СобытиеДополнительно требует
order.delivered, order.failed, order.refundedorders:read
balance.lowbalance:read

Не хватает — 403 forbidden_scope, и в поле reason приходят имена недостающих скоупов через запятую. Ключ «только на вебхуки» подписаться на поток заказов не может.

Список разрешённых адресов

У каждого ключа свой список IP. Пустой список = замка нет, запросы принимаются с любого адреса. Заполненный = принимаются только оттуда, остальные получают 403 ip_not_allowed.

🔴 Заполните этот список. Пустой означает, что ключ, попавший в чужие руки, работает откуда угодно. Заполненный — единственное, что отличает ваш запрос от чужого с тем же ключом. Задайте свои исходящие адреса при выпуске ключа в кабинете; у живого ключа список не правится — нужен другой, выпустите новый ключ с нужным списком и отзовите прежний.

Как записывается адрес. При записи мы приводим его к канонической форме (::ffff:1.2.3.4 сохранится как 1.2.3.4, IPv6 сожмётся и уедет в нижний регистр), дубли схлопываются. Сравнение на входе идёт по той же канонической форме, поэтому разные записи ОДНОГО адреса совпадут: ::ffff:1.2.3.4 и 1.2.3.4 — один адрес, 2001:0DB8::0001 и 2001:db8::1 — тоже. Регистр значения не имеет. Приведение формы не расширяет список: соседний адрес той же сети это другой адрес, а маски мы не поддерживаем (см. ниже).

Если вы всё же получаете 403 ip_not_allowed при заведомо верном адресе — не просите очистить список: он один отличает ваш запрос от чужого с тем же ключом. Сообщите нам форму записи, разберём со своей стороны.

Чего список не принимает — отказ приходит сразу, с кодом причины, а не молча:

  • CIDR-маски (10.0.0.0/8) — не поддерживаются в этой версии, перечисляйте адреса по одному;

  • строку, которая не является IP-адресом;

  • больше 50 адресов в списке.

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

Что означают отказы доступа

ОтветЧто случилосьЧто делать
401 unauthorizedключ не принятпроверить заголовок и сам ключ; не подбирать
403 forbidden_scopeу ключа нет права на эту операциювыпустить в кабинете ключ с нужным скоупом
403 ip_not_allowedзапрос пришёл с адреса вне списка, разрешённого ключусм. предупреждение ниже

🔴 403 ip_not_allowed, которого вы не ожидали, — это в первую очередь сигнал об УТЕЧКЕ КЛЮЧА, а не о неверной настройке. Он означает, что ключ предъявлен с чужого адреса. Если вы не меняли свою исходящую сеть — считайте, что ключом пользуется кто-то ещё, и действуйте по §13 «Подозрение на утечку ключа». Расширять список адресов в этой ситуации — худшее из возможного: вы своими руками откроете доступ тому, кто увёл ключ.

401 намеренно не различает «нет такого ключа» и «ключ есть, но с ним что-то не так»: по разнице ответов подбирался бы сам факт существования ключа.


4. Каталог, наличие, баланс

GET /v1/catalog — товарная полка

Скоуп catalog:read. Фильтры ?brand=, ?region= (регистр не важен), пагинация ?cursor=, страница — 100 позиций.

🔴 Других параметров у ручки нет, и размер страницы не настраивается. ?limit=, ?page=, ?offset= не поддерживаются: неизвестные параметры запроса мы игнорируем молча, ответ приходит обычной страницей в 100 позиций. Проверять это ответом бесполезно — ?limit=2 вернёт те же 100 строк и 200, а не ошибку. Дочитывается каталог только курсором: непустой meta.next_cursor приходит на ПОЛНОЙ странице, null означает «дочитали».

Поля строки:

ПолеСмысл
skuартикул, ваш ключ на всё остальное
brand, regionбренд и регион активации
region_strictрегион жёсткий (код не активируется в другом)
region_countriesстраны, где код активируется — список ISO 3166-1 alpha-2, только там, где он есть; разбор ниже
denomination, currencyноминал и его валюта — валюта номинала, не расчётов
delivery_kindвид выдачи — key_code · game_key · topup_by_id · topup_by_login · gift_link; заказом поддержаны все, кроме gift_link (§10.5)
min_batchминимальная партия
code_formatsформаты кода — по нему валидируйте выдачу у себя; только у позиций, выдаваемых кодом, и только там, где формат объявлен; разбор ниже
code_format⚠️ устарел, снимается 18.11.2026 — формат ПОЗИЦИИ без учёта того, кто печатает; приходит и уходит ПАРОЙ с code_formats; разбор ниже
title_ruназвание
availableможно ли заказать минимальную партию прямо сейчас — см. предупреждение ниже
priceможет отсутствовать — у позиции, цена которой у нас сейчас не определена (заказ по ней получит 422 price-changed). Наша оптовая цена за штуку, минорные единицы валюты расчёта контура — центы доллара при любом currency_code вашего счёта (разбор ниже) — при заказе ниже первой ступени; та же единица, что у market_price
price_tiersоптовые ступени «объём → цена», только там, где они есть — см. ниже
market_priceрозничный якорь рынка, только там, где он есть — минорные единицы той же валюты расчёта, что и price
closed_for_saletrue — позиция закрыта к продаже, только когда «да»

🔴 price и market_price — В ОДНОЙ ВАЛЮТЕ (изменено 02.09.2026, ломающее). Значит market_price − price это законная арифметика, и это ровно то, что изменилось: до 02.09 якорь приезжал рублёвыми копейками рядом с центами, и разрыв между единицами составлял около 86×.

Что это за валюта — говорим прямо. Обе цены каталога названы в валюте расчёта контура, и сегодня это доллар: price и market_price — центы. Валюта вашего БАЛАНСА живёт отдельным полем currency_code (GET /v1/balance, §1); с 31.08.2026 умолчание контура — USD, и у всех счетов, заведённых с этой даты, обе величины совпадают. Если у вашей компании currency_code остался RUB (такие счета переход 31.08 сохранил и не переводил), напишите нам по каналу связи из §1 прежде чем считать наценку: единица баланса и единица цен каталога у вас разойдутся, и разойдутся молча. Сам ответ каталога валюту пока не называет — это наш долг, а не ваша забота. Заказ по такому счёту не пройдёт: POST /v1/orders ответит 403 currency_not_supported до всякого списания (§8).

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

⚠️ Если вы считали наценку по старым числам — пересчитайте. В примере из §1 "price": 31900 это $319.00, а "market_price": 39900 — $399.00; оба числа синтетические, но подписаны они теперь одной валютой, и разность между ними имеет смысл.

Якоря может не быть: там, где розничного замера нет или он несвеж, поля market_price в строке нет вовсе — не null и не 0 (правило 7 из §2). Полем оно не становится и тогда, когда перевести замер нечем.

Регион: region_strict и region_countries

🔴 Есть товар, у которого ограничение задано СПИСКОМ стран, а не одной — это игровые ключи. Их поле region_countries перечисляет страны, где код активируется, кодами ISO 3166-1 alpha-2. Поля нет вовсе там, где такого списка не существует (гифт-карты) — как и у market_price, отсутствие поля это не null и не «нигде».

Читать это поле выгодно, а не обязательно. region ВСЕГДА входит в region_countries. Продаёте только в region — попадаете в страну, где ключ точно работает; читаете список — продаёте во все страны списка. То есть незнание нового поля стоит вам недопроданных заказов, а не мёртвого кода у покупателя.

🔴 Тем же списком судит приём заказа. Поле deliver_region строки заказа обязано входить в region_countries, иначе строка отклоняется кодом region-mismatch. Показанное вам и проверяемое у нас — одно и то же поле.

{
  "sku": "ABYSSUS-RU-VAR-BASE",
  "brand": "ABYSSUS",
  "region": "RU",
  "region_strict": true,
  "region_countries": ["AM", "AZ", "BY", "KG", "KZ", "MD", "RU", "TJ", "TM", "UA", "UZ"],
  "denomination": "VAR",
  "currency": "USD",
  "delivery_kind": "key_code",
  "min_batch": 1,
  "title_ru": "Abyssus, ключ Steam",
  "available": true
}

🪤 denomination: "VAR" здесь означает «номинала у товара нет как факта» — у игрового ключа его и не бывает. В прайс-файле (/v1/catalog/export) список стран едет ПОСЛЕДНЕЙ колонкой region_countries, коды через пробел; у строк-ступеней ячейка пуста.

Формат кода: code_formats (и устаревший code_format)

🔴 ОБОИХ ПОЛЕЙ НЕТ У ПОЗИЦИИ, ВЫДАВАЕМОЙ БЕЗ КОДА (с 28.08.2026 — это topup_by_login и topup_by_id). Кода у неё не существует: поставщик зачисляет средства прямо на аккаунт, и вам приезжает подтверждение зачисления. Объявить формат у такого товара значило бы дать вам обещание, которое §9 велит вам ПРОВЕРЯТЬ, — и вы завернули бы корректное подтверждение уже после оплаты. Отличать такие позиции надёжнее всего по delivery_kind. 🪤 Если ваш клиент читает row.code_format.mask без проверки на наличие — добавьте её; у позиций с кодом поле на месте и ничего не потеряло.

🔴 ОБОИХ ПОЛЕЙ МОЖЕТ НЕ БЫТЬ И У ПОЗИЦИИ С КОДОМ — если формат её кода мы не объявляем (с 03.09.2026). Такое бывает, когда позицию печатает поставщик, чью форму кода мы ещё не измерили: объявить маску наугад значило бы дать вам обещание, которое §9 велит ПРОВЕРЯТЬ, — и вы завернули бы корректный оплаченный код. Молчание здесь — единственный правдивый ответ, и оно временное: как только форма замерена, оба поля возвращаются той же строкой каталога.

Что это значит для вашего клиента, одной фразой: code_formats отсутствует ⇒ проверять выданный код по формату не нужно и нечем — принимайте его как есть. Поля приходят и уходят парой, «один есть, другого нет» не бывает по построению. Отличать «кода не будет вовсе» от «код будет, формат не объявлен» — по delivery_kind, как и раньше.

🔴 code_formats — массив, и проверять выданный код нужно по нему (§9). Выданный код обязан пройти хотя бы один формат из массива; не прошёл ни одного — это дефект на нашей стороне, и мы хотим о нём знать (§13).

Почему массив, а не один объект. Формат кода — свойство пары «позиция × поставщик», а не одной позиции. У каждого поставщика свой станок печати: маска, алфавит и приставка у них разные, и один и тот же артикул, купленный у разных, приходит к вам в разном виде. Кто именно напечатает ваш код, решается в момент заказа — по доступности линии, — поэтому назвать заранее один формат физически нельзя. Массив перечисляет все, которыми позиция может быть напечатана; лишних значений в нём нет.

⚠️ code_format (единственное число) объявлен устаревшим 20.08.2026 и снимается 18.11.2026 (§11, 90 дней). Он описывает формат ПОЗИЦИИ и потому правдив только тогда, когда печатающий поставщик ничего в нём не меняет: код от другого поставщика он отвергнет, хотя код валиден и оплачен. Поле продолжает приходить весь переходный период. Переход — одна строка: вместо «код проходит code_format» проверяйте «код проходит любой из code_formats».

Поля каждого формата (и в code_formats, и в code_format) — одни и те же:

ПолеОбяз.Смысл
maskдаформа кода: X — знакоместо, всё остальное (-, /, пробел) — разделитель как есть
alphabetдаперечень допустимых символов знакоместа, строкой — например ABCDEFGHJKLMNPQRSTUVWXYZ23456789. Это набор, а не его название: символы берите из него буквально
prefixнетлитеральное начало кода, в маску НЕ входит
suffixнетлитеральный хвост кода, в маску тоже не входит
exampleнетобразец кода для человека. Значением никогда не является, но объявленный формат проходит

Проверка целиком — это prefix + маска, где каждый X заменён любым символом из alphabet, + suffix. Ни одно из полей не бывает пустой строкой: необязательного поля просто нет в ответе. ⚠️ Собираете регулярку — экранируйте подставляемые значения: сегодня в наборе только буквы и цифры, но обещания «здесь не будет символа со значением в регулярке» мы не давали.

"code_formats": [
  {
    "mask": "XXXXXXXXXXXXXXXX",
    "alphabet": "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
  },
  {
    "mask": "XXXXXXXXXXXXXXXX",
    "alphabet": "0123456789",
    "prefix": "ALT-"
  }
]

Код первого формата — ровно 16 символов, по числу знакомест маски. Второму отвечает, например, ALT-9302847715630284: 20 символов — приставка в 4 символа плюс 16 знакомест. Считать длину по одной маске нельзя — именно на этом расхождении ломается первая самодельная проверка, а считать её по ОДНОМУ формату из массива нельзя тем более.

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

Отсюда правило: берите prefix целиком из ответа, ничего не отрезайте и ничего не достраивайте сами.

⚠️ Буквенные литералы живут в prefix/suffix, а не в маске, и это не украшение: буква X внутри префикса иначе стала бы знакоместом. Маска состоит только из X и разделителей.

Форматы меняются несопоставимо реже цены — перечитывать их перед каждой сверкой не нужно. Достаточно обновлять вместе с каталогом.

Оптовые ступени price_tiers

Цена за штуку зависит от количества в одной строке заказа. Ступени приходят массивом, по возрастанию количества; поля два и оба обязательные:

{
  "sku": "APPLE-DE-10EUR",
  "price": 139300,
  "price_tiers": [
    { "min_qty": 5,  "unit_price_minor": 137200 },
    { "min_qty": 10, "unit_price_minor": 133000 }
  ]
}

Читается так: 1–4 штуки — price, 5–9 — 137 200, от 10 — 133 000. Ступени начинаются со второй штуки: цену за одну штуку задаёт price и только он.

🔴 unit_price_minor в заказе обязан быть ценой ТОЙ ступени, в которую попадает ваше количество. Прислали цену другой ступени — строка отобьётся кодом price-changed до списания денег, и в отказе придёт expected_unit_price_minor с нашим числом (§5.1). Отдельного кода ошибки для ступеней нет: для нас это обычное расхождение цены.

⚠️ Поля price_tiers нет вовсе, когда ступеней у позиции нет — пустого массива не приходит никогда. Отсутствие поля не значит «объёмных цен не бывает»: сетка может появиться позже.

⚠️ Мы показываем только достижимые ступени. Ступень выше действующего потолка количества в строке (сегодня 50 штук, §5.1) в ответ не попадает: заказать её всё равно нельзя, и печатать цену, по которой мы сами откажем, мы не станем. Вырастет потолок — ступени появятся сами.

Скидку в процентах мы не отдаём: в ответе только цена — то самое число, которым вы эхом подтверждаете заказ.

GET /v1/catalog/export — прайс файлом

Скоуп catalog:read. Тот же каталог, только файлом: CSV, UTF-8 без BOM, разделитель — запятая, перевод строки CRLF (RFC 4180). Фильтры ?brand=, ?region= работают так же, как у каталога; пагинации нет — прайс отдаётся целиком, потому что это документ, а не лента.

Одна строка файла = одна цена, а не одна позиция. Базовая цена приходит строкой с min_qty=1, каждая ступень — своей строкой. Колонки, в этом порядке:

sku,brand,region,region_strict,denomination,currency,delivery_kind,min_batch,
title_ru,available,closed_for_sale,min_qty,unit_price_minor,market_price,region_countries

market_price заполнен только у строки min_qty=1: якорь рынка — розничная цена за штуку, к объёму отношения не имеющая. Единица у него та же, что у unit_price_minor, — минорные валюты расчёта (§4 выше). Позиция без цены остаётся в файле с пустой ячейкой unit_price_minor — не вычищайте по этому признаку свой справочник.

⚠️ Форматов кода в файле нет ни в каком виде — ни code_formats, ни устаревшего code_format. Это вложенные объекты (маска, алфавит, префикс, суффикс, пример), и плоская таблица их не выражает без пяти лишних колонок на каждый формат каждой строки. Форматы берите из GET /v1/catalog — они меняются несопоставимо реже цены. Всё остальное, что есть в каталоге, в файле есть.

🔴 Числа в файле и в GET /v1/catalog совпадают до минорной единицы, потому что это одни и те же числа: файл печатается из того же снимка каталога, ничего не пересчитывая. Убедиться можно заголовком X-Kodrio-Snapshot — он приходит и у каталога, и у прайса и называет момент сборки снимка. Совпал у обоих ответов — вы смотрите на одну и ту же полку; разошёлся — между вашими запросами мы обновили снимок, снимите оба заново.

Что в X-Kodrio-Snapshot лежит: момент сборки снимка временем ISO-8601 в UTC, с миллисекундами — 2026-08-12T09:12:44.117Z. Разобрать его как время можно, но сравнивайте строкой на равенство: заголовок отвечает на вопрос «это одна и та же полка», а не «какая свежее». Возраст цены измеряется не им, а обещаниями выше (10 и 20 минут). Значение непрозрачным не станет молча: смена формата — ломающее изменение по §11.

⚠️ Открываете в Excel — импортируйте как UTF-8 («Данные → Из текста/CSV → кодировка UTF-8»), а не двойным щелчком: BOM мы не ставим, потому что он ломает прямые парсеры, а файл прежде всего машинный.

🔴 available: false и closed_for_sale: true — разные вещи, и решение по ним у вас разное. available: false — «сейчас нельзя заказать» (кончилось, не сходится минимальная партия). closed_for_sale: true — «мы это не закупаем»: живого канала поставки у позиции нет. Первое — повод подождать, второе — повод снять карточку у себя. Слить их в одно значило бы заставить вас годами ждать товар, которого не будет.

🔴 available: true — это обещание min_batch, а НЕ любого количества. Оно означает: цена есть, вид выдачи мы умеем, и остатка хватает на минимальную партию. Заказ на большее количество может честно отбиться кодом not-enough-stock (§5.1) — это нормальная работа, а не расхождение с каталогом: чисел склада мы не отдаём (§2 п.6) НИГДЕ, в том числе в тексте отказа. Практический способ найти проходящий объём — уменьшать количество, а не рассчитывать узнать остаток от нас.

Каталог отдаётся из кэша: снимок обновляется раз в 10 минут, а если очередная пересборка не удалась — вам честно отдаётся прежний, но цена в нём никогда не старше 20 минут. За этим порогом мы цену не показываем ни при каких обстоятельствах: вместо неё придёт 500 (§5.4). То есть данные могут отставать от нашей полки на треть часа; на этот срок и рассчитывайте частоту перечитывания.

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

Обратная сторона названа честно: пока курс у нас проверить нечем, каталог отвечает 500, а не показывает старую цену. В редком случае — если наша сторона только что перезапустилась и курс не успел провериться ни разу — это может случиться сразу, а не через 20 минут. 500 ваш клиент обязан переживать повтором (§8); заказ по нему проваленным считать нельзя.

🔴 Но одно отставание мы убрали: закрытие позиции по потере канала поставки доходит до вас МГНОВЕННО. Если поставщик перестал нас пускать или привязка позиции снята, она получает closed_for_sale: true в первом же вашем запросе, не дожидаясь обновления снимка. Раньше на это уходило до двадцати минут, и всё это время вы могли отправить нам заказ, который мы не смогли бы исполнить. Обратное тоже верно: вернувшаяся позиция открывается так же быстро — но только пока у нас всё в порядке: если в этот момент у нас авария, каталог отвечает 500 (§8), и возврат позиции вы увидите после того, как наша сторона восстановится.

⚠️ Мгновенно доходит именно потеря канала. Позиция может закрыться и по третьей причине — наши данные о ней просто устарели по календарю; такое закрытие приезжает вместе с обычным обновлением снимка, то есть в пределах тех же 20 минут.

available: false не значит «позиция удалена» — не вычищайте по этому признаку свой справочник.

GET /v1/stock?skus=A,B,C — наличие батчем

Скоуп catalog:read. Кап — 100 артикулов за запрос, больше → 400 validation_failed.

{
  "data": [ { "sku": "STEAM-TR-100TRY", "available": true } ],
  "meta": { "unknown_skus": ["STEAM-TR-999TRY"] }
}

Регистр артикулов не важен — мы поднимаем его сами, дубли в запросе схлопываются (кап в 100 считается уже после этого). Но в ответе sku приходит в НАШЕЙ канонической форме, заглавными, и порядок строк ответу запросу не соответствует: сопоставляйте по полю sku, а не по позиции в массиве.

🔴 Неизвестный артикул не отвечает available: false — он уезжает в meta.unknown_skus. «Нет в наличии» говорит о нашем товаре; про артикул, которого мы не знаем, мы не утверждаем ничего. Опечатка в вашем справочнике иначе выглядела бы как вечно отсутствующий товар, которого вы бы ждали.

GET /v1/recipients/fields?sku= — чем адресуется зачисление позиции

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

{ "data": { "sku": "8BALLPOOL-GLOBAL-VAR-110CASH", "forma": "polya",
  "polya": [ { "key": "user_id", "type": "text", "secret": false },
             { "key": "platform", "type": "select", "secret": false, "options": ["ios", "android"] } ] } }
formaЧто значитЧто класть в строку заказа
netполучателя у позиции нет по устройству (код или ссылка)ни recipient, ни recipient_fields
strokaодиночный реквизит — кошелёк Steam, звёзды Telegramrecipient
polyaименованные поля категории, перечислены в polya[]recipient_fields теми же ключами

🔴 net — успех, а не ошибка. Спрашивать про любую позицию своего справочника законно, и отличать «мы не знаем» от «спрашивать нечего» по коду отказа вам не придётся.

key — имя, которое вы обязаны прислать обратно в recipient_fields. type — text либо select; у select рядом лежит options[] с допустимыми значениями. secret: true означает «поле несёт секрет» (password, email): не показывайте его в открытую и не сохраняйте.

⚠️ Подписей полей мы не отдаём — они пришли бы текстом поставщика, а его наружу мы не выпускаем (§2). Рисуйте свою подпись, откатываясь на само имя ключа.

🔴 Состав полей эта ручка и приём заказа берут одним источником: «что показала ручка» и «что примет заказ» разойтись не могут. Регистр артикула не важен, в ответе sku приходит в нашей канонической форме.

🔴 Не смогли узнать — 503 recipient-unavailable, отказ ПОВТОРЯЕМЫЙ. Состав полей знает поставщик; молчит он или отвечает пустым — мы говорим «повторите позже», а не «полей не нужно» и не «одна строка»: иначе вы собрали бы партию под форму, которую заказ отвергнет на приёме. Неизвестный артикул — 404 not_found, пустой sku — 400 validation_failed.

⚠️ Это не проверка получателя. Ручка отвечает «какие поля прислать», а не «годится ли значение»: valid тут нет и быть не может, ответ одинаков для всех и ни о каком аккаунте не говорит. Машинной /v1/recipients/check по-прежнему нет — §10.4.

GET /v1/balance — остаток

Скоуп balance:read.

{ "data": { "currency_code": "USD", "available_minor": 150000, "overdraft_limit_minor": 0 } }

overdraft_limit_minor — насколько глубоко разрешено уйти в минус; по умолчанию 0. Глубже списание не пройдёт: заказ отклоняется insufficient-balance до любых эффектов.

Пополнение на волне 1 идёт не через API — попросите по каналу связи из §1.

GET /v1/balance/entries?cursor= — журнал движений

Скоуп balance:read. Append-only, новые сверху, пагинация курсорная.

{
  "data": [
    { "operation": "debit", "amount_minor": 63800, "reason": "заказ pord_01J...",
      "order_id": "pord_01J...", "at": "2026-08-07T09:12:44.117Z" }
  ],
  "meta": { "next_cursor": "pbe_01J..." }
}

🔴 Курсор берите ТОЛЬКО из meta.next_cursor. Собственного id у строки журнала в ответе нет — поля не существует, и это не пропуск примера: наружу уходят ровно пять полей выше. Передайте полученный meta.next_cursor в ?cursor= следующего запроса; null означает «журнал дочитан». ⚠️ Курсор — непрозрачное значение: не сочиняйте его сами и не выводите из order_id или at. Нечитаемый курсор отвечает 400 validation_failed, но сочинённый ПО ФОРМЕ читаемым — например от чужой строки — честно отдаст не тот кусок журнала, и молча.

operation — debit (списание) либо credit (зачисление: пополнение и любой возврат).

order_id есть у движений по заказу. У движения без заказа (пополнение, ручная корректировка) поля нет вовсе — не null.

🔴 reason — человеческий текст, а не код. Он написан для человека, читающего журнал, формулировка меняется без предупреждения, и в него подставлены числа и идентификаторы. Не разбирайте его регулярками и не сравнивайте строкой. Машинная связь с заказом — поле order_id, направление движения — поле operation.

Страница журнала — 50 строк, размер не настраивается.


5. Заказы

⛔ Приём заказов через /v1 ещё не открыт — §10.1. Контракт ниже зафиксирован и меняться не будет; пишите клиента по нему.

5.1. Создание

POST /v1/orders, скоуп orders:create, заголовок Idempotency-Key обязателен.

POST /v1/orders
Authorization: Bearer kodrio_live_0000000000000000000000000000000000000000
Idempotency-Key: zakaz-2026-09-21-0001
Content-Type: application/json

{ "lines": [ { "sku": "STEAM-TR-100TRY", "quantity": 2, "unit_price_minor": 31900 } ] }

Правила тела:

  • lines — непустой массив, до 100 строк. Пустой → 400 validation_failed.

  • Дубли sku в lines[] запрещены — одна строка = один артикул. Иначе потолок количества обходился бы сотней строк одного артикула.

  • quantity — до 50 в строке (потолок из конфигурации). Сверх → invalid-quantity. ⚠️ У линии пополнений потолок свой и он равен единице. Позиции, которые зачисляются на чужой аккаунт (STEAMTOPUP-*, TGSTARS-* и прочие формы topup_by_login), принимаются только по одной штуке в строке: у поставщика для них нет поля количества вовсе. Партия пополнений — отдельная работа, и до неё заказ на две штуки такой позиции вернёт вам деньги целиком (batch-over-cap), а не выдаст половину.

  • unit_price_minor — эхо цены, которую вы видели в каталоге. Обязательно.

  • recipient — получатель ОДНОЙ СТРОКОЙ (с 28.08.2026). Обязателен у позиций линий Steam-кошелька и звёзд Telegram (артикулы STEAMTOPUP-*, TGSTARS-*), запрещён у позиций, выдаваемых кодом. Поле построчное: в одном заказе вы вправе пополнить разные аккаунты.

  • recipient_fields — получатель ИМЕНОВАННЫМИ ПОЛЯМИ, объект {"ключ": "значение"} (с 28.08.2026). Обязателен у позиций линии пополнения игр и сервисов — это ВСЕ остальные позиции с topup_by_id и topup_by_login, — где набор полей объявляет сама категория поставщика (от одного до трёх). Формы не взаимозаменяемы: разбор и способ узнать состав полей — §10.5. Отказы обеих форм: recipient-required · recipient-invalid · recipient-unknown — все в таблице отказов §5.1 ниже.

🔴 Эхо ценой списания не становится никогда. Списание всегда идёт по цене нашего бэкенда; ваше число только сверяется. Разошлось — заказ отклоняется price-changed до денег, и в строке отказа приходит expected_unit_price_minor — наша цена на момент отказа, чтобы вам не пришлось вслепую перечитывать каталог. ⚠️ У price-changed есть вторая причина, и в ней этого поля НЕТ: если цена позиции у нас сейчас не определена вовсе, отказ придёт с тем же кодом, но без expected_unit_price_minor — подставлять туда ваше же эхо значило бы подтвердить ваше число как нашу цену. Проверяйте наличие поля, а не полагайтесь на него: нет поля — перечитайте каталог, позиция могла уйти. Цена, которую вы прочитали в GET /v1/catalog, и цена списания — одно и то же число, а не два одинаково посчитанных.

Ответы:

ОтветТелоЧто значит
202{"data":{"order_id":"...","status":"accepted"}}заказ принят в обработку
200тот же ответ, что в первый разповтор того же ключа с тем же телом; ничего не списано повторно
409 processingконверт ошибкиключ занят заказом, исход которого ещё не подтверждён. Повторять безопасно
409 idempotency_conflictконверт ошибкиэтот ключ уже занят заказом с другим содержимым
422 rejectedline_rejections[]заказ отклонён; деньги не тронуты, ключ свободен
403 currency_not_supportedконверт ошибкисчёт вашей компании не в долларах (currency_code ≠ USD); деньги не тронуты, ключ свободен — §8
429 rate_limited+ Retry-Afterпревышен лимит частоты
400 validation_failedконверт ошибкитело не по схеме или нет Idempotency-Key
500 internal+ request_idнаша ошибка. Заказ НЕ считать проваленным — §5.4

Отказ по строкам:

{
  "error": {
    "code": "rejected",
    "message": "Заказ отклонён. Причины — по строкам.",
    "request_id": "9d4f...",
    "line_rejections": [
      { "sku": "STEAM-TR-100TRY", "code": "price-changed",
        "reason": "цена позиции изменилась: в заказе 31900, у нас 32400 (минорных единиц)",
        "expected_unit_price_minor": 32400 }
    ]
  }
}

Отказ уровня всего заказа (например, insufficient-balance) приходит строкой с sku: "*".

🔴 Ветвитесь по code, а не по reason. reason — человеческий текст с подставленными числами, его формулировка меняется без предупреждения. code — закрытый список из таблицы ниже.

Коды отказа строки:

КодЧто значитЧто делать
unknown-skuтакого артикула у нас нетсверить справочник
invalid-quantityколичество выше потолка 50разбить на заказы
batch-over-capв line_rejections не приходит: это исход ПОСЛЕ приёма — партия больше того, что принимает линия поставщика (у пополнений потолок — 1 штука); заказ завершается failed с полным возвратом, код стоит в причине возвратазаказать по одной
below-min-batchменьше минимальной партиидобрать до min_batch
not-enough-stockостатка не хватает на это количествоуменьшить количество или подождать
invalid-priceсумма строки выше нашего потолка на одну строкусверить количество и цену с каталогом
price-changedэхо разошлось с нашей ценой либо цена позиции у нас не определенаесть expected_unit_price_minor — повторить с ним; поля нет — перечитать каталог
closed-for-saleпозиция закрыта к продаже — живого канала поставки нетснять карточку у себя
supplier-unavailableзакупка по этому заказу сейчас недоступна — временное состояние на НАШЕЙ сторонеповторить позже, карточку НЕ снимать
kind-not-supportedвид выдачи виден в каталоге, но адаптера заказа у нас на него нет (сегодня это gift_link)§10.5
recipient-requiredу позиции пополнения не назван получательдобавить recipient либо recipient_fields — что именно, скажет текст отказа
recipient-invalidполучатель не по правилу линии · назван НЕ ТОЙ формой (строка вместо полей или наоборот) · назван у позиции, выдаваемой кодомпоправить строку заказа; недостающие поля перечислены в reason
recipient-unknownпоставщик не подтвердил такой аккаунтпроверить логин у покупателя
insufficient-balanceне хватает денег с учётом овердрафтапополнить
region-unknown, region-mismatchчерез /v1 недостижимы — регион задан самим артикулом, у вас его никто не спрашиваетне писать обработчик

Остаток склада reason у not-enough-stock не называет и называть не будет: числа склада наружу не выходят (§2 п.6). В тексте отказа только ваше собственное число — сколько вы заказали. Точный доступный объём мы не отдаём ни каталогом, ни отказом.

🔴 Граница между 400 и 422 — не вкус, и знать её надо заранее. 422 со строками — это «тело верное, но заказ не проходит по существу». Всё, что не по СХЕМЕ, отсекается раньше и отвечает голым 400 validation_failed без line_rejections[] — то есть без объяснения, какая именно строка виновата (причину наружу мы не отдаём намеренно). В 400 попадают: нецелое, нулевое или отрицательное quantity; нецелое, нулевое или отрицательное unit_price_minor; отсутствие любого из трёх полей строки; пустой или длиннее 100 строк lines[]; дубли sku; sku не строкой или длиннее 128 символов; recipient не строкой или длиннее 100 символов; recipient_fields не объектом (массив, строка, null), либо со значением не-строкой, либо более чем с десятью полями, либо с пустым или длиннее 64 символов именем поля, либо со значением длиннее 100 символов; отсутствие заголовка Idempotency-Key, его длина свыше 255 символов, а также ключ идемпотентности, начинающийся с sandbox: — этот префикс зарезервирован нами. Проверяйте эти условия у себя до отправки — иначе отладка идёт вслепую.

🪤 Обратите внимание: recipient_fields — объект, а не массив. Поставщик перечисляет поля массивом, и повторить его форму — естественная догадка; мы принимаем именно объект {"user_id": "123"}, массив отвергается как 400.

⚠️ У ключа идемпотентности обрезаются ведущие и хвостовые пробелы: "ord-1 " и "ord-1" — это ОДИН И ТОТ ЖЕ ключ, а не два разных заказа.

Частичной выдачи в v1 нет: отказ любой строки = отказ всего заказа. Принятый заказ либо выдаётся целиком, либо деньги возвращаются целиком.

5.2. Идемпотентность

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

  • Ключ — строка до 255 символов, своя на каждый заказ. Годится ваш собственный номер заказа, если он не переиспользуется.

  • Повтор с тем же ключом и тем же телом отдаёт 200 с прежним ответом. Второй раз не спишет.

  • Повтор с тем же ключом и другим телом → 409 idempotency_conflict. Это защита: считать такой запрос новым заказом значило бы списать дважды по вашей же опечатке.

  • Ключ действует, пока существует его строка, и не менее 24 часов. 🔴 Не переиспользуйте ключ «через сутки, он уже истёк» — он не истекает по времени. Повтор старого ключа с тем же телом отдаст прежний заказ, и вы сочтёте размещённым новый.

  • После 422 rejected ключ свободен. Отказ не записывается: вы правите тело и повторяете тем же ключом, заказ оценивается заново. 409 idempotency_conflict возможен только по ключу, который уже занят принятым (202) заказом.

🔴 «То же тело» считается по lines[], и только по ним. Сверяются пять полей каждой строки — sku, quantity, unit_price_minor, recipient и recipient_fields (с 28.08.2026); порядок строк и порядок ключей внутри recipient_fields значения не имеет (перестановка описывает тот же заказ). Отсутствующий recipient и пустая строка — одно и то же; отсутствующий recipient_fields и пустой объект — тоже. 🪤 Получатель входит в сверку намеренно: тот же артикул в том же количестве по той же цене, но на ДРУГОЙ логин — это другой заказ и другие деньги на другой аккаунт. Не входи он в канон, повтор с исправленным логином вернул бы вам ответ первого заказа, и вы сочли бы исправление принятым. Заголовки в сверку тела не входят.

5.3. Статусы заказа

accepted → processing → delivered | failed | refunded

🔴 accepted — это статус ОТВЕТА на размещение, а не ответ ручки чтения. Он приходит ровно в теле 202 (и 200 на повторе) у POST /v1/orders. GET /v1/orders/{id} его не возвращает никогда, даже если спросить сразу после 202: оттуда приходит либо processing, либо уже терминальный статус (быстрая выдача успевает завершиться до вашего первого чтения). Обрабатывать accepted как нетерминальный — верно; писать ветку «вдруг придёт из чтения» — не надо.

  • processing — не отказ. Выдача асинхронна. Заказ в processing нельзя помечать у себя проваленным: терминальны только три правых статуса.

  • delivered — выдан.

  • failed — не выдали, деньги вернулись автоматически; возврат виден отдельной записью в журнале баланса.

  • refunded — возврат после выдачи (отзыв кода, претензия).

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

  • По заказу приходит одно терминальное событие. Единственный разрешённый переход после терминала — delivered → refunded.

  • 🟢 status_detail при processing (появилось 08.08.2026). Необязательное поле; когда оно есть, оно объясняет, ЧТО происходит с заказом: · queued — заказ ждёт очереди на выдачу (поставщик временно не отдаёт коды, мы повторяем по расписанию); · stuck — ждёт дольше обычного (свыше 15 минут). Оба значения — не отказ, и реакция на них одна: перечитать статус позже. Поля НЕТ, когда объяснять нечего, — отсутствие поля не означает проблемы. Мы не обещаем, что список значений останется из двух: новые добавляются без депрекейта, поэтому незнакомое значение читайте как «просто processing».

5.4. Что делать, если наш ответ не пришёл

Таймаут, обрыв соединения или 500 на POST /v1/orders:

Повторите запрос с тем же Idempotency-Key. Это безопасно всегда — второй раз он не спишет.

⚠️ Одно исключение, пока приём заказов не открыт (§10.1): сейчас 500 приходит на КАЖДЫЙ заказ, и это постоянное состояние, а не сбой. Повтор его не лечит. Правило ниже — про настоящую аварию, то есть про отказ на части запросов при работающих остальных ручках.

🔴 Никогда не создавайте повтор с новым ключом, не выяснив судьбу старого. Новый ключ — это новый заказ, и вы получите два списания за один заказ своего клиента.

Тот же порядок годится для 409 processing, но с паузой: повторяйте не чаще раза в минуту. Чтобы выяснить судьбу заказа, номер которого у вас есть, повтор заказа не нужен — читайте §5.5. Типовой заказ разрешается за секунды; заказ, зависший из-за обрыва на нашей стороне, добивает наша сверочная петля, и это может занять до 20 минут. Плотный цикл здесь не ускорит ответ, а выжжет вашу квоту на создание заказов (§7) — и 429 получат все ваши интеграции, включая здоровые.

🔴 «Не чаще раза в минуту» — это про ПОВТОР POST /v1/orders, и это рекомендация, а не отдельный лимит. Технически создание заказов ограничено 30 запросами в минуту на компанию, чтение — 120 (§7); машинного «раз в минуту» не существует нигде, ни на записи, ни на чтении. Пауза названа потому, что чаще спрашивать НЕЧЕГО: заказ, ушедший в очередь, продвигает фоновая петля с шагом в 5 минут (§1), и ответ раньше её тика не изменится. Темп чтения статуса — §5.5.

5.5. Забрать коды — статус заказа и выдача

GET /v1/orders/{id} · скоуп orders:read. Это источник правды о заказе: вебхук (§6) уведомляет, а отвечает эта ручка. Разойтись они не могут — статус в обоих считается одним и тем же правилом.

curl -sS "$BASE/v1/orders/pord_01K1Z8Q0EXAMPLE" \
  -H "Authorization: Bearer $KODRIO_API_KEY"
{
  "data": {
    "order_id": "pord_01K1Z8Q0EXAMPLE",
    "status": "delivered",
    "items": [
      { "sku": "STEAM-TR-100TRY", "quantity": 2, "codes": ["TEST-ABCDE-FGHIJ", "TEST-KLMNO-PQRST"] }
    ]
  }
}
  • items приходит ТОЛЬКО у delivered. У processing и failed этого поля нет вовсе — не пустой массив, а именно нет. Пустой список вы прочитали бы как «выдача закончилась, кодов ноль».

  • По одной позиции на SKU, quantity всегда равен длине codes — отдельного счётчика, который мог бы разойтись с массивом, мы не отдаём.

  • Статусы — §5.3. Сегодня достижимы processing, delivered и failed; accepted из этой ручки не приходит никогда (§5.3). У processing рядом может стоять status_detail (queued / stuck) — см. §5.3.

  • Валидируйте коды по code_formats из каталога (§4), а не регуляркой «на глаз» и не по устаревшему code_format. Код обязан пройти любой один формат из массива: печатал его один из наших поставщиков, и чей именно станок сработал — решается в момент заказа. Формат описывает код ЦЕЛИКОМ, вместе с prefix: если у формата есть приставка, она входит в код. Отрезать её перед сверкой не надо. ⚠️ Проверка по единственному code_format отвергнет валидный оплаченный код, если позицию напечатал не тот поставщик, чей формат стоит в этом поле. Это и есть причина депрекейта (§11).

🔴 Перечитывать можно бессрочно, сколько угодно раз. «Показать один раз» на пути API нет и не планируется: правда о заказе живёт у нас, а не в одном ответе, который вы могли не получить из-за обрыва связи. Срок хранения мы не ограничиваем; появись однажды такое ограничение — оно пойдёт депрекейтом по §11, то есть не раньше чем через 90 дней после объявления.

⚠️ 404 not_found означает ровно одно: «такого заказа у вас нет». Мы намеренно не различаем «не существует» и «чужой». Если вы уверены, что заказ ваш, — проверьте номер заказа и то, что ключ выпущен вашей компанией: заказы читаются любым её боевым ключом (§3); тестовый ключ боевых заказов не видит (§10.2).

⚠️ processing — не отказ и не «потерялся». Так отвечает заказ, принятый (202), но ещё не дошедший до терминального исхода. Если рядом стоит status_detail: "queued" или "stuck" — заказ ждёт выдачи у поставщика; мы повторяем попытки сами, вмешательства с вашей стороны не нужно.

Темп опроса этой ручки. Отдельного ограничения «раз в минуту» на чтение НЕТ: действует общая квота чтения — 120 запросов в минуту на компанию (§7). Раз в минуту — наш совет, и вот его цена: заказ в очереди продвигает петля с шагом в 5 минут (§1), поэтому опрос раз в секунду вернёт то же самое пятьсот раз подряд и сожжёт квоту, общую со всеми вашими интеграциями. Разумный бюджет ожидания — от 5,5 минут на попытку; надёжнее опроса — вебхук (§6), он приносит исход сам.

5.6. Перебрать свои заказы — список

GET /v1/orders?cursor=&limit= · скоуп orders:read, тот же, что у чтения одного заказа: это одно право — «перечитать свои заказы».

curl -sS "$BASE/v1/orders?limit=50" \
  -H "Authorization: Bearer $KODRIO_API_KEY"
{
  "data": [
    {
      "order_id": "pord_01K1Z8Q0EXAMPLE",
      "status": "delivered",
      "created_at": "2026-09-01T09:00:00.000Z",
      "sandbox": false,
      "currency_code": "USD",
      "amount_minor": 700,
      "lines": [{ "sku": "STEAM-TR-100TRY", "quantity": 2 }]
    }
  ],
  "meta": { "next_cursor": "pord_01K1Z8Q0EXAMPLE" }
}
  • Новые сверху. Порядок — по номеру заказа убыванием; номер растёт со временем, поэтому это же и хронология.

  • Пагинация курсорная (§2.7): передайте meta.next_cursor в ?cursor= — получите следующую страницу. next_cursor: null означает, что дальше ничего нет. Заказ, размещённый МЕЖДУ вашими запросами, на следующие страницы не попадёт и ничего с них не вытеснит: он получает больший номер и остаётся на первой. limit — до 200, по умолчанию 50.

  • created_at — момент ПРИНЯТИЯ заказа, тот же, от которого мы считаем «застрял» (§5.3), а не момент, когда записан исход.

  • status и status_detail — те же значения и по тому же правилу, что у GET /v1/orders/{id} (§5.3, §5.5). Разойтись они не могут: статус в обеих ручках считает один и тот же код.

  • lines — состав БЕЗ кодов. Коды отдаёт только GET /v1/orders/{id}, по одному заказу за запрос (§5.5), и это граница, а не экономия: список — перебор, и коды в нём означали бы, что один запрос выносит весь ваш товар за всё время.

  • sandbox — контур заказа. Своим ключом вы видите только свой контур (§10.2), так что для машинного пути это поле постоянно; оно есть затем, что то же тело показывает и кабинет, а он видит оба.

🔴 Заказ, не стоивший денег, в этом списке ЕСТЬ. Это главное, чем список отличается от свода по журналу баланса, который мы советовали раньше. Отложенный, стоящий в очереди и возвращённый заказ в журнал движений либо не попадает вовсе, либо попадает парой строк — то есть свод по деньгам молча терял часть ваших партий. Ось этого списка — сами заказы, а деньги приходят к ним третьим источником.

⚠️ currency_code и amount_minor — необязательная ПАРА. Их нет ни у одного заказа, по которому мы не можем назвать сумму: так выглядит партия старше окна хранения служебных записей, за которую денег не двигалось ни разу. Это «сумму назвать нечем», а не «заказ был бесплатным» — поэтому поля именно отсутствуют, а не приходят нулём. lines отсутствует по той же причине и означает «состав не сохранился».

⚠️ Фильтра ?status= у списка нет — почему и что делать, см. §10.3.


6. Вебхуки

Мы шлём события на ваш адрес, чтобы вы узнавали об исходе, не опрашивая нас.

🔴 Вебхук — уведомление, а не источник правды. Правда живёт у нас, и её читают ручками чтения. Не стройте на одном лишь вебхуке денежных решений.

6.1. Подписка

POST /v1/webhooks, скоуп webhooks:manage + читательское право на каждое событие (§3).

{ "url": "https://hooks.example.com/kodrio", "events": ["order.delivered", "order.failed"] }

Ответ 201 — единственный раз, когда вы видите секрет подписи:

{ "data": { "id": "pwh_01J...", "url": "https://hooks.example.com/kodrio",
            "events": ["order.delivered","order.failed"], "secret": "<секрет подписи>" } }

Восстановить секрет нельзя: у нас он лежит зашифрованным. Потеряли или меняете — читайте порядок ниже, он не такой, как кажется.

🔴 Ротация секрета без потери событий. Порядок только такой:

  1. поднимите ВТОРОЙ адрес приёмника (например /kodrio-v2) и заведите подписку на него — получите новый секрет;

  2. убедитесь по last_success_at в GET /v1/webhooks, что события пошли на новый адрес;

  3. только теперь отзовите старую подписку.

⚠️ Обратный порядок теряет события безвозвратно. Пока активной подписки нет, событие не просто не доставляется — оно не создаётся вовсе, и поднять его нечем: ни ретраем, ни повтором, ни через нас. Ротация «на том же адресе» невозможна: на один адрес разрешена одна активная подписка (409 webhook_duplicate_url), поэтому старую пришлось бы снять первой.

Требования к адресу (это анти-SSRF-контроль, послаблений нет):

  • строго https:// — без исключений, включая loopback;

  • адрес не должен резолвиться в приватный, loopback, link-local или metadata-диапазон;

  • редиректы не следуются — 3xx считается неуспехом и уедет в ретрай.

⚠️ Не кладите секрет и в query-строку адреса. Адрес хранится у нас целиком и возвращается в GET /v1/webhooks любому вашему ключу со скоупом webhooks:manage. Подлинность нашего запроса доказывает подпись (§6.3) — другого секрета в адресе не нужно.

Адрес проверяется и при регистрации, и на каждой отправке. Не принят — 422 url_rejected, причина кодом в поле reason. Набор кодов закрытый, чтобы ваша автоматизация разбиралась без нас:

reasonЧто не так с адресом
not-httpsсхема не https:
private-addressадрес резолвится во внутренний диапазон
dns-failedимя не резолвится вовсе
not-a-urlстрока не разбирается как адрес
too-longадрес длиннее допустимого
credentials-in-urlлогин или пароль внутри адреса (https://user:pass@…) — они уехали бы в наш журнал доставок

Прочие ручки реестра:

РучкаЧто
GET /v1/webhooksсписок ваших подписок (секретов в нём нет)
DELETE /v1/webhooks/{id}отозвать подписку → {"data":{"id":"...","revoked":true}}
POST /v1/webhooks/{id}/replayповторить событие, {"event_id":"..."} → {"data":{"event_id":"...","queued":true}}. Ограничения — сразу ниже

Ограничения: 10 активных подписок на компанию (сверх → 409 webhook_limit), одна активная подписка на один адрес (409 webhook_duplicate_url). Повторить можно только событие, доставка которого уже завершилась; событие в очереди → 409 replay_not_terminal.

🔴 Что нужно знать про повтор ДО того, как вы на него понадеетесь:

  • event_id вы знаете только по событиям, которые до вас доехали. Машинного перечня событий и доставок в v1 нет — то есть повторить событие, которое вы НЕ получили ни разу, вам нечем. Если приёмник лежал дольше суток и события исчерпали окно ретраев, поднять их можно только через нас: напишите по каналу связи из §1.

  • Повтор — это одна попытка, а не новый цикл ретраев. Счётчик попыток и 24-часовое окно считаются от рождения события, а не от повтора. Не ответили 2xx — событие снова становится доступным для повтора, но само по себе оно не повторится.

  • Повторить можно в течение 30 суток с рождения события; дальше оно удаляется вместе с журналом доставок.

  • {"queued": true} означает «поставлено в очередь», а НЕ «доставлено». Факт доставки — только 2xx от вашего приёмника.

Что отдаёт GET /v1/webhooks (полями, чтобы вы не гадали): id · sandbox · url · events[] · secret_prefix · secret_last4 · created_at · revoked_at · last_success_at · consecutive_failures · last_http_status · last_failure_code. Секрета в списке нет ни в каком виде. sandbox — служебное поле; у подписок, заведённых боевым ключом, оно всегда false. 🔴 В списке есть и ОТОЗВАННЫЕ подписки — живая та, у которой revoked_at: null. Проверка «подписка на этот адрес уже есть» без этого условия сочтёт канал настроенным, когда его нет. consecutive_failures, last_http_status и last_failure_code — здоровье вашего приёмника; это единственный машинный способ увидеть, что он разваливается.

6.2. События волны 1

Событиеdata
order.deliveredorder_id, status: "delivered", amount_minor (списано), currency_code
order.failedorder_id, status: "failed", amount_minor (возвращено), currency_code
order.refundedorder_id, status: "refunded", amount_minor, currency_code
balance.lowcurrency_code, available_minor, threshold_minor

Конверт события:

{
  "event_id": "pwev_01J...",
  "event": "order.delivered",
  "created_at": "2026-08-07T09:12:44.117Z",
  "data": { "order_id": "pord_01J...", "status": "delivered",
            "amount_minor": 63800, "currency_code": "USD" }
}

К data события заказа может добавиться служебное поле sandbox; в ваших событиях его нет.

Порог balance.low по умолчанию выключен — пока он не назначен, события не будет. Молчание здесь означает «порог не настроен», а не «денег достаточно».

🔴 Назначить порог через /v1 сегодня нельзя — машинной ручки настроек в v1 нет. Назовите нужное значение по каналу связи из §1, мы выставим его. Пока порог не выставлен, подписка на balance.low примется (201), но не пришлёт ни одного события — не считайте её работающим предупреждением о деньгах.

В каких единицах называть порог: в минорных, как и все деньги в этом контракте (§1) — 500000 означает 5 000,00 в валюте вашей компании (для USD-счёта это $5,000.00), а не пятьсот тысяч: единицу берите из currency_code ответа, а не из предположения (§1). Тем же числом порог приезжает обратно полем threshold_minor события, так что проверить, что мы поняли вас верно, можно первым же событием.

Как выбрать значение. Порог — это не «мало денег», а «времени на пополнение осталось впритык», поэтому считается он от вашего расхода, а не от круглой суммы: возьмите средний расход за сутки и умножьте на срок, который у вас уходит на пополнение. Пополнение на волне 1 идёт по каналу связи (§1), то есть требует живого человека с обеих сторон — сутки-двое запаса разумнее часа. Порог сверяется с остатком после каждого вашего заказа — любого исхода, включая отказ по нехватке денег.

⚠️ Событие приходит ОДИН раз на пересечение вниз, а не на каждый заказ ниже порога. Пока остаток не поднялся обратно на порог или выше, второго balance.low не будет. Поднялся — при следующем заказе оповещение молча взводится заново; отдельного события «денег снова хватает» мы не шлём, это видно по балансу. Практическое следствие: не считайте молчание подтверждением, что денег хватает, и не стройте на этом событии единственную защиту от пустого кошелька — читайте GET /v1/balance.

6.3. Подпись

Заголовок X-Kodrio-Signature: sha256=<HMAC-SHA256(секрет, сырое тело)>.

Рядом едут заголовки-удобства — X-Kodrio-Event, X-Kodrio-Event-Id, X-Kodrio-Attempt. 🔴 Они не подписаны. Дедуп и любые решения — по event_id из тела, не из заголовка.

🔴 Подпись не содержит времени, поэтому дедуп у вас обязателен, и он обязан быть ДОЛГОВЕЧНЫМ. Захваченный валидный запрос иначе воспроизводится бессрочно. Храните обработанные event_id в таблице с UNIQUE, а не в памяти процесса: рестарт приёмника обнулил бы дедуп ровно тогда, когда идут наши ретраи. Дополнительно отбрасывайте события, у которых created_at старше 15 минут.

🔴 Считайте HMAC по сырым байтам тела, до разбора JSON. JSON.stringify(JSON.parse(x)) возвращает другую последовательность байтов (порядок ключей, пробелы, экранирование, точность чисел), и честная подпись перестанет сходиться. Это самая частая ошибка приёмника.

const crypto = require("crypto")

// express.raw, а не express.json: нужен нетронутый буфер тела.
// 🔴 express.json(), смонтированный ВЫШЕ по цепочке, разберёт тело раньше — express.raw тогда
// пропустит запрос, req.body окажется объектом, и hmac.update(req.body) бросит TypeError.
// Симптом: 500 на каждое событие и сутки ретраев, а не «подпись не сошлась». Монтируйте этот
// маршрут ДО глобального express.json() либо исключайте его путь из него.
app.post("/kodrio", express.raw({ type: "application/json" }), (req, res) => {
  const ozhidaem = "sha256=" + crypto.createHmac("sha256", process.env.KODRIO_WEBHOOK_SECRET)
                                     .update(req.body).digest("hex")
  const prislano = String(req.headers["x-kodrio-signature"] || "")

  const a = Buffer.from(ozhidaem), b = Buffer.from(prislano)
  // Сравнение постоянного времени: обычное `===` подсказывает подбирающему длину общего префикса.
  if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return res.sendStatus(401)

  const sobytie = JSON.parse(req.body.toString("utf8"))
  if (uzheObrabotano(sobytie.event_id)) return res.sendStatus(200)  // дедуп обязателен, см. §6.4

  // 🔴 ОТВЕЧАЕМ РАНЬШЕ, ЧЕМ ОБРАБАТЫВАЕМ (требование §6.4). У нас дедлайн 10 секунд по стенным
  // часам: обработка внутри запроса однажды упрётся в него, мы разорвём соединение и пришлём
  // событие снова — параллельно первой, ещё не завершившейся обработке. Дедуп от этого не спасёт:
  // отметка о ней ещё не поставлена.
  res.sendStatus(200)
  vOchered(sobytie)
})

6.4. Доставка, ретраи, порядок

  • Ответ 2xx за 10 секунд = доставлено. Всё остальное (включая 3xx) — неуспех.

  • Ретраи с экспоненциальной паузой и разбросом ±20 %: первая пауза 30 с, дальше удвоение до потолка в 1 час. Окно ретраев — 24 часа от рождения события; дальше событие остаётся в журнале, но само уже не придёт. Поднять его повтором можно, только если вы знаете его event_id, то есть если оно до вас хоть раз доехало — ограничения повтора в §6.1.

  • 🔴 Доставка at-least-once, а не exactly-once. Одно событие может прийти дважды — дедуп по event_id обязателен на вашей стороне.

  • 🔴 Порядок доставки не гарантируется. Упорядочивайте по created_at сами; не полагайтесь на то, что order.delivered придёт раньше следующего события.

  • Журнал доставок хранится 30 дней.

Отвечайте 2xx сразу, а обрабатывайте асинхронно: 10 секунд — это дедлайн по стенным часам, и медленный приёмник сам себе устраивает ретраи.


7. Лимиты частоты

На каждом успешном ответе и на 429 приходят заголовки. На отказах доступа (401, 403) их нет: запрос отбивается раньше, чем считается квота.

X-RateLimit-Limit: 120
X-RateLimit-Remaining: 118
X-RateLimit-Reset: 41

X-RateLimit-Reset — число СЕКУНД до сброса окна, а не отметка времени unix. Это расходится с привычкой многих API, поэтому названо прямо: 41 значит «через 41 секунду», а не «1970 год».

Превысили — 429 rate_limited плюс Retry-After, тоже в секундах. Стройте темп по Remaining, а не по 429.

Стартовые значения (не обещание — пересматриваются, изменение пойдёт строкой в журнал):

КатегорияРучкиЛимит
чтениекаталог, прайс файлом, наличие, баланс, журнал, чтение заказа GET /v1/orders/{id}, список заказов GET /v1/orders, реестр подписок120/мин
создание заказовPOST /v1/orders30/мин
повтор событияPOST /v1/webhooks/{id}/replay10/мин

Категория «чтение» — это «всё остальное». В неё попадает любая ручка /v1, кроме двух ниже: перечисление в строке описывает сегодняшний состав, а не ограничивает его.

🔴 Квота общая на все ключи компании, а не на ключ. Один разогнавшийся скрипт ловит 429 остальным вашим интеграциям, и выпуск дополнительных ключей квоту не увеличивает.

Заголовки могут и отсутствовать. Их отсутствие означает «неизвестно», а не «безлимитно»: держите темп по последнему известному значению.

⚠️ Существует второй 429 — от нашей инфраструктуры, а не от квоты. Он приходит без X-RateLimit-*, с коротким Retry-After, и означает «слишком плотный поток С ВАШЕГО АДРЕСА». Считается он по адресу, а не по компании, и в этом вся разница: одна интеграция в свои квоты его не поймает, а вот несколько ваших интеграций (или несколько наших партнёров) за общим исходящим адресом — могут, особенно если шлют пачками. Отличать просто — по отсутствию X-RateLimit-Limit; реакция та же, Retry-After. Ловите такой 429 регулярно — напишите нам: порог поднимается.


8. Ошибки: полный словарь

Ошибка всегда приходит одним конвертом, message — из закрытого словаря, request_id есть всегда. Назовите нам request_id, когда пишете о сбое.

{ "error": { "code": "forbidden_scope",
             "message": "У ключа нет права на эту операцию.",
             "request_id": "9d4f2b1e-...", "reason": "orders:read" } }
HTTPcodeКогда
400validation_failedтело не по схеме, пустой lines[], >100 SKU в /v1/stock, нечитаемый cursor, нет Idempotency-Key
401unauthorizedключа нет / неверен / отозван
403forbidden_scopeнет нужного скоупа; при подписке в reason — недостающие скоупы
403ip_not_allowedадрес вне списка, разрешённого ключу
403verification_requiredкомпания не прошла проверку. Боевой ключ не допускается на денежный путь (POST /v1/orders) до проверки компании — сумма заказа роли не играет. Каталог, склад и остаток читаются как обычно. В message — что делать и ссылка на раздел «Компания» в кабинете
403currency_not_supportedзаказы по счёту не в долларах не принимаются. Цены каталога и списание ведутся в центах доллара; у счёта с currency_code, отличным от USD, единицы разошлись бы, поэтому POST /v1/orders отклоняется до денег, ключ идемпотентности свободен. Каталог, склад и остаток читаются как обычно. Напишите нам — переведём счёт
404not_foundнет такого объекта — или он не ваш. Существование чужого мы не подтверждаем. Этим же кодом отвечает любой адрес под /v1, которого у нас нет (опечатка в пути, ручка из будущей волны): ответом на неизвестный адрес приходит такой же конверт, а не страница с разметкой
409idempotency_conflictключ занят заказом с другим телом
409processingключ занят, тело то же, исход не подтверждён. Повторять безопасно, но не чаще раза в минуту — разбор до 20 мин (§5.4)
409webhook_limitуже 10 активных подписок
409webhook_duplicate_urlактивная подписка на этот адрес уже есть
409replay_not_terminalсобытие ещё в очереди, повторять не нужно
422rejectedзаказ отклонён, причины — в line_rejections[] (§5.1)
422url_rejectedадрес подписки не принят, причина кодом в reason
422kind-not-supportedпроверка получателя не применима к виду выдачи. Через /v1 сегодня недостижим — ручки ещё нет (§10.4)
422recipient-invalidполучатель не годится — назван не по правилу линии либо поставщик его не подтвердил. Чинится правкой логина в своей строке; повторять без изменений бессмысленно. Через /v1 сегодня недостижим — ручки ещё нет (§10.4); построчный отказ с тем же именем внутри заказа — §5.1
503recipient-unavailableсостав полей получателя узнать не смогли (GET /v1/recipients/fields, §4): схему знает поставщик, и он молчит либо отвечает пустым. Отказ ПОВТОРЯЕМЫЙ — повторите позже без изменений. Мы намеренно не отвечаем «полей не нужно» и не «одна строка»: под такую форму вы собрали бы партию, которую заказ отвергнет
429rate_limitedлимит частоты, пауза в Retry-After
500internalнаша ошибка. Заказ, если он был создан, НЕ считается проваленным (§5.4). ⚠️ На POST /v1/orders сегодня это ПОСТОЯННОЕ состояние, а не авария — §10.1
503secret_not_configuredмы не настроены выпускать подписки. Повторить позже без изменений
503sandbox_not_availableключ снятого типа — тестовый, выпущенный до 28.09.2026: баланс, журнал и заказы он больше не обслуживает (§10.2). Повтор не поможет — выпустите боевой ключ в кабинете (§1)

🔴 500 и 503 — разные ответы, и реагировать на них надо по-разному. 500 — мы сломались: повторите (заказ — тем же ключом) и сообщите нам request_id по каналу связи из §1 — кроме 500 на корректный заказ, пока приём не открыт: это постоянное состояние (§10.1). 503 — мы не настроены: запрос верен, повторите позже без изменений, писать не нужно, мы уже знаем. Исключение — sandbox_not_available: его лечит не повтор, а боевой ключ.


9. Формат кодов и выдача

  • Заказом поддержаны key_code, game_key, topup_by_id и topup_by_login; без адаптера заказа остался gift_link — он в каталоге виден и отвергается kind-not-supported (§10.5).

  • 🔴 «Вид выдачи поддержан» ≠ «эту позицию можно купить сейчас». Заказуемость конкретной позиции всегда смотрите по available и closed_for_sale, а не по этому перечню: у позиции может не быть цены, остатка или открытой у поставщика категории.

  • Форматы кода описаны в каталоге полем code_formats — валидируйте выдачу у себя по нему, а не регуляркой «на глаз». Разбор полей и пример проверки — §4 «Формат кода».

  • 🔴 Формат — свойство ПАРЫ «позиция × поставщик». У каждого поставщика свой станок печати, а кто напечатает ваш код — решается в момент заказа. Поэтому форматов приходит массив, и выданный код обязан пройти хотя бы один из них. Единственное поле code_format описывало формат позиции, было правдой не про всякую выдачу и снимается 18.11.2026 (§11).

  • 🔴 alphabet — это перечень допустимых символов строкой (ABCDEFGHJKLMNPQRSTUVWXYZ23456789), из которого вы строите класс символов. Не название набора и не диапазон в записи регулярки: подставляйте его в проверку буквально.

  • 🔴 Код проверяется ЦЕЛИКОМ, вместе с prefix и suffix, а не одной маской: код длиннее маски ровно на них — снимать их перед сверкой не нужно и не следует (§4).

  • 🔴 Код, не прошедший НИ ОДИН объявленный формат, к вам не приедет. На нашей стороне выдача сверяется с форматом той пары, которая его напечатала, и несовпадение закрывает заказ: деньги возвращаются, код партнёру не уходит. Обещание §1 «мы не принимаем заказ, который не исполним» распространяется и на вид выданного кода.

  • 🔴 Коды отдаёт GET /v1/orders/{id} (§5.5), и только она. Ни ответ на заказ, ни событие order.delivered кодов не несут и нести не будут: в ответе на заказ — order_id и статус, в событии — order_id, статус и сумма. Причина не техническая: событие уходит на ваш адрес по сети, которую мы не контролируем, а код — это товар. За ним нужно прийти со своим ключом.

  • Срок хранения не ограничен. Перечитывать выданное можно бессрочно и сколько угодно раз; «показать один раз» на пути API нет. Появись однажды ограничение — оно пойдёт депрекейтом по §11 (не раньше 90 дней после объявления). Сохранять коды у себя всё равно стоит: это ваш товар, и зависеть от чужой доступности в момент выдачи покупателю незачем.


10. Чего v1 не обещает

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

10.1. Приём заказов ещё не открыт

Контракт §5 зафиксирован и меняться не будет.

🔴 Как это выглядит в ответе, и почему это исключение из §8. Пока боевой приём не открыт, корректный заказ боевым ключом получает 500 internal — и это ПОСТОЯННОЕ состояние, а не наша авария. Значит, к нему НЕ применяются обычные советы про 500 (§5.4 и §8): повторять такой заказ бессмысленно — ответ не изменится ни через секунду, ни завтра, — и сообщать нам о нём не нужно, мы знаем. Отличить одно от другого просто: корректный заказ — артикулы существуют, цена совпадает с каталогом, количество в пределах потолка — сегодня получает 500 ВСЕГДА. Если в ответе 422, это не оно: разбирайтесь по line_rejections[], до замка дело не дошло.

Ваши деньги не тронуты ни в одном сценарии — отказ наступает до списания, и ключ идемпотентности остаётся свободным.

Открытие приёма — отдельная строка в журнале изменений.

10.2. Тестового контура нет

Тестового контура у v1 нет — ни отдельного адреса, ни тестовых ключей. Первый заказ идёт боевым ключом, как только открыт приём (§10.1). Тестовый ключ, выпущенный до 28.09.2026, читает каталог, но форматы кодов приходят ему с приставкой тестового контура, которой у боевых кодов нет; подписку он заводит, но событий на неё не будет; боевых заказов он не видит, а на баланс, журнал и заказ получает 503 sandbox_not_available (§8). Переведите на боевой ключ всю интеграцию, включая каталог и вебхуки.

10.3. У списка заказов нет фильтра по статусу

Сам список открыт 07.09.2026 (§5.6) — то, чего у него пока НЕТ, это параметр ?status=.

Причина не в очереди задач, а в устройстве: статус заказа у нас не хранится отдельным полем, он считается в момент ответа из журнала выдач, маркера приёма и очереди дожатия. Отобрать по нему в запросе нечем, а отбор уже собранной страницы сделал бы пагинацию лживой: вы получили бы три строки из пятидесяти вместе с курсором, указывающим на пятидесятую, и решили бы, что «в работе» у вас ровно три партии. Фильтр приезжает вместе с машиной состояний заказа; до неё честнее не иметь его, чем иметь сломанным.

Практический обход на сегодня: страница отдаёт status каждой строки, отбирайте на своей стороне — порядок и курсор при этом остаются верными.

10.4. Проверки получателя /v1/recipients/check ещё нет

Ручка нужна видам выдачи «пополнение по логину/ID». С 28.08.2026 пополнение по логину заказуемо, и получателя мы проверяем САМИ — среди предикатов заказа, ДО списания (§5.1): опечатка отвергается кодом recipient-unknown, а не превращается в деньги на чужом аккаунте. Отдельная машинная ручка предварительной проверки приезжает своей волной; до неё безопасный порядок — отправить заказ и прочитать отказ, он бесплатен и приходит до денег. ⚠️ Не путайте её с GET /v1/recipients/fields (§4), которая приехала 31.08.2026. Та отвечает «какие поля прислать», эта — «годится ли значение»; состав полей известен, годность аккаунта по машинному пути по-прежнему судит сам заказ. Для key_code получателя не существует по устройству, и такой запрос отвечает 422 kind-not-supported.

10.5. Что заказуемо по видам выдачи

Заказом поддержаны key_code, game_key, topup_by_id, topup_by_login. Без адаптера заказа остался gift_link: он в каталоге виден и отвергается kind-not-supported. Мы показываем такие позиции честно, а не прячем — ассортимент реален.

🔴 Перечень отвечает «мы умеем это заказать», а НЕ «это продаётся сегодня». Заказуемость позиции — available и closed_for_sale, и она решается другими вещами: есть ли цена, есть ли остаток, открыта ли у поставщика категория, включена ли линия. Позиция поддержанного вида вполне может прийти closed_for_sale: true.

🔴 Позиции БЕЗ кода требуют реквизит получателя, и он у линий РАЗНЫЙ (§5.1):

🔴 Форму реквизита задаёт ЛИНИЯ, а не delivery_kind — и это важно, потому что одна и та же форма выдачи встречается у обеих линий:

ЛинияКак узнатьРеквизит в строке заказа
Пополнение кошелька Steam и звёзды Telegramартикулы STEAMTOPUP-* и TGSTARS-* (сегодня их девять)recipient — одна строка (логин / @username)
Пополнение игр и сервисов (всё остальное)любой другой артикул с topup_by_id или topup_by_loginrecipient_fields — ИМЕНОВАННЫЕ поля категории

⚠️ Не судите по delivery_kind. Позиций topup_by_login сегодня 321, и из них лишь девять — Steam и Telegram; остальные 312 («Auto Via Login») принадлежат линии пополнения сервисов и требуют recipient_fields (email, password, server). Отдельного поля-признака линии в каталоге пока нет — если сомневаетесь, отправьте recipient_fields: линия, которой нужна одна строка, перечислена выше поимённо и коротка.

У линии пополнения сервисов поставщик спрашивает у каждой категории свой набор — от одного поля (user_id) до трёх (email, password, server), — поэтому одиночный recipient ей не подходит даже при единственном поле: подставить строку «в первое поле» значило бы догадываться, а догадка станет неверной в день, когда у категории появится второе. Такая строка отвергается recipient-invalid, и наоборот — recipient_fields у Steam/Telegram отвергается тем же кодом.

⚠️ Как узнать нужные поля. Спросите — GET /v1/recipients/fields?sku= (§4) отдаёт состав полей структурой, скоупом catalog:read. Второй способ остаётся и стоит ноль: отправьте строку с любым набором, отказ приедет в line_rejections[].reason, перечислит недостающие поля по имени, а для поля с закрытым списком назовёт допустимые значения, — и приходит он до списания.

🔴 У позиций без кода полей code_format / code_formats НЕТ ВОВСЕ, и это ответ, а не пропуск: кода не существует, поставщик зачисляет средства прямо на аккаунт, и вам приезжает подтверждение зачисления. Получателя мы проверяем ДО списания.

🔴 game_key — не синоним key_code, и различать их вам стоит. Вам в обоих случаях приезжает код, но у игрового ключа своё региональное ограничение: он активируется в СПИСКЕ стран (region_countries), а не в одной. Получателя у него не бывает — recipient и recipient_fields в такой строке отвергаются recipient-invalid.

10.6. Прочее, чего в v1 нет намеренно

  • События наличия (stock.out / stock.back) — наличие читается каталогом и отказом not-enough-stock.

  • Частичная выдача — отказ любой строки = отказ всего заказа с полным возвратом.

  • Партии больше потолка (quantity > 50 в строке) — потолок снимается расширением, форма запроса при этом не меняется.

  • Ступени оптовой цены (price_tiers[]) — поле появится рядом с price, семантика сверки эха не изменится.

  • Числовые SLA — публикуем статусы и обязательство «принят быстро, выдача асинхронна»; числовых сроков выдачи не обещаем.

  • Пополнение баланса через API — на волне 1 идёт по каналу связи из §1.

  • Самообслуживаемая проверка компании — регистрация ваша, проверку проводим мы; ключи после неё вы выпускаете сами в кабинете (§1).

  • Настройка порога balance.low через API — на волне 1 идёт по каналу связи из §1 (§6.2).

  • Машинный перечень событий и доставок — повторить (§6.1) можно только событие, event_id которого вы уже видели.


11. Совместимость и депрекейт

Обязательство: не раньше 90 дней. Любое поле, ручка или код ошибки, которые мы решим убрать или изменить ломающим образом, отменяются не раньше чем через 90 дней после объявления. Объявление — строка в журнале изменений с датой вступления в силу.

Ломающим мы считаем: удаление ручки, удаление поля из ответа, сужение допустимых значений, новое обязательное поле в запросе, изменение смысла существующего кода ошибки, смену ЕДИНИЦЫ или смысла существующего поля при неизменных имени и типе, а также формат заголовка X-Kodrio-Signature и алгоритм подписи — все они ломают приёмник молча, поэтому объявляются теми же 90 днями.

🔴 Смена единицы дописана 02.09.2026, и дописана собственной ошибкой. Этого класса в перечне не было, а он самый тихий из возможных: имя поля прежнее, тип прежний, схема проходит, число другое. Ровно так market_price месяц ехал рублёвыми копейками под подписью «минорные единицы» рядом с центовым price. Пункт добавлен, чтобы в следующий раз он попал под 90 дней по правилу, а не по чьей-то внимательности.

🪤 Дважды мы это обязательство не исполнили, и говорим об этом здесь, а не только в журнале. 31.08.2026 (учётная валюта контура) и 02.09.2026 (единица market_price) — обе записи 🔴 без предшествующей 🟡 и без 90 дней. Основание в обоих случаях одно и названо в самих записях: зондом боевой базы показано, что живых потребителей ручки нет ни одного — не выпущено ни единого ключа. Правило не отменено и продолжает действовать: как только у контракта появится первый живой читатель, обход перестанет быть возможным, потому что перестанет быть правдой его единственное основание. Обход без свежего зонда с числами в записи — не обход, а нарушение.

Не ломающим (приезжает без 90 дней и без предупреждения — ваш клиент обязан это переживать):

  • новое необязательное поле в ответе — не падайте на незнакомых полях;

  • новое значение в списке (новый бренд, новый регион, новый вид выдачи);

  • новый код ошибки в существующем HTTP-классе — обрабатывайте по HTTP-статусу, а не только по списку известных кодов;

  • новое событие вебхука — вы получаете только те, на которые подписаны.

Правило нашей стороны: меняется контракт — строка в журнале изменений в том же изменении, иначе изменение не считается сделанным.

Действующие объявления депрекейта

ЧтоОбъявленоСнимаетсяЧем заменено
поле code_format строки каталога (GET /v1/catalog)20.08.202618.11.2026code_formats — массив форматов пары «позиция × поставщик», §4

🔴 Почему это ломающее, а не «новое необязательное поле». Само по себе появление code_formats ломающим не является. Ломающим является то, что мы снимаем обещание, которое давали §5.5 и §9: «валидируйте выдачу по code_format». Оно было неверным — формат зависит от того, кто печатает код, а не только от позиции, — и клиент, проверяющий выдачу по единственному формату, отвергает валидный оплаченный код. Мы предпочли назвать это ломающим и дать полные 90 дней, а не тихо переопределить смысл существующего поля.

🔴 Новый элемент в code_formats мы объявляем ЗАРАНЕЕ, хотя формально это «новое значение в списке». По букве правила выше такое приезжает без предупреждения — но именно на этом и ломается ваша проверка: подключили мы поставщика со своим станком, а у вас в справочнике лежит вчерашний массив, и валидный оплаченный код вы завернёте. Поэтому: появление нового формата у существующего артикула идёт строкой в журнал изменений заранее, как ломающее. Со своей стороны просим о взаимном: перечитывайте code_formats вместе с каталогом, а не один раз при интеграции.

Что делать до 18.11.2026: переведите проверку на code_formats (одна строка: «проходит любой из»). До этой даты приходят оба поля. ⚠️ Не считайте code_format первым элементом массива — он описывает позицию, а не пару, и совпадает с одним из code_formats только тогда, когда печатающий поставщик формат не меняет. Ровно поэтому на него и нельзя опираться.


12. Справочник ручек /v1

Полный список. Ручки, которых здесь нет, не существует; всё, что существует, — здесь.

⚠️ «Построена» ≠ «доступна вашему ключу прямо сейчас»: все ручки отвечают на боевом адресе, но заказ боевым ключом ещё не принимается (§10.1). Столбец говорит о готовности контракта.

РучкаСкоупРазделСостояние
GET /v1/catalogcatalog:read§4✅ построена
GET /v1/catalog/exportcatalog:read§4✅ построена
GET /v1/stockcatalog:read§4✅ построена
GET /v1/recipients/fieldscatalog:read§4✅ построена
GET /v1/balancebalance:read§4✅ построена
GET /v1/balance/entriesbalance:read§4✅ построена
GET /v1/webhookswebhooks:manage§6.1✅ построена
POST /v1/webhookswebhooks:manage + право на события§6.1✅ построена
DELETE /v1/webhooks/{id}webhooks:manage§6.1✅ построена
POST /v1/webhooks/{id}/replaywebhooks:manage§6.1✅ построена
POST /v1/ordersorders:create§5✅ построена; приём боевым ключом ещё не открыт (§10.1)
GET /v1/orders/{id}orders:read§5.5✅ построена
GET /v1/ordersorders:read§5.6✅ построена
GET /v1/openapi.jsonлюбой живой ключ, скоуп не нужен§12✅ построена
POST /v1/recipients/check——⛔ не построена (§10.4)

GET /v1/openapi.json — машиночитаемый контракт. Тот же контракт, что на этой странице, но файлом OpenAPI 3.1: по нему генерируется клиент, его импортируют в Postman по адресу. Открывается любым вашим действующим ключом, конкретный скоуп не нужен; без ключа — 401 unauthorized тем же конвертом §8. Файл приходит как есть, без конверта {"data": …} — иначе инструменты не примут его по адресу; так же, без конверта, отдаётся прайс-файл GET /v1/catalog/export. Запрос считается в квоту «чтение» (§7). Расходятся файл и эта страница — напишите нам: такое расхождение мы считаем дефектом.


13. Если что-то пошло не так

Подозрение на утечку ключа

Признаки: 403 ip_not_allowed при неизменной исходящей сети · движения в журнале баланса, которых вы не делали · подписка в GET /v1/webhooks, которую вы не заводили · last_used_at у ключа, которым вы не пользуетесь.

Порядок действий — все три шага, отзыв ключа сам по себе НЕ закрывает утечку:

  1. Отзовите ключ в кабинете, раздел «Ключи доступа». Доступ прекращается мгновенно, мы решение по ключу не кэшируем.

  2. Пройдите GET /v1/webhooks и отзовите все подписки, которых вы не заводили. Подписки принадлежат компании и переживают отзыв ключа: чужая подписка продолжит слать ваш остаток и ваши заказы на чужой адрес, пока вы её не снимете.

  3. Сверьте журнал баланса (GET /v1/balance/entries) за период с момента, когда ключ мог утечь, и сообщите нам о движениях, которых вы не делали.

Не расширяйте список разрешённых адресов, чтобы «починить» 403 ip_not_allowed, — это ровно тот шаг, которого ждёт тот, кто увёл ключ.

Прочее

  1. Возьмите request_id из тела ошибки.

  2. Сверьтесь с §8 — большая часть отказов чинится на вашей стороне и описана там.

  3. 500/503/таймаут на заказе — §5.4: повторить тем же Idempotency-Key, никогда не новым. Кроме 500 на корректный заказ, пока приём не открыт (§10.1), и 503 sandbox_not_available (§8).

  4. Осталось непонятным — напишите по каналу связи из §1, назвав request_id, время и ручку.

Вопрос, ответа на который нет на этой странице, — наш недочёт, а не ваш вопрос. Сообщите о нём: страница чинится текстом, а не устным ответом.


Журнал изменений Kodrio Partner API /v1

Здесь же — правило депрекейта: сняли что-то из контракта, значит объявили это заранее и выдержали срок. Записи идут сверху вниз от свежих к старым.

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

Документация контракта — 10-partner-api-public.md.

28.09.2026 — машиночитаемый контракт GET /v1/openapi.json и исправления документации

🟢 Совместимо. Новая ручка; поведение существующих не меняется — ниже исправлена ДОКУМЕНТАЦИЯ там, где она расходилась с тем, что ручки отдавали всегда.

Новое. GET /v1/openapi.json — контракт файлом OpenAPI 3.1 для генерации клиента и импорта в Postman. Открывается любым действующим ключом, скоуп не нужен (§12 документации).

Исправлено в документации (ответы ручек не менялись):

  1. Страница журнала движений — 50 строк, а не 200 (§4).

  2. batch-over-cap в line_rejections не приходит: это исход после приёма — заказ failed с полным возвратом (§5.1).

  3. price в каталоге может отсутствовать — у позиции, цена которой у нас не определена (§4).

  4. Прайс-файл несёт 15-ю колонку region_countries — она была описана абзацем, но не в списке колонок (§4).

  5. Скоуп catalog:read открывает ещё GET /v1/catalog/export и GET /v1/recipients/fields (§3).

  6. У подписки есть служебное поле sandbox — всегда false (§6.1).

28.09.2026 — боевой адрес https://kodrio.com, боевой ключ — в кабинете

🟢 Совместимо. Ни одно поле, ни один код отказа, ни одна ручка не изменены. Меняется, где взять адрес и ключ.

Что было. Документация обещала, что боевой адрес «приедет вместе с доменом» и что ключ приходит вам сообщением от нас.

Что стало. Боевой адрес — https://kodrio.com, с TLS; боевой ключ, его скоупы и список разрешённых адресов вы выпускаете и отзываете сами, в кабинете (раздел «Ключи доступа»), после проверки компании. Тестового контура у v1 нет: тестовые ключи, выпущенные раньше, больше не годятся для работы: каталог отдаёт им форматы кодов с приставкой тестового контура, события на их подписки не приходят, а на баланс, журнал и заказ они получают 503 sandbox_not_available (§8, §10.2) — переведите всю интеграцию на боевой ключ. Связь с нами — раздел «Поддержка». Приём заказов по-прежнему не открыт (§10.1 документации).

27.09.2026 — заказ по счёту не в долларах отклоняется 403 currency_not_supported

🟢 Совместимо для счетов в USD — а это все счета, заведённые с 31.08.2026: для них не меняется ни поле, ни ответ. Добавлен один код отказа.

Что было. Цены каталога и unit_price_minor заказа названы в центах доллара, а списание шло в валюте счёта (currency_code). У счёта, оставшегося в RUB, центы списались бы копейками.

Что стало. POST /v1/orders по счёту с currency_code, отличным от USD, отвечает 403 currency_not_supported до всякого списания; ключ идемпотентности остаётся свободным. Каталог, склад и остаток такой счёт читает как раньше. Чтобы заказывать, напишите нам — переведём счёт в доллары (§8 документации).

23.09.2026 — позиция, временно отсутствующая у поставщика, остаётся в каталоге «нет в наличии»

🟢 Совместимо. Ни одно поле, ни один код отказа не добавлены и не переименованы. Меняется, КАКОЙ ответ вы получите по позиции, которой у поставщика сейчас нет.

Что было. Такая позиция исчезала из GET /v1/catalog и GET /v1/stock (артикул уходил в meta.unknown_skus, заказ — unknown-sku), а в отдельных случаях приходила с closed_for_sale: true. Оба ответа по §5 означают «снимите карточку», хотя позиция обычно возвращается.

Что стало. Позиция остаётся в каталоге: available: false, цена прежняя, без closed_for_sale. GET /v1/stock отвечает available: false, а не «неизвестный артикул». Заказ по ней получает 422 с кодом not-enough-stock — «сейчас столько взять нельзя», карточку снимать не нужно (§5, таблица кодов отказа). Когда позиция вернётся к поставщику, она снова станет available: true с очередным обновлением нашего каталога.

Что НЕ изменилось. closed_for_sale: true по-прежнему означает «мы это не закупаем» и приходит, когда канала поставки у позиции нет. Если позиция долго не возвращается, она уходит из каталога окончательно, как раньше; перед этим она может на время прийти с closed_for_sale: true.

23.09.2026 — «ровно одно терминальное событие» теперь переживает наш перезапуск

🟢 Совместимо. Контракт /v1 не изменился ни на поле: ни формы событий, ни их словарь, ни дедуп по event_id на вашей стороне. Меняется то, насколько крепко мы держим уже данное обещание.

Что было. §6 обещает по заказу ровно одно терминальное событие (order.delivered либо order.failed). Держалось это обещание на отметке в памяти нашего процесса. Для ветки доставки памяти хватало, для ветки отказа — нет: после нашего перезапуска сверочная петля могла повторно признать заказ неуспешным и прислать ВТОРОЕ order.failed с ДРУГИМ event_id. Дедуп по event_id такой дубль не схлопывает по построению — он на то и другой.

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

🔴 §6 не отменяется, и снимать свою защиту не нужно. Ниже честно названы две границы, за которыми повтор остаётся возможным. Идемпотентность на вашей стороне остаётся последним рубежом — как и у нас.

⚠️ Граница 1 — подписка. Признак хранится парой «подписка + заказ», а не «компания + заказ», и это осознанно: одно событие домена уходит по строке на каждый живой приёмник, и ключ по компании оставил бы вторую подписку без события вовсе. Практическое следствие для вас: если вы отзовёте подписку и заведёте новую на тот же адрес, для неё признак пуст, и терминальное событие по заказу, о котором уже знала прежняя подписка, может прийти повторно. Ротация приёмника — редкое и осознанное действие, но §6 остаётся вашим рубежом именно на этот случай.

⚠️ Граница 2 — время. Признак живёт ровно столько, сколько строка очереди, а она чистится ретенцией через 30 суток. Подавляющее большинство заказов закрывается в сутки, но заказ, ушедший на ручной разбор, может оставаться незакрытым и дольше — и тогда граница становится проходимой: строка события уйдёт по ретенции, ключ освободится, и повторное терминальное по такому заказу возможно. Случай редкий и требует стечения двух условий, но он ВОЗМОЖЕН, и мы называем его здесь, а не прячем: §6 остаётся вашим рубежом именно на такие края.

order.refunded это не касается. Операторский возврат по уже терминальному заказу — законное второе событие, оно приходит как приходило (§5.3, разрешённый переход delivered → refunded).

19.09.2026 — партия в строке заказа выросла с 10 до 50 штук

🟢 Совместимо. Форма запроса не меняется ни на поле: тот же lines[{sku, quantity, unit_price_minor}]. Меняется только число, выше которого строка отвергается invalid-quantity.

Что стало можно. quantity в одной строке — до 50 (было 10). Заказ на пятьдесят штук одной позиции больше не нужно разбивать на пять заказов. Ступени цен (price_tiers[]) в GET /v1/catalog подтягиваются сами: ступени, которые раньше не показывались как недостижимые, теперь в ответе есть — формат при этом прежний.

⚠️ Линия пополнений исключение: у неё потолок равен единице. Позиции, зачисляемые на чужой аккаунт (формы topup_by_login), принимаются по одной штуке в строке — у поставщика для них нет поля количества. Это не изменилось и этой записью не меняется.

Что НЕ изменилось и изменится не может. Кап строк в заказе — по-прежнему 100, дубли sku в lines[] по-прежнему запрещены. Заказ исполняется ЦЕЛИКОМ либо не списывает денег вовсе: частичной выдачи нет, и увеличение партии этого не меняет.

Заказ из нескольких артикулов исполняется быстрее. Формат ответа и коды ошибок прежние; числовых обязательств по времени исполнения контракт по-прежнему не даёт (§10.6).

19.09.2026 — в ленте кабинета появляется строка о КАЖДОМ возврате денег

Раньше лента показывала возврат только по заказу, который мы не выдали. Возврат ПОСЛЕ выдачи — отзыв кода или удовлетворённая претензия — деньги вам возвращал, но в ленте не появлялся: о нём вы узнавали не от нас. Теперь такая строка есть.

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

Контракт /v1 не изменился: ни одна ручка, ни одно поле ответа, ни один код ошибки.

Как читать

Каждая запись помечена одним из трёх:

  • 🟢 Совместимо — приезжает сразу, ваш клиент продолжает работать без правок.

  • 🟡 Объявление депрекейта — названо, что и когда отменяется. Не раньше 90 дней с даты записи; дата вступления в силу указана в самой записи.

  • 🔴 Ломающее — по общему правилу только по истечении объявленных 90 дней и только после 🟡-записи. Единственное исключение, и оно проверяемо: пока у затронутой ручки нет ни одного живого потребителя, 90 дней защищать некого. Такая запись обязана назвать это прямо и назвать дату свежей проверки боевой базы — без неё обход считается нарушением, а не исключением. Так выпущены две записи ниже (31.08 и 02.09); правило этим не отменено — с первым живым читателем контракта исключение перестанет быть применимым.

Что мы не считаем ломающим (приезжает без предупреждения, ваш клиент обязан это переживать): новое необязательное поле в ответе · новое значение в списке (бренд, регион, вид выдачи) · новый код ошибки в существующем HTTP-классе · новое событие вебхука. Разбор — §11 документации.


2026-09-07 — ОТКРЫТ СПИСОК ЗАКАЗОВ: GET /v1/orders

🟢 Совместимо. Новая ручка, ни одна существующая не меняется.

Что появилось. GET /v1/orders?cursor=&limit= — перебор ваших заказов, скоуп orders:read (тот же, что у чтения одного заказа). Новые сверху, пагинация курсорная: meta.next_cursor → ?cursor=. Строка несёт order_id, status (+ status_detail), created_at, необязательную пару currency_code / amount_minor и lines — состав без кодов. Полное описание — §5.6 документации.

Что это отменяет в наших прежних словах. До сегодня §10.3 говорила: «перебрать свои заказы — пока нет, сводите по order_id из GET /v1/balance/entries». Этот совет теряет часть ваших партий, и мы называем это вслух: заказ, за который денег не двигалось — отложенный, стоящий в очереди, — в журнал движений не попадает по построению, а возвращённый попадает парой строк. Ось нового списка — сами заказы; деньги приходят к ним третьим источником и только за суммой. Если вы уже построили сверку по журналу баланса, она не сломалась — но теперь у неё есть источник точнее.

Чего у списка НЕТ: фильтра ?status=. Причина названа в §10.3 и она устройственная, а не очередная: статус у нас не хранится полем, он считается в момент ответа, а отбор уже собранной страницы сделал бы курсор лживым. Отбирайте по полю status на своей стороне — порядок и пагинация при этом остаются верными.

Коды в списке не приходят. Товар по-прежнему отдаёт только GET /v1/orders/{id}, по одному заказу за запрос (§5.5).

Квота — категория «чтение», 120 запросов в минуту на компанию (§7), общая с остальными читающими ручками. Отдельного ограничения у списка нет.

2026-09-03 — code_formats МОЖЕТ ОТСУТСТВОВАТЬ И У ПОЗИЦИИ С КОДОМ: формат, который мы не измерили, мы не объявляем

🟢 Совместимо. Ничего не меняется в форме полей и в их смысле. Меняется лишь набор строк, у которых поля code_formats и code_format присутствуют.

Что произошло. Часть позиций печатает поставщик, чью форму кода мы ещё не измерили ни на одном живом заказе. Объявить маску наугад означало бы дать вам обещание, которое §9 велит вам ПРОВЕРЯТЬ, — и вы завернули бы КОРРЕКТНЫЙ ОПЛАЧЕННЫЙ код. Поэтому у таких строк мы не объявляем формата вовсе.

Что делать вам — одна проверка, которая у вас, скорее всего, уже есть.

  1. Нет code_formats ⇒ проверять выданный код нечем и не нужно — принимайте его как есть. Это ровно то же поведение, которое вы уже обязаны были завести 28.08 для позиций, выдаваемых без кода (topup_by_login / topup_by_id): читать row.code_format.mask без проверки на наличие было нельзя и тогда.

  2. Поля приходят и уходят ПАРОЙ. «code_format есть, code_formats нет» не бывает по построению — ни в одну, ни в другую сторону. Если ваш клиент опирается на устаревшее единственное число, ветка «поля нет» у него общая с новым массивом.

  3. Отличать «кода не будет вовсе» от «код будет, формат не объявлен» — по delivery_kind, как и раньше. Первое — topup_by_login / topup_by_id; второе — любой кодовый вид выдачи.

Это временное состояние, и оно самоустраняется. Как только форма кода замерена на живой выдаче, оба поля возвращаются той же строкой каталога, без правок на вашей стороне: источник у объявления и у нашей внутренней сверки один, и включаются они одновременно.


2026-09-02 — market_price ПЕРЕЕХАЛ В ВАЛЮТУ РАСЧЁТА: обе цены каталога теперь в одной единице

🔴 Ломающее. Меняется единица поля market_price в GET /v1/catalog, в GET /v1/catalog/export и в кабинетном каталоге. Числа в этом поле стали ДРУГИМИ. Читайте до конца, даже если вы берёте якорь «просто как справку».

Что произошло. market_price приезжал рублёвыми копейками рядом с центовым price — то есть два числа одной строки были в разных деньгах, а выглядели сравнимыми. Теперь якорь переводится в валюту вашего счёта той же курсовой точкой, которой посчитан price. Замеряем мы его по-прежнему в российской рознице: рублём он рождается, но наружу уходит в вашей валюте.

Что делать вам.

  1. Пересчитайте всё, что считало наценку по старым числам. market_price − price теперь законная арифметика; раньше это было вычитание копеек из центов, и разрыв между единицами составлял около 86×. Если у вас была поправка «делить якорь на курс» — снимите её, иначе получите ту же ошибку в другую сторону.

  2. Единицу берите из currency_code ответа GET /v1/balance, а не из предположения. Для USD оба поля — центы.

  3. Поле по-прежнему необязательное. Нет розничного замера, замер несвеж или перевести его нечем — поля в строке нет вовсе: не null и не 0 (правило 7 из §2). Это не изменилось. 🪤 Отдельного рубежа свежести у якоря нет и не заводилось: возраст курса судится один раз на весь ответ, вместе с price. Пока цена отдаётся, отдаётся и якорь — по тому же курсу, что означает, что отношение между ними верно, даже если оба слегка отстают.

  4. Колонка market_price в CSV-прайсе сменила единицу вместе с полем; её место в порядке колонок прежнее.

Почему без 🟡-объявления и 90 дней, как требует наше же правило. Правило защищает живых потребителей контракта, и мы проверили боевую базу сегодня: у ручки не было ни одного живого потребителя. Читать эту ручку физически некому — ломать оказалось нечего и некого, а цена перехода растёт с каждым партнёром, поэтому он сделан сейчас. Мы называем это прямо, а не подводим под «совместимо»: правило не отменено — оно неприменимо к контракту, у которого пока нет ни одного живого читателя. Тот же порядок и по тому же основанию мы применили 31.08. С 02.09 полные числа зонда лежат во внутреннем журнале: эта страница про наш контракт перед вами, а не про наши обороты. Запись 31.08 ниже приведена к той же форме 28.09.2026.

🔴 Записи ниже читайте как историю, а не как действующее правило. Прямо под этой записью стоит вторая от той же даты — «price и market_price в каталоге — В РАЗНЫХ ВАЛЮТАХ». Она описывала состояние ДО этой правки и снята ею целиком: искать market_price грепом и наткнуться на «приезжает в рублёвых копейках» теперь можно, но это про вчерашний день. Ленту изменений задним числом мы не переписываем — вместо этого говорим здесь, какая запись какую сменила.

Поправка к адресу раздела. Записи от 2026-08-25 и 2026-09-02 (ниже) называют раздел с полями каталога пятым. Это опечатка: поля каталога описаны в §4 «Каталог, наличие, баланс», а §5 — это «Заказы».


2026-09-02 — ИСПРАВЛЕНИЕ ДОКУМЕНТАЦИИ: price и market_price в каталоге — В РАЗНЫХ ВАЛЮТАХ

🟢 Совместимо, поведение не менялось. Ни одно число в GET /v1/catalog не изменилось; мы исправляем документацию, которая называла оба поля одинаково — «минорные единицы».

Что было не так. price — минорные единицы валюты вашего счёта (с 31.08 центы доллара), а market_price — розничный якорь российского рынка, и он приезжает в рублёвых копейках, без пересчёта. В §5 оба поля стояли рядом в одном примере ("price": 31900, "market_price": 39900), подписанные одной формулировкой, — то есть выглядели сравнимыми, не будучи ими.

🔴 Если вы считали наценку как market_price − price, пересчитайте её. Это вычитание копеек из центов: разрыв между единицами около 86×. Считайте маржу от price и price_tiers; market_price годится только как ориентир розницы относительно другого рублёвого якоря.

Что дальше. Форма поля — открытый вопрос: market_price либо переедет в валюту расчёта, либо получит собственную подпись валюты. Это будет ЛОМАЮЩЕЙ записью, и мы предупредим заранее, как обещано в §1. До тех пор поведение остаётся прежним, а §5 теперь называет валюту каждого поля.

2026-09-01 — ИСПРАВЛЕНИЕ ДОКУМЕНТАЦИИ: порог balance.low называется в минорных, а не в рублях

🟢 Совместимо, поведение не менялось. Это правка ТЕКСТА документации, а не контракта: порог balance.low как принимался в минорных единицах валюты компании, так и принимается.

Что было не так. §10 объяснял единицу порога на рублёвом примере — «500000 означает 5 000,00 ₽» — хотя с 31.08 учётная валюта контура доллар, и §1 той же страницы говорит читать единицу из currency_code. Страница противоречила сама себе, и рублёвый пример стоял в том месте, где партнёр называет нам НАСТОЯЩЕЕ число своих денег.

🔴 Если вы называли нам порог до этой даты, сверьте его. Партнёр, прочитавший пример как рубли, мог назвать значение, которое на долларовом счёте означает сумму примерно в 86 раз больше задуманной — то есть предупреждение о деньгах, которое сработает слишком поздно. Порог приезжает обратно полем threshold_minor в событии; назовите новое значение по каналу связи из §1, если оно разошлось с задуманным.

2026-08-31 — УЧЁТНАЯ ВАЛЮТА КОНТУРА — ДОЛЛАР: *_minor теперь ЦЕНТЫ, курс из пополнения ушёл

🔴 Ломающее. Меняется единица всех денежных чисел вашего счёта и форма тела пополнения. Читайте до конца, даже если ваш клиент «просто складывает available_minor».

Что произошло. Счёт партнёра переведён из рублей в доллары. Валюта была и остаётся ПОЛЕМ компании (currency_code) — сменилось умолчание контура. Компании, у которых на момент перехода были деньги или движения по счёту, НЕ переводились: у них currency_code остался rub, и для них не изменилось ничего.

Что делать вам.

  1. Читайте единицу из currency_code ответа, а не из предположения. GET /v1/balance и вебхуки называют валюту в том же объекте, где стоит число. Для USD *_minor — центы: 150000 = $1,500.00.

  2. Тело пополнения сменило поле: POST /store/crypto-topup принимает amount_cent (ЦЕЛЫЕ ЦЕНТЫ) вместо amount_rub (целые рубли). Ответы: min_cent / max_cent / hint_cent вместо min_rub / max_rub / hint_rub; в счёте amount_minor + currency_code вместо amount_rub. Старые имена НЕ поддерживаются намеренно: поле с рублёвым именем и долларовым значением приняло бы 10000 как $100 у того, кто имел в виду 10 000 ₽.

  3. Курса в пополнении больше нет. USDT зачисляется к доллару один к одному — и заказанный счёт, и опоздавший платёж. Вместе с курсом исчез отказ rate_unavailable («курс сейчас не обновляется») и курсовой риск между пополнением и тратой. Поле rate в счёте осталось ради ИСТОРИИ: у счетов с 31.08 в нём всегда "1".

  4. Новый отказ 409 currency_not_supported на пополнении: приём USDT 1:1 определён только для долларового счёта. Компания с иной валютой счёт на пополнение криптой не выставит.

Почему без 🟡-объявления и 90 дней, как требует наше же правило. Правило защищает живых потребителей контракта, и мы проверили боевую базу 31.08: ни одного живого потребителя у контракта не было. Ломать оказалось нечего и некого; цена перехода сегодня нулевая и растёт с каждым партнёром, поэтому он сделан сейчас, а не позже. Мы называем это прямо, а не подводим под «совместимо»: правило не отменено — оно неприменимо к контракту, у которого пока нет ни одного живого читателя.

2026-08-31 — ПЕРЕЧЕНЬ ПОЛЕЙ РЕКВИЗИТА ОТДЕЛЬНОЙ РУЧКОЙ: GET /v1/recipients/fields

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

Что приехало. Обещание записи от 28.08 («отдельная ручка перечня полей приедет вместе с ближайшим расширением каталога») закрыто:

GET /v1/recipients/fields?sku=8BALLPOOL-GLOBAL-VAR-110CASH
Authorization: Bearer <ключ>
{ "data": { "sku": "8BALLPOOL-GLOBAL-VAR-110CASH", "forma": "polya",
  "polya": [ { "key": "user_id", "type": "text", "secret": false } ] } }

Скоуп — catalog:read, тот же, что у полки и наличия: ручка отвечает про позицию каталога и ничего не создаёт. Категория лимита — «чтение» (§7 документации).

Три формы ответа, и третья — это ответ, а не ошибка:

formaЧто значитЧто класть в строку заказа
netполучателя у позиции нет по устройству (выдаётся кодом или ссылкой)ни recipient, ни recipient_fields
strokaодиночный реквизит — кошелёк Steam, звёзды Telegramrecipient
polyaименованные поля категории, перечислены в polya[]recipient_fields теми же ключами

🔴 net приходит успехом, а не отказом. Спрашивать про ЛЮБУЮ позицию своего справочника — законно, и отличать «мы не знаем» от «спрашивать нечего» по коду ошибки вам не придётся.

Что в polya[]. key — имя, которое вы обязаны прислать обратно в recipient_fields. type — text или select; у select рядом лежит options[] с допустимыми ЗНАЧЕНИЯМИ. secret: true — поле несёт секрет (password, email): не показывайте его в открытую и не кладите в долговременное хранилище. Подписи полей мы не отдаём: они пришли бы текстом поставщика, а его мы наружу не выпускаем — рисуйте свою подпись, откатываясь на само имя ключа.

🔴 Состав полей ручка и приём заказа берут ОДНИМ швом. «Что показала ручка» и «что примет заказ» разойтись не могут по построению — это то же обещание, что у пары «проверен» и «принят».

🔴 Не смогли узнать — 503 recipient-unavailable, и отказ ПОВТОРЯЕМЫЙ. Схему полей знает поставщик; молчит он или отвечает пустым — мы отвечаем «повторите позже», а не «полей не нужно» и не «одна строка». Обратное подсунуло бы вам форму, под которую вы собрали бы партию, а рубеж заказа отверг бы её на приёме. Ошибки в артикуле по-прежнему 404 not_found, пустой sku — 400 validation_failed.

⚠️ Это НЕ проверка получателя. Ручка отвечает «какие поля прислать», а не «годится ли это значение». Машинной /v1/recipients/check по-прежнему нет (§10.4): форму реквизита судит сам заказ, и отказ приходит до списания и стоит вам ноль.


2026-08-28 — ЗАКАЗУЕМЫ КЛЮЧИ ИГР И ПОПОЛНЕНИЯ СЕРВИСОВ: НОВОЕ ПОЛЕ recipient_fields

🟢 Совместимо. Новое необязательное поле в запросе и два новых заказуемых значения delivery_kind (см. «что мы не считаем ломающим» выше). Клиент, торгующий подарочными картами и пополнением по логину, не делает ничего: форма его запроса не изменилась ни на байт, а ключ идемпотентности по старому телу считается ровно как раньше.

Что приехало. Две линии перестали отвечать kind-not-supported:

delivery_kindЧто этоКак выдаётся
game_keyключи игр (Steam и другие площадки)КОДОМ, получателя нет
topup_by_idпополнение игр и сервисов по идентификатору аккаунтазачислением на аккаунт

Что нужно сделать, если вы продаёте пополнение сервисов. У этой линии реквизит получателя именованный, а не одна строка: поставщик спрашивает у каждой категории свой набор полей — от одного (user_id) до трёх (email, password, server). Поэтому у неё своё поле:

🔴 Линию задаёт не delivery_kind, а артикул. Одну строку recipient принимают ТОЛЬКО Steam-кошелёк и звёзды Telegram (STEAMTOPUP-*, TGSTARS-*, сегодня девять позиций); все прочие позиции с topup_by_id И topup_by_login — линия пополнения сервисов и требуют recipient_fields. Из 321 позиции topup_by_login таких 312 («Auto Via Login»), так что судить по форме выдачи нельзя.

{ "lines": [
  { "sku": "8BALLPOOL-GLOBAL-VAR-110CASH", "quantity": 1, "unit_price_minor": 82000,
    "recipient_fields": { "user_id": "1234567890" } }
] }

🔴 Одиночное поле recipient этой линии НЕ подходит, даже когда полей у категории ровно одно. Мы не угадываем, в какое поле его положить: сегодня оно единственное, завтра поставщик добавит второе, и догадка молча станет неверной уже после списания. Такая строка отвергается как recipient-invalid. Обратное тоже верно: у пополнения Steam и звёзд Telegram реквизит одиночный, и recipient_fields в их строке отвергается тем же кодом.

Как узнать нужные поля. Отправьте строку с любым набором — отказ приедет в line_rejections[].reason и перечислит недостающие поля по имени, а для поля с закрытым списком назовёт допустимые значения. Отказ приходит до списания и стоит вам ноль. То же поле принимает кабинетная ручка проверки получателя, и судит она ТЕМ ЖЕ правилом, что приём заказа, — «проверен» и «принят» разойтись не могут. Отдельная ручка перечня полей приехала 31.08.2026 — GET /v1/recipients/fields?sku=, запись выше; обещание, стоявшее в этом абзаце, закрыто, и собирать состав полей из текста отказа больше не нужно.

⚠️ Ключи игр получателя НЕ ИМЕЮТ — они выдаются кодом. recipient или recipient_fields в такой строке отвергается как recipient-invalid: почти всегда это перепутанные строки заказа.

🔴 Заказуемость по-прежнему смотрите по available и closed_for_sale, а не по этому списку. «Вид выдачи мы умеем заказать» и «эту позицию можно купить сейчас» — разные утверждения: у позиции может не быть цены, остатка или открытой у поставщика категории. Позиции обеих линий каталог показывает всегда — §11 прямо это предусматривает.


2026-08-28 — ПОПОЛНЕНИЕ ПО ЛОГИНУ ЗАКАЗЫВАЕТСЯ: НОВОЕ ПОЛЕ recipient, ТРИ КОДА ОТКАЗА

🟢 Совместимо. Новое необязательное поле в запросе, три новых кода отказа в существующем классе 422 и одно новое заказуемое значение delivery_kind (см. «что мы не считаем ломающим» выше). Клиент, торгующий только подарочными картами, не делает ничего.

Что приехало. Позиции delivery_kind: "topup_by_login" (пополнение кошелька Steam, звёзды Telegram) стали заказуемыми. У них нет кода: поставщик зачисляет средства прямо на аккаунт, и вам приезжает подтверждение зачисления, а не секрет.

Что нужно сделать, если вы их продаёте. В строке заказа назвать получателя:

{ "lines": [
  { "sku": "STEAMTOPUP-RU-1000RUB", "quantity": 1, "unit_price_minor": 110000,
    "recipient": "steam_login_pokupatelya" }
] }

Поле построчное, а не на заказ: в одном заказе вы вправе пополнить Steam одному покупателю и Telegram другому. У позиций, выдаваемых кодом, поля быть НЕ должно — лишний recipient у подарочной карты отвергается как recipient-invalid, потому что почти всегда означает перепутанные строки заказа.

Три новых кода отказа (§10.5), все класса «правьте заказ»:

КодКогдаЧто делать
recipient-requiredу позиции пополнения не назван получательдобавить recipient в строку
recipient-invalidполучатель не по правилу линии либо назван там, где его не бываетпоправить строку
recipient-unknownпоставщик не подтвердил такой аккаунтпроверить логин у покупателя

🔴 Отказ приходит ДО списания. Ошибка в получателе необратима — деньги уходят на названный аккаунт напрямую, и вернуть их неоткуда, — поэтому получатель проверяется среди предикатов заказа, а не при выдаче. Временную нашу беду («проверить сейчас нечем») вы отличите по коду supplier-unavailable: он повторяемый, логин править не нужно.

⚠️ Проверить получателя заранее можно из кабинета — экран ручного заказа ходит в POST /store/partner/recipients/check, и та ручка судит ровно тем же правилом, что приём заказа, поэтому «проверен» и «принят» разойтись не могут. 🪤 МАШИННОЙ ручки /v1/recipients/check пока НЕТ (§10.4): по машинному пути форму проверяет сам заказ — отказ приходит до списания и стоит ноль.

Полей code_format и code_formats у таких позиций НЕТ, и это ответ, а не пропуск. Кода не существует — объявленный формат был бы обещанием, которое §5.5 велит вам проверять, и вы завернули бы корректное подтверждение зачисления. Отличать эти позиции надёжнее всего по delivery_kind. 🪤 Если ваш клиент читает row.code_format.mask без проверки — добавьте её: у позиций, выдаваемых кодом, поле на месте и ничего не потеряло.

Заказуемость по-прежнему видна по available и closed_for_sale, а не по списку видов выдачи: пока мы не открыли линию, её позиции приходят closed_for_sale: true и без price.


2026-08-26 — НОВОЕ ЗНАЧЕНИЕ delivery_kind: game_key У ИГРОВЫХ КЛЮЧЕЙ

🟢 Совместимо. Новое значение в существующем списке (см. «что мы не считаем ломающим» выше). Форма ответа не менялась, поля не появлялись и не пропадали. Если ваш клиент читает delivery_kind как строку — делать не нужно ничего.

Что приехало. У позиций игровых ключей delivery_kind теперь game_key, а не key_code. Раньше игровой ключ и подарочная карта приходили одним значением, и отличить их в каталоге было нечем.

Зачем вам это. Ровно за одним: у игрового ключа другое региональное ограничение. Карта активируется в своём region, ключ — в СПИСКЕ стран region_countries (запись ниже). Одно значение на два разных правила означало, что автомат, разбирающий каталог, не мог понять, какое правило применять, не заглядывая в другие поля.

Заказать game_key сегодня нельзя, и это не изменилось. Такие позиции приходят с closed_for_sale: true и без price, а заказ по ним отвергается кодом kind-not-supported (§10.5) — ровно как и до этой записи. Мы показываем их честно, а не прячем: товар у поставщика есть, боевую выдачу на него мы ещё не открыли.

Если вы жёстко сравниваете delivery_kind == "key_code": это по-прежнему верный способ отобрать заказуемое сегодня, и он ничего не потерял — game_key заказуемым и не был. Но надёжнее опираться на available и closed_for_sale: когда мы откроем выдачу ключей, они станут доступными сами, без записи в этом журнале о ломающем изменении.


2026-08-26 — НОВОЕ НЕОБЯЗАТЕЛЬНОЕ ПОЛЕ region_countries: СТРАНЫ, ГДЕ КОД АКТИВИРУЕТСЯ

🟢 Совместимо. Новое необязательное поле в ответе — ваш клиент продолжает работать без правок (см. список «что мы не считаем ломающим» выше). Но прочитать его выгодно, и вот почему.

Что приехало. У части позиций каталога появилось поле region_countries — список стран кодами ISO 3166-1 alpha-2, в которых код активируется. Оно есть там, где поставщик задаёт ограничение СПИСКОМ стран, а не одной: сегодня это игровые ключи. У гифт-карт такого списка не существует, и поля у них нет вовсе.

Почему это не косметика. Ключ, проданный в страну вне списка, у покупателя НЕ АКТИВИРУЕТСЯ, а деньги уже списаны. Это не «товар кончился» — это невозвратно. Поэтому список приходит вам ДО покупки, а не выясняется после неё.

Что делать вашему клиенту:

  • ничего — и это безопасно. region ВСЕГДА входит в region_countries. Продавая только в region, вы попадаете в страну, где ключ точно работает. Незнание поля стоит вам недопроданных заказов, а не мёртвого кода у покупателя;

  • прочитать список — и продавать шире. Тогда вам доступны все страны списка, а не одна.

Приём заказа судит ТЕМ ЖЕ списком. deliver_region строки заказа обязан входить в region_countries, иначе строка отклоняется кодом region-mismatch (§4). Показанное вам и проверяемое у нас — одно поле, а не два.

Мелочи, которые лучше знать заранее:

  • отсутствие поля — не null и не «нигде» (§5, правило 7). Нет списка — нет и поля;

  • пустым список не бывает. Пустой массив читался бы как «не активируется нигде», а мы такого не утверждаем;

  • denomination: "VAR" у таких позиций означает «номинала у товара нет как факта» — у игрового ключа его и не бывает. Значение документировано с первого дня и формы ответа не меняет;

  • в прайс-файле (GET /v1/catalog/export) список едет ПОСЛЕДНЕЙ колонкой region_countries, коды через пробел, у строк-ступеней ячейка пуста. Колонка добавлена В КОНЕЦ намеренно: если вы читаете прайс по номерам колонок, порядок прежних колонок не сдвинулся.


2026-08-26 — КАТАЛОГ ВЫРОС ДО 49 БРЕНДОВ И 2 772 ПОЗИЦИЙ

🟢 Совместимо. Форма ответа не менялась — ни одного нового или пропавшего поля.

Что приехало. GET /v1/catalog вырос с одного бренда до 49 и с 491 позиции до 2 772: игры и игровая валюта, подписки и стриминг, мессенджеры, магазины и маркетплейсы, путешествия и связь. Новые бренды, новые коды регионов и новые валюты номинала — это «новое значение в списке» (§11), ломающим изменением не является.

Что стоит знать про наличие. Часть новых позиций приходит с closed_for_sale: true и без price: товар у поставщика есть, а денежный путь на него мы ещё не открыли. Поле closed_for_sale и правило «нет цены — нет поля» работают ровно как описаны в §4, ничего нового делать не нужно; позиции открываются по мере проверки и молча начнут приходить с ценой.

Форматы кода приходят у всех строк, как и раньше — code_formats (и устаревший code_format, он снимается 18.11.2026) в строке каталога есть всегда. Если вы валидируете выдачу по §9, менять у себя нечего.


2026-08-25 — market_price НАЧАЛ ПРИХОДИТЬ

🟢 Совместимо. Форма ответа не менялась: market_price был объявлен необязательным полем и остаётся им. Менять в клиенте ничего не нужно.

Что было. Поле market_price (розничный якорь рынка) документировано с первого дня каталога, но фактически не приходило ни у одной позиции. Причина была на нашей стороне и к вашему клиенту отношения не имела.

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

Что при этом НЕ изменилось и меняться не будет:

  • Отсутствие поля — по-прежнему не ноль и не null. Нет якоря — нет и поля (§5, правило 7 документации). Ни ноля, ни null мы не подставляем: и то и другое читалось бы как утверждение о рынке, которого мы не делали.

  • Поле есть НЕ У ВСЕХ позиций, и это надолго. Якорь ставится только там, где рыночная цена измерена по ТОЙ ЖЕ позиции — тот же бренд, та же страна, тот же номинал, та же валюта номинала. Совпадения «по смыслу похоже» мы не делаем: чужая цена, поставленная рядом с вашей закупкой, хуже отсутствующей.

  • Якорь протухает вместе со срезом. Несвежий или неполный замер рынка не даёт ни одного значения — вместо старой цены поле просто исчезает.

  • market_price — не наша цена и ни к чему не обязывает. Списывается всегда price (а при количестве — ступень из price_tiers).


2026-08-22 — НЕИЗВЕСТНЫЙ АДРЕС ПОД /v1 ОТВЕЧАЕТ КОНВЕРТОМ ОШИБКИ, А НЕ HTML-СТРАНИЦЕЙ

🟢 Совместимо. Ваш клиент продолжает работать без правок; менять в нём ничего не нужно.

Что было неправдой. §2 обещает: у /v1 ОДИН конверт ответа, а ошибка всегда приходит объектом error с кодом из §8 и request_id. На практике это держалось только для написанных адресов. Опечатка в пути (/v1/orderz, лишний сегмент в конце) уходила мимо всех наших обработчиков и получала ответ веб-фреймворка — страницу text/html «Cannot GET …» со статусом 404. Клиент, разбирающий ответ как JSON, падал на ней РАЗБОРОМ: вместо понятного not_found — исключение парсера и никакого request_id, которым можно было бы назвать нам сбой.

Что приехало сегодня. Любой адрес под /v1, которого у нас нет, отвечает 404 application/json тем же конвертом:

{ "error": { "code": "not_found", "message": "Объект не найден.", "request_id": "9d4f2b1e-…" } }
  • Код — not_found из §8, новых кодов не заведено: §8 остаётся закрытым словарём, и ваше перечисление error.code менять не нужно.

  • Тем же кодом отвечает адрес, существующий только для другого метода (POST /v1/catalog).

  • Несуществующий адрес отвечает так же и БЕЗ ключа доступа: адрес судится раньше ключа. На написанных адресах проверка ключа не изменилась ничем — отсутствующий или отозванный ключ по-прежнему 401 unauthorized.

  • Написанные ручки не задеты ничем.

Как это влияет на вас. Обработчик ошибок можно упростить: ветка «ответ не разобрался как JSON» для /v1 больше не нужна. Оставить её тоже можно — она просто перестанет срабатывать.


2026-08-20 — ФОРМАТ КОДА СТАЛ МАССИВОМ code_formats; code_format СНИМАЕТСЯ 18.11.2026

🟡 Объявление депрекейта. Отменяемое поле работает по 17.11.2026 включительно и снимается 18.11.2026 — 90 дней с этой записи (§11). Новое поле приходит уже сегодня, и переход занимает одну строку кода.

Что было неправдой. §5.5 и §9 требовали валидировать выдачу по code_format из каталога, а само поле описывало формат позиции — маску, алфавит и приставку из нашего товарного справочника. Но код печатает поставщик, и у каждого поставщика свой станок: маска, алфавит и приставка у них разные. Один и тот же артикул, купленный у разных поставщиков, приходит к вам в разном виде — и клиент, проверяющий выдачу по единственному объявленному формату, отвергает валидный, уже оплаченный вами код. Кто именно напечатает ваш код, решается в момент заказа, по доступности линии, поэтому назвать формат заранее одним объектом физически нельзя.

Что приехало сегодня (🟢 совместимо, ничего не ломает):

  • code_formats — массив форматов в каждой строке GET /v1/catalog. Перечисляет все виды, в которых позиция может быть напечатана. Поля каждого элемента — те же, что были у code_format (mask, alphabet, prefix, suffix, example), с той же семантикой. Массив никогда не пуст.

  • Правило проверки: выданный код обязан пройти ЛЮБОЙ ОДИН формат из code_formats. Это вся правка на вашей стороне.

  • Разбор — §4 «Формат кода», §5.5 «Забрать коды», §9 «Формат кодов и выдача».

Что отменяется 18.11.2026 (🟡):

  • поле code_format (единственное число) в строке GET /v1/catalog. До этой даты приходит без изменений. ⚠️ Не считайте его первым элементом code_formats: оно описывает позицию, а не пару «позиция × поставщик», и совпадает с одним из форматов массива только тогда, когда печатающий поставщик формат не меняет. Ровно поэтому на него и нельзя опираться.

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

Как понять, касается ли это вас: если в вашем коде есть строка вида «код должен совпасть с code_format» — касается. Если вы коды не валидируете — не касается, но валидировать стоит.

🔴 Со своей стороны мы закрыли это же в тот же день, а не только в документации. Выдача теперь сверяется с форматом той пары «позиция × поставщик», которая код напечатала; код, не прошедший сверку, к вам не уходит — заказ закрывается возвратом. Это относится и к тем 90 дням, пока code_format ещё жив.


2026-08-13 — БОЕВОЙ КЛЮЧ НЕПРОВЕРЕННОЙ КОМПАНИИ БОЛЬШЕ НЕ СОЗДАЁТ ЗАКАЗЫ

🔴 Ломающее — и выпущено сразу, без 90 дней. Правило про 90 дней защищает контракт, о котором мы договорились; здесь закрывается доступ, которого мы не давали. Условие «боевой доступ открывается после проверки компании» действует с самого начала (§1), но применялось оно только В МОМЕНТ ВЫДАЧИ ключа — ключ, выпущенный до 12.08, работал по прежним правам. Это наша ошибка, и держать её открытой ещё квартал ради формальности мы не будем.

  • POST /v1/orders боевым ключом компании без проверки → 403 verification_required, какой бы ни была сумма. В message — что делать и ссылка на раздел «Компания» в кабинете; там же подаётся заявка. После проверки повторите запрос ТЕМ ЖЕ Idempotency-Key: отказ ключ не сжигает, и это будет ваш первый заказ, а не повтор.

  • Что НЕ изменилось: GET /v1/catalog, /v1/stock, /v1/balance, /v1/orders/{id} и вебхуки работают как прежде — читать каталог и свои данные проверка не требует.

Как понять, касается ли это вас: если ваши заказы проходили вчера и перестали сегодня с этим кодом — компания в нашей базе не отмечена проверенной. Напишите нам, проверка занимает не дни.


2026-08-12 — code_format СТАЛ ПРИГОДЕН ДЛЯ ВАЛИДАЦИИ: ЧЕСТНЫЙ АЛФАВИТ И ПОЛЕ prefix

🟢 Совместимо — но значение одного поля изменилось, и это названо ниже прямым текстом.

Документация двумя разделами (§5.5, §9) требовала валидировать выдачу по code_format из каталога, а сам code_format этого не позволял. Нашёл партнёр, прошедший путь по документации, не мы.

  • code_format.alphabet теперь ПЕРЕЧЕНЬ СИМВОЛОВ, а не название набора. Было — "A-Z0-9-NO-CONFUSABLES" (у всех позиций каталога), стало — "ABCDEFGHJKLMNPQRSTUVWXYZ23456789". Прежнее значение было нашим внутренним ИМЕНЕМ алфавита и уезжало наружу по недосмотру: честная проверка кода по нему давала ложный отказ на КАЖДОМ коде — цифры 3 в имени нет, а в кодах она есть. Пример в §4 всё это время показывал именно перечень символов, то есть контракт в описании и контракт в ответе расходились.

  • code_format.prefix теперь приходит: каталог отдаёт то, что стоит у самой позиции (у большинства — ничего, и тогда поля нет вовсе).

  • Код проверяется целиком по prefix + маска + suffix. Разбор полей и порядок проверки — новый раздел §4 «Формат кода code_format».

🪤 Честно про границы. Если ваш клиент сравнивал alphabet СТРОКОЙ с прежним значением — сравнение перестанет совпадать, и это не побочный эффект, а смысл правки: по имени валидировать было нельзя. Ломающим по §11 мы это изменение не считаем — поле привели к тому, что описание обещало с первого дня, — но если вы завязались на старое значение, поправьте сверку.

Ни одной ручки, ни одного кода ошибки этим изменением не добавлено и не убрано.


2026-08-12 — УТОЧНЕНИЯ ДОКУМЕНТАЦИИ ПО ИТОГАМ ПЕРВОГО ВНЕШНЕГО ПРОГОНА

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

  • Каталог: ?limit= не существует. Пример быстрого старта показывал ?limit=2, чего ручка никогда не поддерживала: неизвестные параметры игнорируются молча, страница всегда 100 позиций, пагинация только курсорная. Пример исправлен, поведение названо в §4.

  • Журнал баланса: курсор берётся только из meta.next_cursor. §4 обещал «id последней строки», но поля id в строке журнала нет и не было.

  • accepted не приходит из GET /v1/orders/{id} — это статус ответа на размещение. Названо в §5.3 и §5.5.

  • «Не чаще раза в минуту» — рекомендация, а не лимит, и относится к повтору POST /v1/orders. Чтение ограничено общей квотой 120/мин (§7); отдельного ограничения на GET /v1/orders/{id} нет. §5.4, §5.5.

  • X-Kodrio-Snapshot — время сборки снимка в ISO-8601 UTC, сравнивать на равенство. §4.

  • Порог balance.low называется в минорных единицах и срабатывает один раз на пересечение вниз. §6.2.

  • Очередь: честные числа. Попытка назначается примерно через 30 секунд, но забирает её фоновая петля с шагом 5 минут — верхняя оценка ожидания одной попытки около 5,5 минут. Прежний текст называл только 30 секунд, и ожидание в пять-шесть минут выглядело поломкой. §1, §5.4, §5.5.


2026-08-11 — ЗАКАЗ В ОЧЕРЕДИ БОЛЬШЕ НЕ ВОЗВРАЩАЕТСЯ НА ПЕРВОЙ ЖЕ ПОПЫТКЕ ДОЖАТИЯ

🟢 Совместимо. Ни одного нового кода ошибки, ни одного нового поля.

Починка. Заказ, вставший в очередь выдачи, мог получить failed с возвратом на первой же попытке дожатия — вместо того чтобы дождаться подъёма линии. Затрагивало позиции с жёстко заданным регионом активации (это почти весь каталог). Ваши деньги при этом не терялись — возврат был честным, — но заказ, который мы могли исполнить, исполнен не был. Исправлено; поведение теперь ровно то, что описывает §5.3.


2026-08-09 — ОПТОВЫЕ СТУПЕНИ ЦЕНЫ И ПРАЙС ФАЙЛОМ

🟢 Совместимо. Новое НЕОБЯЗАТЕЛЬНОЕ поле в ответе и новая ручка — оба вида изменений §11 относит к неломающим. Но если вы заказываете больше одной штуки в строке, прочитать это стоит: цена теперь зависит от количества.

  • У позиции появились оптовые ступени — поле price_tiers[] в GET /v1/catalog. Форма: [{ "min_qty": 5, "unit_price_minor": 137200 }, …], по возрастанию количества. price по-прежнему цена за штуку НИЖЕ первой ступени; ступени начинаются со второй штуки. Поля нет вовсе, если ступеней у позиции нет — пустого массива не приходит никогда.

  • 🔴 Что от вас требуется: unit_price_minor в заказе — цена ТОЙ ступени, в которую попадает ваше количество. Клиент, который всегда шлёт price, на строке из пяти штук получит price-changed с expected_unit_price_minor — отказ до списания денег, без потери идемпотентности. Нового кода ошибки нет: для нас это обычное расхождение цены.

  • Показываем только те ступени, которые можно заказать. Ступень выше потолка количества в строке (сегодня 10 штук) в ответ не попадает: заказать её всё равно нельзя. Вырастет потолок — ступени появятся сами, без изменения формата.

  • Новая ручка GET /v1/catalog/export — прайс файлом (CSV, UTF-8 без BOM, RFC 4180, скоуп catalog:read, без пагинации). Одна строка = одна цена: базовая идёт с min_qty=1, каждая ступень своей строкой. Числа в файле и в каталоге совпадают до копейки, потому что это одни и те же числа — файл печатается из того же снимка.

  • Новый заголовок X-Kodrio-Snapshot у GET /v1/catalog и GET /v1/catalog/export — момент сборки снимка. Совпал у обоих ответов — вы смотрите на одну полку; разошёлся — мы обновили снимок между вашими запросами.

  • Скидку в процентах мы не отдаём — только цену. Это то же правило, по которому наружу не выходит себестоимость.

2026-08-08 (вечер) — ЗАКАЗ В ОЧЕРЕДИ ПЕРЕЖИВАЕТ НАШУ АВАРИЮ

🟢 Совместимо. Ни одна форма ответа не изменилась, новых обязательных полей нет. Изменилось наблюдаемое поведение — в вашу пользу, но знать о нём надо.

  • Заказ в очереди больше не возвращается из-за нашей минутной аварии. Раньше повторная попытка, упёршаяся в отказ «закупать сейчас не у кого» (supplier-unavailable), означала немедленный возврат и failed — то есть просевшая на минуту закупка отменяла ВСЕ ждущие заказы разом. Теперь такой отказ на повторной попытке заказ не убивает: он остаётся processing, и мы дожимаем его дальше. status_detail при этом читается как обычно: "queued" в первые 15 минут от приёма заказа, "stuck" дальше — для заказа, который мы повторяем часами, "stuck" норма, а не сигнал об аварии. Пределы прежние: сутки либо 24 попытки, что наступит раньше. Отказы, которые сами не пройдут (несуществующий артикул, снятая с продажи позиция, разошедшееся эхо цены, нехватка остатка), по-прежнему заканчиваются честным failed с возвратом.

2026-08-08 — ЗАКАЗ БОЛЬШЕ НЕ ВОЗВРАЩАЕТСЯ ПРИ ПЕРВОМ ЖЕ МОЛЧАНИИ ПОСТАВЩИКА

🟢 Совместимо. Новых обязательных полей нет; новое НЕОБЯЗАТЕЛЬНОЕ поле в ответе и новый код отказа строки в существующем HTTP-классе — оба вида изменений §11 относит к неломающим. Но наблюдаемое поведение изменилось заметно, и вот в чём.

  • Что было. Поставщик не ответил (таймаут, ошибка апстрима, нет товара) — заказ немедленно становился failed, деньги возвращались на баланс. Одна секунда молчания поставщика означала для вас несостоявшуюся продажу.

  • Что стало. Заказ уходит второму поставщику, а если и он не отдал — ВСТАЁТ В ОЧЕРЕДЬ и остаётся processing. Мы повторяем попытки по расписанию до суток; товар приезжает сам. Возврат остаётся исходом исчерпанной очереди, а не первой неудачи. Что делать вам: ничего нового. Заказ в processing по-прежнему нельзя считать проваленным (§5.3) — теперь это правило просто чаще работает.

  • Новое поле status_detail у GET /v1/orders/{id} при status: "processing": queued — ждём очереди на выдачу, stuck — ждём дольше обычного (свыше 15 минут). Поля НЕТ, когда объяснять нечего. Значения могут добавляться без депрекейта: незнакомое читайте как «просто processing».

  • Новый код отказа строки supplier-unavailable (422 rejected, §5.1): закупка по этому заказу сейчас недоступна — временное состояние на НАШЕЙ стороне. 🔴 Это НЕ closed-for-sale, и различать их важно: closed-for-sale — про позицию («мы это не закупаем»), и по нему разумно снять карточку у себя; supplier-unavailable — про нас и про сейчас, карточку снимать НЕ надо, правильная реакция — повторить позже. Отказ приходит ДО списания денег: пары «списание — возврат» в журнале баланса по такому заказу не будет.

2026-08-08 — ЗАКРЫТИЕ ПОЗИЦИИ ДОХОДИТ ДО ВАС МГНОВЕННО, А ЦЕНА СТАРШЕ 20 МИНУТ НЕ ПОКАЗЫВАЕТСЯ

🟢 Совместимо. Ни одного нового поля, кода ошибки или формы ответа. Изменилось НАБЛЮДАЕМОЕ поведение каталога в двух местах — оба в вашу пользу, но второе стоит прочитать.

  • Что было. Каталог отдавался из снимка, который обновлялся раз в десять минут. Всё это время позиция, у которой пропал канал поставки, продолжала показываться доступной — и заказ по ней мы принимали. Узнавали вы об этом не отказом, а тем, что заказ не исполнялся.

  • Что стало. Признак closed_for_sale и available: false приезжают в ПЕРВОМ ЖЕ вашем запросе после того, как канал поставки пропал, — снимок пересобирается по событию, а не по таймеру. Вернувшаяся позиция открывается так же быстро. Практическое следствие: 422 closed-for-sale на заказ вы теперь получите реже, а расхождений «каталог обещал — заказ не исполнился» быть не должно вовсе — по этой причине закрытия. Закрытие по третьей причине (наши данные о позиции устарели по календарю) приезжает вместе с обычным обновлением снимка, в пределах тех же 20 минут.

  • Второе изменение, и его стоит учесть. Возраст отдаваемой ЦЕНЫ теперь имеет ЖЁСТКИЙ потолок в 20 минут, и считается он от момента последней УДАЧНОЙ проверки курса, а не от момента пересборки витрины. Если за этим сроком свежей цены у нас нет, каталог отвечает 500 internal вместо того, чтобы показать цену старше потолка. Раньше граница была той же по замыслу, но проходила не по всем путям: недоступный источник курса пересборку не ронял, поэтому потолок в этой — самой частой — аварии не срабатывал вовсе. 500 ваш клиент обязан переживать ретраем с первого дня (§5.4), и заказ по нему считать проваленным нельзя.

  • Числа 10 и 20 минут — потолок, а не наша текущая настройка. Конфигурация умеет только сокращать эти окна; поднять их выше объявленного здесь нельзя.

  • Новый отказ, которого раньше не было. Пока курс проверить нечем, каталог и заказы отвечают 500 вместо старой цены — в том числе сразу после нашего перезапуска, если курс не успел провериться ни разу. Раньше в этом состоянии мы продолжали отдавать цену. Кодов и форм ответа это не меняет: тот же 500 internal, который ваш клиент обязан переживать повтором с первого дня.

  • Скорость ответа выросла. Раньше один запрос раз в десять минут ждал полной пересборки каталога — теперь пересборка идёт фоном, а вам отдаётся готовый снимок. Это касается и POST /v1/orders: он берёт цену из того же снимка.

  • Значения квот и лимитов не менялись.

2026-08-07 — ЛИМИТ ЧАСТОТЫ ПЕРЕЖИВАЕТ АВАРИЮ НАШЕГО СЧЁТЧИКА

🟢 Совместимо. Ни одна форма ответа не изменилась, новых кодов ошибок нет. Но НАБЛЮДАЕМОЕ поведение в редком состоянии изменилось, и поэтому запись здесь есть.

  • Что было. Наш счётчик темпа — общий для всех процессов. Когда он переставал отвечать, лимит переставал действовать ЦЕЛИКОМ: в этом состоянии вы не получали 429 вообще, сколько бы запросов ни слали, и заголовки X-RateLimit-* не приходили.

  • Что стало. В том же состоянии лимит продолжает действовать — считает запасной счётчик нашей стороны. Практическое следствие для вашего клиента одно: 429 может прийти там, где раньше в этом состоянии не приходил ни один. Никаких новых кодов и полей — это тот же 429 rate_limited с Retry-After, который описан в §7 и который ваш клиент уже обязан переживать.

  • Заголовки квоты в этом состоянии. Retry-After у 429 приходит как обычно — это по-прежнему указание, когда повторить, и полагаться на него можно. А числа X-RateLimit-Limit/Remaining/Reset в этом состоянии НЕ приходят, и у ручки кабинета «остаток по категориям» числа тоже не будет (поле counted: false). Причина: запасной счётчик не видел запросов, ушедших в основной до его падения, поэтому остаток по нему был бы больше настоящего — а по такому числу ваш клиент разогнал бы темп ровно в момент нашей аварии. Отсутствующий заголовок ваш клиент обязан переживать с первого дня (§7), выдуманное число — нет.

  • Почему это не ломающее изменение. Обещание §7 звучало и звучит одинаково: превышение темпа отвечает 429. Клиент, который его переживал, ничего не заметит; заметит только тот, кто полагался на отсутствие лимита в момент нашей аварии, — а такого обещания мы не давали.

  • Значения квот не менялись: чтение 120/мин, заказы 30/мин, повтор вебхука и проверка получателя по 10/мин, на компанию.

2026-08-07 — ОТКРЫТА ВЫДАЧА КОДОВ: GET /v1/orders/{id}

🟢 Совместимо. Новая ручка; ни одна существующая форма ответа не изменилась.

  • GET /v1/orders/{id} построена и открыта (§5.5 документации), скоуп orders:read. Отдаёт статус заказа, а у выданного — состав: items[{sku, quantity, codes[]}], по одной позиции на SKU, quantity равен длине codes. Это закрывает последний незакрытый шаг пути: заказ теперь проходится до конца, включая приёмку самих кодов.

  • Ручка — источник правды о заказе. Вебхук (§6) уведомляет, отвечает она; статус в обоих считается одним правилом и разойтись не может.

  • Перечитывать выданное можно бессрочно и сколько угодно раз — «показать один раз» на пути API нет. Прежняя формулировка §9 («про срок хранения мы пока не даём обещания») этой записью заменена на обязательство: появись однажды ограничение срока, оно пойдёт депрекейтом по §11, то есть не раньше чем через 90 дней после объявления.

  • Правило журнала выдач действует с первого дня. Выданное записывается у нас в постоянное хранилище в момент выдачи, а не держится в памяти процесса: перезапуск нашей стороны кодов не теряет. До этой записи такого хранилища не существовало — именно поэтому ручку нельзя было открыть раньше, и в документации это было названо честным ограничением.

  • 404 not_found по-прежнему не различает «нет такого» и «чужой» (§5.5).

  • Чего в этой записи НЕТ: список заказов GET /v1/orders не построен (§10.3), и приём заказов БОЕВЫМ ключом по-прежнему не открыт (§10.1).

2026-08-07 — числа склада больше не выходят наружу в отказе заказа

🟢 Совместимо (поле не исчезло и форма не изменилась), но поведение изменилось, и на нём могла быть построена логика — поэтому запись отдельная.

Текст reason у отказа not-enough-stock больше не называет доступный остаток. Раньше он звучал как «доступно N, заказано M»; теперь — «заказано M, доступно меньше». Числа склада наружу не выходят нигде (§2 п.6), в том числе в тексте отказа; в прежней документации это было отмечено как наша недоработка с прямым предупреждением «не стройте на нём логику».

Код отказа (not-enough-stock), структура line_rejections[] и HTTP-статус не изменились.

2026-08-07 — разные записи одного IP-адреса теперь совпадают

🟢 Совместимо. Список разрешённых адресов ключа (§3) сравнивается по той же канонической форме, в которой он хранится: ::ffff:1.2.3.4 и 1.2.3.4 — один адрес, 2001:0DB8::0001 и 2001:db8::1 — тоже, регистр значения не имеет.

Раньше сравнение было посимвольным, и адрес, записанный в другой форме, давал 403 ip_not_allowed на каждом запросе при верно настроенном списке. Если вы обходили это, очистив список, — заполните его снова: пустой список означает, что ключ, попавший в чужие руки, работает откуда угодно. Приведение формы список не расширяет: соседний адрес той же сети — другой адрес, маски по-прежнему не поддерживаются.


2026-08-07 — публикация документации v1

🟢 Совместимо. Изменений контракта нет — опубликована сама документация.

Зафиксировано и вступает в силу с этой даты:

  • обязательство депрекейта: 90 дней. Любое ломающее изменение v1 объявляется здесь не позднее чем за 90 дней до вступления в силу;

  • /v1 в пути остаётся на всё время жизни этого контракта: ломающее изменение — это новая версия, а не правка v1;

  • описание контракта заказа POST /v1/orders (тело, идемпотентность, коды отказа, статусная модель) считается зафиксированным: приём заказов ещё не открыт, но форма меняться не будет.

Состояние на дату публикации (таблица «Что открыто сегодня», §0 документации):

🔴 /v1 ещё не выложен наружу — публичного адреса нет ни у одной ручки. Документация опубликована раньше доступа намеренно: по ней можно написать и отладить клиента заранее.

Построены и с окончательным контрактом: каталог, наличие, баланс, журнал движений, реестр подписок и доставка событий. Не построены: приём заказов через /v1, чтение заказа, проверка получателя.

Открытие доступа и открытие каждой ручки пойдут отдельными записями сюда — это и есть способ узнать, что можно начинать.


Cookie — для работы сайта и аналитики. Подробнее

Документация партнёрского API | Kodrio