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

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

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

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


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

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

🔴 САМОЕ ГЛАВНОЕ, ЧТО НАДО ЗНАТЬ ПЕРВЫМ: снаружи открыт ПЕСОЧНЫЙ контур, боевого адреса пока нет. С 07.08.2026 /v1 доступен по песочному адресу (§1): с песочным ключом можно прочитать каталог, разместить заказ, увидеть списание в журнале и забрать коды — путь проходится целиком, не спрашивая нас ни о чём. Боевой адрес приедет вместе с доменом; до него боевые ключи никуда не ходят и, если предъявить их песочному адресу, отвергаются им намеренно (§1).

ВозможностьСостояние
Каталог с ценами, наличие батчем✅ открыто
Баланс и журнал движений✅ открыто
Подписки на события, подпись, ретраи, повтор✅ открыто
Размещение заказа POST /v1/ordersоткрыто ПЕСОЧНЫМ ключом; боевым — ещё нет, §10.1
Тестовый контур («песочница») на /v1открыт 07.08.2026 — §1 «Песочница», границы в §10.2
Забрать коды заказа GET /v1/orders/{id}открыто 07.08.2026 — §5.5. Перечитывать можно бессрочно
Список заказов GET /v1/orders⛔ не построено — §10.3
Проверка получателя /v1/recipients/check⛔ не построено — §10.4

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


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

Один путь, без ветвлений. С песочным ключом он выполняется прямо сейчас, целиком и без единого обращения к нам (кроме самого ключа) — адрес и порядок ниже, в разделе «Песочница». С боевым ключом шаги 1–4 описывают построенные ручки, шаг 5 ждёт открытия боевого приёма (§10.1).

Что вы получаете от нас перед началом

Четыре вещи, и все приходят одним сообщением:

ЧтоЗачем
Базовый адрес APIвсе пути ниже приписываются к нему: базовый адрес + /v1/catalog
Ключ доступазаголовок Authorization (§3)
Набор скоупов вашего ключакакие ручки он открывает (§3)
Список разрешённых IP, если он заданс каких адресов ключ принимается (§3)

⚠️ Последние две строки приезжают сообщением от нас и машинной ручкой «какие у меня права» не проверяются — такой ручки в v1 нет. Сохраните их вместе с ключом.

Базовых адресов будет два, и сегодня существует один — песочный (см. «Песочница» ниже). Боевой приедет вместе с доменом и придёт вам сообщением; открытие пойдёт строкой в журнал изменений. В примерах ниже вместо адреса стоит https://api.example.invalid — подставьте песочный сейчас и боевой, когда он у вас будет.

Песочница — пройти весь путь, ничего не потратив

Адрес: http://157.22.189.243 · открыт 07.08.2026 · принимает только песочные ключи (kodrio_test_…).

За песочным ключом стоит отдельный кошелёк со стартовой суммой, фиктивный поставщик и коды с меткой TEST-. Заказ проходит ТЕМ ЖЕ путём, что боевой: те же проверки, тот же конверт ответа, та же идемпотентность, те же события. Ничего вашего при этом не тратится.

⚠️ Стартовая сумма появляется на кошельке вместе с ПЕРВЫМ ЗАКАЗОМ, а не в момент выпуска ключа. Поэтому GET /v1/balance, вызванный до первого POST /v1/orders, честно отдаёт available_minor: 0 — это не «ключ без денег» и не ошибка выдачи. Разместите заказ (шаг 2 ниже) и перечитайте баланс: увидите и зачисление стартовой суммы, и списание за заказ, обе строки в журнале. Порядок шагов быстрого старта выбран именно так и не случайно.

🔴 Боевой ключ этому адресу не предъявляйте — он его и не примет. У песочного адреса нет TLS (он появится вместе с доменом), а Authorization: Bearer уезжает в открытом виде: боевой ключ, отправленный сюда, следовало бы считать скомпрометированным с этой секунды. Поэтому боевые ключи отсекаются на входе, не доходя до приложения; ответ — обычный 401 unauthorized §4, без особого текста: словарь ошибок один, и объяснять состояние конкретного адреса машинным конвертом мы не станем — оно объяснено здесь. Песочный ключ этой цены не имеет, им по HTTP пользоваться можно.

