Оптовая выдача цифровых товаров по 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. Основания контракта
Семь правил, которые действуют на каждой ручке. Если что-то в описании конкретной ручки противоречит этому разделу — верно то, что здесь.
/v1 в пути с первого дня. Ломающее изменение — это новая версия, а не правка
v1.Единый конверт. Успех —
{"data": ...}, рядом может стоятьmetaсо служебными данными ответа:next_cursorпри пагинации,unknown_skusуGET /v1/stock. Разбирайтеmetaне только в пагинирующем коде. Ошибка —{"error": {"code", "message", "request_id"}}.Пагинация только курсорная —
?cursor=, следующий курсор вmeta.next_cursor.nullвnext_cursorзначит «страниц больше нет». Ниpage, ниoffsetне поддерживаются.Деньги — целые в минорных единицах (
*_minor). Валюта берётся из профиля вашей компании, в запросе её не передают. Одна компания — одна валюта.Идемпотентность обязательна на создании заказа — §5.2.
Наличие отдаётся булевым:
available: true|false, без остатка и без «осталось мало». Сколько именно можно взять, вы узнаёте отказомnot-enough-stockна конкретном заказе.Отсутствующее поле — это не 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:read | GET /v1/catalog, GET /v1/stock |
balance:read | GET /v1/balance, GET /v1/balance/entries |
orders:create | POST /v1/orders |
orders:read | чтение заказов (ручка — §5.5) |
webhooks:manage | реестр подписок /v1/webhooks* |
🔴 Подписка на событие требует ещё и права читать его данные. Тело события несёт ровно то же,
что и ручки чтения, только уезжает на выбранный вами адрес. Поэтому POST /v1/webhooks сверяет
events[] с правами ключа:
| Событие | Дополнительно требует |
|---|---|
order.delivered, order.failed, order.refunded | orders:read |
balance.low | balance: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_sale | true — позиция закрыта к продаже, только когда «да» |
Регион: 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 — это ДВЕ разные вещи сразу, и путать их дорого.
Приставка станка поставщика. Часть формата пары: у одного поставщика её нет вовсе, у другого — есть. Поэтому в боевом каталоге разные элементы code_formats приходят с разным prefix, и у части из них поле есть. Не пишите валидатор, который отбрасывает
prefix«потому что у нас его никогда не было»: у соседнего формата того же артикула он будет.Метка контура 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_pricemarket_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, но сочинённый ПО ФОРМЕ читаемым — например от
чужой строки — честно отдаст не тот кусок журнала, и молча.
operation — debit (списание) либо 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 rejected | line_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 — это статус ОТВЕТА на размещение, а не ответ ручки чтения. Он приходит ровно в
теле 202 (и 200 на повторе) у POST /v1/orders. GET /v1/orders/{id} его не возвращает
никогда, даже если спросить сразу после 202: оттуда приходит либо processing, либо уже
терминальный статус (быстрая выдача успевает завершиться до вашего первого чтения). Обрабатывать
accepted как нетерминальный — верно; писать ветку «вдруг придёт из чтения» — не надо.
processing — не отказ. Выдача асинхронна. Заказ в
processingнельзя помечать у себя проваленным: терминальны только три правых статуса.delivered— выдан.failed— не выдали, деньги вернулись автоматически; возврат виден отдельной записью в журнале баланса.refunded— возврат после выдачи (отзыв кода, претензия).Заказ обязан дойти до терминального статуса. Зависшие добивает наша сверочная петля: сначала повторной попыткой выдачи, и только при невозможности выдать —
failedс возвратом.По заказу приходит одно терминальное событие. Единственный разрешённый переход после терминала —
delivered → refunded.🟢 status_detail при processing (появилось 08.08.2026). Необязательное поле; когда оно есть, оно объясняет, ЧТО происходит с заказом: ·
queued— заказ ждёт очереди на выдачу (поставщик временно не отдаёт коды, мы повторяем по расписанию); ·stuck— ждёт дольше обычного (свыше 15 минут). Оба значения — не отказ, и реакция на них одна: перечитать статус позже. Поля НЕТ, когда объяснять нечего, — отсутствие поля не означает проблемы. Мы не обещаем, что список значений останется из двух: новые добавляются без депрекейта, поэтому незнакомое значение читайте как «простоprocessing».
5.4. Что делать, если наш ответ не пришёл
Таймаут, обрыв соединения или 500 на POST /v1/orders:
Повторите запрос с тем же Idempotency-Key. Это безопасно всегда — второй раз он не спишет.
⚠️ Одно исключение, пока приём заказов не открыт (§10.1): сейчас 500 приходит на КАЖДЫЙ
заказ, и это постоянное состояние, а не сбой. Повтор его не лечит. Правило ниже — про настоящую
аварию, то есть про отказ на части запросов при работающих остальных ручках.
🔴 Никогда не создавайте повтор с новым ключом, не выяснив судьбу старого. Новый ключ — это новый заказ, и вы получите два списания за один заказ своего клиента.
Тот же порядок годится для 409 processing, но с паузой: повторяйте не чаще раза в минуту.
Чтобы выяснить судьбу заказа, номер которого у вас есть, повтор заказа не нужен — читайте §5.5.
Типовой заказ разрешается за секунды; заказ, зависший из-за обрыва на нашей стороне, добивает
наша сверочная петля, и это может занять до 20 минут. Плотный цикл здесь не ускорит ответ, а
выжжет вашу квоту на создание заказов (§7) — и 429 получат все ваши интеграции, включая
здоровые.
🔴 «Не чаще раза в минуту» — это про ПОВТОР POST /v1/orders, и это рекомендация, а не отдельный лимит. Технически создание заказов ограничено 30 запросами в минуту на компанию, чтение — 120 (§7); машинного «раз в минуту» не существует нигде, ни на записи, ни на чтении. Пауза названа потому, что чаще спрашивать НЕЧЕГО: заказ, ушедший в очередь, продвигает фоновая петля с шагом в 5 минут (§1), и ответ раньше её тика не изменится. Темп чтения статуса — §5.5.
5.5. Забрать коды — статус заказа и выдача
GET /v1/orders/{id} · скоуп orders:read. Это источник правды о заказе: вебхук (§6)
уведомляет, а отвечает эта ручка. Разойтись они не могут — статус в обоих считается одним и тем же
правилом.
curl -sS 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": "<секрет подписи>" } }Восстановить секрет нельзя: у нас он лежит зашифрованным. Потеряли или меняете — читайте порядок ниже, он не такой, как кажется.
🔴 Ротация секрета без потери событий. Порядок только такой:
поднимите ВТОРОЙ адрес приёмника (например
/kodrio-v2) и заведите подписку на него — получите новый секрет;убедитесь по
last_success_atвGET /v1/webhooks, что события пошли на новый адрес;только теперь отзовите старую подписку.
⚠️ Обратный порядок теряет события безвозвратно. Пока активной подписки нет, событие не
просто не доставляется — оно не создаётся вовсе, и поднять его нечем: ни ретраем, ни
повтором, ни через нас. Ротация «на том же адресе» невозможна: на один адрес разрешена одна
активная подписка (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.delivered | order_id, status: "delivered", amount_minor (списано), currency_code |
order.failed | order_id, status: "failed", amount_minor (возвращено), currency_code |
order.refunded | order_id, status: "refunded", amount_minor, currency_code |
balance.low | currency_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: 41X-RateLimit-Reset — число СЕКУНД до сброса окна, а не отметка времени unix. Это расходится
с привычкой многих API, поэтому названо прямо: 41 значит «через 41 секунду», а не «1970 год».
Превысили — 429 rate_limited плюс Retry-After, тоже в секундах. Стройте темп по Remaining,
а не по 429.
Стартовые значения (не обещание — пересматриваются, изменение пойдёт строкой в журнал):
| Категория | Ручки | Лимит |
|---|---|---|
| чтение | каталог, прайс файлом, наличие, баланс, журнал, чтение заказа GET /v1/orders/{id}, реестр подписок | 120/мин |
| создание заказов | POST /v1/orders | 30/мин |
| повтор события | POST /v1/webhooks/{id}/replay | 10/мин |
Категория «чтение» — это «всё остальное». В неё попадает любая ручка /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" } }| HTTP | code | Когда |
|---|---|---|
| 400 | validation_failed | тело не по схеме, пустой lines[], >100 SKU в /v1/stock, нечитаемый cursor, нет Idempotency-Key |
| 401 | unauthorized | ключа нет / неверен / отозван / тестовый ключ на /v1 |
| 403 | forbidden_scope | нет нужного скоупа; при подписке в reason — недостающие скоупы |
| 403 | ip_not_allowed | адрес вне списка, разрешённого ключу |
| 403 | verification_required | компания не прошла проверку. Боевой ключ не допускается на денежный путь (POST /v1/orders) до проверки компании — сумма заказа роли не играет. Каталог, склад и остаток читаются как обычно; проверка компании песочного ключа не касается (в боевой сборке у песочного ключа свой отказ — 503 sandbox_not_available, он про контур, а не про проверку). В message — что делать и ссылка на раздел «Компания» в кабинете |
| 404 | not_found | нет такого объекта — или он не ваш. Существование чужого мы не подтверждаем. Этим же кодом отвечает любой адрес под /v1, которого у нас нет (опечатка в пути, ручка из будущей волны): ответом на неизвестный адрес приходит такой же конверт, а не страница с разметкой |
| 409 | idempotency_conflict | ключ занят заказом с другим телом |
| 409 | processing | ключ занят, тело то же, исход не подтверждён. Повторять безопасно, но не чаще раза в минуту — разбор до 20 мин (§5.4) |
| 409 | webhook_limit | уже 10 активных подписок |
| 409 | webhook_duplicate_url | активная подписка на этот адрес уже есть |
| 409 | replay_not_terminal | событие ещё в очереди, повторять не нужно |
| 422 | rejected | заказ отклонён, причины — в line_rejections[] (§5.1) |
| 422 | url_rejected | адрес подписки не принят, причина кодом в reason |
| 422 | kind-not-supported | проверка получателя не применима к виду выдачи. Через /v1 сегодня недостижим — ручки ещё нет (§10.4) |
| 429 | rate_limited | лимит частоты, пауза в Retry-After |
| 500 | internal | наша ошибка. Заказ, если он был создан, НЕ считается проваленным (§5.4). ⚠️ На POST /v1/orders сегодня это ПОСТОЯННОЕ состояние, а не авария — §10.1 |
| 503 | secret_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.2026 | 18.11.2026 | code_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/catalog | catalog:read | §4 | ✅ построена |
GET /v1/catalog/export | catalog:read | §4 | ✅ построена |
GET /v1/stock | catalog:read | §4 | ✅ построена |
GET /v1/balance | balance:read | §4 | ✅ построена |
GET /v1/balance/entries | balance:read | §4 | ✅ построена |
GET /v1/webhooks | webhooks:manage | §6.1 | ✅ построена |
POST /v1/webhooks | webhooks:manage + право на события | §6.1 | ✅ построена |
DELETE /v1/webhooks/{id} | webhooks:manage | §6.1 | ✅ построена |
POST /v1/webhooks/{id}/replay | webhooks:manage | §6.1 | ✅ построена |
POST /v1/orders | orders: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
у ключа, которым вы не пользуетесь.
Порядок действий — все три шага, отзыв ключа сам по себе НЕ закрывает утечку:
Отзовите ключ (напишите нам) — доступ прекращается мгновенно, мы решение по ключу не кэшируем.
Пройдите GET /v1/webhooks и отзовите все подписки, которых вы не заводили. Подписки принадлежат компании и переживают отзыв ключа: чужая подписка продолжит слать ваш остаток и ваши заказы на чужой адрес, пока вы её не снимете.
Сверьте журнал баланса (
GET /v1/balance/entries) за период с момента, когда ключ мог утечь, и сообщите нам о движениях, которых вы не делали.
Не расширяйте список разрешённых адресов, чтобы «починить» 403 ip_not_allowed, — это ровно
тот шаг, которого ждёт тот, кто увёл ключ.
Прочее
Возьмите
request_idиз тела ошибки.Сверьтесь с §8 — большая часть отказов чинится на вашей стороне и описана там.
500/503/таймаут на заказе — §5.4: повторить тем жеIdempotency-Key, никогда не новым.Осталось непонятным — напишите по каналу связи из §1, назвав
request_id, время и ручку.
Вопрос, ответа на который нет на этой странице, — наш недочёт, а не ваш вопрос. Сообщите о нём: страница чинится текстом, а не устным ответом.