> 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

When an API-placed order reaches a terminal status, Voodoo Center `POST`s a
signed JSON event to your configured **webhook URL**. This is the recommended
way to learn an order's outcome — it is push-based, so you don't have to poll.

## When webhooks fire

A webhook is sent when **all** of the following are true:

* The order reaches a terminal status: `completed`, `failed` or `partial`.
* The order was placed through the API (`source: "api"`). Orders created in the
  dashboard do **not** trigger webhooks.
* A webhook URL is configured for your account.

## Payload

The request body is compact, **sorted-keys** JSON with these fields:

| Field                | Type                       | Description                                              |
| -------------------- | -------------------------- | -------------------------------------------------------- |
| `order_id`           | string (uuid)              | The order id (matches `id` from the API).                |
| `merchant_order_id`  | string                     | Your reference id, if you supplied one.                  |
| `status`             | string                     | Terminal status: `completed`, `failed` or `partial`.     |
| `item_id`            | integer \| null            | The numeric item id.                                     |
| `item_name`          | string                     | Human-readable item name.                                |
| `quantity`           | number                     | Ordered quantity.                                        |
| `total_price`        | number                     | Amount charged, in major currency units.                 |
| `delivered_quantity` | integer                    | Units actually delivered.                                |
| `refund_amount`      | number                     | Amount refunded for undelivered units.                   |
| `fields`             | object                     | Submitted input fields (choice values as display names). |
| `codes`              | string\[]                  | Delivered key/code strings (key items).                  |
| `error`              | string                     | Machine error code when failed (e.g. `OUT_OF_STOCK`).    |
| `error_message`      | string                     | Human-readable error message when failed.                |
| `completed_at`       | string (date-time) \| null | When the order completed.                                |
| `created_at`         | string (date-time)         | When the order was created.                              |

```json title="Completed key order"
{
  "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="Partial delivery with refund"
{
  "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="Failed order (fully refunded)"
{
  "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"
}
```

## Verifying the signature

Every webhook carries a header:

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

where `<hex>` is `HMAC-SHA256(webhook_secret, raw_request_body_bytes)`.

Verify against the **raw received bytes**, exactly as delivered — never against
a dict you re-serialized. The signed body is compact, sorted-keys JSON, so any
re-encoding (different key order, spacing or number formatting) will produce a
different signature and fail verification.

Compute the same HMAC with your signing secret and compare it to the header
using a **constant-time** comparison.

## FastAPI receiver + verifier

This receiver verifies the raw bytes before parsing, uses a constant-time
compare, and returns `200` quickly:

```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()  # verify the RAW bytes BEFORE parsing 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()
    # Idempotency: you may receive an event more than once — dedupe on order_id.
    # process(event["status"], event.get("codes"), event.get("refund_amount"), ...)
    return {"ok": True}  # return 200 quickly; do heavy work in the background
```

## Acknowledging, retries and timeouts

* **Acknowledge with HTTP `200`** as soon as you have stored the event. Any
  non-`200` response (or a timeout) is treated as a failed delivery.
* Failed deliveries are retried **up to 3 attempts, 60 seconds apart**.
* Each attempt has a **30-second timeout**.
* **Redirects are not followed**, and your URL must be **publicly routable** — a
  server-side request forgery (SSRF) guard blocks internal/private addresses.

Because a single event may be delivered more than once (e.g. a retry after your
`200` was slow to arrive), make your handler **idempotent**: dedupe on
`order_id` and treat re-delivery of an already-processed event as a no-op.

Do heavy work (fulfilling your own downstream order, sending email, etc.) in the
background and return `200` fast — a slow handler risks the 30-second timeout
and an unnecessary retry.

## Configuring the URL and rotating the secret

Both the webhook settings live on the dashboard **API** page (they are
dashboard-only, not API endpoints):

* **Webhook URL** — where events are `POST`ed. Must be publicly reachable over
  HTTPS.
* **Signing secret** — used to compute `X-Webhook-Signature`. Its format is
  `whsec_...` and it is shown **once**, at creation/rotation. Rotate it if it
  may have leaked, and update `WEBHOOK_SECRET` in your receiver to match.

How orders reach the terminal statuses that trigger webhooks.

Why a create can fail vs. why an accepted order settles as `failed`.