⚠️ Практическое следствие: если ваш клиент настроен на боевой ключ и вы просто подменили адрес на песочный, вы получите 401 на КАЖДОМ запросе. Это не «ключ протух» — это не тот ключ для этого адреса.

Что проходится целиком (замерено на этом же адресе):

KEY=kodrio_test_…            # песочный ключ от нас
BASE=http://157.22.189.243

# 1. каталог с нашими ценами (страница — 100 позиций, дальше по ?cursor=; см. §4)
curl -sS "$BASE/v1/catalog" -H "Authorization: Bearer $KEY"

# 2. заказ — 202 и номер заказа
curl -sS -X POST "$BASE/v1/orders" \
  -H "Authorization: Bearer $KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: moy-pervy-zakaz-1" \
  -d '{"lines":[{"sku":"APPLE-AU-2AUD","quantity":1,"unit_price_minor":17300}]}'

# 3. повтор ТЕМ ЖЕ ключом — 200 и тот же номер, второго списания нет
# 4. списание видно в журнале, у строки есть order_id
curl -sS "$BASE/v1/balance/entries" -H "Authorization: Bearer $KEY"

# 5. ЗАБРАТЬ КОДЫ — по номеру заказа из шага 2 (§5.5)
curl -sS "$BASE/v1/orders/pord_XXXXXXXXXXXXXXXX" -H "Authorization: Bearer $KEY"

Артикул и цена в примере — живые, снятые с этого же каталога. Разойдутся — заказ честно ответит 422 price-changed и вернёт expected_unit_price_minor: повторите с ним (§5.1), это и есть штатная работа вашего клиента, а не поломка примера.

Сценарии отказов по запросу. Чтобы отладить не только счастливый путь, песочница умеет вызывать нужный исход заголовком X-Kodrio-Scenario у POST /v1/orders:

ЗначениеЧто произойдёт
errorпоставщик ответит отказом → заказ встанет в очередь: processing + status_detail: "queued" (а спустя 15 минут — "stuck"), деньги остаются у нас, мы повторяем сами
out_of_stockотказ по остатку у поставщика → тот же исход: очередь и повторные попытки
timeoutпоставщик не ответит вовремя → тот же исход: очередь и повторные попытки
slow_okуспешный ответ с искусственной задержкой. ⚠️ По умолчанию она мала (десятки миллисекунд) — если вам нужна ощутимая пауза под ваши таймауты, скажите нам, включим на вашем контуре
stuckзаказ примется (202) и «зависнет» без исхода. Повтор ТЕМ ЖЕ Idempotency-Key раньше 30 секунд ответит 409 processing, после 30 секунд — дожмёт заказ. Так проверяется ваш опрос статуса
line_down_onceлиния недоступна ОДИН раз, следующая попытка проходит. Заказ встаёт в очередь (processing + queued) и через расписание попыток становится delivered с кодами, при одном списании. Это самый короткий способ увидеть весь путь заказа целиком, а не только его начало

Заголовок действует ТОЛЬКО у песочного ключа: боевой его не читает, поэтому случайно оставленный в коде сценарий не изменит ничего в бою.

👉 Если вы отлаживаете обработку processing, берите line_down_once. Остальные три (error, out_of_stock, timeout) тоже ставят заказ в очередь и тоже доводят его до выдачи — разница в том, что line_down_once поднимается ДЕТЕРМИНИРОВАННО со второй попытки по вашему же ключу, а не «когда поставщику полегчает». Две оговорки, обе честные:

  • наш рестарт посреди очереди сбрасывает счёт «один раз», и заказ получит ещё один круг;

  • примерно после трёх сценарных заказов подряд песочная линия «остывает» около минуты. Отказы по сценарию считаются наравне с настоящими, и предохранитель размыкается — это не поломка, а тот же механизм, которым мы защищаем боевую выдачу. Заказ в это окно получает processing и дожимается позже. Гоняете сценарий в цикле — делайте паузу либо чередуйте со счастливым путём.

