API Ludovex
Подключите каталог, кошелёк и заказы через REST и JSON. Баланс и цены заказов указаны в USDT; произвольная сумма кошелька Steam поддерживает USD, RUB, KZT и UAH.
Аутентификация и разрешения
Передавайте X-API-Key в каждом защищённом запросе. Создайте ключ в Кабинет → API-ключи; полное значение показывается только один раз.
- readЧтение профиля, каталога и заказов.
- orderСоздание заказов. Добавьте read отдельно, если этот ключ также проверяет их статус.
- balanceЧтение баланса кошелька. Одного разрешения read для этого недостаточно.
Храните ключи на сервере, не помещайте их в код браузера или журналы. По возможности ограничьте ключ внешними IP/CIDR вашего сервера; пустой список разрешает любой IP.
Примеры читают LUDOVEX_API_KEY из окружения сервера. В LUDOVEX_GAME_KEY и LUDOVEX_VARIATION_ID укажите значения из каталога, а в LUDOVEX_ORDER_KEY — уникальный идентификатор, сохранённый для одного логического заказа. Для проверки статуса запишите полученный ID заказа в LUDOVEX_ORDER_ID.
curl --fail-with-body "https://api.ludovex.org/api/v2/me" \
-H "X-API-Key: $LUDOVEX_API_KEY" \
-H "Accept: application/json"От каталога до подтверждённой доставки
Уточните обязательные поля товара до отправки платного заказа.
- GET /catalog возвращает доступные предложения игр. Сохраняйте game_key без изменений: идентификатор может быть непрозрачным. Не составляйте его из названия товара.
- GET /catalog/{gameKey} возвращает текущие предложения. Выберите полученный variation_id, проверьте доступность и обязательные поля; сохраняйте идентификаторы category/region без изменений.
- POST /order создаёт заказ с этими идентификаторами и заранее сохранённым ключом идемпотентности. Передавайте player_id, zone_id или provider_inputs только по требованиям выбранного товара. Не помещайте пароли в player_id или zone_id; если каталог явно требует учётные данные, передавайте их только в объявленных полях provider_inputs и не записывайте в журналы.
- HTTP 201 подтверждает создание, а не доставку. Проверяйте status: для PROCESSING нужен последующий GET /order/{id} или проверенный webhook. Только COMPLETED означает выполнение; FAILED и REFUNDED обрабатывайте отдельно.
Прежние идентификаторы принимаются для совместимости. В новых интеграциях используйте точные значения из ответов Ludovex. Запрос цены по каталогу не фиксирует цену.
Частота запросов и дневные квоты
Для API-ключа действует лимит 60 запросов в минуту. POST /order дополнительно ограничен 30 запросами в минуту; запросы заказов учитываются в обоих лимитах.
Учитывайте X-RateLimit-Limit, X-RateLimit-Remaining и X-RateLimit-Reset, когда они возвращаются. При RATE_LIMITED дождитесь срока Retry-After; не повторяйте платный заказ с новым ключом идемпотентности.
Дневная квота заказов общая для всех API-ключей аккаунта и зависит от настроек тарифа. DAILY_LIMIT_EXCEEDED означает исчерпание этой квоты, а не минутного лимита. Отсчёт обновляется в полночь Asia/Tashkent. GET /me возвращает профиль и тариф, но не текущее дневное использование или поля времени сброса.
Ошибки
Формат ответа: { ok, data } или { ok: false, error: { code, message } }. Принимайте решения по error.code, а не по переведённому тексту сообщения.
| Код | HTTP | Описание |
|---|---|---|
| UNAUTHORIZED | 401 | API-ключ отсутствует, неверен или отозван. |
| FORBIDDEN_SCOPE | 403 | У ключа нет требуемого разрешения. |
| IP_NOT_ALLOWED | 403 | Ключ действителен, но исходящий IP запроса не разрешён. |
| NOT_FOUND | 404 | Ресурс не найден или не принадлежит этому аккаунту. |
| VALIDATION_FAILED | 422 | Неверные поля запроса. Исправьте их перед повтором. |
| IDEMPOTENCY_REQUIRED | 422 | Передайте idempotency_key в теле или заголовок Idempotency-Key. |
| INVALID_ORDER | 422 | Неверный товар, сумма, входные данные или заказ. Прочитайте сообщение и исправьте запрос. |
| INSUFFICIENT_BALANCE | 402 | Недостаточно средств в кошельке. |
| RATE_LIMITED | 429 | Достигнут минутный лимит. Учитывайте Retry-After. |
| DAILY_LIMIT_EXCEEDED | 429 | Дневная квота тарифа исчерпана. Повторите после её обновления или смены тарифа. |
Безопасные повторные запросы
До отправки сохраните один ключ идемпотентности вместе с точным запросом заказа. При тайм-ауте повторите тот же запрос и ключ; не создавайте новый ключ для восстановления неизвестного результата.
Для нового логического заказа или изменённых данных используйте новый ключ. Повтор ключа не редактирует существующий заказ. Сохраняйте полученный ID заказа для проверки статуса и сверки.
Эндпоинты
https://api.ludovex.org/api/v2
/api/v2/meРазрешение: readПоля владельца ключа: id, email, name, status и plan.
curl --fail-with-body "https://api.ludovex.org/api/v2/me" \
-H "X-API-Key: $LUDOVEX_API_KEY" \
-H "Accept: application/json"/api/v2/balanceРазрешение: balanceТекущий баланс кошелька и валюта (USDT); требуется разрешение balance.
curl --fail-with-body "https://api.ludovex.org/api/v2/balance" \
-H "X-API-Key: $LUDOVEX_API_KEY" \
-H "Accept: application/json"/api/v2/catalogРазрешение: readТекущий каталог игр. Сохраняйте каждый полученный game_key.
curl --fail-with-body "https://api.ludovex.org/api/v2/catalog" \
-H "X-API-Key: $LUDOVEX_API_KEY" \
-H "Accept: application/json"/api/v2/catalog/{gameKey}Разрешение: readПредложения одной игры с ценами для вашего аккаунта. Необязательный category выбирает полученный идентификатор региона.
gameKeyОбязательноТочный game_key из каталога; кодируйте его как компонент пути URL.categoryНеобязательноНеобязательный идентификатор category/region из каталога; используйте URL-кодирование.
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"/api/v2/orderРазрешение: orderСоздание платного заказа. После HTTP 201 статус может оставаться PROCESSING. Эти примеры предназначены для обычного товара с variation; произвольные суммы Steam описаны ниже.
game_keyОбязательноТочный game_key из каталога; кодируйте его как компонент пути URL.variation_idПри необходимостиОбязателен для обычных товаров. Необязателен для Steam с amount + currency или старым amount_usd; если указан, должен совпадать с расчётом.amountПри необходимостиЗапрошенная сумма кошелька Steam десятичной строкой, до 2 знаков после точки. Читайте min/max/step выбранной валюты из wallet_currencies. Передавайте вместе с currency, без старых полей суммы.currencyПри необходимостиВалюта кошелька Steam: USD, RUB, KZT или UAH. Обязательна вместе с amount. База курсов — 1 USD; итоговое списание остаётся в USDT.expected_price_usdtНеобязательноЗначение price_usdt из расчёта Steam, точной строкой с 8 знаками после точки. Рекомендуется для фиксации принятой цены; изменение возвращает STEAM_QUOTE_CHANGED до списания. При неоднозначном тайм-ауте повторяйте исходный ключ идемпотентности.amount_usdПри необходимостиСтарый вариант суммы Steam в USD десятичной строкой, например "10.25". Читайте min_usd/max_usd/step из custom_amount. Не сочетайте с amount + currency или custom_amount_uzs.player_idПри необходимостиID или логин игрока, если этого требует выбранный товар. Не обязателен для каждого кода или подарочной карты.zone_idПри необходимостиСервер/зона, если требуется выбранным товаром.provider_inputsПри необходимостиДополнительные поля, явно объявленные схемой ввода товара; учётные данные передаются только при таком требовании схемы. Отправляйте объект с точными именами полей; не записывайте учётные данные в журналы или в player_id/zone_id.idempotency_keyПри необходимостиСохранённый уникальный ключ логического заказа либо заголовок Idempotency-Key.
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/api/v2/order/{id}Разрешение: readТекущий статус и доставленные данные заказа, принадлежащего этому аккаунту.
idОбязательноID, полученный при создании заказа; целочисленный параметр пути.
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"/api/v2/ordersРазрешение: readИстория заказов по убыванию ID. Без page или before_id сохраняется прежний ответ-массив (по умолчанию 50, максимум 100 записей).
limitНеобязательноКоличество новых строк от 1 до 100; по умолчанию 50.pageНеобязательноВключает объект с пагинацией: страницы 1–1000. Для глубокой истории используйте before_id.before_idНеобязательноИсключающий курсор по убыванию ID, полученный как next_cursor. page должен отсутствовать или равняться 1.snapshot_idНеобязательноПовторяйте snapshot_id из первого ответа, чтобы новые заказы не сдвигали последующие страницы.created_fromНеобязательноНачальная дата создания включительно, YYYY-MM-DD, Asia/Tashkent (UTC+5).created_toНеобязательноКонечная дата создания включительно, YYYY-MM-DD, Asia/Tashkent (UTC+5).updated_sinceНеобязательноВремя обновления включительно в RFC3339, например 2026-10-21T00:00:00Z. Перекрывайте интервалы и удаляйте дубликаты.statusНеобязательноPROCESSING, COMPLETED, FAILED или REFUNDED. Фильтр учитывает флаг возврата средств.
Для полной истории начните с page=1. Читайте data.items, data.has_more, data.next_cursor и data.snapshot_id. Продолжайте с before_id=next_cursor, сохраняя snapshot_id и фильтры, пока has_more=false. Не сочетайте before_id с page>1. Snapshot ограничивает новые ID, но существующие заказы могут меняться: удаляйте повторы по ID и используйте перекрывающиеся окна updated_since.
Объект также содержит total, page и limit. Записи в пагинации включают updated_at и refunded. Экспорт аккаунта доступен на страницах заказов и транзакций. Для слишком больших экспортов сократите период.
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 и UAH
Выберите валюту кошелька Steam. С баланса Ludovex списывается USDT с учётом рассчитанной скидки Steam 3.5%.
- Найдите Steam через GET /api/v2/catalog и сохраните game_key. В деталях каталога прочитайте custom_amount, exchange_rates и wallet_currencies; нужен scope read.
- GET /api/v2/catalog/{gameKey}?amount=1000.00¤cy=RUB возвращает массив data с одним расчётом. Без суммы возвращаются минимальный расчёт в USD и текущие параметры валют. Заказ и списание не создаются.
- В POST /api/v2/order передайте те же game_key, amount и currency, логин Steam в player_id, принятую price_usdt в expected_price_usdt и сохранённый ключ идемпотентности. Нужен scope order.
В примерах задайте STEAM_GAME_KEY или LUDOVEX_STEAM_GAME_KEY равным идентификатору lvxa_g_… из каталога. Не формируйте его самостоятельно. Старый ключ steam-topup также принимается.
# 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 — сумма пополнения, wallet_currency — её валюта в расчёте. В запросе используется currency: USD, RUB, KZT или UAH. Передавайте JSON-строку, например "1000.00", не более чем с 2 знаками после точки.
- price_usdt / currency
- price_usdt — точная сумма списания USDT строкой с 8 знаками после точки; currency — USDT. Сохраняйте строку без изменений. amount_usd_equivalent — справочный эквивалент USD, а не сумма списания USDT.
- exchange_rates / wallet_currencies
- exchange_rates.base — USD: курс показывает сумму валюты кошелька за 1 USD, не за USDT. Используйте updated_at и выбранный exchange_rate; не подставляйте выдуманный курс при недоступности.
- nominal_usd / estimated_wallet_amount
- Для RUB, KZT и UAH nominal_usd округляет amount / rate вверх до 2 знаков перед скидкой 3.5%. estimated_wallet_amount — этот номинал, умноженный на курс: оценка, а не гарантия итогового зачисления. amount остаётся запрошенной суммой.
- custom_amount
- Актуальные min, max и step для каждой валюты указаны в wallet_currencies. custom_amount описывает поля amount/currency и ограничения в эквиваленте USD. Не задавайте пересчитанные границы вручную.
- discount_percent / discount_amount_usdt
- discount_percent и discount_amount_usdt показывают скидку Steam 3.5% от base_price_usdt. Итоговую price_usdt вычисляет сервер; не округляйте её до центов и не пересчитывайте в клиенте.
- expected_price_usdt
- Передайте принятую price_usdt в expected_price_usdt. Если сумма списания изменилась, до списания вернётся STEAM_QUOTE_CHANGED. Получите новый расчёт и явное подтверждение перед новой попыткой.
# 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В новом контракте всегда передавайте amount и currency. Один variation_id их не заменяет. player_id — логин Steam.
Старое amount_usd остаётся явным вариантом для USD; custom_amount_uzs также поддерживается. Используйте только один вариант суммы в запросе.
После тайм-аута повторяйте тот же заказ с тем же ключом идемпотентности. Потеря ответа не повод создавать новый ключ. Новый ключ нужен для другого подтверждённого заказа или явного отказа цены до списания. Следите за status: 201 может означать обработку.
Неизвестная валюта, сумма JSON-числом, более 2 знаков после точки, смешанные варианты суммы или выход за границы отклоняются. Если курс или цена недоступны, не используйте приблизительный расчёт.
Подписанные webhook-события
Настройте публичный адрес приёмника и события в Кабинет → Настройки → Исходящий webhook. Доставка выполняется как минимум один раз, поэтому обработчик должен быть идемпотентным.
Тело имеет вид { event, data }. Одно событие может поступить несколько раз.
order.completedЗаказ выполнен; проверьте доставленные данные.order.failedЗаказ завершился ошибкой.order.refundedЗафиксирован возврат средств по заказу.payment.succeededПодтверждено пополнение баланса.
Проверяйте X-Ludovex-Signature как sha256=HMAC_SHA256(secret, timestamp + "." + rawBody), используя X-Ludovex-Timestamp и точные исходные байты запроса. Сравнивайте за постоянное время и отклоняйте отсутствующие, неверные или устаревшие метки времени; в примерах допускается разница часов до пяти минут.
Используйте X-Ludovex-Delivery-Id для постоянного учёта повторных доставок. Повтор сохраняет ID и тело, но получает новую метку времени и подпись. Подписанное тестовое событие не содержит delivery-ID: проверьте подпись, подтвердите приём и не выполняйте бизнес-операции.
Примеры подтверждают бизнес-событие только после записи в закрытую SQLite-таблицу входящих событий с уникальным delivery ID. Отдельный обработчик должен читать необработанные строки и атомарно фиксировать бизнес-операцию и завершение. Ответ 2xx до постоянного сохранения может привести к потере события.
Обеспечьте идемпотентность бизнес-операций также по подписанному идентификатору заказа/платежа и событию. Заголовок delivery-ID не входит в HMAC. Не зачисляйте средства только на основании верной подписи. Синхронизируйте часы, защищайте таблицу и секрет, не записывайте доставленное содержимое в журналы.
При ответе не 2xx или тайм-ауте отправитель повторяет доставку с увеличением интервалов, до пяти попыток. Обработчик должен отвечать быстро. Повторы обработки входящих событий выполняет ваш рабочий процесс; если исходное тело надёжно сохранено, на повторную доставку возвращайте 2xx.
Проверяйте статус доставки, попытки и HTTP-ответы в истории webhook в кабинете. Разовые тестовые события в этой истории не сохраняются.
Открыть историю доставки webhookПример Node: Node 24+, Express и WEBHOOK_SECRET. Пример Python: Flask и WEBHOOK_SECRET. WEBHOOK_INBOX_DIR должен быть закрытым постоянным каталогом. Эти примеры для одного сервера слушают loopback за вашим HTTPS-прокси; для нескольких серверов используйте общую транзакционную таблицу входящих событий.
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');