Документация

Идемпотентность и ошибки

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

Почему ключ обязателен

POST /b2b/orders требует заголовок X-Idempotency-Key. Запрос без него отбивается с 400 и текстом "X-Idempotency-Key header is required": без списания, без заказа, без расчёта цены, ничего не резервируется.

Эта проверка — первое, что делает хендлер, а значит, она идёт после двух вещей, которые выполняются до любого хендлера: аутентификации вашего ключа и валидации формы тела фреймворком. Поэтому вызов без заголовка и без ключа получит 401, а вызов с телом, не соответствующим схеме, — 422; 400 про отсутствующий заголовок вы увидите, когда всё остальное в запросе корректно. Проверяйте эти три случая по отдельности, а не считайте, что их покрывает один статус.

Это сделано намеренно. Отказ, от которого защищает ключ, совсем не экзотический: ваш запрос дошёл, мы списали деньги и создали заказ, а ответ потерялся на обратном пути. Без ключа у вас два варианта — повторить (и заплатить дважды) или не повторять (и потерять уже оплаченный заказ). С ключом повтор вернёт исходный результат.

Заводите один ключ на одно бизнес-намерение — на один чекаут покупателя — и сохраняйте его рядом со своим заказом до вызова. Ключ, сгенерированный внутри цикла повторов, идемпотентным ключом не является.

POST /b2b/orders
X-Api-Key: gc_live_…
X-Idempotency-Key: 7f3c1a9e-2b44-4d1a-9d4e-1c0f9a2b3c4d

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

Что считается «тем же запросом»

Решение о повторе принимает не только ключ. Мы ещё считаем отпечаток запроса, и он у́же, чем всё тело.

Отпечаток строится по items и только по ним — productId, quantity и deliveryData, нормализованные: ключи схемы отсортированы, значения обрезаны по краям, а username приведён к нижнему регистру со снятой ведущей @, поэтому @Player1 и player1 — это один заказ, а не два. Затем позиции сортируются по productId, а внутри одного товара — по нормализованному deliveryData, так что перестановка корзины отпечаток не меняет.

Один краевой случай эту сортировку переживает. Две строки одного и того же товара с одинаковым deliveryData и разным quantity сравниваются как равные — quantity попадает в хэш, но не участвует в упорядочивании, — поэтому их взаимный порядок остаётся таким, каким вы его прислали, и та же корзина с этими двумя строками, переставленными местами, даст другой отпечаток и, значит, 409 вместо повтора. Сливайте такие строки в одну с суммарным quantity — они и означают ровно это, — и краевой случай станет невозможен.

callbackUrl и externalOrderId намеренно вне отпечатка. Поэтому повтор с тем же ключом, теми же позициями, но другим адресом колбэка, который проходит валидацию, — это не конфликт, а повтор: вы получите исходный заказ, а новый адрес колбэка на него не подействует. Исправляйте адрес колбэка до того, как заказ создан, а не после.

Оговорка про валидацию здесь несущая: callbackUrl проверяется до того, как мы ищем ваш ключ. Подставленный адрес, не прошедший проверку — неразбираемый URL, схема не http(s), логин-пароль внутри адреса или хост, к которому мы отказываемся ходить (приватные, loopback- и link-local-литералы, любой IPv6-литерал, внутренние имена), — получает 400 Invalid callbackUrl: … и до поиска по ключу вообще не доходит. То есть плохой адрес не воспроизводит исходный заказ и не даёт конфликта: он просто отклонён, а ключ остаётся ровно в том состоянии, в каком был.

Сколько именно мы помним ключ, стоит сказать точно, потому что «24 часа» — это только половина ответа. При первом успешном использовании пишутся две строки — одна хранит ответ, вторая привязывает ваш ключ к позициям, с которыми он был использован, — и обе помечены сроком в 24 часа. Неудавшаяся попытка не пишет ни одной, почему — в следующем разделе. Уборка идёт раз в час и удаляет их после истечения срока. В промежутке между истечением и уборкой строки на месте и всё ещё решают: повтор по-прежнему вернёт сохранённый ответ, а несовпавшая корзина по-прежнему даст конфликт.

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

Повтор, конфликт и «в полёте»

Что вы отправилиЧто получите
Тот же ключ, те же позиции, после успешного первого запроса200 с исходным телом ответа, байт в байт. Ни второго заказа, ни второго списания.
Тот же ключ, те же позиции, пока первый запрос ещё выполняетсяВаш вызов ждёт наш. Когда первый завершится, вы получите его сохранённый 200 — а если он всё-таки упал, ваш вызов пойдёт дальше как первая попытка. Опрашивать нас не нужно.
Тот же ключ, другие позиции, после успешного первого запроса409, idempotency_key_reused_with_different_body: …
Тот же ключ после неудавшейся попыткиОбычная новая попытка — с теми же позициями или с другими. Ничего не воспроизводится, потому что ничего не сохранилось.

