REST API — v1

API пополнения игр и ваучеров для реселлеров

Один REST API для пополнения игр, игровых счетов и кодов ваучеров: читайте каталог, проверяйте игрока, создавайте заказ и забирайте результат вебхуком или запросом статуса.

Это host-to-host (H2H) API пополнений, на котором работает SHOP2TOPUP. Обычный JSON API поверх HTTPS с Bearer-аутентификацией, ключами идемпотентности на UUID, опубликованными лимитами запросов и стабильными машиночитаемыми кодами ошибок — магазин, витрина или бот могут продавать цифровую игровую валюту программно, без клиентской библиотеки. Через этот же интерфейс прошло 3 000 000+ доставленных заказов за 5+ лет непрерывной работы.

Ищете цены, способы оплаты и описание самого партнёрского аккаунта? Прочитайте обзор реселлерской программы SHOP2TOPUP.

Первый вызов — ещё до всяких подписаний

Каждый эндпоинт — это обычный HTTPS-запрос с одним заголовком. Нечего устанавливать, нечего добавлять в зависимости и нечего дописывать в сборку. Вставьте вызов ниже, подставьте свой ключ — и живой ответ каталога уже в вашем терминале.

Запрос — cURL
curl -s "https://shop2topup.com/api/endpoints/v1/catalog/categories?bigCategoryId=1" \
  -H "Authorization: Bearer YOUR_KEY_ID.YOUR_KEY_SECRET"
Ответ — 200 OK
{
  "success": true,
  "categories": [
    {
      "id": 12,
      "name": "Free Fire",
      "description": "Garena Free Fire diamonds",
      "big_category_id": 1,
      "big_category_name": "Mobile Games"
    }
  ]
}

Базовый URL

https://shop2topup.com/api/endpoints/v1

Все задокументированные пути указаны относительно этого базового URL. Ответы приходят в JSON и всегда содержат булево поле success, поэтому одна ветка в клиенте отделяет успешный сценарий от всего остального.

Конверт ошибки

Ошибки сохраняют одинаковую форму при любом статусе. Ветвитесь по error.code — он часть контракта; никогда не ветвитесь по error.message, который написан для людей и может быть переформулирован в любой момент.

Ответ с ошибкой
{
  "success": false,
  "error": {
    "code": "PRICE_INCREASED",
    "message": "Price has increased beyond expected",
    "action": "FIX_INPUT",
    "retryable": false,
    "retry_after": null,
    "details": {
      "expected_unit_price": "0.950000",
      "current_unit_price": "0.980000"
    }
  }
}

Вся поверхность API на одном экране

Одиннадцать эндпоинтов покрывают всю интеграцию: прочитать каталог, узнать цену товара, проверить игрока, оформить заказ и потом свести его. Каждая строка ведёт прямо в полный справочник — со всеми параметрами, примером запроса и примером ответа.

МетодПутьЧто делаетСправочник
GET/accountПолучить данные аккаунтаОткрыть справочник
GET/catalog/big-categoriesСписок основных категорийОткрыть справочник
GET/catalog/categoriesСписок категорийОткрыть справочник
GET/catalog/subcategoriesСписок подкатегорий (товаров)Открыть справочник
GET/catalog/subcategory/:itemId/priceЦена товараОткрыть справочник
GET/catalog/category/:categoryId/requirementsТребования категорииОткрыть справочник
POST/player/validateПроверка игрокаОткрыть справочник
POST/orders/createСоздание заказаОткрыть справочник
GET/orders/:orderIdСтатус заказаОткрыть справочник
POST/orders/batchПакетный запрос заказовОткрыть справочник
GET/ordersСписок заказовОткрыть справочник

Плюс вебхуки статуса заказа — они полностью описаны ниже на этой странице.

Аутентификация и контроль доступа

Аутентификация — это один HTTP-заголовок. Каждый запрос несёт заголовок Authorization с Bearer-учёткой из идентификатора ключа и секрета, выпущенных на странице API Access в панели реселлера. Идентификатор ключа определяет аккаунт, секрет доказывает, что он ваш, и проверяется на сервере HMAC-проверкой.

