Вебхуки: подписанные события заказов
Заказ, вышедший из 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-Signature | sha256= и следом HMAC в нижнем регистре, hex. |
X-Webhook-Timestamp | Unix-время в секундах, ровно то, что было подписано. |
X-Idempotency-Key | Идентификатор события. Одинаков во всех повторах. |
X-Webhook-Event | order.completed или order.failed. |
User-Agent | GameCore-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 hashlibimport hmacimport timedef verify(raw_body: bytes, headers, secret: str) -> bool:try:timestamp = int(headers["X-Webhook-Timestamp"])except (KeyError, ValueError):return Falseif abs(int(time.time()) - timestamp) > 300:return Falsesigning_input = f"{timestamp}.".encode() + raw_bodyexpected = "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 часов:
| Повтор | Задержка после предыдущей попытки |
|---|---|
| 1 | 1 минута |
| 2 | 5 минут |
| 3 | 15 минут |
| 4 | 1 час |
| 5 | 3 часа |
| 6 | 6 часов |
| 7 | 12 часов |
| 8 | 24 часа |
Очередь просматривается каждые 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.
Обновлено 18 августа 2026 г.
Смотрите также: Поставка по API · Цены и тарифы
Нужен ключ API?
Напишите, какие игры и регионы вы продаёте, — выдадим ключ и пройдём интеграцию вместе с вами. На вопросы по интеграции отвечают те же люди, которые ведут API.