Ludovex API

Connect your catalogue, wallet and orders through REST and JSON. Wallet balances and order prices are in USDT; custom Steam wallet amounts support USD, RUB, KZT and UAH.

OpenAPI 3.1 JSONGet an API keyhttps://api.ludovex.org/api/v2

Authentication and permissions

Send X-API-Key with each authenticated request. Create a key in Dashboard → API keys; its full value is displayed once.

  • readRead your profile, catalogue and orders.
  • orderCreate orders. Add read separately if this key also polls their status.
  • balanceRead the wallet balance. The read scope alone does not grant balance access.

Keep keys on your server, outside browser code and logs. Restrict a key to your server's outbound IPs/CIDRs when possible; an empty allowlist permits any IP.

Examples read LUDOVEX_API_KEY from your server environment. Set LUDOVEX_GAME_KEY and LUDOVEX_VARIATION_ID to values returned by the catalogue, and LUDOVEX_ORDER_KEY to a unique reference persisted for one logical order. Set LUDOVEX_ORDER_ID to the returned order ID when checking its status.

Example
curl --fail-with-body "https://api.ludovex.org/api/v2/me" \
  -H "X-API-Key: $LUDOVEX_API_KEY" \
  -H "Accept: application/json"

From catalogue to confirmed delivery

Discover valid product inputs before submitting a paid order.

  1. GET /catalog lists the currently offered games. Keep the returned game_key exactly as received; identifiers can be opaque. Do not construct them from product names.
  2. GET /catalog/{gameKey} returns current offers. Choose the returned variation_id, inspect availability and required input fields, and keep any category/region identifier unchanged.
  3. POST /order creates an order using those identifiers and a persisted idempotency key. Send player_id, zone_id or provider_inputs only as required by the chosen product. Never put passwords in player_id or zone_id; when the catalogue explicitly requires credentials, send them only in the declared provider_inputs fields and keep them out of logs.
  4. HTTP 201 confirms creation, not delivery. Inspect status: PROCESSING needs a later GET /order/{id} or a verified webhook. Only treat COMPLETED as completed; handle FAILED and REFUNDED separately.

Legacy identifiers remain accepted for compatibility. New integrations should use the exact identifiers returned by Ludovex. A catalogue quote does not reserve its price.

Rate limits and daily quotas

The API-key limit is 60 requests/minute. POST /order also has a 30 requests/minute limit; order calls count toward both limits.

Use X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset when returned. For RATE_LIMITED, wait for Retry-After before retrying; do not retry paid orders with a new idempotency key.

The daily order quota is shared by the account's API keys and depends on its plan configuration. DAILY_LIMIT_EXCEEDED means this quota is exhausted, not the minute limit. The daily window resets at midnight Asia/Tashkent. GET /me returns identity and plan, not current daily usage or reset fields.

Errors

Responses use { ok, data } or { ok: false, error: { code, message } }. Branch on error.code, not translated message text.

CodeHTTPMeaning
UNAUTHORIZED401Missing, invalid or revoked API key.
FORBIDDEN_SCOPE403The key lacks the required scope.
IP_NOT_ALLOWED403The key is valid, but the request's source IP is not allowed.
NOT_FOUND404The requested resource was not found or is not owned by this account.
VALIDATION_FAILED422Invalid request fields. Correct the request before retrying.
IDEMPOTENCY_REQUIRED422Send idempotency_key in the body or the Idempotency-Key header.
INVALID_ORDER422The product, amount, input or order is invalid. Inspect the message and correct the request.
INSUFFICIENT_BALANCE402Insufficient wallet balance.
RATE_LIMITED429Minute rate limit reached. Follow Retry-After.
DAILY_LIMIT_EXCEEDED429Daily plan quota reached. Retry only after the quota resets or your plan changes.

Safe retries

Persist one idempotency key with the exact order request before sending it. If the connection times out, retry that same request and key; do not create a new key to recover an uncertain result.

