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

Вебхуки: подписанные события заказов

Заказ, вышедший из POST /b2b/orders, находится в статусе processing. Поставщик ответит через секунды или через минуты. Вебхуки — это способ узнать результат, не опрашивая каждый когда-либо созданный заказ.

Эта страница про канал вебхуков уровня заказа: вы передаёте callbackUrl при создании заказа, а мы доставляем на него подписанное событие, когда заказ разрешится.

Как устроен канал

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

{
"items": [{ "productId": 34521, "deliveryData": { "gameUserId": "5123456789" } }],
"externalOrderId": "MP-98765",
"callbackUrl": "https://api.yourshop.com/webhooks/gamecore"
}

Секрет подписи заводится для вашего аккаунта при первой доставке и дальше не меняется: 64 шестнадцатеричных символа. Запросите его у нас — в ответе на создание заказа он не отдаётся.

Каждая доставка несёт такие заголовки:

ЗаголовокЧто это
X-Webhook-Signaturesha256= и следом HMAC в нижнем регистре, hex.
X-Webhook-TimestampUnix-время в секундах, ровно то, что было подписано.
X-Idempotency-KeyИдентификатор события. Одинаков во всех повторах.
X-Webhook-Eventorder.completed или order.failed.
User-AgentGameCore-B2B-Webhook/1.0

X-Idempotency-Key — ваш ключ дедупликации. Повторы переиспользуют его, поэтому «сохранить ключ и игнорировать повторы» — это и есть вся обработка at-least-once.

Если вы одновременно используете витринный SDK GameCore, не берите отсюда его хелпер verifyWebhookSignature. Он проверяет другую схему подписи и отклонит любое событие этого канала. Проверяйте сырым HMAC, как показано ниже.

Тело события

Типов событий ровно два: order.completed и order.failed.

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

{
"event_id": "7f3c1a9e-2b44-4d1a-9d4e-1c0f9a2b3c4d",
"event_type": "order.completed",
"occurred_at": "2026-08-18T18:31:15.432Z",
"data": {
"orderCode": "ash-XY7K3M",
"externalOrderId": "MP-98765",
"totalAmount": 1064.5,
"status": "completed",
"items": [
{ "productName": "PUBG Mobile 660 UC", "amount": 1, "price": 1064.5 }
],
"completedAt": "2026-08-18T18:31:15.432Z"
}
}

У товаров с ключами каждая позиция дополнительно несёт cdKeys: [{ "code": "…" }].

Неудавшийся заказ. Массива items в нём нет вообще — не обращайтесь к нему по индексу:

{
"event_id": "1b8d4f22-6a90-41c7-8f3e-5d21c7a4b019",
"event_type": "order.failed",
"occurred_at": "2026-08-18T18:31:15.432Z",
"data": {
"orderCode": "ash-XY7K3M",
"externalOrderId": "MP-98765",
"totalAmount": 1064.5,
"status": "failed",
"error": "All items failed at supplier",
"reason": "ID игрока указан неверно",
"completedAt": "2026-08-18T18:31:15.432Z"
}
}

error — фиксированная техническая строка. reason — объяснение на русском, которое можно показать покупателю, либо null, если поставщик его не дал. Именно reason стоит выводить в интерфейсе поддержки.

Ветвитесь по event_type, а не только по заголовку X-Webhook-Event: заголовок — удобство, тело — документ.

Проверка подписи

Подписывается строка из таймстемпа, литеральной точки и сырого тела запроса:

signing_input = "{X-Webhook-Timestamp}" + "." + raw_body
signature     = "sha256=" + hex(HMAC_SHA256(secret, signing_input))

Тело должно быть байтами как они пришли. Если распарсить JSON и сериализовать заново, изменится порядок ключей и формат чисел — подпись не сойдётся. Сначала читайте сырое тело, потом проверяйте, потом парсите.

Node.js, обработчик в стиле Express с сохранённым сырым телом:

