Placing orders

Create an order, then track it to its terminal status
View as Markdown

Placing an order is a single call to POST /v1/orders. It charges your balance and returns 201 immediately with status: "pending". Fulfillment happens asynchronously — the final result arrives on your webhook and is always readable from GET /v1/orders/{id}.

Item types

Every catalog item has a product type that determines what you must send:

Product typeWhat it isquantityfields
keyGift-card codes / license keysInteger count, default 1Not used
topupBalance/currency top-upsDecimal amount, requiredRequired
serviceManual/game servicesOmit (always 1)Required

For a deep dive on each type — how top-up pricing works (price vs amount_per_price), calculating the rate per $1, and per-type Python recipes — see Item types.

You discover an item’s item_id, product type, quantity bounds and field definitions from the catalog — a snapshot you download, not part of this API. The API has no catalog-browsing endpoint.

Request fields

  • item_id (integer, required) — the numeric item id from the catalog.
  • quantity (number) — must fall within the item’s min/max range.
    • key: an integer count (defaults to 1).
    • topup: a decimal amount (required).
    • service: omit it — it is always 1.
  • merchant_order_id (string, optional) — your own reference id, unique per account. Use it for idempotency and to correlate webhook events with your records.
  • fields (object) — required for topup and service items only, keyed by the item’s field names. key items take no fields.
  • price (number, optional) — the total you expect to be charged, in the same units as the response’s price (max 2 decimals). Send it to have the order rejected rather than charged at a different amount — see Price protection below.

Field values: text vs choice

Each item field is either a text field or a choice field:

  • Text field → send the value as a string (e.g. "steam_login": "test").
  • Choice field → send the numeric choice id, not the label.

In responses, submitted choice fields are echoed back as their human-readable display name, even though you submitted the numeric id. This is display-only — keep sending numeric choice ids on create.

Creating an order

Amazon Gift Card 25 TRY (key item)
$curl -X POST https://api.voodoo.center/v1/orders \
> -H "Authorization: Bearer <access_token>" \
> -H "Content-Type: application/json" \
> -d '{ "item_id": 695516, "quantity": 1 }'
Steam Top-up USD (top-up with a text field)
$curl -X POST https://api.voodoo.center/v1/orders \
> -H "Authorization: Bearer <access_token>" \
> -H "Content-Type: application/json" \
> -d '{ "item_id": 689556, "quantity": 1, "fields": { "steam_login": "test" } }'
PUBG direct top-up (service item)
$curl -X POST https://api.voodoo.center/v1/orders \
> -H "Authorization: Bearer <access_token>" \
> -H "Content-Type: application/json" \
> -d '{ "item_id": 693038, "fields": { "user_id": "<User ID>" } }'

A successful create returns 201 with the order in pending:

201 Created
1{
2 "id": "0190f8a1-6b2c-7e33-9a10-4c1d2e3f5a6b",
3 "item": 689556,
4 "item_name": "Steam Top-up (USD)",
5 "item_product_type": "topup",
6 "quantity": 1,
7 "delivered_quantity": 0,
8 "price": 5.00,
9 "refund_amount": 0,
10 "fields": { "steam_login": "test" },
11 "status": "pending",
12 "error": "",
13 "error_message": "",
14 "codes": [],
15 "merchant_order_id": "",
16 "source": "api",
17 "created_by": { "id": "0190f8a0-1111-7000-8000-000000000001", "email": "[email protected]" },
18 "created_at": "2026-07-05T12:00:00Z",
19 "completed_at": null
20}

Price protection

Your prices come from a catalog snapshot you download — so between the moment you read an item’s price and the moment you place the order, the real price can have moved (a fee change, a provider price update, a subscription that started or lapsed). By default the order is charged at the current price, whatever your snapshot said.

Send the optional price field to make that explicit: it is the total you expect to be charged, and the order is rejected instead of charged if it doesn’t match.

Order only if it costs exactly 24.00
$curl -X POST https://api.voodoo.center/v1/orders \
> -H "Authorization: Bearer <access_token>" \
> -H "Content-Type: application/json" \
> -d '{ "item_id": 695516, "quantity": 1, "price": 24.00 }'

Two rules to get it right:

  • It is the order total, not the unit price — already multiplied by quantity (and, for top-ups, scaled by amount_per_price; see Item types for that math).
  • Maximum 2 decimals. A finer value is rejected as a validation_error rather than silently rounded.

On a mismatch you get 400 with code: "price_mismatch" and the real price, so you can decide and retry in one round trip — nothing is created and nothing is charged:

400 — price mismatch
1{
2 "detail": "Price mismatch: the current price for this order is 26.50",
3 "code": "price_mismatch",
4 "actual_price": 26.50,
5 "submitted_price": 24.00
6}

Because the rejected request created no order, retrying with actual_price (and the same merchant_order_id) is safe — the id was never consumed.

Omit price and nothing changes: the order is charged at the current price, as before. Existing integrations need no update.

The order lifecycle

An order starts pending and settles on one of three terminal statuses. There are six statuses in total — three you wait through and three that end the order:

Every status

statusTerminal?MeaningFields worth readingDo you act?
pendingNoAccepted and charged; fulfillment has not started yet.price (already debited)No — wait
processingNoFulfillment is running at the provider.delivered_quantity (may climb)No — wait
need_client_codeNoPaused: the provider sent a confirmation code to your end customer and will not continue without it.item_product_type: "topup"Yessubmit the code
completedYesEvery unit delivered.codes (key items), delivered_quantity, completed_atNo
partialYesSome units delivered, the rest refunded.delivered_quantity, refund_amount, error (often empty)No
failedYesNothing delivered; the charge was fully refunded.error, error_message, refund_amountNo