Последняя строка удивляет чаще всего — и именно на ней держится политика повторов ниже.

Обе строки идемпотентности пишутся внутри той же транзакции базы, что создаёт платёж и заказы, а строка с ответом заполняется настоящим 200 до коммита этой транзакции. Промежуточного состояния, которое можно было бы увидеть, попросту нет. Если что-то в транзакции падает — не хватило баланса, товар отклонён, сбой на нашей стороне, — вся транзакция откатывается и уносит с собой обе строки. От попытки не остаётся следа, и ключ снова свободен.

Отсюда два следствия, которые стоит сказать прямо. 402 Insufficient balance повторяется тем же ключом ровно потому, что ключ не был израсходован. А конфликт 409 возникает только после успешного использования: другая корзина под ключом, чья первая попытка упала, — это не конфликт, а новый заказ.

409 Duplicate request in progress, retry later — оборонительный ответ для состояния, которого штатный путь не производит; скорее всего, вы его никогда не увидите. Если увидели, считайте его временным: подождите секунду-другую и повторите тот же самый запрос.

А вот idempotency_key_reused_with_different_body повтором не лечится. Он будет отдавать 409 ровно столько, сколько хранится привязка ключа к позициям, — минимум 24 часа с первого, успешного использования, — а когда эта строка будет убрана, тот же вызов сделает кое-что похуже отказа: по-настоящему купит новую корзину под ключом, который вы считали израсходованным. Он означает, что ваш код изменил корзину под уже использованным ключом: считайте это багом в собственном состоянии заказа, заводите новый ключ для по-настоящему нового заказа, а старый ключ используйте только для повтора идентичных позиций.

Конвертов два, а не один

Эта деталь подводит первой, поэтому ветвитесь по HTTP-статусу, а не по полю:

{ "error": "Unauthorized", "message": "Invalid or missing API key" }

Отказы аутентификации и доступа приходят в конверте { error, message } и без поля success: это 401 при отсутствующем или непригодном API-ключе и 403, когда аккаунт не оптовый либо область видимости ключа не покрывает эндпоинт. Клиент, который проверяет response.success, увидит undefined ровно на тех двух отказах, с которыми новая интеграция сталкивается раньше всего.

Один 403 из этой семьи выпадает. Проверка Origin (защита от CSRF) срабатывает только на пишущем запросе с заголовком Origin, которого нет в списке разрешённых, — то есть вы встретите её, когда вызовы идут из браузера, а не с вашего бэкенда, — и отвечает { "success": false, "error": "Forbidden: Origin not allowed" }. Два 403 — две формы: ещё одна причина ветвиться по статусу и считать оба поля необязательными.

{ "success": false, "error": "too_many_items: maximum 200 items per order" }

Бизнес-отказы приходят как { success: false, error }, где error — читаемая человеком строка. Часть строк начинается с машиночитаемого токена перед двоеточием (too_many_items:, region_not_allowed:, ZERO_PRICE_ITEM:, idempotency_key_reused_with_different_body:); сопоставляйте именно этот префикс, а не всё предложение — формулировку могут переписать.

По краям есть ещё две формы. Тело, нарушающее схему запроса — число без кавычек в deliveryData, отсутствующий items, — отбивается собственной валидацией фреймворка с кодом 422 и конвертом валидации, не похожим ни на один из двух выше. А внутренний сбой отдаёт намеренно бессодержательный 500: внутренности мы в тело ошибки не выносим.

Таблица ошибок

HTTPКогдаПример error
400Нет заголовка идемпотентностиX-Idempotency-Key header is required
400Пустая или слишком большая корзинаItems list is empty · too_many_items: maximum 200 items per order
400Плохой callbackUrlInvalid callbackUrl: …
400Товар неизвестен, неактивен или невидим для васProduct 34521 not found or unavailable · Product 34521 is not available for this site
400Товару нужна загрузка файлаProduct 34521 requires an image upload, which is not supported via the B2B API
400Данные доставки не проходят схему товара или форматное правилосообщение по конкретному полю
400Регион недоступен вашему аккаунтуregion_not_allowed: product 34521 is not available in your site's allowed regions
400У товара нет пригодной ценыZERO_PRICE_ITEM: product 34521 has a non-positive base price
400Количество вне диапазона или не целоеInvalid quantity for product 34521
401Ключ отсутствует, неизвестен, истёк или заблокирован по доменуInvalid or missing API key
402Баланс или кредитный лимитInsufficient balance · Exceeds credit limit (available: …)
402Баланс изменился, пока этот заказ коммитился (обычно ваш же параллельный вызов)Concurrent balance modification
403Аккаунт не оптовый либо не та область видимости ключаThis endpoint is only available for B2B API partners
403Пишущий запрос с недопустимым OriginForbidden: Origin not allowed
404Неизвестный код заказа, slug игры или id товараOrder not found · Game not found · Product not found
409Конфликт идемпотентности или дубль в полётесм. таблицу выше
422Тело запроса не соответствует схемеконверт валидации фреймворка
429Превышен лимит запросовToo many requests. Please try again later.
503Правило доставки не удалось вычислитьсообщение правила
500Внутренний сбойInternal server error