import crypto from "node:crypto";
function verify(rawBody, headers, secret) {
const timestamp = Number(headers["x-webhook-timestamp"]);
const received = headers["x-webhook-signature"] ?? "";
// Отсекаем всё за пределами окна повтора до криптографии.
const age = Math.abs(Math.floor(Date.now() / 1000) - timestamp);
if (!Number.isFinite(timestamp) || age > 300) return false;
const expected =
"sha256=" +
crypto
.createHmac("sha256", secret)
.update(`${timestamp}.${rawBody}`)
.digest("hex");
// Сравнение за постоянное время — обычный === утекает подпись побайтно.
const a = Buffer.from(expected);
const b = Buffer.from(received);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}

Python:

import hashlib
import hmac
import time
def verify(raw_body: bytes, headers, secret: str) -> bool:
try:
timestamp = int(headers["X-Webhook-Timestamp"])
except (KeyError, ValueError):
return False
if abs(int(time.time()) - timestamp) > 300:
return False
signing_input = f"{timestamp}.".encode() + raw_body
expected = "sha256=" + hmac.new(
secret.encode(), signing_input, hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, headers.get("X-Webhook-Signature", ""))

Две детали, на которых ломаются наивные реализации: значение заголовка содержит префикс sha256=, а сам HMAC — нет; и сравнение должно быть за постоянное время.

Окно повтора

Отклоняйте события, у которых X-Webhook-Timestamp расходится с вашими часами больше чем на 300 секунд в любую сторону. Это правило приёмника — на нашей стороне оно не форсируется, и без него ничто не мешает злоумышленнику повторно проиграть перехваченный запрос.

Повторы за окно не выпадают: каждая попытка подписывается заново в момент отправки, поэтому доставка спустя сутки приходит со свежим таймстемпом. Значит, устаревший таймстемп означает replay, а не медленный ретрай.

Держите часы сервера на NTP. Дрейф часов на приёмнике — самая частая причина внезапного «все вебхуки перестали проходить проверку подписи».

Как правильно отвечать

Возвращайте любой 2xx сразу, как только сохранили событие. Тело вашего ответа мы не читаем никогда — только код статуса, — поэтому класть в него нечего.

Ваш ответЧто делаем мы
2xxДоставлено. Готово.
429 или 408Повторяем по лестнице ниже. Пришлёте Retry-After — мы его соблюдём для этого повтора.
Любой другой 4xxСразу в dead-letter. Повторов не будет.
5xxПовторяем по лестнице ниже.
Таймаут или ошибка соединенияПовторяем по лестнице ниже.
Редирект 3xxСчитается постоянной ошибкой и уходит в dead-letter. Редиректы не переходятся.

Три следствия, которые стоит заложить в архитектуру:

  • 429 и 408 — те два кода 4xx, которые оставляют событие живым. Они читаются как backpressure: вы просите нас притормозить, а не сообщаете, что запрос неверный, — поэтому они уходят на лестницу повторов наравне с 5xx. Любой другой 4xx — окончательный вердикт: если обработчик отвечает 400, потому что форма payload его удивила, 401, потому что ваш собственный auth-middleware отработал раньше проверки подписи, или 404, потому что маршрут переехал, — событие исчезнет после первой же попытки. Когда не уверены — отвечайте 500; когда сбрасываете нагрузку — 429. Оба ответа покупают вам лестницу.
  • 429 замедляет доставку, но не добавляет попыток. Повтор, вызванный ответом 429, тратит одну из восьми — ровно как повтор после 5xx. Эндпоинт, который отвечает 429 на всё, всё равно доведёт событие до dead-letter, когда лестница кончится.
  • Указывайте в callbackUrl конечный адрес. Редирект с http на https или с домена без www на www — это dead-letter, а не переход.

На попытку мы ждём до 10 секунд. Делайте работу асинхронно: сначала подтвердите, потом обрабатывайте. Обработчик, который пополняет кошелёк и отправляет чек до ответа, под нагрузкой рано или поздно выйдет за 10 секунд, и мы повторим событие, которое вы уже обработали.