Секрет показывается один раз, в момент создания, и потом его нельзя получить снова. Положите его в тот менеджер секретов, который уже есть в вашем стеке, прокидывайте переменной окружения и держите вне системы контроля версий. Потеряли — не восстанавливаете, а ротируете.

Нет ни редиректа OAuth, ни сессионной куки, ни refresh-токена, и планировать нечего. Для автоматизированных интеграций это важно: cron-задача или воркер очереди могут годами держать одну и ту же учётку без пути обновления, и нет счётчика истечения, из-за которого приходится вставать в три часа ночи.

К ключу можно прикрепить список разрешённых IP. Как только он непустой, запросы с любых других адресов отклоняются с IP_NOT_ALLOWED и HTTP 403 ещё до выполнения бизнес-логики, поэтому утёкшая учётка бесполезна вне ваших серверов. Ротация — это деплой, а не миграция: создайте новый ключ, выкатите, удалите старый.

Ключи принадлежат одному аккаунту реселлера и тратят кошелёк этого аккаунта, поэтому никогда не отправляйте секрет в браузер, в мобильную сборку или в публичный репозиторий. Вызывайте API со своего бэкенда, а фронтенд пусть общается с вашим бэкендом. Всё описанное здесь рассчитано на вызывающую сторону server-to-server.

Заголовок запроса
Authorization: Bearer <keyId>.<secret>

Проверка ключа одним вызовом

GET /account — это health-check для учётки. Он сразу отвечает на три вопроса: проходит ли ключ аутентификацию, включён ли аккаунт и хватает ли на кошельке средств на то, что вы собираетесь заказать. Вызывайте его при старте и перед любым пакетным прогоном.

Открыть справочник по аутентификации и аккаунту

Полная интеграция от начала до конца

Рабочая интеграция — это четыре шага. Примеры ниже проходят их все на живом API: на cURL, Node и PHP. Подставьте свою учётку — и они выполнятся как есть.

  1. 1

    Прочитайте каталог

    Пройдите от больших категорий к категориям и подкатегориям, затем узнайте текущую цену товара, который собираетесь продать. Дерево кэшируйте, цену запрашивайте заново.

  2. 2

    Проверьте игрока

    Сопоставьте идентификатор с игровым именем и регионом до того, как пойдут деньги. Именно здесь ловятся опечатки и ошибки с чужим регионом.

  3. 3

    Создайте заказ

    Сгенерируйте UUID, сохраните его и отправьте как order_id. Это ваш ключ идемпотентности и единственный безопасный способ повторить запрос на создание.

  4. 4

    Подтвердите доставку

    Дождитесь вебхука со статусом заказа, а для всего, что осталось в pending после вашего окна ожидания, используйте чтение статуса.

cURL — полный сценарийПоказать код
export S2T_KEY="YOUR_KEY_ID.YOUR_KEY_SECRET"
export S2T_BASE="https://shop2topup.com/api/endpoints/v1"

# 1. Find the item you want to sell.
curl -s "$S2T_BASE/catalog/subcategories?categoryId=12" \
  -H "Authorization: Bearer $S2T_KEY"

# 2. Read the exact price you will be charged for it.
curl -s "$S2T_BASE/catalog/subcategory/999/price" \
  -H "Authorization: Bearer $S2T_KEY"

# 3. Confirm the player exists BEFORE any money moves.
curl -s -X POST "$S2T_BASE/player/validate" \
  -H "Authorization: Bearer $S2T_KEY" \
  -H "Content-Type: application/json" \
  -d '{"sub_category_id": 999, "player_id": "123456789", "server": "Asia"}'

# 4. Create the order. order_id is YOUR uuid and YOUR idempotency key.
curl -s -X POST "$S2T_BASE/orders/create" \
  -H "Authorization: Bearer $S2T_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "order_id": "01912345-6789-7abc-8def-0123456789ab",
    "sub_category_id": 999,
    "quantity": 1,
    "requirements": { "player_id": "123456789", "server": "Asia" },
    "expected_unit_price": "0.950000"
  }'

