Оптовая выдача цифровых товаров по API: каталог с нашими оптовыми ценами, наличие, заказ, баланс и события. Всё, что нужно, чтобы подключиться, — на этой странице; логин не требуется.
Версия контракта — v1. Журнал изменений — 11-partner-api-changelog.md,
там же правило депрекейта.
0. Что открыто сегодня
Мы не пишем в документации того, чего нет. Таблица ниже — состояние на дату последней записи журнала изменений; всё, что в ней помечено «закрыто», описано в §10 отдельным разделом с причиной.
🔴 САМОЕ ГЛАВНОЕ, ЧТО НАДО ЗНАТЬ ПЕРВЫМ: адрес один, и тестового контура нет. Адрес —
https://kodrio.com: ключ kodrio_live_… вы выпускаете сами в кабинете после проверки компании;
он читает каталог, остаток и события, а приём заказов ещё не открыт (§10.1). Первый заказ пойдёт
этим же боевым ключом (§10.2).
| Возможность | Состояние |
|---|---|
| Каталог с ценами, наличие батчем | ✅ открыто |
| Баланс и журнал движений | ✅ открыто |
| Подписки на события, подпись, ретраи, повтор | ✅ открыто |
| Размещение заказа POST /v1/orders | ⛔ ещё не открыто — §10.1 |
| Тестовый контур | ⛔ нет — §10.2 |
| Забрать коды заказа GET /v1/orders/{id} | ✅ открыто 07.08.2026 — §5.5. Перечитывать можно бессрочно |
| Список заказов GET /v1/orders | ✅ открыт 07.09.2026 — §5.6. Курсорная пагинация, заказ без движения денег в списке ЕСТЬ |
Проверка получателя /v1/recipients/check | ⛔ не построено — §10.4 |
Контракт заказа (§5) описан полностью и меняться не будет. Открытие боевого приёма заказов пойдёт отдельной строкой в журнал изменений.
1. Быстрый старт
Один путь, без ветвлений. Шаги 1–4 описывают построенные ручки и проходятся боевым ключом уже сейчас; шаг 5 ждёт открытия приёма заказов (§10.1), шаг 6 идёт за ним.
Что нужно перед началом
| Что | Значение |
|---|---|
| Базовый адрес API | https://kodrio.com |
| Ключ доступа | выпускаете в кабинете, раздел «Ключи доступа» (/cabinet/api), после проверки компании (раздел «Компания») |
| Набор скоупов ключа | выбираете при выпуске (§3) |
| Список разрешённых IP | задаёте при выпуске; пустой — замка нет (§3) |
Все пути ниже приписываются к базовому адресу: https://kodrio.com/v1/catalog. Ключ показывается
один раз (§3). ⚠️ Скоупы и список адресов машинной ручкой «какие у меня права» не проверяются —
такой ручки в v1 нет: они видны в кабинете. В примерах ниже адрес и ключ берутся из переменных:
BASE=https://kodrio.com; KODRIO_API_KEY=kodrio_live_…Порядок шагов
Связь с нами — раздел «Поддержка» в кабинете (/cabinet/support). Пришли туда с экрана
заказа — номер заказа подставится в обращение сам. Там же — вопросы по пополнению баланса и по
этой странице.
Шаг 1. Получить ключ. Выпустите его в кабинете, раздел «Ключи доступа», после проверки компании. Сохраните ключ сразу: у нас в базе только хеш, второго способа узнать ключ не существует. Потеряли — выпустите новый и отзовите старый в кабинете.
Шаг 2. Проверить, что ключ живой. (Ниже — ручка баланса; если ваш ключ выдан без
balance:read, проверяйте GET /v1/catalog. Ответ 403 forbidden_scope тоже означает, что
ключ ЖИВОЙ — просто у него нет права именно на эту ручку.)
curl -sS "$BASE/v1/balance" \
-H "Authorization: Bearer $KODRIO_API_KEY"{ "data": { "currency_code": "USD", "available_minor": 150000, "overdraft_limit_minor": 0 } }Деньги счёта — целые в минорных единицах валюты вашей компании (currency_code).
Для USD это центы: 150000 = $1,500.00. Цены каталога — не в ней: price, market_price
и ступени названы в валюте расчёта контура, в центах доллара, при любом currency_code (§4).
🔴 С 31.08.2026 учётная валюта контура — доллар (USD). До этой даты счета велись в рублях, и
*_minor означало копейки. Если ваш currency_code по-прежнему RUB — ваш счёт не переводился, и
деньги счёта (available_minor, overdraft_limit_minor, amount_minor, threshold_minor) у вас
остаются копейками: единицу денег счёта читайте из currency_code ответа, а не из
предположения. Цены (price, market_price, *unit_price_minor) и у такого счёта в центах
доллара — единицы разойдутся (§4). Курс между пополнением и тратой исчез: пополнение в USDT зачисляется один к
одному к доллару.
Шаг 3. Забрать каталог.
curl -sS "$BASE/v1/catalog?brand=STEAM" \
-H "Authorization: Bearer $KODRIO_API_KEY"{
"data": [
{
"sku": "STEAM-TR-100TRY",
"brand": "STEAM",
"region": "TR",
"region_strict": true,
"denomination": 100,
"currency": "TRY",
"delivery_kind": "key_code",
"min_batch": 1,
"code_formats": [
{ "mask": "XXXXXXXXXXXXXXXX", "alphabet": "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" },
{ "mask": "XXXXXXXXXXXXXXXX", "alphabet": "0123456789", "prefix": "ALT-" }
],
"code_format": { "mask": "XXXXXXXXXXXXXXXX", "alphabet": "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" },
"title_ru": "Steam Турция 100 TRY",
"available": true,
"price": 31900,
"market_price": 39900
}
],
"meta": { "next_cursor": null }
}Как понять, что каталог дочитан: next_cursor: null. Непустой курсор приходит только на
ПОЛНОЙ странице (100 позиций) — неполная страница всегда последняя.
Шаг 4. Подписаться на события — §6. Не обязательный, но полезный: вебхук приносит исход сам, и вам не нужно опрашивать статус заказа в цикле.
Шаг 5. Разместить заказ — контракт в §5. Приём заказов ещё не открыт (§10.1).
🔴 Сколько заказ ждёт в очереди — считайте по двум числам, а не по одному. Заказ, который
поставщик не исполнил с первого раза, встаёт в очередь (processing + status_detail: "queued",
§5.3), и попытки мы повторяем сами. Попытка НАЗНАЧАЕТСЯ примерно через 30 секунд (разброс ±20 %),
но забирает назначенное фоновая петля с шагом в 5 минут. Значит верхняя оценка ожидания одной
попытки — около 5,5 минут, и это штатная работа, а не зависший заказ. Дальше пауза удваивается
(30 с → 1 мин → 2 мин …) до потолка в час, но пока она короче шага петли, темп задаёт именно шаг.
Бюджет ожидания в вашем клиенте стройте от 5,5 минут на попытку, а не от 30 секунд — иначе он
объявит провалившимся заказ, который просто ждёт ближайшего тика. Попытки не бесконечны: через
сутки либо после 24 попыток, что наступит раньше, заказ становится failed с полным возвратом (§5.3).
Шаг 6. Забрать коды — GET /v1/orders/{id} (§5.5). Это последний шаг: код на руках.
2. Основания контракта
Семь правил, которые действуют на каждой ручке. Если что-то в описании конкретной ручки противоречит этому разделу — верно то, что здесь.
/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), и классов у них два. Деньги счёта (available_minor,overdraft_limit_minor,amount_minor,threshold_minor) — в валюте вашей компании (currency_code); она берётся из профиля, в запросе её не передают. Одна компания — одна валюта. Цены (price,market_priceи все*unit_price_minor— ступени, заказ, прайс-файл, отказprice-changed) — в центах доллара, валюте расчёта контура, при любомcurrency_code. У счёта не вUSDэти два класса расходятся — §4.Идемпотентность обязательна на создании заказа — §5.2.
Наличие отдаётся булевым:
available: true|false, без остатка и без «осталось мало». Сколько именно можно взять, вы узнаёте отказомnot-enough-stockна конкретном заказе.Отсутствующее поле — это не null. Необязательные поля (
market_price,closed_for_sale,order_idв журнале) появляются, только когда ответ «да». Ни нуля, ниnullвместо них не приходит: и то и другое читалось бы как утверждение, которого мы не делали.
3. Аутентификация
Заголовок и формат ключа
Authorization: Bearer kodrio_live_<40 hex>Схема снимается строго: слово Bearer, один пробел, ключ. Формат ключа —
kodrio_live_ плюс 40 строчных шестнадцатеричных символов. Регистр
значим: ключ, где-нибудь приведённый к верхнему регистру, отвечает 401 без пояснений.
Ключ показывается один раз при выпуске. Ротация — процедура: выпустить новый, отозвать старый; оба действия — в кабинете, раздел «Ключи доступа». Отзыв мгновенный, решение по ключу мы не кэшируем.
🔴 Как хранить. Ключ — предъявитель к деньгам вашей компании: кто им владеет, тот тратит ваш
баланс. Держите его в менеджере секретов или переменных окружения — не в репозитории, не в
тикете, не в переписке, не в логах. В примерах ниже он подставляется из переменной окружения
намеренно: ключ, набранный в командной строке, остаётся в истории оболочки и в логах CI.
Тело события и заголовок X-Kodrio-Signature тоже не логируйте.
Заказы и подписки принадлежат компании, а не ключу. Отзыв одного ключа не прерывает ни доставку событий, ни доступ к прежним заказам с остальных ваших ключей.
🔴 Обратная сторона, и она важнее удобства: подписки ПЕРЕЖИВАЮТ отзыв ключа. Если вы
отзываете ключ из-за подозрения на компрометацию, одного отзыва НЕДОСТАТОЧНО: подписка,
заведённая чужими руками, продолжит слать ваш остаток и все ваши заказы на чужой адрес.
Обязательно откройте GET /v1/webhooks и отзовите всё, чего не заводили (§13).
Скоупы
У ключа набор прав; ручка требует своё. Нет права — 403 forbidden_scope.
| Скоуп | Что открывает |
|---|---|
catalog:read | GET /v1/catalog, GET /v1/catalog/export, GET /v1/stock, GET /v1/recipients/fields |
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; заказом поддержаны все, кроме gift_link (§10.5) |
min_batch | минимальная партия |
code_formats | форматы кода — по нему валидируйте выдачу у себя; только у позиций, выдаваемых кодом, и только там, где формат объявлен; разбор ниже |
code_format | ⚠️ устарел, снимается 18.11.2026 — формат ПОЗИЦИИ без учёта того, кто печатает; приходит и уходит ПАРОЙ с code_formats; разбор ниже |
title_ru | название |
available | можно ли заказать минимальную партию прямо сейчас — см. предупреждение ниже |
price | может отсутствовать — у позиции, цена которой у нас сейчас не определена (заказ по ней получит 422 price-changed). Наша оптовая цена за штуку, минорные единицы валюты расчёта контура — центы доллара при любом currency_code вашего счёта (разбор ниже) — при заказе ниже первой ступени; та же единица, что у market_price |
price_tiers | оптовые ступени «объём → цена», только там, где они есть — см. ниже |
market_price | розничный якорь рынка, только там, где он есть — минорные единицы той же валюты расчёта, что и price |
closed_for_sale | true — позиция закрыта к продаже, только когда «да» |
🔴 price и market_price — В ОДНОЙ ВАЛЮТЕ (изменено 02.09.2026, ломающее). Значит
market_price − price это законная арифметика, и это ровно то, что изменилось: до 02.09 якорь
приезжал рублёвыми копейками рядом с центами, и разрыв между единицами составлял около 86×.
Что это за валюта — говорим прямо. Обе цены каталога названы в валюте расчёта контура, и
сегодня это доллар: price и market_price — центы. Валюта вашего БАЛАНСА живёт отдельным полем
currency_code (GET /v1/balance, §1); с 31.08.2026 умолчание контура — USD, и у всех счетов,
заведённых с этой даты, обе величины совпадают. Если у вашей компании currency_code остался
RUB (такие счета переход 31.08 сохранил и не переводил), напишите нам по каналу связи из §1
прежде чем считать наценку: единица баланса и единица цен каталога у вас разойдутся, и
разойдутся молча. Сам ответ каталога валюту пока не называет — это наш долг, а не ваша забота. Заказ по
такому счёту не пройдёт: POST /v1/orders ответит 403 currency_not_supported до всякого списания (§8).
Откуда берётся якорь. Он измеряется в российской рознице — это цена, по которой ту же карту продают конечному покупателю у нас, — а наружу отдаётся переведённым в валюту расчёта той же курсовой точкой, которой посчитан price. Второй формулы перевода у нас нет, и это не мелочь устройства: пока курс замер, оба числа замирают вместе, поэтому отношение между ними остаётся верным даже тогда, когда каждое из них слегка отстаёт.
⚠️ Если вы считали наценку по старым числам — пересчитайте. В примере из §1 "price": 31900
это $319.00, а "market_price": 39900 — $399.00; оба числа синтетические, но подписаны они
теперь одной валютой, и разность между ними имеет смысл.
Якоря может не быть: там, где розничного замера нет или он несвеж, поля market_price в строке
нет вовсе — не null и не 0 (правило 7 из §2). Полем оно не становится и тогда, когда перевести
замер нечем.
Регион: region_strict и region_countries
🔴 Есть товар, у которого ограничение задано СПИСКОМ стран, а не одной — это игровые ключи.
Их поле region_countries перечисляет страны, где код активируется, кодами ISO 3166-1 alpha-2.
Поля нет вовсе там, где такого списка не существует (гифт-карты) — как и у market_price,
отсутствие поля это не null и не «нигде».
Читать это поле выгодно, а не обязательно. region ВСЕГДА входит в region_countries.
Продаёте только в region — попадаете в страну, где ключ точно работает; читаете список —
продаёте во все страны списка. То есть незнание нового поля стоит вам недопроданных заказов,
а не мёртвого кода у покупателя.
🔴 Тем же списком судит приём заказа. Поле deliver_region строки заказа обязано входить в
region_countries, иначе строка отклоняется кодом region-mismatch. Показанное вам и
проверяемое у нас — одно и то же поле.
{
"sku": "ABYSSUS-RU-VAR-BASE",
"brand": "ABYSSUS",
"region": "RU",
"region_strict": true,
"region_countries": ["AM", "AZ", "BY", "KG", "KZ", "MD", "RU", "TJ", "TM", "UA", "UZ"],
"denomination": "VAR",
"currency": "USD",
"delivery_kind": "key_code",
"min_batch": 1,
"title_ru": "Abyssus, ключ Steam",
"available": true
}🪤 denomination: "VAR" здесь означает «номинала у товара нет как факта» — у игрового ключа его и
не бывает. В прайс-файле (/v1/catalog/export) список стран едет ПОСЛЕДНЕЙ колонкой
region_countries, коды через пробел; у строк-ступеней ячейка пуста.
Формат кода: code_formats (и устаревший code_format)
🔴 ОБОИХ ПОЛЕЙ НЕТ У ПОЗИЦИИ, ВЫДАВАЕМОЙ БЕЗ КОДА (с 28.08.2026 — это topup_by_login и
topup_by_id). Кода у неё не существует: поставщик зачисляет средства прямо на аккаунт, и вам
приезжает подтверждение зачисления. Объявить формат у такого товара значило бы дать вам обещание,
которое §9 велит вам ПРОВЕРЯТЬ, — и вы завернули бы корректное подтверждение уже после оплаты.
Отличать такие позиции надёжнее всего по delivery_kind. 🪤 Если ваш клиент читает
row.code_format.mask без проверки на наличие — добавьте её; у позиций с кодом поле на месте и
ничего не потеряло.
🔴 ОБОИХ ПОЛЕЙ МОЖЕТ НЕ БЫТЬ И У ПОЗИЦИИ С КОДОМ — если формат её кода мы не объявляем (с 03.09.2026). Такое бывает, когда позицию печатает поставщик, чью форму кода мы ещё не измерили: объявить маску наугад значило бы дать вам обещание, которое §9 велит ПРОВЕРЯТЬ, — и вы завернули бы корректный оплаченный код. Молчание здесь — единственный правдивый ответ, и оно временное: как только форма замерена, оба поля возвращаются той же строкой каталога.
Что это значит для вашего клиента, одной фразой: code_formats отсутствует ⇒ проверять
выданный код по формату не нужно и нечем — принимайте его как есть. Поля приходят и уходят
парой, «один есть, другого нет» не бывает по построению. Отличать «кода не будет вовсе» от «код
будет, формат не объявлен» — по delivery_kind, как и раньше.
🔴 code_formats — массив, и проверять выданный код нужно по нему (§9). Выданный код обязан пройти хотя бы один формат из массива; не прошёл ни одного — это дефект на нашей стороне, и мы хотим о нём знать (§13).
Почему массив, а не один объект. Формат кода — свойство пары «позиция × поставщик», а не одной позиции. У каждого поставщика свой станок печати: маска, алфавит и приставка у них разные, и один и тот же артикул, купленный у разных, приходит к вам в разном виде. Кто именно напечатает ваш код, решается в момент заказа — по доступности линии, — поэтому назвать заранее один формат физически нельзя. Массив перечисляет все, которыми позиция может быть напечатана; лишних значений в нём нет.
⚠️ code_format (единственное число) объявлен устаревшим 20.08.2026 и снимается 18.11.2026
(§11, 90 дней). Он описывает формат ПОЗИЦИИ и потому правдив только тогда, когда печатающий
поставщик ничего в нём не меняет: код от другого поставщика он отвергнет, хотя код валиден и
оплачен. Поле продолжает приходить весь переходный период.
Переход — одна строка: вместо «код проходит code_format» проверяйте «код проходит любой из
code_formats».
Поля каждого формата (и в code_formats, и в code_format) — одни и те же:
| Поле | Обяз. | Смысл |
|---|---|---|
mask | да | форма кода: X — знакоместо, всё остальное (-, /, пробел) — разделитель как есть |
alphabet | да | перечень допустимых символов знакоместа, строкой — например ABCDEFGHJKLMNPQRSTUVWXYZ23456789. Это набор, а не его название: символы берите из него буквально |
prefix | нет | литеральное начало кода, в маску НЕ входит |
suffix | нет | литеральный хвост кода, в маску тоже не входит |
example | нет | образец кода для человека. Значением никогда не является, но объявленный формат проходит |
Проверка целиком — это prefix + маска, где каждый X заменён любым символом из alphabet, +
suffix. Ни одно из полей не бывает пустой строкой: необязательного поля просто нет в ответе.
⚠️ Собираете регулярку — экранируйте подставляемые значения: сегодня в наборе только буквы и
цифры, но обещания «здесь не будет символа со значением в регулярке» мы не давали.
"code_formats": [
{
"mask": "XXXXXXXXXXXXXXXX",
"alphabet": "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
},
{
"mask": "XXXXXXXXXXXXXXXX",
"alphabet": "0123456789",
"prefix": "ALT-"
}
]Код первого формата — ровно 16 символов, по числу знакомест маски. Второму отвечает, например,
ALT-9302847715630284: 20 символов — приставка в 4 символа плюс 16 знакомест. Считать
длину по одной маске нельзя — именно на этом расхождении ломается первая самодельная проверка, а
считать её по ОДНОМУ формату из массива нельзя тем более.
🔴 prefix — приставка станка поставщика, и у разных форматов она разная. Часть формата пары:
у одного поставщика её нет вовсе, у другого — есть. Поэтому в боевом каталоге разные элементы
code_formats приходят с разным prefix, и у части из них поле есть. Не пишите валидатор,
который отбрасывает prefix «потому что у нас его никогда не было»: у соседнего формата того же
артикула он будет.
Отсюда правило: берите prefix целиком из ответа, ничего не отрезайте и ничего не достраивайте сами.
⚠️ Буквенные литералы живут в prefix/suffix, а не в маске, и это не украшение: буква X
внутри префикса иначе стала бы знакоместом. Маска состоит только из X и разделителей.
Форматы меняются несопоставимо реже цены — перечитывать их перед каждой сверкой не нужно. Достаточно обновлять вместе с каталогом.
Оптовые ступени price_tiers
Цена за штуку зависит от количества в одной строке заказа. Ступени приходят массивом, по возрастанию количества; поля два и оба обязательные:
{
"sku": "APPLE-DE-10EUR",
"price": 139300,
"price_tiers": [
{ "min_qty": 5, "unit_price_minor": 137200 },
{ "min_qty": 10, "unit_price_minor": 133000 }
]
}Читается так: 1–4 штуки — price, 5–9 — 137 200, от 10 — 133 000. Ступени начинаются со второй
штуки: цену за одну штуку задаёт price и только он.
🔴 unit_price_minor в заказе обязан быть ценой ТОЙ ступени, в которую попадает ваше
количество. Прислали цену другой ступени — строка отобьётся кодом price-changed до списания
денег, и в отказе придёт expected_unit_price_minor с нашим числом (§5.1). Отдельного кода
ошибки для ступеней нет: для нас это обычное расхождение цены.
⚠️ Поля price_tiers нет вовсе, когда ступеней у позиции нет — пустого массива не приходит никогда. Отсутствие поля не значит «объёмных цен не бывает»: сетка может появиться позже.
⚠️ Мы показываем только достижимые ступени. Ступень выше действующего потолка количества в строке (сегодня 50 штук, §5.1) в ответ не попадает: заказать её всё равно нельзя, и печатать цену, по которой мы сами откажем, мы не станем. Вырастет потолок — ступени появятся сами.
Скидку в процентах мы не отдаём: в ответе только цена — то самое число, которым вы эхом подтверждаете заказ.
GET /v1/catalog/export — прайс файлом
Скоуп catalog:read. Тот же каталог, только файлом: CSV, UTF-8 без BOM, разделитель — запятая,
перевод строки CRLF (RFC 4180). Фильтры ?brand=, ?region= работают так же, как у каталога;
пагинации нет — прайс отдаётся целиком, потому что это документ, а не лента.
Одна строка файла = одна цена, а не одна позиция. Базовая цена приходит строкой с min_qty=1,
каждая ступень — своей строкой. Колонки, в этом порядке:
sku,brand,region,region_strict,denomination,currency,delivery_kind,min_batch,
title_ru,available,closed_for_sale,min_qty,unit_price_minor,market_price,region_countriesmarket_price заполнен только у строки min_qty=1: якорь рынка — розничная цена за штуку, к
объёму отношения не имеющая. Единица у него та же, что у unit_price_minor, — минорные валюты
расчёта (§4 выше). Позиция без цены остаётся в файле с пустой ячейкой unit_price_minor
— не вычищайте по этому признаку свой справочник.
⚠️ Форматов кода в файле нет ни в каком виде — ни code_formats, ни устаревшего
code_format. Это вложенные объекты (маска, алфавит, префикс, суффикс, пример), и плоская таблица
их не выражает без пяти лишних колонок на каждый формат каждой строки. Форматы берите из
GET /v1/catalog — они меняются несопоставимо реже цены. Всё остальное, что есть в каталоге, в
файле есть.
🔴 Числа в файле и в GET /v1/catalog совпадают до минорной единицы, потому что это одни и те же числа:
файл печатается из того же снимка каталога, ничего не пересчитывая. Убедиться можно заголовком
X-Kodrio-Snapshot — он приходит и у каталога, и у прайса и называет момент сборки снимка.
Совпал у обоих ответов — вы смотрите на одну и ту же полку; разошёлся — между вашими запросами мы
обновили снимок, снимите оба заново.
Что в X-Kodrio-Snapshot лежит: момент сборки снимка временем ISO-8601 в UTC, с
миллисекундами — 2026-08-12T09:12:44.117Z. Разобрать его как время можно, но сравнивайте
строкой на равенство: заголовок отвечает на вопрос «это одна и та же полка», а не «какая
свежее». Возраст цены измеряется не им, а обещаниями выше (10 и 20 минут). Значение непрозрачным
не станет молча: смена формата — ломающее изменение по §11.
⚠️ Открываете в Excel — импортируйте как UTF-8 («Данные → Из текста/CSV → кодировка UTF-8»), а не двойным щелчком: BOM мы не ставим, потому что он ломает прямые парсеры, а файл прежде всего машинный.
🔴 available: false и closed_for_sale: true — разные вещи, и решение по ним у вас разное.
available: false — «сейчас нельзя заказать» (кончилось, не сходится минимальная партия).
closed_for_sale: true — «мы это не закупаем»: живого канала поставки у позиции нет. Первое —
повод подождать, второе — повод снять карточку у себя. Слить их в одно значило бы заставить вас
годами ждать товар, которого не будет.
🔴 available: true — это обещание min_batch, а НЕ любого количества. Оно означает: цена
есть, вид выдачи мы умеем, и остатка хватает на минимальную партию. Заказ на большее количество
может честно отбиться кодом not-enough-stock (§5.1) — это нормальная работа, а не расхождение с
каталогом: чисел склада мы не отдаём (§2 п.6) НИГДЕ, в том числе в тексте отказа. Практический
способ найти проходящий объём — уменьшать количество, а не рассчитывать узнать остаток от нас.
Каталог отдаётся из кэша: снимок обновляется раз в 10 минут, а если очередная пересборка не
удалась — вам честно отдаётся прежний, но цена в нём никогда не старше 20 минут. За этим
порогом мы цену не показываем ни при каких обстоятельствах: вместо неё придёт 500 (§5.4). То есть
данные могут отставать от нашей полки на треть часа; на этот срок и рассчитывайте частоту
перечитывания.
Оба числа — это ПОТОЛОК, а не настройка, которую мы можем поднять: наша конфигурация умеет только сокращать эти окна, но не удлинять их. «Возраст цены» считается честно — от момента, когда мы в последний раз ПРОВЕРИЛИ курс, а не от момента, когда пересобрали витрину по уже известному курсу. Проверка эта стоит на любом ответе, а не только на том, что пришёл из памяти: пересобрать витрину по замороженному курсу мы можем сколько угодно раз, свежее цена от этого не станет.
Обратная сторона названа честно: пока курс у нас проверить нечем, каталог отвечает 500, а не
показывает старую цену. В редком случае — если наша сторона только что перезапустилась и курс не
успел провериться ни разу — это может случиться сразу, а не через 20 минут. 500 ваш клиент
обязан переживать повтором (§8); заказ по нему проваленным считать нельзя.
🔴 Но одно отставание мы убрали: закрытие позиции по потере канала поставки доходит до вас
МГНОВЕННО. Если поставщик перестал нас пускать или привязка позиции снята, она получает
closed_for_sale: true в первом же вашем запросе, не дожидаясь обновления снимка. Раньше на это
уходило до двадцати минут, и всё это время вы могли отправить нам заказ, который мы не смогли бы
исполнить. Обратное тоже верно: вернувшаяся позиция открывается так же быстро — но только пока у
нас всё в порядке: если в этот момент у нас авария, каталог отвечает 500 (§8), и возврат позиции
вы увидите после того, как наша сторона восстановится.
⚠️ Мгновенно доходит именно потеря канала. Позиция может закрыться и по третьей причине — наши данные о ней просто устарели по календарю; такое закрытие приезжает вместе с обычным обновлением снимка, то есть в пределах тех же 20 минут.
available: false не значит «позиция удалена» — не вычищайте по этому признаку свой
справочник.
GET /v1/stock?skus=A,B,C — наличие батчем
Скоуп catalog:read. Кап — 100 артикулов за запрос, больше → 400 validation_failed.
{
"data": [ { "sku": "STEAM-TR-100TRY", "available": true } ],
"meta": { "unknown_skus": ["STEAM-TR-999TRY"] }
}Регистр артикулов не важен — мы поднимаем его сами, дубли в запросе схлопываются (кап в 100
считается уже после этого). Но в ответе sku приходит в НАШЕЙ канонической форме, заглавными, и
порядок строк ответу запросу не соответствует: сопоставляйте по полю sku, а не по позиции в
массиве.
🔴 Неизвестный артикул не отвечает available: false — он уезжает в meta.unknown_skus.
«Нет в наличии» говорит о нашем товаре; про артикул, которого мы не знаем, мы не утверждаем
ничего. Опечатка в вашем справочнике иначе выглядела бы как вечно отсутствующий товар, которого
вы бы ждали.
GET /v1/recipients/fields?sku= — чем адресуется зачисление позиции
Скоуп catalog:read. Отвечает на вопрос «что класть в строку заказа этой позиции» — до того, как
вы её закажете, и не тратя ни рубля.
{ "data": { "sku": "8BALLPOOL-GLOBAL-VAR-110CASH", "forma": "polya",
"polya": [ { "key": "user_id", "type": "text", "secret": false },
{ "key": "platform", "type": "select", "secret": false, "options": ["ios", "android"] } ] } }forma | Что значит | Что класть в строку заказа |
|---|---|---|
net | получателя у позиции нет по устройству (код или ссылка) | ни recipient, ни recipient_fields |
stroka | одиночный реквизит — кошелёк Steam, звёзды Telegram | recipient |
polya | именованные поля категории, перечислены в polya[] | recipient_fields теми же ключами |
🔴 net — успех, а не ошибка. Спрашивать про любую позицию своего справочника законно, и отличать «мы не знаем» от «спрашивать нечего» по коду отказа вам не придётся.
key — имя, которое вы обязаны прислать обратно в recipient_fields. type — text либо
select; у select рядом лежит options[] с допустимыми значениями. secret: true означает
«поле несёт секрет» (password, email): не показывайте его в открытую и не сохраняйте.
⚠️ Подписей полей мы не отдаём — они пришли бы текстом поставщика, а его наружу мы не выпускаем (§2). Рисуйте свою подпись, откатываясь на само имя ключа.
🔴 Состав полей эта ручка и приём заказа берут одним источником: «что показала ручка» и «что
примет заказ» разойтись не могут. Регистр артикула не важен, в ответе sku приходит в нашей
канонической форме.
🔴 Не смогли узнать — 503 recipient-unavailable, отказ ПОВТОРЯЕМЫЙ. Состав полей знает
поставщик; молчит он или отвечает пустым — мы говорим «повторите позже», а не «полей не нужно» и
не «одна строка»: иначе вы собрали бы партию под форму, которую заказ отвергнет на приёме.
Неизвестный артикул — 404 not_found, пустой sku — 400 validation_failed.
⚠️ Это не проверка получателя. Ручка отвечает «какие поля прислать», а не «годится ли
значение»: valid тут нет и быть не может, ответ одинаков для всех и ни о каком аккаунте не
говорит. Машинной /v1/recipients/check по-прежнему нет — §10.4.
GET /v1/balance — остаток
Скоуп balance:read.
{ "data": { "currency_code": "USD", "available_minor": 150000, "overdraft_limit_minor": 0 } }overdraft_limit_minor — насколько глубоко разрешено уйти в минус; по умолчанию 0. Глубже
списание не пройдёт: заказ отклоняется insufficient-balance до любых эффектов.
Пополнение на волне 1 идёт не через API — попросите по каналу связи из §1.
GET /v1/balance/entries?cursor= — журнал движений
Скоуп balance:read. Append-only, новые сверху, пагинация курсорная.
{
"data": [
{ "operation": "debit", "amount_minor": 63800, "reason": "заказ pord_01J...",
"order_id": "pord_01J...", "at": "2026-08-07T09:12:44.117Z" }
],
"meta": { "next_cursor": "pbe_01J..." }
}🔴 Курсор берите ТОЛЬКО из meta.next_cursor. Собственного id у строки журнала в ответе нет
— поля не существует, и это не пропуск примера: наружу уходят ровно пять полей выше. Передайте
полученный meta.next_cursor в ?cursor= следующего запроса; null означает «журнал дочитан».
⚠️ Курсор — непрозрачное значение: не сочиняйте его сами и не выводите из order_id или at.
Нечитаемый курсор отвечает 400 validation_failed, но сочинённый ПО ФОРМЕ читаемым — например от
чужой строки — честно отдаст не тот кусок журнала, и молча.
operation — debit (списание) либо credit (зачисление: пополнение и любой возврат).
order_id есть у движений по заказу. У движения без заказа (пополнение, ручная
корректировка) поля нет вовсе — не null.
🔴 reason — человеческий текст, а не код. Он написан для человека, читающего журнал,
формулировка меняется без предупреждения, и в него подставлены числа и идентификаторы. Не
разбирайте его регулярками и не сравнивайте строкой. Машинная связь с заказом — поле order_id,
направление движения — поле operation.
Страница журнала — 50 строк, размер не настраивается.
5. Заказы
⛔ Приём заказов через /v1 ещё не открыт — §10.1. Контракт ниже зафиксирован и меняться не будет; пишите клиента по нему.
5.1. Создание
POST /v1/orders, скоуп orders:create, заголовок Idempotency-Key обязателен.
POST /v1/orders
Authorization: Bearer kodrio_live_0000000000000000000000000000000000000000
Idempotency-Key: zakaz-2026-09-21-0001
Content-Type: application/json
{ "lines": [ { "sku": "STEAM-TR-100TRY", "quantity": 2, "unit_price_minor": 31900 } ] }Правила тела:
lines— непустой массив, до 100 строк. Пустой →400 validation_failed.Дубли sku в lines[] запрещены — одна строка = один артикул. Иначе потолок количества обходился бы сотней строк одного артикула.
quantity— до 50 в строке (потолок из конфигурации). Сверх →invalid-quantity. ⚠️ У линии пополнений потолок свой и он равен единице. Позиции, которые зачисляются на чужой аккаунт (STEAMTOPUP-*,TGSTARS-*и прочие формыtopup_by_login), принимаются только по одной штуке в строке: у поставщика для них нет поля количества вовсе. Партия пополнений — отдельная работа, и до неё заказ на две штуки такой позиции вернёт вам деньги целиком (batch-over-cap), а не выдаст половину.unit_price_minor— эхо цены, которую вы видели в каталоге. Обязательно.recipient— получатель ОДНОЙ СТРОКОЙ (с 28.08.2026). Обязателен у позиций линий Steam-кошелька и звёзд Telegram (артикулыSTEAMTOPUP-*,TGSTARS-*), запрещён у позиций, выдаваемых кодом. Поле построчное: в одном заказе вы вправе пополнить разные аккаунты.recipient_fields— получатель ИМЕНОВАННЫМИ ПОЛЯМИ, объект{"ключ": "значение"}(с 28.08.2026). Обязателен у позиций линии пополнения игр и сервисов — это ВСЕ остальные позиции сtopup_by_idиtopup_by_login, — где набор полей объявляет сама категория поставщика (от одного до трёх). Формы не взаимозаменяемы: разбор и способ узнать состав полей — §10.5. Отказы обеих форм:recipient-required·recipient-invalid·recipient-unknown— все в таблице отказов §5.1 ниже.
🔴 Эхо ценой списания не становится никогда. Списание всегда идёт по цене нашего бэкенда;
ваше число только сверяется. Разошлось — заказ отклоняется price-changed до денег, и в
строке отказа приходит expected_unit_price_minor — наша цена на момент отказа, чтобы вам не
пришлось вслепую перечитывать каталог.
⚠️ У price-changed есть вторая причина, и в ней этого поля НЕТ: если цена позиции у нас
сейчас не определена вовсе, отказ придёт с тем же кодом, но без expected_unit_price_minor —
подставлять туда ваше же эхо значило бы подтвердить ваше число как нашу цену. Проверяйте
наличие поля, а не полагайтесь на него: нет поля — перечитайте каталог, позиция могла уйти. Цена, которую вы прочитали в GET /v1/catalog, и цена
списания — одно и то же число, а не два одинаково посчитанных.
Ответы:
| Ответ | Тело | Что значит |
|---|---|---|
202 | {"data":{"order_id":"...","status":"accepted"}} | заказ принят в обработку |
200 | тот же ответ, что в первый раз | повтор того же ключа с тем же телом; ничего не списано повторно |
409 processing | конверт ошибки | ключ занят заказом, исход которого ещё не подтверждён. Повторять безопасно |
409 idempotency_conflict | конверт ошибки | этот ключ уже занят заказом с другим содержимым |
422 rejected | line_rejections[] | заказ отклонён; деньги не тронуты, ключ свободен |
403 currency_not_supported | конверт ошибки | счёт вашей компании не в долларах (currency_code ≠ USD); деньги не тронуты, ключ свободен — §8 |
429 rate_limited | + Retry-After | превышен лимит частоты |
400 validation_failed | конверт ошибки | тело не по схеме или нет Idempotency-Key |
500 internal | + request_id | наша ошибка. Заказ НЕ считать проваленным — §5.4 |
Отказ по строкам:
{
"error": {
"code": "rejected",
"message": "Заказ отклонён. Причины — по строкам.",
"request_id": "9d4f...",
"line_rejections": [
{ "sku": "STEAM-TR-100TRY", "code": "price-changed",
"reason": "цена позиции изменилась: в заказе 31900, у нас 32400 (минорных единиц)",
"expected_unit_price_minor": 32400 }
]
}
}Отказ уровня всего заказа (например, insufficient-balance) приходит строкой с sku: "*".
🔴 Ветвитесь по code, а не по reason. reason — человеческий текст с подставленными
числами, его формулировка меняется без предупреждения. code — закрытый список из таблицы ниже.
Коды отказа строки:
| Код | Что значит | Что делать |
|---|---|---|
unknown-sku | такого артикула у нас нет | сверить справочник |
invalid-quantity | количество выше потолка 50 | разбить на заказы |
batch-over-cap | в line_rejections не приходит: это исход ПОСЛЕ приёма — партия больше того, что принимает линия поставщика (у пополнений потолок — 1 штука); заказ завершается failed с полным возвратом, код стоит в причине возврата | заказать по одной |
below-min-batch | меньше минимальной партии | добрать до min_batch |
not-enough-stock | остатка не хватает на это количество | уменьшить количество или подождать |
invalid-price | сумма строки выше нашего потолка на одну строку | сверить количество и цену с каталогом |
price-changed | эхо разошлось с нашей ценой либо цена позиции у нас не определена | есть expected_unit_price_minor — повторить с ним; поля нет — перечитать каталог |
closed-for-sale | позиция закрыта к продаже — живого канала поставки нет | снять карточку у себя |
supplier-unavailable | закупка по этому заказу сейчас недоступна — временное состояние на НАШЕЙ стороне | повторить позже, карточку НЕ снимать |
kind-not-supported | вид выдачи виден в каталоге, но адаптера заказа у нас на него нет (сегодня это gift_link) | §10.5 |
recipient-required | у позиции пополнения не назван получатель | добавить recipient либо recipient_fields — что именно, скажет текст отказа |
recipient-invalid | получатель не по правилу линии · назван НЕ ТОЙ формой (строка вместо полей или наоборот) · назван у позиции, выдаваемой кодом | поправить строку заказа; недостающие поля перечислены в reason |
recipient-unknown | поставщик не подтвердил такой аккаунт | проверить логин у покупателя |
insufficient-balance | не хватает денег с учётом овердрафта | пополнить |
region-unknown, region-mismatch | через /v1 недостижимы — регион задан самим артикулом, у вас его никто не спрашивает | не писать обработчик |
Остаток склада reason у not-enough-stock не называет и называть не будет: числа склада
наружу не выходят (§2 п.6). В тексте отказа только ваше собственное число — сколько вы заказали.
Точный доступный объём мы не отдаём ни каталогом, ни отказом.
🔴 Граница между 400 и 422 — не вкус, и знать её надо заранее. 422 со строками — это
«тело верное, но заказ не проходит по существу». Всё, что не по СХЕМЕ, отсекается раньше и
отвечает голым 400 validation_failed без line_rejections[] — то есть без объяснения, какая
именно строка виновата (причину наружу мы не отдаём намеренно). В 400 попадают: нецелое,
нулевое или отрицательное quantity; нецелое, нулевое или отрицательное unit_price_minor;
отсутствие любого из трёх полей строки; пустой или длиннее 100 строк lines[]; дубли sku;
sku не строкой или длиннее 128 символов; recipient не строкой или длиннее 100 символов;
recipient_fields не объектом (массив, строка, null), либо со значением не-строкой, либо более
чем с десятью полями, либо с пустым или длиннее 64 символов именем поля, либо со значением длиннее
100 символов; отсутствие заголовка Idempotency-Key, его длина свыше 255 символов, а также ключ
идемпотентности, начинающийся с sandbox: — этот префикс зарезервирован нами.
Проверяйте эти условия у себя до отправки — иначе отладка идёт вслепую.
🪤 Обратите внимание: recipient_fields — объект, а не массив. Поставщик перечисляет поля
массивом, и повторить его форму — естественная догадка; мы принимаем именно объект
{"user_id": "123"}, массив отвергается как 400.
⚠️ У ключа идемпотентности обрезаются ведущие и хвостовые пробелы: "ord-1 " и "ord-1" — это
ОДИН И ТОТ ЖЕ ключ, а не два разных заказа.
Частичной выдачи в v1 нет: отказ любой строки = отказ всего заказа. Принятый заказ либо выдаётся целиком, либо деньги возвращаются целиком.
5.2. Идемпотентность
Зачем. Сеть рвётся, ответы теряются. Ключ — единственный способ отличить «второй заказ» от «тот же заказ, повторённый». Без него безопасного повтора не существует, поэтому заголовок обязателен, а не опционален.
Ключ — строка до 255 символов, своя на каждый заказ. Годится ваш собственный номер заказа, если он не переиспользуется.
Повтор с тем же ключом и тем же телом отдаёт
200с прежним ответом. Второй раз не спишет.Повтор с тем же ключом и другим телом →
409 idempotency_conflict. Это защита: считать такой запрос новым заказом значило бы списать дважды по вашей же опечатке.Ключ действует, пока существует его строка, и не менее 24 часов. 🔴 Не переиспользуйте ключ «через сутки, он уже истёк» — он не истекает по времени. Повтор старого ключа с тем же телом отдаст прежний заказ, и вы сочтёте размещённым новый.
После 422 rejected ключ свободен. Отказ не записывается: вы правите тело и повторяете тем же ключом, заказ оценивается заново.
409 idempotency_conflictвозможен только по ключу, который уже занят принятым (202) заказом.
🔴 «То же тело» считается по lines[], и только по ним. Сверяются пять полей каждой строки —
sku, quantity, unit_price_minor, recipient и recipient_fields (с 28.08.2026); порядок строк
и порядок ключей внутри recipient_fields значения не имеет
(перестановка описывает тот же заказ). Отсутствующий recipient и пустая строка — одно и то же;
отсутствующий recipient_fields и пустой объект — тоже. 🪤 Получатель входит в сверку намеренно: тот же артикул
в том же количестве по той же цене, но на ДРУГОЙ логин — это другой заказ и другие деньги на
другой аккаунт. Не входи он в канон, повтор с исправленным логином вернул бы вам ответ первого
заказа, и вы сочли бы исправление принятым. Заголовки в сверку тела не входят.
5.3. Статусы заказа
accepted → processing → delivered | failed | refunded🔴 accepted — это статус ОТВЕТА на размещение, а не ответ ручки чтения. Он приходит ровно в
теле 202 (и 200 на повторе) у POST /v1/orders. GET /v1/orders/{id} его не возвращает
никогда, даже если спросить сразу после 202: оттуда приходит либо processing, либо уже
терминальный статус (быстрая выдача успевает завершиться до вашего первого чтения). Обрабатывать
accepted как нетерминальный — верно; писать ветку «вдруг придёт из чтения» — не надо.
processing — не отказ. Выдача асинхронна. Заказ в
processingнельзя помечать у себя проваленным: терминальны только три правых статуса.delivered— выдан.failed— не выдали, деньги вернулись автоматически; возврат виден отдельной записью в журнале баланса.refunded— возврат после выдачи (отзыв кода, претензия).Заказ обязан дойти до терминального статуса. Зависшие добивает наша сверочная петля: сначала повторной попыткой выдачи, и только при невозможности выдать —
failedс возвратом.По заказу приходит одно терминальное событие. Единственный разрешённый переход после терминала —
delivered → refunded.🟢 status_detail при processing (появилось 08.08.2026). Необязательное поле; когда оно есть, оно объясняет, ЧТО происходит с заказом: ·
queued— заказ ждёт очереди на выдачу (поставщик временно не отдаёт коды, мы повторяем по расписанию); ·stuck— ждёт дольше обычного (свыше 15 минут). Оба значения — не отказ, и реакция на них одна: перечитать статус позже. Поля НЕТ, когда объяснять нечего, — отсутствие поля не означает проблемы. Мы не обещаем, что список значений останется из двух: новые добавляются без депрекейта, поэтому незнакомое значение читайте как «простоprocessing».
5.4. Что делать, если наш ответ не пришёл
Таймаут, обрыв соединения или 500 на POST /v1/orders:
Повторите запрос с тем же Idempotency-Key. Это безопасно всегда — второй раз он не спишет.
⚠️ Одно исключение, пока приём заказов не открыт (§10.1): сейчас 500 приходит на КАЖДЫЙ
заказ, и это постоянное состояние, а не сбой. Повтор его не лечит. Правило ниже — про настоящую
аварию, то есть про отказ на части запросов при работающих остальных ручках.
🔴 Никогда не создавайте повтор с новым ключом, не выяснив судьбу старого. Новый ключ — это новый заказ, и вы получите два списания за один заказ своего клиента.
Тот же порядок годится для 409 processing, но с паузой: повторяйте не чаще раза в минуту.
Чтобы выяснить судьбу заказа, номер которого у вас есть, повтор заказа не нужен — читайте §5.5.
Типовой заказ разрешается за секунды; заказ, зависший из-за обрыва на нашей стороне, добивает
наша сверочная петля, и это может занять до 20 минут. Плотный цикл здесь не ускорит ответ, а
выжжет вашу квоту на создание заказов (§7) — и 429 получат все ваши интеграции, включая
здоровые.
🔴 «Не чаще раза в минуту» — это про ПОВТОР POST /v1/orders, и это рекомендация, а не отдельный лимит. Технически создание заказов ограничено 30 запросами в минуту на компанию, чтение — 120 (§7); машинного «раз в минуту» не существует нигде, ни на записи, ни на чтении. Пауза названа потому, что чаще спрашивать НЕЧЕГО: заказ, ушедший в очередь, продвигает фоновая петля с шагом в 5 минут (§1), и ответ раньше её тика не изменится. Темп чтения статуса — §5.5.
5.5. Забрать коды — статус заказа и выдача
GET /v1/orders/{id} · скоуп orders:read. Это источник правды о заказе: вебхук (§6)
уведомляет, а отвечает эта ручка. Разойтись они не могут — статус в обоих считается одним и тем же
правилом.
curl -sS "$BASE/v1/orders/pord_01K1Z8Q0EXAMPLE" \
-H "Authorization: Bearer $KODRIO_API_KEY"{
"data": {
"order_id": "pord_01K1Z8Q0EXAMPLE",
"status": "delivered",
"items": [
{ "sku": "STEAM-TR-100TRY", "quantity": 2, "codes": ["TEST-ABCDE-FGHIJ", "TEST-KLMNO-PQRST"] }
]
}
}items приходит ТОЛЬКО у delivered. У
processingиfailedэтого поля нет вовсе — не пустой массив, а именно нет. Пустой список вы прочитали бы как «выдача закончилась, кодов ноль».По одной позиции на SKU,
quantityвсегда равен длинеcodes— отдельного счётчика, который мог бы разойтись с массивом, мы не отдаём.Статусы — §5.3. Сегодня достижимы
processing,deliveredиfailed;acceptedиз этой ручки не приходит никогда (§5.3). Уprocessingрядом может стоятьstatus_detail(queued/stuck) — см. §5.3.Валидируйте коды по code_formats из каталога (§4), а не регуляркой «на глаз» и не по устаревшему
code_format. Код обязан пройти любой один формат из массива: печатал его один из наших поставщиков, и чей именно станок сработал — решается в момент заказа. Формат описывает код ЦЕЛИКОМ, вместе сprefix: если у формата есть приставка, она входит в код. Отрезать её перед сверкой не надо. ⚠️ Проверка по единственномуcode_formatотвергнет валидный оплаченный код, если позицию напечатал не тот поставщик, чей формат стоит в этом поле. Это и есть причина депрекейта (§11).
🔴 Перечитывать можно бессрочно, сколько угодно раз. «Показать один раз» на пути API нет и не планируется: правда о заказе живёт у нас, а не в одном ответе, который вы могли не получить из-за обрыва связи. Срок хранения мы не ограничиваем; появись однажды такое ограничение — оно пойдёт депрекейтом по §11, то есть не раньше чем через 90 дней после объявления.
⚠️ 404 not_found означает ровно одно: «такого заказа у вас нет». Мы намеренно не различаем «не существует» и «чужой». Если вы уверены, что заказ ваш, — проверьте номер заказа и то, что ключ выпущен вашей компанией: заказы читаются любым её боевым ключом (§3); тестовый ключ боевых заказов не видит (§10.2).
⚠️ processing — не отказ и не «потерялся». Так отвечает заказ, принятый (202), но ещё не
дошедший до терминального исхода. Если рядом стоит status_detail: "queued" или "stuck" — заказ
ждёт выдачи у поставщика; мы повторяем попытки сами, вмешательства с вашей стороны не нужно.
Темп опроса этой ручки. Отдельного ограничения «раз в минуту» на чтение НЕТ: действует общая квота чтения — 120 запросов в минуту на компанию (§7). Раз в минуту — наш совет, и вот его цена: заказ в очереди продвигает петля с шагом в 5 минут (§1), поэтому опрос раз в секунду вернёт то же самое пятьсот раз подряд и сожжёт квоту, общую со всеми вашими интеграциями. Разумный бюджет ожидания — от 5,5 минут на попытку; надёжнее опроса — вебхук (§6), он приносит исход сам.
5.6. Перебрать свои заказы — список
GET /v1/orders?cursor=&limit= · скоуп orders:read, тот же, что у чтения одного заказа: это одно
право — «перечитать свои заказы».
curl -sS "$BASE/v1/orders?limit=50" \
-H "Authorization: Bearer $KODRIO_API_KEY"{
"data": [
{
"order_id": "pord_01K1Z8Q0EXAMPLE",
"status": "delivered",
"created_at": "2026-09-01T09:00:00.000Z",
"sandbox": false,
"currency_code": "USD",
"amount_minor": 700,
"lines": [{ "sku": "STEAM-TR-100TRY", "quantity": 2 }]
}
],
"meta": { "next_cursor": "pord_01K1Z8Q0EXAMPLE" }
}Новые сверху. Порядок — по номеру заказа убыванием; номер растёт со временем, поэтому это же и хронология.
Пагинация курсорная (§2.7): передайте
meta.next_cursorв?cursor=— получите следующую страницу.next_cursor: nullозначает, что дальше ничего нет. Заказ, размещённый МЕЖДУ вашими запросами, на следующие страницы не попадёт и ничего с них не вытеснит: он получает больший номер и остаётся на первой.limit— до 200, по умолчанию 50.created_at — момент ПРИНЯТИЯ заказа, тот же, от которого мы считаем «застрял» (§5.3), а не момент, когда записан исход.
status и status_detail — те же значения и по тому же правилу, что у GET /v1/orders/{id} (§5.3, §5.5). Разойтись они не могут: статус в обеих ручках считает один и тот же код.
lines — состав БЕЗ кодов. Коды отдаёт только
GET /v1/orders/{id}, по одному заказу за запрос (§5.5), и это граница, а не экономия: список — перебор, и коды в нём означали бы, что один запрос выносит весь ваш товар за всё время.sandbox — контур заказа. Своим ключом вы видите только свой контур (§10.2), так что для машинного пути это поле постоянно; оно есть затем, что то же тело показывает и кабинет, а он видит оба.
🔴 Заказ, не стоивший денег, в этом списке ЕСТЬ. Это главное, чем список отличается от свода по журналу баланса, который мы советовали раньше. Отложенный, стоящий в очереди и возвращённый заказ в журнал движений либо не попадает вовсе, либо попадает парой строк — то есть свод по деньгам молча терял часть ваших партий. Ось этого списка — сами заказы, а деньги приходят к ним третьим источником.
⚠️ currency_code и amount_minor — необязательная ПАРА. Их нет ни у одного заказа, по
которому мы не можем назвать сумму: так выглядит партия старше окна хранения служебных записей, за
которую денег не двигалось ни разу. Это «сумму назвать нечем», а не «заказ был бесплатным» —
поэтому поля именно отсутствуют, а не приходят нулём. lines отсутствует по той же причине и
означает «состав не сохранился».
⚠️ Фильтра ?status= у списка нет — почему и что делать, см. §10.3.
6. Вебхуки
Мы шлём события на ваш адрес, чтобы вы узнавали об исходе, не опрашивая нас.
🔴 Вебхук — уведомление, а не источник правды. Правда живёт у нас, и её читают ручками чтения. Не стройте на одном лишь вебхуке денежных решений.
6.1. Подписка
POST /v1/webhooks, скоуп webhooks:manage + читательское право на каждое событие (§3).
{ "url": "https://hooks.example.com/kodrio", "events": ["order.delivered", "order.failed"] }Ответ 201 — единственный раз, когда вы видите секрет подписи:
{ "data": { "id": "pwh_01J...", "url": "https://hooks.example.com/kodrio",
"events": ["order.delivered","order.failed"], "secret": "<секрет подписи>" } }Восстановить секрет нельзя: у нас он лежит зашифрованным. Потеряли или меняете — читайте порядок ниже, он не такой, как кажется.
🔴 Ротация секрета без потери событий. Порядок только такой:
поднимите ВТОРОЙ адрес приёмника (например
/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 · sandbox · url · events[] · secret_prefix · secret_last4 · created_at · revoked_at · last_success_at · consecutive_failures · last_http_status · last_failure_code. Секрета в списке нет ни в каком виде.
sandbox — служебное поле; у подписок, заведённых боевым ключом, оно всегда false.
🔴 В списке есть и ОТОЗВАННЫЕ подписки — живая та, у которой revoked_at: null. Проверка
«подписка на этот адрес уже есть» без этого условия сочтёт канал настроенным, когда его нет.
consecutive_failures, last_http_status и last_failure_code — здоровье вашего приёмника;
это единственный машинный способ увидеть, что он разваливается.
6.2. События волны 1
| Событие | data |
|---|---|
order.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": "USD" }
}К data события заказа может добавиться служебное поле sandbox; в ваших событиях его нет.
Порог balance.low по умолчанию выключен — пока он не назначен, события не будет. Молчание здесь означает «порог не настроен», а не «денег достаточно».
🔴 Назначить порог через /v1 сегодня нельзя — машинной ручки настроек в v1 нет. Назовите
нужное значение по каналу связи из §1, мы выставим его. Пока порог не выставлен, подписка на
balance.low примется (201), но не пришлёт ни одного события — не считайте её работающим
предупреждением о деньгах.
В каких единицах называть порог: в минорных, как и все деньги в этом контракте (§1) —
500000 означает 5 000,00 в валюте вашей компании (для USD-счёта это $5,000.00), а не пятьсот
тысяч: единицу берите из currency_code ответа, а не из предположения (§1). Тем же числом порог
приезжает обратно полем threshold_minor события, так что проверить, что мы поняли вас верно,
можно первым же событием.
Как выбрать значение. Порог — это не «мало денег», а «времени на пополнение осталось впритык», поэтому считается он от вашего расхода, а не от круглой суммы: возьмите средний расход за сутки и умножьте на срок, который у вас уходит на пополнение. Пополнение на волне 1 идёт по каналу связи (§1), то есть требует живого человека с обеих сторон — сутки-двое запаса разумнее часа. Порог сверяется с остатком после каждого вашего заказа — любого исхода, включая отказ по нехватке денег.
⚠️ Событие приходит ОДИН раз на пересечение вниз, а не на каждый заказ ниже порога. Пока
остаток не поднялся обратно на порог или выше, второго balance.low не будет. Поднялся — при
следующем заказе оповещение молча взводится заново; отдельного события «денег снова хватает» мы не
шлём, это видно по балансу. Практическое следствие: не считайте молчание подтверждением, что денег хватает, и не
стройте на этом событии единственную защиту от пустого кошелька — читайте GET /v1/balance.
6.3. Подпись
Заголовок X-Kodrio-Signature: sha256=<HMAC-SHA256(секрет, сырое тело)>.
Рядом едут заголовки-удобства — X-Kodrio-Event, X-Kodrio-Event-Id, X-Kodrio-Attempt.
🔴 Они не подписаны. Дедуп и любые решения — по event_id из тела, не из заголовка.
🔴 Подпись не содержит времени, поэтому дедуп у вас обязателен, и он обязан быть
ДОЛГОВЕЧНЫМ. Захваченный валидный запрос иначе воспроизводится бессрочно. Храните обработанные
event_id в таблице с UNIQUE, а не в памяти процесса: рестарт приёмника обнулил бы дедуп ровно
тогда, когда идут наши ретраи. Дополнительно отбрасывайте события, у которых created_at старше
15 минут.
🔴 Считайте HMAC по сырым байтам тела, до разбора JSON. JSON.stringify(JSON.parse(x))
возвращает другую последовательность байтов (порядок ключей, пробелы, экранирование, точность
чисел), и честная подпись перестанет сходиться. Это самая частая ошибка приёмника.
const crypto = require("crypto")
// express.raw, а не express.json: нужен нетронутый буфер тела.
// 🔴 express.json(), смонтированный ВЫШЕ по цепочке, разберёт тело раньше — express.raw тогда
// пропустит запрос, req.body окажется объектом, и hmac.update(req.body) бросит TypeError.
// Симптом: 500 на каждое событие и сутки ретраев, а не «подпись не сошлась». Монтируйте этот
// маршрут ДО глобального express.json() либо исключайте его путь из него.
app.post("/kodrio", express.raw({ type: "application/json" }), (req, res) => {
const ozhidaem = "sha256=" + crypto.createHmac("sha256", process.env.KODRIO_WEBHOOK_SECRET)
.update(req.body).digest("hex")
const prislano = String(req.headers["x-kodrio-signature"] || "")
const a = Buffer.from(ozhidaem), b = Buffer.from(prislano)
// Сравнение постоянного времени: обычное `===` подсказывает подбирающему длину общего префикса.
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return res.sendStatus(401)
const sobytie = JSON.parse(req.body.toString("utf8"))
if (uzheObrabotano(sobytie.event_id)) return res.sendStatus(200) // дедуп обязателен, см. §6.4
// 🔴 ОТВЕЧАЕМ РАНЬШЕ, ЧЕМ ОБРАБАТЫВАЕМ (требование §6.4). У нас дедлайн 10 секунд по стенным
// часам: обработка внутри запроса однажды упрётся в него, мы разорвём соединение и пришлём
// событие снова — параллельно первой, ещё не завершившейся обработке. Дедуп от этого не спасёт:
// отметка о ней ещё не поставлена.
res.sendStatus(200)
vOchered(sobytie)
})6.4. Доставка, ретраи, порядок
Ответ 2xx за 10 секунд = доставлено. Всё остальное (включая
3xx) — неуспех.Ретраи с экспоненциальной паузой и разбросом ±20 %: первая пауза 30 с, дальше удвоение до потолка в 1 час. Окно ретраев — 24 часа от рождения события; дальше событие остаётся в журнале, но само уже не придёт. Поднять его повтором можно, только если вы знаете его
event_id, то есть если оно до вас хоть раз доехало — ограничения повтора в §6.1.🔴 Доставка at-least-once, а не exactly-once. Одно событие может прийти дважды — дедуп по event_id обязателен на вашей стороне.
🔴 Порядок доставки не гарантируется. Упорядочивайте по
created_atсами; не полагайтесь на то, чтоorder.deliveredпридёт раньше следующего события.Журнал доставок хранится 30 дней.
Отвечайте 2xx сразу, а обрабатывайте асинхронно: 10 секунд — это дедлайн по стенным часам, и
медленный приёмник сам себе устраивает ретраи.
7. Лимиты частоты
На каждом успешном ответе и на 429 приходят заголовки. На отказах доступа (401, 403)
их нет: запрос отбивается раньше, чем считается квота.
X-RateLimit-Limit: 120
X-RateLimit-Remaining: 118
X-RateLimit-Reset: 41X-RateLimit-Reset — число СЕКУНД до сброса окна, а не отметка времени unix. Это расходится
с привычкой многих API, поэтому названо прямо: 41 значит «через 41 секунду», а не «1970 год».
Превысили — 429 rate_limited плюс Retry-After, тоже в секундах. Стройте темп по Remaining,
а не по 429.
Стартовые значения (не обещание — пересматриваются, изменение пойдёт строкой в журнал):
| Категория | Ручки | Лимит |
|---|---|---|
| чтение | каталог, прайс файлом, наличие, баланс, журнал, чтение заказа GET /v1/orders/{id}, список заказов GET /v1/orders, реестр подписок | 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 | ключа нет / неверен / отозван |
| 403 | forbidden_scope | нет нужного скоупа; при подписке в reason — недостающие скоупы |
| 403 | ip_not_allowed | адрес вне списка, разрешённого ключу |
| 403 | verification_required | компания не прошла проверку. Боевой ключ не допускается на денежный путь (POST /v1/orders) до проверки компании — сумма заказа роли не играет. Каталог, склад и остаток читаются как обычно. В message — что делать и ссылка на раздел «Компания» в кабинете |
| 403 | currency_not_supported | заказы по счёту не в долларах не принимаются. Цены каталога и списание ведутся в центах доллара; у счёта с currency_code, отличным от USD, единицы разошлись бы, поэтому POST /v1/orders отклоняется до денег, ключ идемпотентности свободен. Каталог, склад и остаток читаются как обычно. Напишите нам — переведём счёт |
| 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) |
| 422 | recipient-invalid | получатель не годится — назван не по правилу линии либо поставщик его не подтвердил. Чинится правкой логина в своей строке; повторять без изменений бессмысленно. Через /v1 сегодня недостижим — ручки ещё нет (§10.4); построчный отказ с тем же именем внутри заказа — §5.1 |
| 503 | recipient-unavailable | состав полей получателя узнать не смогли (GET /v1/recipients/fields, §4): схему знает поставщик, и он молчит либо отвечает пустым. Отказ ПОВТОРЯЕМЫЙ — повторите позже без изменений. Мы намеренно не отвечаем «полей не нужно» и не «одна строка»: под такую форму вы собрали бы партию, которую заказ отвергнет |
| 429 | rate_limited | лимит частоты, пауза в Retry-After |
| 500 | internal | наша ошибка. Заказ, если он был создан, НЕ считается проваленным (§5.4). ⚠️ На POST /v1/orders сегодня это ПОСТОЯННОЕ состояние, а не авария — §10.1 |
| 503 | secret_not_configured | мы не настроены выпускать подписки. Повторить позже без изменений |
| 503 | sandbox_not_available | ключ снятого типа — тестовый, выпущенный до 28.09.2026: баланс, журнал и заказы он больше не обслуживает (§10.2). Повтор не поможет — выпустите боевой ключ в кабинете (§1) |
🔴 500 и 503 — разные ответы, и реагировать на них надо по-разному. 500 — мы сломались:
повторите (заказ — тем же ключом) и сообщите нам request_id по каналу связи из §1 — кроме 500 на
корректный заказ, пока приём не открыт: это постоянное состояние (§10.1). 503 — мы не настроены: запрос
верен, повторите позже без изменений, писать не нужно, мы уже знаем. Исключение — sandbox_not_available:
его лечит не повтор, а боевой ключ.
9. Формат кодов и выдача
Заказом поддержаны
key_code,game_key,topup_by_idиtopup_by_login; без адаптера заказа осталсяgift_link— он в каталоге виден и отвергаетсяkind-not-supported(§10.5).🔴 «Вид выдачи поддержан» ≠ «эту позицию можно купить сейчас». Заказуемость конкретной позиции всегда смотрите по
availableиclosed_for_sale, а не по этому перечню: у позиции может не быть цены, остатка или открытой у поставщика категории.Форматы кода описаны в каталоге полем
code_formats— валидируйте выдачу у себя по нему, а не регуляркой «на глаз». Разбор полей и пример проверки — §4 «Формат кода».🔴 Формат — свойство ПАРЫ «позиция × поставщик». У каждого поставщика свой станок печати, а кто напечатает ваш код — решается в момент заказа. Поэтому форматов приходит массив, и выданный код обязан пройти хотя бы один из них. Единственное поле
code_formatописывало формат позиции, было правдой не про всякую выдачу и снимается 18.11.2026 (§11).🔴 alphabet — это перечень допустимых символов строкой (
ABCDEFGHJKLMNPQRSTUVWXYZ23456789), из которого вы строите класс символов. Не название набора и не диапазон в записи регулярки: подставляйте его в проверку буквально.🔴 Код проверяется ЦЕЛИКОМ, вместе с prefix и suffix, а не одной маской: код длиннее маски ровно на них — снимать их перед сверкой не нужно и не следует (§4).
🔴 Код, не прошедший НИ ОДИН объявленный формат, к вам не приедет. На нашей стороне выдача сверяется с форматом той пары, которая его напечатала, и несовпадение закрывает заказ: деньги возвращаются, код партнёру не уходит. Обещание §1 «мы не принимаем заказ, который не исполним» распространяется и на вид выданного кода.
🔴 Коды отдаёт GET /v1/orders/{id} (§5.5), и только она. Ни ответ на заказ, ни событие
order.deliveredкодов не несут и нести не будут: в ответе на заказ —order_idи статус, в событии —order_id, статус и сумма. Причина не техническая: событие уходит на ваш адрес по сети, которую мы не контролируем, а код — это товар. За ним нужно прийти со своим ключом.Срок хранения не ограничен. Перечитывать выданное можно бессрочно и сколько угодно раз; «показать один раз» на пути API нет. Появись однажды ограничение — оно пойдёт депрекейтом по §11 (не раньше 90 дней после объявления). Сохранять коды у себя всё равно стоит: это ваш товар, и зависеть от чужой доступности в момент выдачи покупателю незачем.
10. Чего v1 не обещает
Честный список дешевле разбирательства. Всё, что здесь, — либо ещё не построено, либо намеренно
не входит в v1. Появится — пойдёт строкой в журнал изменений.
10.1. Приём заказов ещё не открыт
Контракт §5 зафиксирован и меняться не будет.
🔴 Как это выглядит в ответе, и почему это исключение из §8. Пока боевой приём не открыт,
корректный заказ боевым ключом получает 500 internal — и это ПОСТОЯННОЕ состояние, а не наша
авария.
Значит, к нему НЕ применяются обычные советы про 500 (§5.4 и §8): повторять такой заказ
бессмысленно — ответ не изменится ни через секунду, ни завтра, — и сообщать нам о нём не нужно,
мы знаем. Отличить одно от другого просто: корректный заказ — артикулы существуют, цена
совпадает с каталогом, количество в пределах потолка — сегодня получает 500 ВСЕГДА. Если в
ответе 422, это не оно: разбирайтесь по line_rejections[], до замка дело не дошло.
Ваши деньги не тронуты ни в одном сценарии — отказ наступает до списания, и ключ идемпотентности остаётся свободным.
Открытие приёма — отдельная строка в журнале изменений.
10.2. Тестового контура нет
Тестового контура у v1 нет — ни отдельного адреса, ни тестовых ключей. Первый заказ идёт боевым
ключом, как только открыт приём (§10.1). Тестовый ключ, выпущенный до 28.09.2026, читает каталог, но форматы
кодов приходят ему с приставкой тестового контура, которой у боевых кодов нет; подписку он заводит, но событий на
неё не будет; боевых заказов он не видит, а на баланс, журнал и заказ получает 503 sandbox_not_available
(§8). Переведите на боевой ключ всю интеграцию, включая каталог и вебхуки.
10.3. У списка заказов нет фильтра по статусу
Сам список открыт 07.09.2026 (§5.6) — то, чего у него пока НЕТ, это параметр ?status=.
Причина не в очереди задач, а в устройстве: статус заказа у нас не хранится отдельным полем, он считается в момент ответа из журнала выдач, маркера приёма и очереди дожатия. Отобрать по нему в запросе нечем, а отбор уже собранной страницы сделал бы пагинацию лживой: вы получили бы три строки из пятидесяти вместе с курсором, указывающим на пятидесятую, и решили бы, что «в работе» у вас ровно три партии. Фильтр приезжает вместе с машиной состояний заказа; до неё честнее не иметь его, чем иметь сломанным.
Практический обход на сегодня: страница отдаёт status каждой строки, отбирайте на своей стороне
— порядок и курсор при этом остаются верными.
10.4. Проверки получателя /v1/recipients/check ещё нет
Ручка нужна видам выдачи «пополнение по логину/ID». С 28.08.2026 пополнение по логину заказуемо, и
получателя мы проверяем САМИ — среди предикатов заказа, ДО списания (§5.1): опечатка отвергается
кодом recipient-unknown, а не превращается в деньги на чужом аккаунте. Отдельная машинная ручка
предварительной проверки приезжает своей волной; до неё безопасный порядок — отправить заказ и
прочитать отказ, он бесплатен и приходит до денег.
⚠️ Не путайте её с GET /v1/recipients/fields (§4), которая приехала 31.08.2026. Та отвечает
«какие поля прислать», эта — «годится ли значение»; состав полей известен, годность аккаунта по
машинному пути по-прежнему судит сам заказ.
Для key_code получателя не существует по устройству, и такой запрос отвечает
422 kind-not-supported.
10.5. Что заказуемо по видам выдачи
Заказом поддержаны key_code, game_key, topup_by_id, topup_by_login. Без адаптера заказа
остался gift_link: он в каталоге виден и отвергается kind-not-supported. Мы показываем такие
позиции честно, а не прячем — ассортимент реален.
🔴 Перечень отвечает «мы умеем это заказать», а НЕ «это продаётся сегодня». Заказуемость
позиции — available и closed_for_sale, и она решается другими вещами: есть ли цена, есть ли
остаток, открыта ли у поставщика категория, включена ли линия. Позиция поддержанного вида вполне
может прийти closed_for_sale: true.
🔴 Позиции БЕЗ кода требуют реквизит получателя, и он у линий РАЗНЫЙ (§5.1):
🔴 Форму реквизита задаёт ЛИНИЯ, а не delivery_kind — и это важно, потому что одна и та же форма выдачи встречается у обеих линий:
| Линия | Как узнать | Реквизит в строке заказа |
|---|---|---|
| Пополнение кошелька Steam и звёзды Telegram | артикулы STEAMTOPUP-* и TGSTARS-* (сегодня их девять) | recipient — одна строка (логин / @username) |
| Пополнение игр и сервисов (всё остальное) | любой другой артикул с topup_by_id или topup_by_login | recipient_fields — ИМЕНОВАННЫЕ поля категории |
⚠️ Не судите по delivery_kind. Позиций topup_by_login сегодня 321, и из них лишь девять —
Steam и Telegram; остальные 312 («Auto Via Login») принадлежат линии пополнения сервисов и требуют
recipient_fields (email, password, server). Отдельного поля-признака линии в каталоге пока
нет — если сомневаетесь, отправьте recipient_fields: линия, которой нужна одна строка,
перечислена выше поимённо и коротка.
У линии пополнения сервисов поставщик спрашивает у каждой категории свой набор — от одного поля
(user_id) до трёх (email, password, server), — поэтому одиночный recipient ей не
подходит даже при единственном поле: подставить строку «в первое поле» значило бы догадываться,
а догадка станет неверной в день, когда у категории появится второе. Такая строка отвергается
recipient-invalid, и наоборот — recipient_fields у Steam/Telegram отвергается тем же кодом.
⚠️ Как узнать нужные поля. Спросите — GET /v1/recipients/fields?sku= (§4) отдаёт состав
полей структурой, скоупом catalog:read. Второй способ остаётся и стоит ноль: отправьте строку с
любым набором, отказ приедет в line_rejections[].reason, перечислит недостающие поля по имени, а
для поля с закрытым списком назовёт допустимые значения, — и приходит он до списания.
🔴 У позиций без кода полей code_format / code_formats НЕТ ВОВСЕ, и это ответ, а не пропуск: кода не существует, поставщик зачисляет средства прямо на аккаунт, и вам приезжает подтверждение зачисления. Получателя мы проверяем ДО списания.
🔴 game_key — не синоним key_code, и различать их вам стоит. Вам в обоих случаях приезжает
код, но у игрового ключа своё региональное ограничение: он активируется в СПИСКЕ стран
(region_countries), а не в одной. Получателя у него не бывает — recipient и recipient_fields
в такой строке отвергаются recipient-invalid.
10.6. Прочее, чего в v1 нет намеренно
События наличия (
stock.out/stock.back) — наличие читается каталогом и отказомnot-enough-stock.Частичная выдача — отказ любой строки = отказ всего заказа с полным возвратом.
Партии больше потолка (
quantity> 50 в строке) — потолок снимается расширением, форма запроса при этом не меняется.Ступени оптовой цены (
price_tiers[]) — поле появится рядом сprice, семантика сверки эха не изменится.Числовые SLA — публикуем статусы и обязательство «принят быстро, выдача асинхронна»; числовых сроков выдачи не обещаем.
Пополнение баланса через API — на волне 1 идёт по каналу связи из §1.
Самообслуживаемая проверка компании — регистрация ваша, проверку проводим мы; ключи после неё вы выпускаете сами в кабинете (§1).
Настройка порога balance.low через API — на волне 1 идёт по каналу связи из §1 (§6.2).
Машинный перечень событий и доставок — повторить (§6.1) можно только событие,
event_idкоторого вы уже видели.
11. Совместимость и депрекейт
Обязательство: не раньше 90 дней. Любое поле, ручка или код ошибки, которые мы решим убрать или изменить ломающим образом, отменяются не раньше чем через 90 дней после объявления. Объявление — строка в журнале изменений с датой вступления в силу.
Ломающим мы считаем: удаление ручки, удаление поля из ответа, сужение допустимых значений, новое обязательное поле в запросе, изменение смысла существующего кода ошибки, смену ЕДИНИЦЫ или смысла существующего поля при неизменных имени и типе, а также формат заголовка X-Kodrio-Signature и алгоритм подписи — все они ломают приёмник молча, поэтому объявляются теми же 90 днями.
🔴 Смена единицы дописана 02.09.2026, и дописана собственной ошибкой. Этого класса в перечне
не было, а он самый тихий из возможных: имя поля прежнее, тип прежний, схема проходит, число
другое. Ровно так market_price месяц ехал рублёвыми копейками под подписью «минорные единицы»
рядом с центовым price. Пункт добавлен, чтобы в следующий раз он попал под 90 дней по правилу,
а не по чьей-то внимательности.
🪤 Дважды мы это обязательство не исполнили, и говорим об этом здесь, а не только в журнале.
31.08.2026 (учётная валюта контура) и 02.09.2026 (единица market_price) — обе записи 🔴 без
предшествующей 🟡 и без 90 дней. Основание в обоих случаях одно и названо в самих записях: зондом
боевой базы показано, что живых потребителей ручки нет ни одного — не выпущено ни единого ключа. Правило не отменено и продолжает действовать: как только у контракта появится
первый живой читатель, обход перестанет быть возможным, потому что перестанет быть правдой его
единственное основание. Обход без свежего зонда с числами в записи — не обход, а нарушение.
Не ломающим (приезжает без 90 дней и без предупреждения — ваш клиент обязан это переживать):
новое необязательное поле в ответе — не падайте на незнакомых полях;
новое значение в списке (новый бренд, новый регион, новый вид выдачи);
новый код ошибки в существующем HTTP-классе — обрабатывайте по HTTP-статусу, а не только по списку известных кодов;
новое событие вебхука — вы получаете только те, на которые подписаны.
Правило нашей стороны: меняется контракт — строка в журнале изменений в том же изменении, иначе изменение не считается сделанным.
Действующие объявления депрекейта
| Что | Объявлено | Снимается | Чем заменено |
|---|---|---|---|
поле code_format строки каталога (GET /v1/catalog) | 20.08.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
Полный список. Ручки, которых здесь нет, не существует; всё, что существует, — здесь.
⚠️ «Построена» ≠ «доступна вашему ключу прямо сейчас»: все ручки отвечают на боевом адресе, но заказ боевым ключом ещё не принимается (§10.1). Столбец говорит о готовности контракта.
| Ручка | Скоуп | Раздел | Состояние |
|---|---|---|---|
GET /v1/catalog | catalog:read | §4 | ✅ построена |
GET /v1/catalog/export | catalog:read | §4 | ✅ построена |
GET /v1/stock | catalog:read | §4 | ✅ построена |
GET /v1/recipients/fields | 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 | orders:read | §5.6 | ✅ построена |
GET /v1/openapi.json | любой живой ключ, скоуп не нужен | §12 | ✅ построена |
POST /v1/recipients/check | — | — | ⛔ не построена (§10.4) |
GET /v1/openapi.json — машиночитаемый контракт. Тот же контракт, что на этой странице, но
файлом OpenAPI 3.1: по нему генерируется клиент, его импортируют в Postman по адресу. Открывается
любым вашим действующим ключом, конкретный скоуп не нужен; без ключа —
401 unauthorized тем же конвертом §8. Файл приходит как есть, без конверта {"data": …} —
иначе инструменты не примут его по адресу; так же, без конверта, отдаётся прайс-файл
GET /v1/catalog/export. Запрос
считается в квоту «чтение» (§7). Расходятся файл и эта страница — напишите нам: такое расхождение
мы считаем дефектом.
13. Если что-то пошло не так
Подозрение на утечку ключа
Признаки: 403 ip_not_allowed при неизменной исходящей сети · движения в журнале баланса,
которых вы не делали · подписка в GET /v1/webhooks, которую вы не заводили · last_used_at
у ключа, которым вы не пользуетесь.
Порядок действий — все три шага, отзыв ключа сам по себе НЕ закрывает утечку:
Отзовите ключ в кабинете, раздел «Ключи доступа». Доступ прекращается мгновенно, мы решение по ключу не кэшируем.
Пройдите GET /v1/webhooks и отзовите все подписки, которых вы не заводили. Подписки принадлежат компании и переживают отзыв ключа: чужая подписка продолжит слать ваш остаток и ваши заказы на чужой адрес, пока вы её не снимете.
Сверьте журнал баланса (
GET /v1/balance/entries) за период с момента, когда ключ мог утечь, и сообщите нам о движениях, которых вы не делали.
Не расширяйте список разрешённых адресов, чтобы «починить» 403 ip_not_allowed, — это ровно
тот шаг, которого ждёт тот, кто увёл ключ.
Прочее
Возьмите
request_idиз тела ошибки.Сверьтесь с §8 — большая часть отказов чинится на вашей стороне и описана там.
500/503/таймаут на заказе — §5.4: повторить тем жеIdempotency-Key, никогда не новым. Кроме500на корректный заказ, пока приём не открыт (§10.1), и503 sandbox_not_available(§8).Осталось непонятным — напишите по каналу связи из §1, назвав
request_id, время и ручку.
Вопрос, ответа на который нет на этой странице, — наш недочёт, а не ваш вопрос. Сообщите о нём: страница чинится текстом, а не устным ответом.