Лестница повторов

Одна первая попытка плюс до восьми повторов — в худшем случае девять доставок примерно за 46 часов:

ПовторЗадержка после предыдущей попытки
11 минута
25 минут
315 минут
41 час
53 часа
66 часов
712 часов
824 часа

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

Заголовок Retry-After мы читаем — но только на 429 и 408. Работают обе формы из RFC 9110: число секунд (Retry-After: 120) и HTTP-дата (Retry-After: Wed, 21 Oct 2026 07:28:00 GMT). Если мы смогли его разобрать, это значение заменяет задержку из лестницы для этого одного повтора; следующие ступени снова берутся из таблицы. Значение зажимается в диапазон от 5 секунд до 6 часов: всё меньшее становится 5 секундами — включая дату, которая уже прошла, — а всё большее становится 6 часами, так что ни Retry-After: 0, ни Retry-After: 999999 не превратят очередь в горячий цикл и не запаркуют реальный заказ на неделю. Заголовок, который мы не смогли разобрать, игнорируется, и работает лестница. На любом другом коде статуса мы в него вообще не смотрим.

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

Что после последней попытки

Событие, исчерпавшее все девять попыток — или попавшее в одно из условий немедленного отказа выше, — уходит в dead-letter. Оно сохраняется, наша дежурная команда получает алерт, и автоматически оно больше не доставляется никогда. Восстановление — только по запросу к нам.

Немедленные причины полезно знать, потому что все они про конфигурацию, а не про везение: любой 4xx, кроме 429 и 408, ответ-редирект, некорректный callbackUrl и адрес, резолвящийся в запрещённую сеть.

Проектируйте так, чтобы dead-letter не означал потерянный заказ. То есть:

  • Сохраняйте заказ в момент создания, а не в момент прихода вебхука.
  • Сверяйтесь через GET /b2b/orders/:code по расписанию для всего, что осталось не в финальном статусе спустя, скажем, час.

Каким должен быть callbackUrl

URL проверяется при создании заказа и заново перед каждой доставкой. Отклоняются сразу:

  • Любая схема, кроме http и https.
  • Учётные данные внутри URL (https://user:pass@host/…).
  • Приватные, loopback, link-local, carrier-NAT и cloud-metadata адреса.
  • Любые литералы IPv6.
  • Внутренние имена — localhost, всё в зоне .local, а также контейнерные и cloud-metadata имена.

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

Некорректный callbackUrl роняет сам вызов создания заказа с 400, до любого списания.

По частично выполненным заказам событий нет

Заказ, где часть позиций выполнилась, а часть нет, не порождает вебхук в этом канале. Строка заказа переходит в failed, стоимость невыполненных позиций возвращается на ваш баланс, но ни order.completed, ни order.failed не доставляется.

Это единственный случай, где одни вебхуки оставят вас слепым, поэтому опрос — не опциональная оптимизация:

  • Сверяйте через GET /b2b/orders/:code любой заказ, оставшийся в pending или processing дольше разумного таймаута.
  • Читайте там items[].status, а не только статус заказа: частичный результат виден именно там.

Тот же цикл сверки закрывает и dead-letter, и любой сбой на вашей стороне — один механизм окупает два риска.

Перед выходом в прод

  • Сырое тело сохраняется до парсинга JSON, и подпись проверяется именно по нему.
  • Сравнение идёт за постоянное время, префикс sha256= учтён.
  • Таймстемпы за пределами 300 секунд отклоняются, часы сервера на NTP.
  • X-Idempotency-Key сохраняется, повторы игнорируются, а не обрабатываются заново.
  • Обработчик отвечает 2xx заметно быстрее 10 секунд и делает работу после ответа.
  • На неожиданный payload отдаётся 5xx, а при сбросе нагрузки — 429; обычный 4xx отправляет событие в dead-letter на месте.
  • Есть задача сверки, которая опрашивает нефинальные заказы, — так ловятся частичные результаты и dead-letter.

Нужен ключ API?

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