Placing orders
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:
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 to1).topup: a decimal amount (required).service: omit it — it is always1.
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 fortopupandserviceitems only, keyed by the item’s field names.keyitems take nofields.price(number, optional) — the total you expect to be charged, in the same units as the response’sprice(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
cURL
Python
A successful create returns 201 with the order in pending:
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.
Two rules to get it right:
- It is the order total, not the unit price — already multiplied by
quantity(and, for top-ups, scaled byamount_per_price; see Item types for that math). - Maximum 2 decimals. A finer value is rejected as a
validation_errorrather 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:
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
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_codeis the only status that goes backwards — toprocessing— and only because you submitted a code.
The fields those statuses expose:
codes— delivered key strings, forkeyitems. Empty untilcompletedorpartial.delivered_quantity— units delivered so far; equalsquantityoncompleted.refund_amount— refunded for undelivered units onpartialorfailed;0otherwise.error/error_message— a stable machine code plus human text. Always set onfailed. Onpartialit is usually empty: an ordinary short delivery carries no error code, and only apartialthat ran out of time reports one (ORDER_TIMEOUT). Treaterroras present-or-empty rather than as the reason for every shortfall — the authoritative signal for a shortfall isdelivered_quantityvsquantity. Branch onerror, displayerror_message. The full list is in Order error codes.completed_at— set only oncompleted. It staysnullonpartialandfailed, so do not use it to detect that an order has settled — teststatusinstead.
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_codeevent (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:
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}:
cURL
Python
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.
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.