⚠️ Первые три сценария больше НЕ дают немедленного failed, и это не опечатка в таблице, а изменение поведения (08.08.2026). Отказ поставщика перестал означать возврат: заказ уходит в очередь и повторяется до суток (см. §5.3 и status_detail). Заголовок — команда РАЗОВАЯ, на один запрос, поэтому следующая попытка идёт уже без него и обычно заканчивается выдачей. Практическое следствие для вашего клиента: отлаживайте на этих сценариях именно ветку «принят, ещё исполняется», а не ветку отказа.

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

Терминальный failed с возвратом остаётся достижимым и в песочнице — он наступает, когда отказ НЕ проходит сам: попытки исчерпаны (сутки либо 24 захода), заказ невалиден по каталогу (например, несуществующий артикул или разошедшееся эхо цены) либо поставщик отдал код, уже выданный другому заказу. Хотите прогнать ветку отказа быстро — пришлите заказ с заведомо неверной строкой: он ответит 422 сразу, не тронув деньги.

⚠️ У stuck есть и верхняя граница: 15 минут. Зависший песочный заказ через этот срок разбирает наша сверочная петля и ОСВОБОЖДАЕТ ключ идемпотентности — повтор после этого станет новым заказом с новым номером, а не дожатием прежнего. Если вы отлаживаете длинный опрос, укладывайтесь в окно или начинайте сценарий заново.

Путь проходится целиком, включая приёмку кодов (с 07.08.2026, §5.5). Коды песочницы несут метку TEST- и выдаются фиктивным поставщиком, но конверт ответа и порядок шагов — те же, что в бою, а формат отличается ровно меткой и описывает её сам (ниже): клиент, отлаженный здесь, менять под боевой контур не придётся. Метку TEST- несут и коды, выданные не с первой попытки, а очередью.

🔴 Метка входит в формат, снимать её самим НЕ надо. GET /v1/catalog, прочитанный ПЕСОЧНЫМ ключом, отдаёт prefix с меткой TEST- впереди у КАЖДОГО формата в code_formats — то есть формат уже описывает код целиком, вместе с меткой. Валидируйте выдачу по code_formats как есть (§9), ничего не отрезая: тот же ключ, тот же каталог, те же форматы. Боевой ключ метки не получает — у боевых кодов её нет. ⚠️ Отсюда следствие для вашего справочника: форматы берите тем же ключом, которым будете заказывать. Формат, прочитанный боевым ключом, песочные коды не проходят — и наоборот; это не рассогласование, а два разных контура.

⚠️ Песочный баланс живёт до нашего обновления бэкенда, и это единственное место, где песочница ведёт себя не как бой. Остаток и журнал движений песочного контура мы держим в памяти сервиса, поэтому наш деплой их обнуляет: после него ваш песочный баланс снова равен стартовой сумме, а прежние тестовые списания из журнала исчезают. Заказы при этом НЕ теряются — заказ, стоявший в очереди, дожимается и после перезапуска. Что из этого следует для вашего клиента: не стройте автотесты на накопленном песочном остатке и на длинной истории GET /v1/balance/entries — сверяйте баланс в начале прогона. В боевом контуре деньги и журнал долговечны, там такого нет.

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

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

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

curl -sS https://api.example.invalid/v1/balance \
  -H "Authorization: Bearer $KODRIO_API_KEY"
{ "data": { "currency_code": "RUB", "available_minor": 1500000, "overdraft_limit_minor": 0 } }

Деньги везде — целые в минорных единицах (копейки). 1500000 = 15 000,00 ₽.

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