# 5. Read the order back until it leaves "pending".
curl -s "$S2T_BASE/orders/01912345-6789-7abc-8def-0123456789ab" \
  -H "Authorization: Bearer $S2T_KEY"
Node.js — полный сценарийПоказать код
// Node 18+ — no dependencies, global fetch and global crypto.
const BASE = 'https://shop2topup.com/api/endpoints/v1';
const KEY = process.env.S2T_KEY; // "<keyId>.<secret>", server-side only.

async function call(path, init = {}) {
  const res = await fetch(BASE + path, {
    ...init,
    headers: {
      Authorization: `Bearer ${KEY}`,
      'Content-Type': 'application/json',
      ...(init.headers || {}),
    },
  });

  if (res.status === 429) {
    const wait = Number(res.headers.get('Retry-After') || 5);
    await new Promise((r) => setTimeout(r, wait * 1000));
    return call(path, init);
  }

  const body = await res.json();
  if (!res.ok || body.success === false) {
    throw Object.assign(new Error(body?.error?.code || 'HTTP_' + res.status), {
      code: body?.error?.code,
      status: res.status,
    });
  }
  return body;
}

async function sell({ itemId, categoryId, playerId, server }) {
  // 1 + 2. Catalog and current price.
  const { subcategories } = await call(
    `/catalog/subcategories?categoryId=${categoryId}`,
  );
  const item = subcategories.find((s) => s.item_id === itemId);
  const { price } = await call(`/catalog/subcategory/${itemId}/price`);

  // 3. Player check before charging.
  const { player } = await call('/player/validate', {
    method: 'POST',
    body: JSON.stringify({
      sub_category_id: itemId,
      player_id: playerId,
      server,
    }),
  });

  // 4. Create the order. Persist orderId BEFORE the call so a crash mid-flight
  //    can be replayed with the same uuid instead of double-charging.
  const orderId = crypto.randomUUID();
  await saveIntent(orderId, itemId, playerId);

  const { order } = await call('/orders/create', {
    method: 'POST',
    body: JSON.stringify({
      order_id: orderId,
      sub_category_id: itemId,
      quantity: 1,
      requirements: { player_id: playerId, server },
      expected_unit_price: price.unit_price,
    }),
  });

  // 5. Confirm. A webhook usually beats the poll; the poll is the safety net.
  let status = order.status;
  while (status === 'pending') {
    await new Promise((r) => setTimeout(r, 20000));
    status = (await call(`/orders/${orderId}`)).order.status;
  }

  return { orderId, status, itemName: item?.name, playerName: player.player_name };
}
PHP — полный сценарийПоказать код
<?php
// PHP 8 — plain cURL, no SDK required.
const S2T_BASE = 'https://shop2topup.com/api/endpoints/v1';

function s2t(string $path, ?array $body = null): array
{
    $ch = curl_init(S2T_BASE . $path);
    $headers = [
        'Authorization: Bearer ' . getenv('S2T_KEY'),
        'Content-Type: application/json',
    ];

    curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_HTTPHEADER     => $headers,
        CURLOPT_TIMEOUT        => 30,
    ]);

    if ($body !== null) {
        curl_setopt($ch, CURLOPT_POST, true);
        curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
    }

    $raw    = curl_exec($ch);
    $status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    curl_close($ch);

    $data = json_decode($raw, true) ?: [];

    if ($status >= 400 || ($data['success'] ?? false) === false) {
        throw new RuntimeException($data['error']['code'] ?? 'HTTP_' . $status);
    }

    return $data;
}

// 1 + 2. Catalog, then the exact price for the item.
$catalog = s2t('/catalog/subcategories?categoryId=12');
$price   = s2t('/catalog/subcategory/999/price');

// 3. Player check before charging.
$player = s2t('/player/validate', [
    'sub_category_id' => 999,
    'player_id'       => '123456789',
    'server'          => 'Asia',
]);

