> 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`.