Terminal means the status will never change again — it is safe to close the order out in your system. The three non-terminal statuses can still move, and only one of them (need_client_code) needs anything from you; pending and processing resolve on their own.

Two properties are worth relying on:

  • An order never returns to pending, and never moves from one terminal status to another.
  • need_client_code is the only status that goes backwards — to processing — and only because you submitted a code.

The fields those statuses expose:

  • codes — delivered key strings, for key items. Empty until completed or partial.
  • delivered_quantity — units delivered so far; equals quantity on completed.
  • refund_amount — refunded for undelivered units on partial or failed; 0 otherwise.
  • error / error_message — a stable machine code plus human text. Always set on failed. On partial it is usually empty: an ordinary short delivery carries no error code, and only a partial that ran out of time reports one (ORDER_TIMEOUT). Treat error as present-or-empty rather than as the reason for every shortfall — the authoritative signal for a shortfall is delivered_quantity vs quantity. Branch on error, display error_message. The full list is in Order error codes.
  • completed_at — set only on completed. It stays null on partial and failed, so do not use it to detect that an order has settled — test status instead.

An item out of stock at create time is rejected up front as a 400 validation error (“Item is not available”). If stock or fulfillment fails after the order is accepted, the order settles as terminal status: "failed" (surfaced on your webhook) — not an HTTP error. See Errors.

Orders awaiting a customer code

Some login-method top-ups make the provider send a confirmation code directly to your end customer rather than to you. The order then parks at status: "need_client_code":

  • polling stops — the order will not move on its own, no matter how long you wait;
  • if you have a webhook URL configured, you get a need_client_code event (this is the only non-terminal status that fires one);
  • nothing is charged or refunded — the order is simply held.

Collect the code from your customer and post it back:

$curl -X POST https://api.voodoo.center/v1/orders/0190f8a1-6b2c-7e33-9a10-4c1d2e3f5a6b/client-code -H "Authorization: Bearer $ACCESS_TOKEN" -H "Content-Type: application/json" -d '{"client_code": "123456"}'

The call is synchronous: the provider’s verdict comes back inline, so you can show a rejected code to your customer and let them retry immediately. On success the order returns to processing and fulfilment restarts from the first step, settling on a terminal status as usual.

The code accepts letters, numbers, spaces, dot, underscore and hyphen, up to 255 characters. Submitting to an order that is not in need_client_code returns 400 with code: "client_code_rejected" — so a retry after the order has already resumed is safely rejected rather than double-applied.

Reading an order

Fetch the current state of any of your orders with GET /v1/orders/{id}:

Get an order
$curl https://api.voodoo.center/v1/orders/0190f8a1-6b2c-7e33-9a10-4c1d2e3f5a6b \
> -H "Authorization: Bearer <access_token>"
Completed key order
1{
2 "id": "0190f8a1-6b2c-7e33-9a10-4c1d2e3f5a6b",
3 "item": 695516,
4 "item_name": "Amazon Gift Card 25 TRY",
5 "item_product_type": "key",
6 "quantity": 1,
7 "delivered_quantity": 1,
8 "price": 24.00,
9 "refund_amount": 0,
10 "fields": {},
11 "status": "completed",
12 "error": "",
13 "error_message": "",
14 "codes": ["ABCD-1234-EFGH-5678"],
15 "merchant_order_id": "",
16 "source": "api",
17 "created_by": { "id": "0190f8a0-1111-7000-8000-000000000001", "email": "[email protected]" },
18 "created_at": "2026-07-05T12:00:00Z",
19 "completed_at": "2026-07-05T12:00:07Z",
20 "webhook": {
21 "status": "delivered",
22 "attempts": 1,
23 "max_attempts": 3,
24 "status_code": 200,
25 "last_attempt_at": "2026-07-05T12:00:08Z",
26 "next_retry_at": null,
27 "error_message": "",
28 "created_at": "2026-07-05T12:00:08Z"
29 }
30}

Webhook delivery status

For orders placed through the API (source: "api") with a webhook URL configured, the order detail carries a webhook object showing whether the terminal event reached your endpoint — handy for debugging your receiver without leaving the API. It is null for dashboard orders or when no webhook is set.

FieldTypeDescription
statusstringpending (queued / awaiting retry), delivered (your endpoint returned 200), or failed (gave up after the max attempts).
attemptsintegerDelivery attempts made so far.
max_attemptsintegerMaximum attempts before giving up (3).
status_codeinteger | nullHTTP status your endpoint returned on the last attempt.
last_attempt_atstring (date-time) | nullWhen the last attempt was made.
next_retry_atstring (date-time) | nullWhen the next retry is scheduled, while pending.
error_messagestringLast delivery error, if any.
created_atstring (date-time)When the webhook was first queued.

See the Webhooks guide for the event payload and signature verification.

GET /v1/orders/{id} returns 404 (code: "not_found") for an order that does not exist or is not owned by your account. There is no list-orders endpoint in the API — browse orders in the dashboard.

Webhook vs polling

Prefer the webhook: Voodoo Center pushes the terminal event to your URL as soon as the order settles — no polling needed. Polling GET /v1/orders/{id} is a fine fallback (e.g. if a delivery was missed), but the webhook is the lower-latency, lower-load path.