Use a new key for a new logical order or changed inputs. Reusing a key is not a way to edit an existing order. Keep the returned order ID for status checks and reconciliation.

Endpoints

https://api.ludovex.org/api/v2

GET/api/v2/meScope: read

The key owner's id, email, name, status and plan.

curl --fail-with-body "https://api.ludovex.org/api/v2/me" \
  -H "X-API-Key: $LUDOVEX_API_KEY" \
  -H "Accept: application/json"
GET/api/v2/balanceScope: balance

Current wallet balance and currency (USDT); requires balance scope.

curl --fail-with-body "https://api.ludovex.org/api/v2/balance" \
  -H "X-API-Key: $LUDOVEX_API_KEY" \
  -H "Accept: application/json"
GET/api/v2/catalogScope: read

Current game catalogue. Preserve each returned game_key.

curl --fail-with-body "https://api.ludovex.org/api/v2/catalog" \
  -H "X-API-Key: $LUDOVEX_API_KEY" \
  -H "Accept: application/json"
GET/api/v2/catalog/{gameKey}Scope: read

Offers for one game, with account-specific prices. Optional category selects a returned region identifier.

  • gameKeyRequiredExact game_key returned by the catalogue; encode it as a URL path component.
  • categoryOptionalOptional category/region identifier returned by the catalogue; URL-encode it.
curl --fail-with-body "https://api.ludovex.org/api/v2/catalog/$LUDOVEX_GAME_KEY" \
  -H "X-API-Key: $LUDOVEX_API_KEY" \
  -H "Accept: application/json"
POST/api/v2/orderScope: order

Create a paid order. Delivery may remain PROCESSING after HTTP 201. These examples submit a standard variation-based product; custom Steam orders are documented below.

  • game_keyRequiredExact game_key returned by the catalogue; encode it as a URL path component.
  • variation_idWhen applicableRequired for ordinary products. Optional for Steam with amount + currency or legacy amount_usd; if supplied, it must match the quote.
  • amountWhen applicableSteam requested wallet amount as a decimal string with up to 2 fractional digits. Read the selected currency’s min/max/step from wallet_currencies. Pair with currency; do not combine with a legacy amount field.
  • currencyWhen applicableSteam wallet currency: USD, RUB, KZT or UAH. Required with amount. Exchange rates use 1 USD as their base; the final debit remains USDT.
  • expected_price_usdtOptionalSteam quote price_usdt, kept as the exact 8-decimal string. Recommended to bind the accepted price; a changed quote returns STEAM_QUOTE_CHANGED before debit. Reuse the original idempotency key after an ambiguous timeout.
  • amount_usdWhen applicableLegacy Steam USD decimal-string alias, for example "10.25". Read min_usd/max_usd/step from custom_amount. Do not combine with amount + currency or custom_amount_uzs.
  • player_idWhen applicablePlayer ID or login when the selected product requires it. Not required for every code or gift card.
  • zone_idWhen applicableServer/zone when required by the selected product.
  • provider_inputsWhen applicableAdditional fields explicitly declared by the product's input schema, including credential fields only when requested there. Send an object with those exact field names; never log credentials or place them in player_id/zone_id.
  • idempotency_keyWhen applicablePersisted unique key for this logical order, or send the Idempotency-Key header.
python3 - <<'PY' > order.json
import json, os
body = {
    "game_key": os.environ["LUDOVEX_GAME_KEY"],
    "variation_id": os.environ["LUDOVEX_VARIATION_ID"],
}
for field in ("player_id", "zone_id"):
    if os.environ.get(field.upper()):
        body[field] = os.environ[field.upper()]
if os.environ.get("PROVIDER_INPUTS_JSON"):
    body["provider_inputs"] = json.loads(os.environ["PROVIDER_INPUTS_JSON"])
print(json.dumps(body))
PY
curl --fail-with-body -X POST "https://api.ludovex.org/api/v2/order" \
  -H "X-API-Key: $LUDOVEX_API_KEY" \
  -H "Idempotency-Key: $LUDOVEX_ORDER_KEY" \
  -H "Content-Type: application/json" \
  --data-binary @order.json
