Webhooks

Receive and verify terminal order-status events

View as Markdown

When an API-placed order reaches a terminal status, Voodoo Center POSTs 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:

FieldTypeDescription
order_idstring (uuid)The order id (matches id from the API).
merchant_order_idstringYour reference id, if you supplied one.
statusstringTerminal status: completed, failed or partial.
item_idinteger | nullThe numeric item id.
item_namestringHuman-readable item name.
quantitynumberOrdered quantity.
total_pricenumberAmount charged, in major currency units.
delivered_quantityintegerUnits actually delivered.
refund_amountnumberAmount refunded for undelivered units.
fieldsobjectSubmitted input fields (choice values as display names).
codesstring[]Delivered key/code strings (key items).
errorstringMachine error code when failed (e.g. OUT_OF_STOCK).
error_messagestringHuman-readable error message when failed.
completed_atstring (date-time) | nullWhen the order completed.
created_atstring (date-time)When the order was created.
Completed key order
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}
Partial delivery with refund
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}
Failed order (fully refunded)
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}

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:

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() # verify the RAW bytes BEFORE parsing 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 # Idempotency: you may receive an event more than once — dedupe on order_id.
15 # process(event["status"], event.get("codes"), event.get("refund_amount"), ...)
16 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 POSTed. 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.