curl -sS "https://api.example.invalid/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", "prefix": "TEST-" },
        { "mask": "XXXXXXXXXXXXXXXX", "alphabet": "0123456789", "prefix": "TEST-ALT-" }
      ],
      "code_format": { "mask": "XXXXXXXXXXXXXXXX", "alphabet": "ABCDEFGHJKLMNPQRSTUVWXYZ23456789", "prefix": "TEST-" },
      "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).

Шаг 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). Валюта берётся из профиля вашей компании, в запросе её не передают. Одна компания — одна валюта.

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

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

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


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

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

Authorization: Bearer kodrio_live_<40 hex>

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

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

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

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

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

Скоупы

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

СкоупЧто открывает
catalog:readGET /v1/catalog, GET /v1/stock
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; в боевой выдаче волны 1 работает только key_code (§10.5)
min_batchминимальная партия
code_formatsформаты кода — по нему валидируйте выдачу у себя; разбор ниже
code_format⚠️ устарел, снимается 18.11.2026 — формат ПОЗИЦИИ без учёта того, кто печатает; разбор ниже
title_ruназвание
availableможно ли заказать минимальную партию прямо сейчас — см. предупреждение ниже
priceнаша оптовая цена за штуку, минорные единицы — при заказе ниже первой ступени
price_tiersоптовые ступени «объём → цена», только там, где они есть — см. ниже
market_priceрозничный якорь рынка, только там, где он есть
closed_for_saletrue — позиция закрыта к продаже, только когда «да»

Регион: 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)

🔴 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нетлитеральное начало кода, в маску НЕ входит. У песочного ключа сюда добавлена метка контура TEST- (см. ниже)
suffixнетлитеральный хвост кода, в маску тоже не входит
exampleнетобразец кода для человека. Значением никогда не является, но объявленный формат проходит

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

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

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

🔴 prefix — это ДВЕ разные вещи сразу, и путать их дорого.

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

  2. Метка контура TEST- (§1 «Песочница»). Песочный ключ дописывает её ПЕРЕД приставкой станка, на КАЖДОМ элементе code_formats: формат без приставки станка выйдет как "TEST-", формат с приставкой "ALT-" — как "TEST-ALT-". Боевой ключ метки не получает нигде.

Отсюда одно правило вместо двух: берите 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 нет вовсе, когда ступеней у позиции нет — пустого массива не приходит никогда. Отсутствие поля не значит «объёмных цен не бывает»: сетка может появиться позже.