GET/api/v2/order/{id}Scope: read

Current status and delivered details for an order owned by this account.

  • idRequiredOrder ID returned by creation, as an integer path parameter.
curl --fail-with-body "https://api.ludovex.org/api/v2/order/$LUDOVEX_ORDER_ID" \
  -H "X-API-Key: $LUDOVEX_API_KEY" \
  -H "Accept: application/json"
GET/api/v2/ordersScope: read

Order history, newest order IDs first. Without page or before_id, the existing array response remains unchanged (default 50, maximum 100 rows).

  • limitOptionalNumber of newest rows, from 1 to 100; default 50.
  • pageOptionalOpt into the paginated object: page 1–1000. Prefer before_id for deeper history.
  • before_idOptionalExclusive descending order-ID cursor returned as next_cursor. Requires page absent or page=1.
  • snapshot_idOptionalRepeat the first response snapshot_id to keep newer orders out of subsequent pages.
  • created_fromOptionalInclusive created-date start, YYYY-MM-DD, in Asia/Tashkent (UTC+5).
  • created_toOptionalInclusive created-date end, YYYY-MM-DD, in Asia/Tashkent (UTC+5).
  • updated_sinceOptionalInclusive update time in RFC3339, for example 2026-10-21T00:00:00Z. Use overlapping windows and deduplicate.
  • statusOptionalPROCESSING, COMPLETED, FAILED or REFUNDED. Refund flags are included in the effective status filter.

For complete history, start with page=1. Read data.items, data.has_more, data.next_cursor and data.snapshot_id. Continue with before_id=next_cursor and the same snapshot_id and filters until has_more=false. Do not combine before_id with page>1. The snapshot bounds new order IDs; existing orders can still change, so deduplicate by order ID and overlap updated_since sync windows.

The paginated response also includes total, page and limit. Paginated items include updated_at and refunded. Account exports are available on the dashboard Orders and Transactions pages; oversized exports must use a smaller date range.

curl --fail-with-body "https://api.ludovex.org/api/v2/orders?page=1&limit=20" \
  -H "X-API-Key: $LUDOVEX_API_KEY" \
  -H "Accept: application/json"

Steam: USD, RUB, KZT and UAH

Choose the Steam wallet currency. Your Ludovex balance is charged in USDT, including the quoted 3.5% Steam discount.

  1. Find Steam using GET /api/v2/catalog and keep its game_key. Read custom_amount, exchange_rates and wallet_currencies from its catalogue detail; the read scope is required.
  2. GET /api/v2/catalog/{gameKey}?amount=1000.00&currency=RUB returns a data array with one quote. Without an amount, the detail returns the minimum USD quote and current currency metadata. No order or wallet debit is created.
  3. Send the same game_key, amount and currency, the Steam login as player_id, the accepted price_usdt as expected_price_usdt, and a persistent idempotency key to POST /api/v2/order. The order scope is required.

Set STEAM_GAME_KEY or LUDOVEX_STEAM_GAME_KEY in the samples to the lvxa_g_… identifier returned by the catalogue. Do not construct it yourself. The legacy steam-topup key is also accepted.

1. Validate the amount and get a price — read scope
# STEAM_GAME_KEY = game_key returned by GET /catalog
# Example wallet amount: 1000.00 RUB. Also supported: USD, KZT, UAH.
curl --get "https://api.ludovex.org/api/v2/catalog/$STEAM_GAME_KEY" \
  --data-urlencode "amount=1000.00" --data-urlencode "currency=RUB" \
  -H "X-API-Key: $LUDOVEX_API_KEY"
