Webhooks

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

Просмотр в формате Markdown

Когда заказ, размещённый через API, достигает терминального статуса, Voodoo Center отправляет POST с подписанным JSON-событием на настроенный вами webhook-URL. Это рекомендуемый способ узнать результат заказа — он основан на push, поэтому вам не нужно выполнять опрос.

Когда срабатывают webhook

Webhook отправляется, когда выполняются все следующие условия:

  • Заказ достигает терминального статуса: completed, failed или partial.
  • Заказ был размещён через API (source: "api"). Заказы, созданные в дашборде, не запускают webhook.
  • Для вашего аккаунта настроен webhook-URL.

Тело запроса

Тело запроса — это компактный JSON с отсортированными ключами со следующими полями:

ПолеТипОписание
order_idстрока (uuid)Id заказа (совпадает с id из API).
merchant_order_idстрокаВаш идентификатор, если вы его предоставили.
statusстрокаТерминальный статус: completed, failed или partial.
item_idцелое число | nullЧисловой id товара.
item_nameстрокаЧитабельное название товара.
quantityчислоЗаказанное количество.
total_priceчислоСписанная сумма в основных денежных единицах.
delivered_quantityцелое числоФактически доставленные единицы.
refund_amountчислоСумма, возвращённая за недоставленные единицы.
fieldsобъектОтправленные поля ввода (значения выбора как отображаемые названия).
codesstring[]Строки доставленных ключей/кодов (товары типа key).
errorстрокаМашинный код ошибки при сбое (например, OUT_OF_STOCK).
error_messageстрокаЧитабельное сообщение об ошибке при сбое.
completed_atстрока (date-time) | nullКогда заказ был выполнен.
created_atстрока (date-time)Когда заказ был создан.
Выполненный заказ с ключами
1{
2 "order_id": "0190f8a1-6b2c-7e33-9a10-4c1d2e3f5a6b",
3 "merchant_order_id": "po-10231",
4 "status": "completed",
5 "item_id": 4090,
6 "item_name": "Sample Gift Card 10 USD",
7 "quantity": 2,
8 "total_price": 18.00,
9 "delivered_quantity": 2,
10 "refund_amount": 0,
11 "fields": {},
12 "codes": ["ABCD-1234-EFGH-5678", "IJKL-9012-MNOP-3456"],
13 "error": "",
14 "error_message": "",
15 "completed_at": "2026-07-05T12:00:07Z",
16 "created_at": "2026-07-05T12:00:00Z"
17}
Частичная доставка с возвратом
1{
2 "order_id": "0190f8a2-7c3d-7f44-ab21-5d2e3f4a6b7c",
3 "merchant_order_id": "po-10240",
4 "status": "partial",
5 "item_id": 4090,
6 "item_name": "Sample Gift Card 10 USD",
7 "quantity": 3,
8 "total_price": 27.00,
9 "delivered_quantity": 1,
10 "refund_amount": 18.00,
11 "fields": {},
12 "codes": ["ABCD-1234-EFGH-5678"],
13 "error": "",
14 "error_message": "",
15 "completed_at": null,
16 "created_at": "2026-07-05T12:10:00Z"
17}
Неудачный заказ (полностью возвращён)
1{
2 "order_id": "0190f8a3-8d4e-7055-bc32-6e3f4a5b7c8d",
3 "merchant_order_id": "po-10250",
4 "status": "failed",
5 "item_id": 4211,
6 "item_name": "Sample Game Top-up",
7 "quantity": 10,
8 "total_price": 12.50,
9 "delivered_quantity": 0,
10 "refund_amount": 12.50,
11 "fields": { "player_id": "123456789", "server": "Europe (EU-West)" },
12 "codes": [],
13 "error": "OUT_OF_STOCK",
14 "error_message": "Out of stock",
15 "completed_at": null,
16 "created_at": "2026-07-05T12:20:00Z"
17}

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

Каждый webhook несёт заголовок:

X-Webhook-Signature: sha256=<hex>

где <hex> — это HMAC-SHA256(webhook_secret, raw_request_body_bytes).

Проверяйте относительно сырых полученных байтов, точно так, как они доставлены — никогда относительно словаря, который вы пересериализовали. Подписанное тело — это компактный JSON с отсортированными ключами, поэтому любое перекодирование (другой порядок ключей, пробелы или форматирование чисел) даст другую подпись и не пройдёт проверку.

Вычислите тот же HMAC со своим секретом для подписи и сравните его с заголовком, используя сравнение постоянного времени (constant-time).

Приёмник и верификатор на FastAPI

Этот приёмник проверяет сырые байты перед парсингом, использует сравнение постоянного времени и быстро возвращает 200:

webhook_receiver.py
1import hmac, hashlib
2from fastapi import FastAPI, Request, Header, HTTPException
3
4app = FastAPI()
5WEBHOOK_SECRET = "whsec_your_secret_from_the_dashboard"
6
7@app.post("/webhooks/voodoo-center")
8async def voodoo_webhook(request: Request, x_webhook_signature: str = Header(default="")):
9 raw = await request.body() # проверьте СЫРЫЕ байты ПЕРЕД парсингом JSON
10 expected = "sha256=" + hmac.new(WEBHOOK_SECRET.encode(), raw, hashlib.sha256).hexdigest()
11 if not hmac.compare_digest(x_webhook_signature, expected):
12 raise HTTPException(status_code=401, detail="invalid signature")
13 event = await request.json()
14 # Идемпотентность: событие можно получить более одного раза — дедуплицируйте по order_id.
15 # process(event["status"], event.get("codes"), event.get("refund_amount"), ...)
16 return {"ok": True} # быстро возвращайте 200; тяжёлую работу делайте в фоне

Подтверждение, повторные попытки и таймауты

  • Подтверждайте HTTP 200, как только вы сохранили событие. Любой ответ, отличный от 200 (или таймаут), считается неудачной доставкой.
  • Неудачные доставки повторяются до 3 попыток с интервалом 60 секунд.
  • Каждая попытка имеет таймаут 30 секунд.
  • Перенаправления не выполняются, а ваш URL должен быть публично маршрутизируемым — защита от подделки запросов на стороне сервера (SSRF) блокирует внутренние/приватные адреса.

Поскольку одно событие может быть доставлено более одного раза (например, повторная попытка после того, как ваш 200 пришёл с задержкой), сделайте свой обработчик идемпотентным: дедуплицируйте по order_id и воспринимайте повторную доставку уже обработанного события как no-op.

Выполняйте тяжёлую работу (выполнение собственного последующего заказа, отправку письма и т. д.) в фоне и быстро возвращайте 200 — медленный обработчик рискует таймаутом в 30 секунд и лишней повторной попыткой.

Настройка URL и ротация секрета

Обе настройки webhook размещены на странице API в дашборде (они доступны только в дашборде, а не как эндпоинты API):

  • Webhook URL — куда отправляются (POST) события. Должен быть публично доступен по HTTPS.
  • Секрет для подписи — используется для вычисления X-Webhook-Signature. Его формат — whsec_..., и он показывается один раз, при создании/ротации. Выполните ротацию, если он мог утечь, и обновите WEBHOOK_SECRET в своём приёмнике, чтобы он соответствовал.