⚠️ Мы показываем только достижимые ступени. Ступень выше действующего потолка количества в строке (сегодня 10 штук, §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

market_price заполнен только у строки min_qty=1: якорь рынка — розничная цена за штуку, к объёму отношения не имеющая. Позиция без цены остаётся в файле с пустой ячейкой 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, а не показывает старую цену. В редком случае — если наша сторона только что перезапустилась и курс не успел проверитьcя ни разу — это может случиться сразу, а не через 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/balance — остаток

Скоуп balance:read.

{ "data": { "currency_code": "RUB", "available_minor": 1500000, "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, но сочинённый ПО ФОРМЕ читаемым — например от чужой строки — честно отдаст не тот кусок журнала, и молча.

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

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

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

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


5. Заказы

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

5.1. Создание

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

POST /v1/orders
Authorization: Bearer kodrio_live_0000000000000000000000000000000000000000
Idempotency-Key: 7f1c9a2e-order-4482
Content-Type: application/json

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

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

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

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

  • quantity — до 10 в строке (потолок волны 1, из конфигурации). Сверх → invalid-quantity.

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

🔴 Эхо ценой списания не становится никогда. Списание всегда идёт по цене нашего бэкенда; ваше число только сверяется. Разошлось — заказ отклоняется 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[]заказ отклонён; деньги не тронуты, ключ свободен
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количество выше потолка 10разбить на заказы
below-min-batchменьше минимальной партиидобрать до min_batch
not-enough-stockостатка не хватает на это количествоуменьшить количество или подождать
invalid-priceсумма строки выше нашего потолка на одну строкусверить количество и цену с каталогом
price-changedэхо разошлось с нашей ценой либо цена позиции у нас не определенаесть expected_unit_price_minor — повторить с ним; поля нет — перечитать каталог
closed-for-saleпозиция закрыта к продаже — живого канала поставки нетснять карточку у себя
supplier-unavailableзакупка по этому заказу сейчас недоступна — временное состояние на НАШЕЙ сторонеповторить позже, карточку НЕ снимать
kind-not-supportedвид выдачи виден в каталоге, но боевой выдачей волны 1 не поддерживается§10.5
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 символов; отсутствие заголовка Idempotency-Key, его длина свыше 255 символов, а также ключ идемпотентности, начинающийся с sandbox: — этот префикс зарезервирован нами. Проверяйте эти условия у себя до отправки — иначе отладка идёт вслепую.

⚠️ У ключа идемпотентности обрезаются ведущие и хвостовые пробелы: "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; порядок строк значения не имеет (перестановка описывает тот же заказ). Заголовки в сверку не входят вообще — ни X-Kodrio-Scenario, ни ваши собственные. Практическое следствие, за которым обычно и приходят: повторяя заказ тем же ключом, сценарный заголовок можно и оставить, и снять — на 200 и на «то же тело» это не влияет никак. Смысла оставлять его при этом нет: исход первого захода уже записан, и повтор отдаёт его дословно, ничего заново не исполняя (§1 «команда РАЗОВАЯ» — про это же).

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

accepted → processing → delivered | failed | refunded

🔴 accepted — это статус ОТВЕТА на размещение, а не ответ ручки чтения. Он приходит ровно в теле 202200 на повторе) у 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 https://api.example.invalid/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: песочные коды приходят с меткой TEST-, и она входит в каждый формат, прочитанный тем же песочным ключом. Отрезать её перед сверкой не надо. ⚠️ Проверка по единственному code_format отвергнет валидный оплаченный код, если позицию напечатал не тот поставщик, чей формат стоит в этом поле. Это и есть причина депрекейта (§11).

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

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

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

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

⚠️ Одно исключение из «404 = не ваш заказ», и оно только песочное. Песочный заказ, зависший без исхода дольше 15 минут (см. верхнюю границу сценария stuck в §1), разбирает наша сверочная петля: она освобождает ключ идемпотентности, и заказ перестаёт существовать — по его номеру ручка ответит 404. Это штатное поведение тестового контура, а не потеря: денег в песочнице никто не терял, повтор с тем же ключом станет новым заказом с новым номером. В боевом контуре такого исхода нет — там заказ обязан дойти до терминального статуса (§5.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 · url · events[] · secret_prefix · secret_last4 · created_at · revoked_at · last_success_at · consecutive_failures · last_http_status · last_failure_code. Секрета в списке нет ни в каком виде. 🔴 В списке есть и ОТОЗВАННЫЕ подписки — живая та, у которой 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": "RUB" }
}

К data события заказа может добавиться поле sandbox: true — признак того, что событие порождено ТЕСТОВЫМ заказом, а не боевым. Поле появляется, только когда ответ «да» (§2 п.7), и боевой контракт им не меняется: у настоящих событий его нет вовсе. Обрабатывайте такие события как тестовые — за ними не стоит ни денег, ни товара. 🪤 Встретить его вы можете и сегодня, пока /v1 закрыт: §10.2 говорит про тестовый контур на /v1, а подписки принадлежат компании и собирают её события с любого пути.

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

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

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

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

🔴 Песочный заказ порог не проверяет, и события balance.low в песочнице не бывает. Это не пропуск, а решение: порог настроен на ваши НАСТОЯЩИЕ деньги и один на компанию, а за песочным ключом стоит отдельный кошелёк со стартовой суммой (§1). Проверь мы порог тестовым заказом — вы получили бы боевое «денег мало» с суммой из песочницы, то есть событие о деньгах, говорящее о деньгах неправду. Практическое следствие: обработчик balance.low в песочнице не отладить — проверяйте его на своей стороне подставным событием (конверт и подпись — §6.2, §6.3), а молчание песочницы не читайте как «порог не выставили».

⚠️ Событие приходит ОДИН раз на пересечение вниз, а не на каждый заказ ниже порога. Пока остаток не поднялся обратно на порог или выше, второго 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}, реестр подписок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ключа нет / неверен / отозван / тестовый ключ на /v1
403forbidden_scopeнет нужного скоупа; при подписке в reason — недостающие скоупы
403ip_not_allowedадрес вне списка, разрешённого ключу
403verification_requiredкомпания не прошла проверку. Боевой ключ не допускается на денежный путь (POST /v1/orders) до проверки компании — сумма заказа роли не играет. Каталог, склад и остаток читаются как обычно; проверка компании песочного ключа не касается (в боевой сборке у песочного ключа свой отказ — 503 sandbox_not_available, он про контур, а не про проверку). В message — что делать и ссылка на раздел «Компания» в кабинете
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)
429rate_limitedлимит частоты, пауза в Retry-After
500internalнаша ошибка. Заказ, если он был создан, НЕ считается проваленным (§5.4). ⚠️ На POST /v1/orders сегодня это ПОСТОЯННОЕ состояние, а не авария — §10.1
503secret_not_configuredмы не настроены выпускать подписки. Повторить позже без изменений

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


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

  • В боевой выдаче волны 1 поддерживается только delivery_kind: "key_code". Остальные виды — game_key, topup_by_id, topup_by_login, gift_link — в каталоге видны и заказом отвергаются (§10.5).

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

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

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

  • 🔴 Код проверяется ЦЕЛИКОМ, вместе с prefix и suffix, а не одной маской. У песочного каталога в prefix каждого формата дописана метка TEST-, поэтому песочный код длиннее маски ровно на метку — снимать её перед сверкой не нужно и не следует (§1 «Песочница»).

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

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

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


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

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