// 4. Create the order with your own uuid as the idempotency key.
$orderId = sprintf(
    '%04x%04x-%04x-7%03x-%04x-%04x%04x%04x',
    random_int(0, 0xffff), random_int(0, 0xffff),
    random_int(0, 0xffff), random_int(0, 0x0fff),
    random_int(0x8000, 0xbfff),
    random_int(0, 0xffff), random_int(0, 0xffff), random_int(0, 0xffff)
);

$order = s2t('/orders/create', [
    'order_id'            => $orderId,
    'sub_category_id'     => 999,
    'quantity'            => 1,
    'requirements'        => ['player_id' => '123456789', 'server' => 'Asia'],
    'expected_unit_price' => $price['price']['unit_price'],
]);

// 5. Read it back until it settles.
do {
    sleep(20);
    $latest = s2t('/orders/' . $orderId);
} while ($latest['order']['status'] === 'pending');

echo $latest['order']['status'];

Две детали стоит скопировать дословно: сохраняйте UUID заказа до вызова создания, а не после, и перечитывайте цену, а не доверяйте закэшированному числу. Эти две привычки убирают почти все двойные списания и почти все отказы PRICE_INCREASED.

Вебхуки: колбэки со статусом заказа

Зарегистрируйте один HTTPS-эндпоинт — и платформа будет присылать на него результаты заказов по мере их появления, чтобы витрина перестала опрашивать и начала реагировать. Колбэк подписан, поэтому вы можете доказать, что тело пришло от нас, прежде чем что-то с ним делать.

События

СобытиеКогда срабатывает
order.completedОдна единица заказа доставлена. Статус всего заказа смотри в data.status.
order.refundedДеньги за одну единицу заказа вернулись на твой кошелёк. Поле data.status станет refunded, когда вернутся все единицы.
webhook.testПодписанная тестовая доставка, запущенная из панели для проверки твоего обработчика.

Заголовки доставки

ЗаголовокЗначение
Content-Typeapplication/json
X-Shop2Topup-Signaturesha256=<hex>: HMAC-SHA256 от сырого тела запроса
X-Shop2Topup-EventНазвание события, например order.completed и т. п.
User-Agentshop2topup-webhook/1.0

Тело колбэка

У каждого колбэка одни и те же три поля верхнего уровня — event, timestamp и data, — а data повторяет объект заказа, который возвращает эндпоинт статуса. Коды ваучеров есть только у выполненного заказа, player_name может быть null, а sub_transaction_summary появляется, когда заказ был разбит на единицы.

Тело POST — order.completed
{
  "event": "order.completed",
  "timestamp": "2026-03-11T14:30:00.000Z",
  "data": {
    "order_id": "01912345-6789-7abc-8def-0123456789ab",
    "status": "completed",
    "player_id": "123456789",
    "player_name": "ProGamer99",
    "subcategory_name": "FF 100 Diamonds",
    "quantity": 1,
    "charged_amount": "0.950000",
    "currency": "USD",
    "created_at": "2026-03-11T14:28:00.000Z",
    "completed_at": "2026-03-11T14:30:00.000Z",
    "vouchers": [
      {
        "code": "XXXX-YYYY-ZZZZ",
        "serial_number": "SN-9928371",
        "expiry_date": "2027-03-11"
      }
    ],
    "sub_transaction_summary": {
      "total": 1,
      "completed": 1,
      "failed": 0,
      "pending": 0,
      "processing": 0,
      "retrying": 0,
      "refunded": 0
    }
  }
}

Проверка подписи

Каждая доставка несёт X-Shop2Topup-Signature вида sha256= и далее hex HMAC-SHA256 от сырого тела запроса на вашем webhook-секрете. Проверяйте по сырым байтам, до любого разбора JSON, и сравнивайте за константное время. Обработчик, который сначала парсит, а потом проверяет, можно обмануть пересобранным телом.

Node.js — проверка и подтверждение
// Express — verify the signature against the RAW body, not the parsed object.
const crypto = require('crypto');