amount / wallet_currency
amount is the wallet amount and wallet_currency is its currency in the quote. Requests use currency: USD, RUB, KZT or UAH. Send a decimal JSON string such as "1000.00", with at most 2 decimal places.
price_usdt / currency
price_usdt is the exact USDT debit as an 8-decimal string; currency is USDT. Keep the string intact. amount_usd_equivalent is a USD conversion reference, not a USDT debit.
exchange_rates / wallet_currencies
exchange_rates.base is USD: each rate is the wallet currency received per 1 USD, not per USDT. Read updated_at and the selected exchange_rate; never invent a rate when unavailable.
nominal_usd / estimated_wallet_amount
For RUB, KZT and UAH, nominal_usd rounds amount / rate upward to 2 decimal places before the 3.5% discount. estimated_wallet_amount is that nominal multiplied by the rate; it is an estimate, not a guarantee of the final wallet credit. amount remains the requested amount.
custom_amount
Read the current per-currency min, max and step from wallet_currencies. custom_amount describes amount/currency fields and USD-equivalent limits. Do not hardcode converted bounds.
discount_percent / discount_amount_usdt
discount_percent and discount_amount_usdt report the 3.5% Steam discount from base_price_usdt. The server calculates the final price_usdt; do not round it to cents or recalculate it in the client.
expected_price_usdt
Send the accepted price_usdt as expected_price_usdt. If the recalculated debit differs, STEAM_QUOTE_CHANGED is returned before debit. Obtain a fresh quote and explicit confirmation before starting a new attempt.
2. Order a top-up for the Steam login — order scope
# Set these from the accepted quote: STEAM_AMOUNT, STEAM_CURRENCY,
# STEAM_PRICE_USDT (= price_usdt). Persist STEAM_ORDER_KEY for this order.
# STEAM_LOGIN is the recipient login; STEAM_GAME_KEY is the same catalog ID.
python3 - <<'PY' > steam-order.json
import json, os
print(json.dumps({
    "game_key": os.environ["STEAM_GAME_KEY"],
    "amount": os.environ["STEAM_AMOUNT"],
    "currency": os.environ["STEAM_CURRENCY"],
    "expected_price_usdt": os.environ["STEAM_PRICE_USDT"],
    "player_id": os.environ["STEAM_LOGIN"],
}))
PY
curl -X POST "https://api.ludovex.org/api/v2/order" \
  -H "X-API-Key: $LUDOVEX_API_KEY" \
  -H "Idempotency-Key: $STEAM_ORDER_KEY" \
  -H "Content-Type: application/json" --data-binary @steam-order.json

Always include amount and currency for the new custom-amount contract. A variation_id alone does not replace them. player_id is the Steam login.

Legacy amount_usd remains an explicit USD alias; custom_amount_uzs is also retained. Choose one amount contract per request; never combine them.

After a timeout, retry the exact order with the same idempotency key. Never create a new key just because the reply was lost. Use a new key for a different confirmed intent or a definite pre-debit quote rejection. Track the order status; 201 can mean processing.

Unsupported currencies, JSON-number amounts, more than 2 decimal places, mixed amount contracts or out-of-range amounts are rejected. Rates or price may be unavailable; do not proceed using a guessed quote.

Signed webhooks

Configure your public receiver and subscribed events in Dashboard → Settings → Outbound webhook. Deliveries are at least once, so receivers must be idempotent.

The body is { event, data }. An event can be delivered more than once.

  • order.completedOrder completed; inspect the order's delivered details.
  • order.failedOrder failed.
  • order.refundedAn order refund was recorded.
  • payment.succeededA balance top-up was confirmed.

Verify X-Ludovex-Signature as sha256=HMAC_SHA256(secret, timestamp + "." + rawBody), using X-Ludovex-Timestamp and the exact request bytes. Compare in constant time and reject missing, malformed or stale timestamps; the examples allow a five-minute clock difference.

Use X-Ludovex-Delivery-Id as the durable delivery deduplication key. Retries retain that ID and body but receive a fresh timestamp/signature. The signed test event has no delivery-ID header: verify it, acknowledge it, and perform no business action.