10.1. Приём заказов БОЕВЫМ ключом ещё не открыт

Контракт §5 зафиксирован и меняться не будет. Песочным ключом заказ проходит целиком с 07.08.2026 (§1 «Песочница») — этот раздел только про боевой контур.

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

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

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

10.2. Границы песочницы

Песочный контур ОТКРЫТ (§1), здесь — чего в нём нет, чтобы вы не искали.

  • Коды здесь фиктивные — их печатает тестовый поставщик, и они несут метку TEST-. Проверить ими можно всё, кроме одного: активируется ли код у площадки. Порядок шагов и конверт ответа — боевые; формат отличается ровно меткой, и она описана в prefix каждого формата code_formats песочного каталога (§4), так что ваша сверка кода в боевом контуре не меняется — меняются только прочитанные форматы.

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

  • TLS у песочного адреса нет — он приедет вместе с доменом. Отсюда правило §1: боевой ключ этому адресу не предъявляют, и дверь его не принимает.

  • Кошелёк песочницы заводится первым заказом (§1): до него GET /v1/balance отдаёт 0, а журнал движений пуст.

  • События balance.low в песочнице нет (§6.2): порог считается по боевым деньгам, а песочный кошелёк к ним отношения не имеет. Событий ЗАКАЗА это не касается — они приходят как обычно, с пометкой sandbox.

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

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

10.3. Списка заказов GET /v1/orders ещё нет

Читать заказ по номеру можно (§5.5), перебрать свои заказы — пока нет. Номер вы получаете в ответ на размещение (202), он же приходит событием (§6.2) и стоит в записях журнала баланса (§4), — то есть потерять его можно только вместе со своей стороной интеграции. Если вы её потеряли, сводите по order_id из GET /v1/balance/entries. Открытие — строкой в журнале изменений.

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

Ручка нужна видам выдачи «пополнение по логину/ID», а в боевой выдаче волны 1 их нет (§10.5). Контракт зафиксирован; для key_code получателя не существует по устройству, и такой запрос отвечал бы 422 kind-not-supported.

10.5. Только key_code в боевой выдаче

