> For clean Markdown of any page, append .md to the page URL.
> For a complete documentation index, see https://docs.voodoo.center/llms.txt.
> For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://docs.voodoo.center/_mcp/server.

# Webhooks

Когда заказ, размещённый через 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`             | объект                     | Отправленные поля ввода (значения выбора как отображаемые названия). |
| `codes`              | string\[]                  | Строки доставленных ключей/кодов (товары типа key).                  |
| `error`              | строка                     | Машинный код ошибки при сбое (например, `OUT_OF_STOCK`).             |
| `error_message`      | строка                     | Читабельное сообщение об ошибке при сбое.                            |
| `completed_at`       | строка (date-time) \| null | Когда заказ был выполнен.                                            |
| `created_at`         | строка (date-time)         | Когда заказ был создан.                                              |

```json title="Выполненный заказ с ключами"
{
  "order_id": "0190f8a1-6b2c-7e33-9a10-4c1d2e3f5a6b",
  "merchant_order_id": "po-10231",
  "status": "completed",
  "item_id": 4090,
  "item_name": "Sample Gift Card 10 USD",
  "quantity": 2,
  "total_price": 18.00,
  "delivered_quantity": 2,
  "refund_amount": 0,
  "fields": {},
  "codes": ["ABCD-1234-EFGH-5678", "IJKL-9012-MNOP-3456"],
  "error": "",
  "error_message": "",
  "completed_at": "2026-07-05T12:00:07Z",
  "created_at": "2026-07-05T12:00:00Z"
}
```

```json title="Частичная доставка с возвратом"
{
  "order_id": "0190f8a2-7c3d-7f44-ab21-5d2e3f4a6b7c",
  "merchant_order_id": "po-10240",
  "status": "partial",
  "item_id": 4090,
  "item_name": "Sample Gift Card 10 USD",
  "quantity": 3,
  "total_price": 27.00,
  "delivered_quantity": 1,
  "refund_amount": 18.00,
  "fields": {},
  "codes": ["ABCD-1234-EFGH-5678"],
  "error": "",
  "error_message": "",
  "completed_at": null,
  "created_at": "2026-07-05T12:10:00Z"
}
```

```json title="Неудачный заказ (полностью возвращён)"
{
  "order_id": "0190f8a3-8d4e-7055-bc32-6e3f4a5b7c8d",
  "merchant_order_id": "po-10250",
  "status": "failed",
  "item_id": 4211,
  "item_name": "Sample Game Top-up",
  "quantity": 10,
  "total_price": 12.50,
  "delivered_quantity": 0,
  "refund_amount": 12.50,
  "fields": { "player_id": "123456789", "server": "Europe (EU-West)" },
  "codes": [],
  "error": "OUT_OF_STOCK",
  "error_message": "Out of stock",
  "completed_at": null,
  "created_at": "2026-07-05T12:20:00Z"
}
```

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

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

```
X-Webhook-Signature: sha256=<hex>
```

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

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

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

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

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

```python title="webhook_receiver.py"
import hmac, hashlib
from fastapi import FastAPI, Request, Header, HTTPException

app = FastAPI()
WEBHOOK_SECRET = "whsec_your_secret_from_the_dashboard"

@app.post("/webhooks/voodoo-center")
async def voodoo_webhook(request: Request, x_webhook_signature: str = Header(default="")):
    raw = await request.body()  # проверьте СЫРЫЕ байты ПЕРЕД парсингом JSON
    expected = "sha256=" + hmac.new(WEBHOOK_SECRET.encode(), raw, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(x_webhook_signature, expected):
        raise HTTPException(status_code=401, detail="invalid signature")
    event = await request.json()
    # Идемпотентность: событие можно получить более одного раза — дедуплицируйте по order_id.
    # process(event["status"], event.get("codes"), event.get("refund_amount"), ...)
    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` в своём приёмнике, чтобы он соответствовал.

Как заказы достигают терминальных статусов, запускающих webhook.

Почему создание может дать сбой против того, почему принятый заказ завершается как `failed`.