Webhooks

Receive and verify order-status events

View as Markdown

When an API-placed order reaches a terminal status — or pauses awaiting a code from your customer — 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 poll.

When webhooks fire

A webhook is sent when all of these are true:

  • The order reaches a terminal status: completed, failed or partial.
  • Or the order pauses on need_client_code — the one NON-terminal event, telling you to collect a confirmation code from your customer and submit it via POST /v1/orders/{id}/client-code. The order does not move until you do. See Orders awaiting a customer code.
  • The order was placed through the API (source: "api"). Dashboard orders 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.
statusstringcompleted, failed, partial — or need_client_code (non-terminal; awaiting a code from your customer).
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 — sent only when status is failed (e.g. OUT_OF_STOCK). Always "" on partial.
error_messagestringHuman-readable text for error; "" whenever error is "".
completed_atstring (date-time) | nullWhen the order was fully delivered. Non-null only when status is completed; null on partial and failed.
created_atstring (date-time)When the order was created.

The webhook body is not identical to GET /v1/orders/{id}. A partial order that timed out shows error: "ORDER_TIMEOUT" on the API but sends error: "" in the webhook — the payload only carries an error code for failed. If you need the error code on a partial, read the order back from the API rather than trusting the event body.

Completed key order
1{
2 "order_id": "0190f8a1-6b2c-7e33-9a10-4c1d2e3f5a6b",
3 "merchant_order_id": "",
4 "status": "completed",
5 "item_id": 695516,
6 "item_name": "Amazon Gift Card 25 TRY",
7 "quantity": 1,
8 "total_price": 24.00,
9 "delivered_quantity": 1,
10 "refund_amount": 0,
11 "fields": {},
12 "codes": ["ABCD-1234-EFGH-5678"],
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": "order-2024",
4 "status": "partial",
5 "item_id": 695516,
6 "item_name": "Amazon Gift Card 25 TRY",
7 "quantity": 2,
8 "total_price": 48.00,
9 "delivered_quantity": 1,
10 "refund_amount": 24.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": "",
4 "status": "failed",
5 "item_id": 689556,
6 "item_name": "Steam Top-up (USD)",
7 "quantity": 1,
8 "total_price": 5.00,
9 "delivered_quantity": 0,
10 "refund_amount": 5.00,
11 "fields": { "steam_login": "test" },
12 "codes": [],
13 "error": "OUT_OF_STOCK",
14 "error_message": "Item is currently 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-Signature: <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) produces a different signature and fails 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_signature: str = Header(default="")):
9 raw = await request.body() # verify the RAW bytes BEFORE parsing JSON
10 expected = hmac.new(WEBHOOK_SECRET.encode(), raw, hashlib.sha256).hexdigest()
11 if not hmac.compare_digest(x_signature, expected):
12 raise HTTPException(status_code=401, detail="invalid signature")
13 event = await request.json()
14 # Idempotency: an event may arrive 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) counts 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), 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 webhook settings live on the dashboard API page (dashboard-only, not API endpoints):

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