402 покрывает две разные проблемы, и разница определяет ваши действия. Insufficient balance и Exceeds credit limit означают, что денег нет: пополните баланс и повторите тот же самый вызов с тем же ключом. Concurrent balance modification означает, что деньги были, но ваш баланс изменило что-то ещё — обычно другой ваш же параллельный вызов создания заказа, — пока этот коммитился, и вся транзакция откатилась, поэтому списания нет и заказа нет. Повторите тот же самый запрос, без изменений и с тем же ключом, после короткой паузы. Пополнение здесь не помогает и не требуется. Если создавать заказы по аккаунту последовательно (или хотя бы не пускать их пачкой в одну секунду), такой ответ становится редкостью.

Лимиты запросов

Бюджет по умолчанию — 500 запросов в минуту, и считается он по IP клиента, а не по ключу: несколько серверов за одним NAT делят одну корзину, а один сервер с двумя ключами двух корзин не получает.

Каждый ответ, который доходит до лимитера, несёт X-RateLimit-Limit, X-RateLimit-Remaining и X-RateLimit-Reset (секунды до обновления окна), так что приближение к потолку видно до того, как вы в него упрётесь. Один ответ до лимитера не доходит: проверка Origin, описанная выше, стоит раньше него, поэтому её 403 Forbidden: Origin not allowed приходит вообще без этих заголовков. Сначала смотрите на статус, потом на бюджет. При превышении:

{
"success": false,
"code": "RATE_LIMITED",
"error": "Too many requests. Please try again later.",
"retryAfter": 37
}

и заголовок Retry-After с тем же числом секунд. Соблюдайте его: повтор раньше срока просто сжигает бюджет, которого у вас нет. Если нагрузка честно требует большего — ночной обход каталога, разовый бэкфилл при миграции, — попросите нас поднять лимит, а не размазывайте вызовы по IP-адресам.

Рабочая политика повторов

ОтветПовторять?Как
Сетевая ошибка, таймаут, ответа нетДаТот же ключ, то же тело. Ради этого ключ и существует.
429ДаПосле Retry-After, затем экспоненциальный бэкофф с джиттером.
503ДаС бэкоффом. Не выкидывайте поле, которое проверяло правило.
500Да, раз-другойС бэкоффом. Если повторяется — алерт, а не цикл.
402 Insufficient balance · Exceeds credit limitДа, после пополненияТем же ключом. Списания не было, заказ не создан.
402 Concurrent balance modificationДа, сразуТем же ключом и телом, после короткой паузы. Это временная конкуренция, а не проблема с деньгами, — пополнять не нужно.
409 Duplicate request in progressДаПосле короткой паузы, тем же запросом. Редкость: штатный путь заставляет вас подождать, а не отвечает так.
409 …reused_with_different_bodyНетЧините своё состояние. Пока привязка хранится, успеха не будет, а когда она истечёт, повтор по-настоящему купит новую корзину.
400, 403, 404, 422НетДетерминированы. Повтор ничего не изменит.

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

Перед запуском

  • Идемпотентный ключ создаётся и сохраняется до вызова и переиспользуется без изменений во всех повторах этого чекаута.
  • Повторы никогда не меняют корзину.
  • Клиент ветвится по HTTP-статусу, а success считается необязательным полем.
  • Сопоставление ошибок идёт по префиксу code:, где он есть, а не по всей фразе.
  • 409 разделён на два случая, и повторяется только один из них.
  • 402 тоже разделён: проблема с балансом ждёт пополнения, а Concurrent balance modification просто повторяется.
  • X-RateLimit-Remaining выведен на дашборд, а Retry-After соблюдается — и дашборд не ломается на единственном ответе, где нет ни того, ни другого.
  • У очереди повторов, способной пережить 24 часа, есть собственный предохранитель от дублей — нашу запись убирают в течение примерно часа после истечения срока.

Обновлено 18 августа 2026 г.

Смотрите также: Поставка по API · Цены и тарифы

Нужен ключ API?

Напишите, какие игры и регионы вы продаёте, — выдадим ключ и пройдём интеграцию вместе с вами. На вопросы по интеграции отвечают те же люди, которые ведут API.