Catalog

Download your product catalog and work with it offline
View as Markdown

Your product catalog — every orderable item with its price, stock, product type and input form — is published as a single downloadable snapshot. Download it, read it locally, and use each item’s id and fields to place orders. There is no paginated “list products” API endpoint; the snapshot is the catalog.

Copy your catalog download URL from the dashboard API page — the same page where you manage API keys and webhooks. It looks like:

https://downloads.voodoo.center/{client_id}.lmdb.zst

No separate authentication is needed to download the file, so keep your catalog link private.

What you get

The download is a single-file LMDB database, compressed with zstandard:

  • One file, one keyspace. Items live in LMDB’s default database. Each item is one record — key = the 8-byte big-endian item id, value = a compact JSON object.
  • Self-contained records. Every item embeds its full input form (fields, each choice field with its options) inline, so a single key lookup gives you everything you need to order that item.
  • Small and fast. A full catalog compresses ~45× (a ~1.5 MB database → ~34 KB). Decompress once, then memory-map the database and read it with no server round-trips.

Prices in the catalog are in cents (integers). Your final charge is always confirmed by the order response and your balance — see Placing orders.

Download and open it

Install the two readers, then download → decompress → open read-only. Use your catalog URL from the dashboard in place of the placeholder below.

Install dependencies
$pip install zstandard lmdb
Download & decompress (shell)
$# Your catalog URL from voodoo.center/dashboard/api
$CATALOG_URL="https://downloads.voodoo.center/{client_id}.lmdb.zst"
$
$# Stream the download straight through the decompressor — nothing is buffered
$# and the compressed archive is never written to disk.
$curl -sS "$CATALOG_URL" | zstd -d -o catalog.lmdb
Download, decompress & read (Python)
1import json
2import struct
3
4import lmdb
5import requests
6import zstandard
7
8# Your catalog URL from the dashboard (voodoo.center/dashboard/api)
9CATALOG_URL = "https://downloads.voodoo.center/{client_id}.lmdb.zst"
10
11
12def item_key(item_id: int) -> bytes:
13 # Records are keyed by the 8-byte big-endian item id (so they sort by id).
14 return struct.pack(">Q", item_id)
15
16
17# 1. Stream the download straight through the zstd decompressor to disk. The
18# archive is never held in memory (compressed or decompressed) as a whole.
19dctx = zstandard.ZstdDecompressor()
20with requests.get(CATALOG_URL, stream=True) as response:
21 response.raise_for_status()
22 response.raw.decode_content = True # undo any HTTP Content-Encoding
23 with open("catalog.lmdb", "wb") as db:
24 dctx.copy_stream(response.raw, db)
25
26# 2. Open the env read-only and memory-mapped. It is a single FILE, not a
27# directory (subdir=False), and read-only readers must pass lock=False. LMDB
28# pages in only the records you touch, so opening costs almost nothing.
29env = lmdb.open("catalog.lmdb", subdir=False, readonly=True, lock=False)
30
31# Look up one item by id — a single page read, not a scan.
32with env.begin() as txn:
33 raw = txn.get(item_key(689556))
34 item = json.loads(raw) if raw else None
35 print(item)
36
37# Iterate the whole catalog one record at a time — constant memory, ordered by id.
38with env.begin() as txn:
39 for key, value in txn.cursor():
40 item = json.loads(value)
41 if item["in_stock"]:
42 price = item["price"] / 100 # cents -> major units
43 print(item["id"], item["product_type"], f"{price:.2f}", item["name"])

Record schema

Each value is a JSON object:

FieldTypeDescription
idintegerItem id. Pass this as item_id when placing an order.
namestringHuman-readable item name.
product_typestringkey, topup, or service — determines the quantity/fields rules when ordering.
min_quantitynumberMinimum orderable quantity.
max_quantitynumberMaximum orderable quantity (effective cap).
priceinteger (cents)Item price. Divide by 100 for major units.
base_priceinteger (cents)Original/list price. When it’s greater than price, show it struck through.
subscriber_priceinteger (cents)Price with an active subscription.
amount_per_pricenumberUnits delivered per unit of quantity (relevant for top-ups). Rate per $1 = (amount_per_price / price) × 100 — see Item types.
in_stockbooleanWhether the item is currently orderable.
updated_atstring (ISO 8601)When this item last changed.
fieldsarrayThe item’s input form — see below.