app.post(
  '/shop2topup/webhook',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const signature = req.get('X-Shop2Topup-Signature') || '';
    const expected =
      'sha256=' +
      crypto
        .createHmac('sha256', process.env.S2T_WEBHOOK_SECRET)
        .update(req.body)
        .digest('hex');

    const a = Buffer.from(expected);
    const b = Buffer.from(signature);
    if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
      return res.status(401).end();
    }

    const payload = JSON.parse(req.body.toString('utf8'));

    // Answer fast, then do the work. The delivery times out after 10 seconds.
    res.status(200).end();
    enqueueSettlement(payload.data.order_id, payload.event);
  },
);

Доставка и повторы

Колбэк отправляется один раз, и у вашего эндпоинта есть десять секунд на ответ. Автоматических повторов и экспоненциальной задержки за этим нет, и это сделано намеренно: авторитетная запись — эндпоинт статуса, поэтому сверка принадлежит опросу, которым управляете вы, а не расписанию повторов, которого вы не видите. Возвращайте 200 сразу, а работу выполняйте в своей очереди.

Идемпотентные обработчики

Стройте обработчик вокруг order_id и делайте повтор колбэка холостым. Даже при одной попытке доставки ваша собственная инфраструктура иногда отдаст одно и то же сообщение двум воркерам, а заказ, который зачислил клиенту дважды, — гораздо худший баг, чем заказ, зачисленный с опозданием.

Регистрация и секреты

Регистрация URL колбэка, отправка подписанной тестовой доставки и ротация секрета подписи выполняются в панели реселлера под вашей учётной записью, а не через публичный API. HTTPS обязателен, приватные адреса не принимаются.

Открыть API access в панели реселлера

Лимиты запросов и обработка ошибок

Лимиты опубликованы, считаются на аккаунт и применяются по скользящему окну. Самый широкий запас — у работы с каталогом; эндпоинты статуса намеренно узкие, потому что задуманы как запасной вариант для сверки, а не как цикл опроса.