game_key, topup_by_id, topup_by_login, gift_link в каталоге видны, но заказ по ним отвергается kind-not-supported. Мы показываем их честно, а не прячем: ассортимент реален, боевая выдача по ним — следующая волна.

🔴 game_key — не синоним key_code, и различать их вам стоит. Вам в обоих случаях приезжает код, но у игрового ключа своё региональное ограничение: он активируется в СПИСКЕ стран (region_countries), а не в одной. Позиции game_key сегодня приходят с closed_for_sale: true и без price — товар у поставщика есть, боевой выдачи на него мы ещё не открыли. Когда откроем, это будет «новое значение в списке» (§11): форма ответа не изменится, и ваш клиент, читающий delivery_kind как строку, продолжит работать без правок.

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

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

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

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

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

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

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

  • Самообслуживаемая регистрация — ключи выпускает владелец руками.

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

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


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

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

Ломающим мы считаем: удаление ручки, удаление поля из ответа, сужение допустимых значений, новое обязательное поле в запросе, изменение смысла существующего кода ошибки, а также формат заголовка X-Kodrio-Signature и алгоритм подписи — их смена ломает приёмник молча, поэтому она объявляется теми же 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

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

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

РучкаСкоупРазделСостояние
GET /v1/catalogcatalog:read§4✅ построена
GET /v1/catalog/exportcatalog:read§4✅ построена
GET /v1/stockcatalog: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/orders⛔ не построена (§10.3)
POST /v1/recipients/check⛔ не построена (§10.4)

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, никогда не новым.

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

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


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

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

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

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

Как читать

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

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

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

  • 🔴 Ломающее — только по истечении объявленных 90 дней и только после 🟡-записи. Ломающее изменение без предшествующего объявления мы не выпускаем.

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


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), с той же семантикой и той же меткой контура TEST- у песочного ключа. Массив никогда не пуст.

  • Правило проверки: выданный код обязан пройти ЛЮБОЙ ОДИН формат из 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} и вебхуки работают как прежде — читать каталог и свои данные проверка не требует.

  • Проверка компании песочного ключа не касается. Ключ kodrio_test_ этим гейтом не судится независимо от статуса компании: интеграцию вы доводите до конца, не дожидаясь никого. (Отдельно от этого у песочного ключа есть свой отказ 503 sandbox_not_available — он про сборку без песочного контура, а не про проверку, и этим изменением не затронут.)

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


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

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

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

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

  • code_format.prefix теперь приходит и зависит от контура вашего ключа. Песочный каталог отдаёт "TEST-" — ту самую метку, с которой песочница и выдаёт коды; боевой каталог отдаёт то, что стоит у самой позиции (у большинства — ничего, и тогда поля нет вовсе). До этой правки песочный код был на пять символов длиннее собственной маски, а описать это расхождение форматом было нечем: prefix в ответе отсутствовал.

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

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

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


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-Scenario в сверку тела не входит. «То же тело» считается по lines[]; на повторе тем же ключом идемпотентности заголовок можно и оставить, и снять. §5.2.

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

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

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


2026-08-11 — УТОЧНЕНИЕ: КОГДА У ПЕСОЧНОГО КЛЮЧА ПОЯВЛЯЮТСЯ ДЕНЬГИ

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

  • Стартовая сумма песочницы выдаётся ПЕРВЫМ ЗАКАЗОМ, а не выпуском ключа. До первого POST /v1/orders ваш GET /v1/balance отдаёт available_minor: 0, журнал движений пуст, и это штатное состояние нового песочного ключа, а не отказ. Названо в §1 и §10.2.

  • Песочный остаток сбрасывается нашим перезапуском — следующий заказ снова получит стартовую сумму. Тоже §10.2. Боевого контура не касается: там остаток постоянный.

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