Each entry in fields:

FieldTypeDescription
idintegerField id.
namestringField key — use this as the key in the order’s fields object.
typestringstring, integer, email, url, or choice.
requiredbooleanWhether a value must be supplied.
optionsarrayFor choice fields only: [{ "id": <int>, "value": "<label>" }, …].
Example record (a top-up item)
1{
2 "id": 689556,
3 "name": "Steam Top-up (USD)",
4 "product_type": "topup",
5 "min_quantity": 1,
6 "max_quantity": 1000,
7 "price": 500,
8 "base_price": 500,
9 "subscriber_price": 450,
10 "amount_per_price": 1,
11 "in_stock": true,
12 "updated_at": "2026-07-05T12:00:00+00:00",
13 "fields": [
14 { "id": 88, "name": "steam_login", "type": "string", "required": true, "options": [] }
15 ]
16}

Using the catalog with the Orders API

Everything you need to build a POST /v1/orders request is in the record:

  • item_id = the record’s id.
  • quantity — bounded by min_quantity/max_quantity. Required for topup (decimal) and key (integer, default 1); omit for service.
  • fields — required for topup and service. Key each entry by the field’s name. For a choice field, send the selected option’s id; for text fields, send the string.
Turn a catalog record into an order (Python)
1# `item` is a record read from the catalog above; `access_token` is your
2# Bearer token (see Authentication).
3import requests
4
5body = {
6 "item_id": item["id"],
7 "quantity": 1, # within min_quantity..max_quantity
8 "merchant_order_id": "order-2024",
9 "fields": {
10 "steam_login": "test", # a text field -> its string value
11 },
12}
13response = requests.post(
14 "https://api.voodoo.center/v1/orders",
15 headers={"Authorization": f"Bearer {access_token}"},
16 json=body,
17)
18response.raise_for_status()
19order = response.json()
20print(order["id"], order["status"])

Staying up to date

The snapshot is regenerated regularly. Its freshness is the object’s HTTP ETag / Last-Modified — use a conditional request so you only re-download when it actually changed:

Re-download only when it changed (Python)
1import os
2
3import lmdb
4import requests
5import zstandard
6
7# Your catalog URL from the dashboard (voodoo.center/dashboard/api)
8CATALOG_URL = "https://downloads.voodoo.center/{client_id}.lmdb.zst"
9
10DATABASE = "catalog.lmdb"
11ETAG_FILE = "catalog.etag"
12
13
14def refresh_catalog() -> bool:
15 """Download the snapshot only if it changed; return True if a new copy was fetched."""
16 headers = {}
17 # Send the ETag from the last download so the server can reply 304 Not Modified.
18 if os.path.exists(ETAG_FILE):
19 with open(ETAG_FILE) as f:
20 headers["If-None-Match"] = f.read().strip()
21
22 dctx = zstandard.ZstdDecompressor()
23 with requests.get(CATALOG_URL, headers=headers, stream=True) as response:
24 if response.status_code == 304: # nothing changed
25 return False
26 response.raise_for_status() # surface any real error
27
28 # A 200 reaches here — stream-decompress straight to the LMDB file, so the
29 # snapshot is never buffered in memory or written to disk compressed.
30 response.raw.decode_content = True
31 with open(DATABASE, "wb") as db:
32 dctx.copy_stream(response.raw, db)
33 etag = response.headers.get("ETag")
34
35 if etag:
36 with open(ETAG_FILE, "w") as f:
37 f.write(etag)
38 return True
39
40
41def open_catalog() -> lmdb.Environment:
42 return lmdb.open(DATABASE, subdir=False, readonly=True, lock=False)
43
44
45# Run refresh_catalog() on a schedule (e.g. every few minutes). It returns True
46# only when a new snapshot was fetched — reopen the LMDB env when it does, and
47# keep using the previous env until then.
48if refresh_catalog():
49 env = open_catalog()

With requests, a 304 Not Modified comes back as an ordinary response — check response.status_code == 304 to see your copy is still current and keep the file you already have. Only a 200 streams a fresh snapshot straight into catalog.lmdb and bumps the stored ETag.