МаршрутЗапросовОкноСчитается на
GET /catalog/* (все пять)500060 сна аккаунт
POST /orders/create500060 сна аккаунт
GET /orders/:orderId360 сна аккаунт + ID заказа
POST /orders/batch260 сна аккаунт
GET /orders260 сна аккаунт
GET /account6060 сна аккаунт
POST /player/validateсуточный лимит проверок игроков24 чна аккаунт (без заголовков X-RateLimit)

Эндпоинты каталога, заказов и аккаунта несут X-RateLimit-Limit, X-RateLimit-Remaining и X-RateLimit-Reset в каждом ответе, поэтому корректному клиенту не приходится гадать, где он находится. Следите за счётчиком remaining и притормаживайте до того, как вас ограничат, а не после. Проверка игрока — исключение: она расходует вашу суточную квоту проверок, не отправляет заголовков X-RateLimit и сообщает эту квоту в теле ответа 429.

Ограниченный ответ — 429
HTTP/1.1 429 Too Many Requests
Retry-After: 45
X-RateLimit-Limit: 2
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1765000000

{
  "success": false,
  "error": {
    "code": "RATE_LIMIT_EXCEEDED",
    "message": "Rate limit exceeded. Try again after retry_after seconds.",
    "action": "RETRY_LATER",
    "retryable": true,
    "retry_after": 45,
    "details": {
      "limit": 2,
      "remaining": 0,
      "retry_after": 45,
      "window_seconds": 60
    }
  }
}

Ошибки, с которыми вы столкнётесь первыми, и их решения

Отказы всегда возвращают стабильный код рядом с HTTP-статусом. Здесь собраны те, на которые новая интеграция натыкается в первую же неделю, и к каждому приложено изменение, которое его снимает.

КодHTTPЧто означаетКак исправить
INVALID_API_KEY401Неверный API-ключПереданный API-ключ не совпадает ни с одним активным ключом в системе.Проверь ID ключа и секрет. Если нужно, создай новый ключ в панели API Access.action: FIX_INPUT · retryable: false · retry_after: null
IP_NOT_ALLOWED403IP-адреса нет в белом спискеЗапрос пришёл с IP-адреса, которого нет в белом списке твоего API-ключа.Добавь IP своего сервера в белый список в панели API Access или сними ограничения по IP.action: FIX_INPUT · retryable: false · retry_after: null
MISSING_REQUIRED_FIELD400Не заполнено обязательное полеВ теле запроса нет обязательного поля. Если речь о данных игрока, в details.field указано, какого поля не хватает.Сверься с документацией эндпоинта, а поля игрока смотри в GET /catalog/category/:categoryId/requirements. Затем передай все обязательные поля.action: FIX_INPUT · retryable: false · retry_after: null
INVALID_UUID_FORMAT400Неверный формат UUIDorder_id не соответствует формату UUID.Подойдёт UUID любой версии (v1, v4, v7 и т. д.), главное, чтобы он был корректным. Нужен строго такой вид (8-4-4-4-12 символов): xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx.action: FIX_INPUT · retryable: false · retry_after: null
PLAYER_NOT_FOUND400Игрок не найденИгра подтвердила, что такого ID игрока нет. Этот код приходит только при подтверждённом ответе. Если ID проверить не удалось, вместо него придёт PLAYER_CHECK_UNAVAILABLE.Проверь, правильно ли указан ID игрока. Узнай, не нужен ли для этой игры zone_id.action: FIX_INPUT · retryable: false · retry_after: null
PLAYER_CHECK_UNAVAILABLE503Сейчас не получается проверить ID игроков в этой игреID игроков в этой игре пока недоступны для проверки. ID игрока НЕ отклонён.Повтори тот же запрос через retry_after секунд. Не говори клиенту, что ID неверный.action: RETRY_LATER · retryable: true · retry_after: 60
REGION_MISMATCH400Этот товар недоступен для региона игрокаИгрок из региона, куда этот товар доставить нельзя. В ошибке приходят player_region и player_name.Выбери товар для региона игрока.action: CHOOSE_OTHER · retryable: false · retry_after: null
INSUFFICIENT_BALANCE400Недостаточно средств на кошелькеНа кошельке не хватает средств для этого заказа.Пополни кошелёк на странице Reload, затем оформи заказ.action: TOP_UP · retryable: false · retry_after: null
OUT_OF_STOCK400Товара нет в наличииЗапрошенный товар сейчас закончился.Попробуй позже или выбери другой товар.action: RETRY_LATER · retryable: true · retry_after: null
PRODUCT_UNAVAILABLE409Товар временно недоступенТовар существует, но сейчас не продаётся.Повтори попытку позже или возьми другой товар.action: RETRY_LATER · retryable: true · retry_after: null
DUPLICATE_ORDER409ID заказа уже используетсяЗаказ с таким UUID уже существует. Это защита по идемпотентности.Для нового заказа сгенерируй новый UUID. Если ошибка пришла на повторный запрос, значит первая попытка прошла: получи этот заказ через GET /orders/{orderId}, а не создавай новый.action: CHECK_ORDER · retryable: false · retry_after: null
PRICE_INCREASED400Цена выросла выше ожидаемойТекущая цена за единицу выше, чем указанная тобой expected_unit_price.Сначала запроси актуальную цену через GET /catalog/subcategory/:id/price. Потом обнови поле expected_unit_price: впиши туда значение, которое вернул запрос.action: FIX_INPUT · retryable: false · retry_after: null
RATE_LIMIT_EXCEEDED429Превышен лимит запросовЛимит запросов к этому эндпоинту исчерпан. Для поминутных лимитов (каталог, заказы, аккаунт) в каждом ответе приходят заголовки X-RateLimit-Limit, X-RateLimit-Remaining и X-RateLimit-Reset, а в объекте details повторяются поля limit, remaining (со значением 0), retry_after и window_seconds. POST /player/validate работает иначе: запросы к нему списываются с твоей дневной квоты на проверку игроков. Заголовков X-RateLimit этот эндпоинт не отдаёт, а в ответе 429 поля limit, current, remaining, reset_at и reset_in_seconds приходят внутри объекта error. Сколько секунд подождать перед следующей попыткой, в обоих случаях показывают поле error.retry_after и заголовок Retry-After.Перед повтором подожди столько секунд, сколько указано в retry_after или в заголовке Retry-After. На эндпоинтах с минутным лимитом следи за X-RateLimit-Remaining, чтобы вообще не упираться в лимит.action: RETRY_LATER · retryable: true · retry_after: сек. до сброса
INTERNAL_ERROR500Внутренняя ошибка сервераНа сервере произошла непредвиденная ошибка.Через retry_after секунд отправь запрос снова. Для POST /orders/create используй тот же order_id, тогда заказ точно не создастся дважды. Если ошибка повторяется, напиши в поддержку.action: RETRY_LATER · retryable: true · retry_after: 30
Полный справочник кодов ошибок — все коды (42) и способы их устранения

Ответы на вопросы по интеграции

Вопросы, которые разработчики действительно задают при подключении: аутентификация, повторы, идемпотентность, лимиты, колбэки и обработка сбоев.

Как реселлерский API аутентифицирует запросы?

Одним HTTP-заголовком: Authorization: Bearer <keyId>.<secret>. Пара ключей выпускается на странице API Access в панели реселлера, секрет показывается один раз при создании, и сервер проверяет его HMAC-проверкой на каждом вызове. Нет ни редиректа OAuth, ни сессионной куки, ни токена, который нужно обновлять, — один и тот же заголовок работает и для первого запроса, и для всех последующих.

На какой базовый URL и версию должен ходить мой клиент?

На один базовый URL, и он боевой: отдельного песочного хоста, на котором сначала пишут, а потом переключаются, здесь нет. Направьте клиент на базовый URL v1, показанный выше, отправляйте Authorization: Bearer <keyId>.<secret> в каждом запросе и начните с GET /account, чтобы убедиться в работоспособности ключа и формате заголовка. POST /orders/create — единственный вызов, который двигает деньги: оставьте его напоследок, запускайте на самом мелком номинале и отправляйте вместе с ним expected_unit_price, пока клиент ещё обкатывается.

Какое поле является ключом идемпотентности в POST /orders/create?

Поле order_id — UUID, который вы генерируете у себя и отправляете в теле создания. Повторное создание с уже существующим order_id получает HTTP 409 и код DUPLICATE_ORDER вместо второго заказа, поэтому повтор запроса не превращается во второе списание; исходный заказ читается через GET /orders/:orderId. Сгенерируйте и сохраните UUID до первой попытки: UUID, созданный после таймаута, — это новый ключ, а не ключ повтора, и сервер не склеит два order_id, которыми вы имели в виду одно и то же.

Запрос завершился таймаутом. Заказ создан?

Выясните это чтением статуса, а не догадками. Вызовите GET /orders/:orderId с тем же UUID, что отправляли: если заказ вернулся, таймаут случился уже после его приёма и повторять запрос не нужно. Если пришёл ORDER_NOT_FOUND, ничего не списано и запрос на создание с тем же UUID можно безопасно повторить.

Как проверить подпись у вебхук-колбэка?

Пересчитайте HMAC по сырому телу запроса и сравните с заголовком X-Shop2Topup-Signature. В заголовке лежит sha256=<hex>: посчитайте HMAC-SHA256 по тем самым байтам, которые пришли, своим секретом вебхука, добавьте префикс sha256= и сравнивайте за константное время — но никогда не по повторно сериализованному JSON-объекту, потому что разбор и обратная сборка меняют байты, и совпадения не будет никогда. Несовпадение отклоняйте с 401, на корректную доставку отвечайте 200 в пределах десяти секунд, а сверку выполняйте потом в своей очереди.

Что происходит при превышении лимита запросов?

Вы получаете HTTP 429 с кодом RATE_LIMIT_EXCEEDED и заголовком Retry-After. В теле ответа то же число повторяется в retry_after, плюс приходят limit, remaining и window_seconds. Подождите ровно столько секунд и повторите один раз; не повторяйте сразу и не размазывайте один и тот же вызов по нескольким ключам — счётчик привязан к аккаунту, а не к соединению.

Какие поля возвращает POST /player/validate?

Поле player_name, а также zone_id и region, если игра их отдаёт, — внутри объекта data в ответе. Создание заказа само разрешает игрока и проверяет регион, а попытка из чужого региона отклоняется кодом REGION_MISMATCH (HTTP 400) до какого-либо списания с кошелька, так что баланс защищает не этот вызов. Вызывайте его потому, что прочитанный player_name — единственный практичный способ показать покупателю аккаунт, который он собирается пополнить, и поймать опечатку в идентификаторе до подтверждения: имя всегда приходит из игры, а не из того, что ввёл покупатель.

Как превратить ответ requirements в тело заказа?

Разложите его по field_name: каждая запись, которую возвращает GET /catalog/category/:categoryId/requirements, становится одним свойством объекта requirements, отправляемого в POST /orders/create. В каждой записи есть data_type — форма знает, что рисовать, а сервер знает, к какому типу приводить; записи single_select и multi_select дополнительно везут список допустимых значений, и предлагать нужно именно его, а не свободный ввод. Кешируйте список по категории вместо чтения на каждый заказ и перечитывайте его, когда создание возвращает MISSING_REQUIRED_FIELD.

Как клиенту обрабатывать ответ PRICE_INCREASED?

Перечитать цену, принять решение и отправить снова — но не повторять то же тело. PRICE_INCREASED (HTTP 400) означает, что текущая цена за единицу выше отправленного вами expected_unit_price, поэтому создание отклонено и ничего не списано; в details ошибки лежат и expected_unit_price, и current_unit_price, а слепой повтор со старым числом будет отклоняться каждый раз. Прочитайте GET /catalog/subcategory/:itemId/price, решите, устраивает ли вас новое число, и отправьте создание снова с обновлённым expected_unit_price и тем же order_id — отклонённая попытка его не израсходовала. Если expected_unit_price не отправлять, защита выключается совсем, а автоматической интеграции это почти никогда не нужно.

Как частично доставленный заказ выглядит в ответе?

Как статус partial, а разбивка лежит в sub_transaction_summary. Этот объект считает единицы как completed, refunded или pending — в сумме эти три счётчика всегда дают total, — а sub_transactions перечисляет каждую единицу с её собственным статусом. Единица, которая ещё не доставлена и не возвращена, остаётся pending. Считайте pending нетерминальным и продолжайте ждать; completed, partial и refunded — терминальные. Счётчики failed, processing и retrying сохранены только для совместимости и всегда равны 0. Колбэки order.completed и order.refunded несут ту же сводку, поэтому payload с проверенной подписью закрывает заказ без повторного чтения.

Что получит запрос с адреса вне списка разрешённых?

HTTP 403 с кодом IP_NOT_ALLOWED — ответ приходит до любой бизнес-логики. Проверка работает, пока список разрешённых IP у ключа непустой, поэтому утёкший ключ бесполезен где-либо, кроме ваших собственных исходящих адресов. Ветвитесь по error.code, а не по тексту сообщения, и внезапный IP_NOT_ALLOWED в проде читайте как смену исходящего адреса — новый NAT-шлюз или новая подсеть воркеров, — а не как сломанный ключ. Оставляйте список пустым только если исходящий адрес действительно нестабилен.

Сколько времени занимает выполнение заказа?

Считайте выполнение асинхронным, а не мгновенным. POST /orders/create отвечает, как только заказ принят и деньги списаны с кошелька, — обычно со статусом pending; дальше исполнение идёт на отдельных воркерах, а терминальное состояние приходит к вам вебхуком или при следующем чтении статуса. Не блокируйте клиентский HTTP-запрос ожиданием финального состояния: примите заказ, ответьте покупателю и завершите расчёт вне основного потока.

Создайте аккаунт, сгенерируйте ключ, запускайтесь

Между регистрацией и первым вызовом API нет ни очереди на одобрение, ни периода ожидания. Создайте аккаунт реселлера, сгенерируйте ключ на странице API Access, пополните кошелёк — и первый заказ может уйти в тот же день.

Вы не разработчик этого проекта? Коммерческая сторона описана на странице реселлерской программы SHOP2TOPUP.

API пополнения игр и ваучеров для реселлеров — REST API