2026-08-11 — ПЕСОЧНИЦА: ПРОГНАТЬ ВЕТКУ «ЗАКАЗ ПОДОЖДАЛ И ВЫДАЛСЯ»

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

  • Новый сценарий песочницы X-Kodrio-Scenario: line_down_once на POST /v1/orders. Первая попытка выдачи по заказу отказывает «линия сейчас недоступна», следующая проходит. Заказ при этом ведёт себя ровно так, как обещает §6: 202 acceptedGET /v1/orders/{id} отдаёт processing с status_detail: "queued" → через расписание попыток delivered с кодами. Деньги списываются один раз, возврата не происходит. ⚠️ В кабинете его в списке пока нет — только через API. Кнопка тестового заказа предлагает прежние пять.

  • Зачем он вам. Переход «стоит в очереди → выдан» наступит на боевом контуре в первую же минуту просадки поставщика, и лучше увидеть его на песочнице, чем на своём проде. Того же пути можно добиться и сценариями error / out_of_stock / timeout — этот отличается тем, что поднимается ДЕТЕРМИНИРОВАННО со второй попытки по вашему ключу и назван по тому, что моделирует.

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

  • 🪤 Честно про границы. «Один раз» считается по вашему ключу идемпотентности. Наш рестарт посреди очереди сбрасывает этот счёт: заказ получит ещё один отказ и ещё один круг очереди (попыток до 24, окно — сутки). Повтор УЖЕ ВЫДАННОГО заказа сценарий не ломает и отдаёт те же коды.

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


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 с возвратом.

  • Песочный заказ, дошедший до failed, теперь ЧИТАЕТСЯ. Раньше GET /v1/orders/{id} по такому заказу отвечал 404, хотя списание в журнале баланса вы видели. Теперь отвечает status: "failed" всегда. Парная строка возврата появляется в журнале баланса тогда, когда списание пережило наш перезапуск: песочный кошелёк живёт в памяти сервиса (см. ниже), и после реинициализации возвращать нечего — выдумывать движение мы не станем. Боевого контура это не касалось никогда: там и списание, и возврат долговечны.

  • Песочный баланс переживает НАШ деплой реинициализацией, а не сохранением: после обновления сервиса остаток снова равен стартовой сумме, а прежние тестовые движения из журнала исчезают. Заказы при этом не теряются — заказ, стоявший в очереди, дожимается и после перезапуска. Что делать вам: не строить автотесты на накопленном песочном остатке и на длинной истории GET /v1/balance/entries — сверяйте баланс в начале прогона. В боевом контуре деньги и журнал долговечны, там такого нет.

  • Сценарии X-Kodrio-Scenario: error | out_of_stock | timeout больше не дают немедленный failed — заказ уходит в очередь и обычно дожимается следующей попыткой. Таблица сценариев в §1 документации приведена к фактическому поведению; отлаживайте на них ветку «принят, ещё исполняется».

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, §10.2).

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

2026-08-07 — /v1 ВЫЛОЖЕН НАРУЖУ: открыт песочный контур

🟢 Совместимо. Форма ответов не менялась ни у одной ручки — открылся доступ.

  • Появился песочный адрес: http://157.22.189.243 (§1 документации, раздел «Песочница»). Принимает только песочные ключи kodrio_test_…; боевой ключ этому адресу предъявлять не нужно и нельзя — у него нет TLS, и дверь такие ключи отвергает, не доводя до приложения.

  • POST /v1/orders открыт песочным ключом. Заказ проходит целиком: те же проверки, тот же конверт, та же идемпотентность, те же события; тратятся песочные деньги, коды помечены TEST-. Боевым ключом приём по-прежнему не открыт (§10.1).

  • Ограничение, названное честно: забрать коды на момент этой записи было нечем — ручки GET /v1/orders/{id} не существовало, кодов не несли ни ответ на заказ, ни событие (§9). В песочнице отлаживалось всё, кроме приёмки самих кодов. ✅ Снято в тот же день записью выше.

  • Боевой адрес приедет вместе с доменом и будет объявлен здесь же.

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, чтобы сайт работал корректно и для аналитики посещаемости. Продолжая пользоваться сайтом, вы соглашаетесь с обработкой cookie. Подробнее.

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