The examples acknowledge a business event only after writing it to a private SQLite inbox with a unique delivery ID. A separate worker must process unprocessed rows and atomically record the business action and completion. Returning 2xx before durable storage can lose events.

Also make business actions idempotent by the signed order/payment identity and event. The delivery-ID header is not included in the HMAC. Do not credit a wallet merely because a signature is valid. Keep clocks synchronized, protect the inbox and secret, and do not log delivered content.

On a non-2xx response or timeout, the dispatcher retries with backoff, up to five attempts. Keep the handler short. Inbox processing retries belong to your worker; duplicate deliveries should receive 2xx once their original payload is safely stored.

Inspect delivery status, attempts and HTTP responses in the dashboard webhook delivery history. One-off test events are not stored in that history.

Open webhook delivery history

Node example: Node 24+, Express and WEBHOOK_SECRET. Python example: Flask and WEBHOOK_SECRET. WEBHOOK_INBOX_DIR must be a private persistent directory. These single-host examples bind to loopback behind your HTTPS reverse proxy; use a shared transactional inbox when running several hosts.

Verify, persist, then acknowledge
import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import { DatabaseSync } from 'node:sqlite';
import express from 'express';

const secret = process.env.WEBHOOK_SECRET;
if (!secret) throw new Error('WEBHOOK_SECRET is required');
const directory = process.env.WEBHOOK_INBOX_DIR ?? './private-webhooks';
fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
const file = path.join(directory, 'inbox.sqlite');
const db = new DatabaseSync(file);
fs.chmodSync(file, 0o600);
db.exec('PRAGMA busy_timeout=5000; PRAGMA synchronous=FULL;');
db.exec('CREATE TABLE IF NOT EXISTS inbox (' +
  'delivery_id TEXT PRIMARY KEY, payload BLOB NOT NULL, payload_hash TEXT NOT NULL,' +
  'received_at INTEGER NOT NULL, processed_at INTEGER)');
const insert = db.prepare('INSERT OR IGNORE INTO inbox (delivery_id,payload,payload_hash,received_at) VALUES (?,?,?,?)');
const existing = db.prepare('SELECT payload_hash FROM inbox WHERE delivery_id=?');
const app = express();

app.post('/webhooks/ludovex', express.raw({ type: 'application/json', limit: '1mb' }), (req, res) => {
  const raw = req.body;
  const ts = req.get('X-Ludovex-Timestamp');
  const sig = req.get('X-Ludovex-Signature');
  if (!Buffer.isBuffer(raw) || !/^\d{10}$/.test(ts ?? '') ||
      Math.abs(Date.now() / 1000 - Number(ts)) > 300 || !/^sha256=[a-f0-9]{64}$/.test(sig ?? '')) {
    return res.sendStatus(401);
  }
  const expected = crypto.createHmac('sha256', secret).update(ts + '.').update(raw).digest();
  const received = Buffer.from(sig.slice(7), 'hex');
  if (!crypto.timingSafeEqual(expected, received)) return res.sendStatus(401);
  let event;
  try { event = JSON.parse(raw.toString('utf8')); } catch { return res.sendStatus(400); }
  if (!event || typeof event.event !== 'string' || !event.data || typeof event.data !== 'object') return res.sendStatus(400);
  if (event.event === 'test') return res.sendStatus(204);
  const deliveryId = req.get('X-Ludovex-Delivery-Id');
  if (!/^[1-9]\d{0,19}$/.test(deliveryId ?? '')) return res.sendStatus(400);
  const hash = crypto.createHash('sha256').update(raw).digest('hex');
  try {
    insert.run(deliveryId, raw, hash, Math.floor(Date.now() / 1000));
    if (existing.get(deliveryId)?.payload_hash !== hash) return res.sendStatus(409);
    return res.sendStatus(204);
  } catch {
    return res.sendStatus(503);
  }
});
app.listen(3000, '127.0.0.1');
Reseller API Documentation: Orders & Game Top-ups | Ludovex