API REST - v1
API de recargas de juegos y gift cards para revendedores
Una sola API REST para recargas de juegos y códigos de gift card: lea el catálogo, valide al jugador, cree el pedido y reciba el resultado por webhook o consultando el estado.
Esta es la API de recargas host-to-host (H2H) que hay detrás de SHOP2TOPUP. Es una API JSON sobre HTTPS, con autenticación Bearer, claves de idempotencia UUID, límites de solicitudes publicados y códigos de error estables legibles por máquina, de modo que una tienda, un sitio o un bot puede vender crédito de juego de forma programática sin ninguna librería cliente. La misma interfaz ya movió 3,000,000+ pedidos entregados a lo largo de 5+ años de operación continua.
¿Busca precios, medios de pago y cómo funciona la cuenta de socio? Lea la presentación del programa de revendedores de SHOP2TOPUP.
Su primera llamada, antes de firmar nada
Cada endpoint es una solicitud HTTPS común con un único encabezado. No hay SDK que instalar, ni librería cliente que incorporar, ni paso de compilación que agregar. Pegue la llamada de abajo, ponga su clave y tendrá una respuesta del catálogo en vivo en su terminal.
curl -s "https://shop2topup.com/api/endpoints/v1/catalog/categories?bigCategoryId=1" \
-H "Authorization: Bearer YOUR_KEY_ID.YOUR_KEY_SECRET"{
"success": true,
"categories": [
{
"id": 12,
"name": "Free Fire",
"description": "Garena Free Fire diamonds",
"big_category_id": 1,
"big_category_name": "Mobile Games"
}
]
}URL base
https://shop2topup.com/api/endpoints/v1
Todas las rutas documentadas son relativas a esta base. Las respuestas son JSON y siempre llevan un campo booleano success, así que una sola condición en su cliente separa el camino feliz de todo lo demás.
Envoltorio de error
Los fallos mantienen la misma forma en cualquier código de estado. Ramifique según error.code, que forma parte del contrato; nunca según error.message, que está escrito para personas y puede reescribirse en cualquier momento.
{
"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"
}
}
}Toda la superficie de la API en una sola pantalla
Once endpoints cubren la integración completa: leer el catálogo, obtener el precio de un artículo, verificar un jugador, hacer un pedido y conciliarlo después. Cada fila enlaza directo a la referencia completa, con todos los parámetros, una solicitud de ejemplo y una respuesta de ejemplo.
| Método | Ruta | Para qué sirve | Referencia |
|---|---|---|---|
| GET | /account | Obtener datos de cuenta | Leer la referencia |
| GET | /catalog/big-categories | Listar categorías principales | Leer la referencia |
| GET | /catalog/categories | Listar categorías | Leer la referencia |
| GET | /catalog/subcategories | Listar subcategorías (productos) | Leer la referencia |
| GET | /catalog/subcategory/:itemId/price | Obtener precio del ítem | Leer la referencia |
| GET | /catalog/category/:categoryId/requirements | Obtener requisitos de la categoría | Leer la referencia |
| POST | /player/validate | Validar jugador | Leer la referencia |
| POST | /orders/create | Crear pedido | Leer la referencia |
| GET | /orders/:orderId | Consultar estado del pedido | Leer la referencia |
| POST | /orders/batch | Consultar pedidos en lote | Leer la referencia |
| GET | /orders | Listar pedidos | Leer la referencia |
A esto se suman los webhooks de estado de pedido, documentados en detalle más abajo en esta página.
Autenticación y control de acceso
La autenticación es un único encabezado HTTP. Cada solicitud lleva un encabezado Authorization con una credencial Bearer formada por un ID de clave y un secreto, emitidos desde la página API Access de su panel de revendedor. El ID de clave identifica la cuenta; el secreto demuestra que es suya y se verifica del lado del servidor con una comprobación HMAC.
El secreto se muestra una sola vez, al crearlo, y después ya no se puede recuperar. Guárdelo en el gestor de secretos que su stack ya utiliza, inyéctelo como variable de entorno y manténgalo fuera del control de versiones. Si lo pierde, se rota; no se recupera.
No hay redirección OAuth, ni cookie de sesión, ni refresh token, ni nada que programar. Eso importa en integraciones automatizadas: una tarea cron o un worker de cola puede conservar la misma credencial durante años sin ninguna vía de renovación, y no hay ningún reloj de expiración que lo despierte a las tres de la mañana.
A una clave se le puede asociar una lista de IP permitidas. En cuanto deja de estar vacía, las solicitudes desde cualquier otra dirección se rechazan con IP_NOT_ALLOWED y HTTP 403 antes de ejecutar cualquier lógica de negocio, así que una credencial filtrada es inútil fuera de sus propios servidores. Rotar es un despliegue, no una migración: cree la clave nueva, publíquela y elimine la anterior.
Las claves pertenecen a una sola cuenta de revendedor y gastan la billetera de esa cuenta, así que nunca envíe un secreto a un navegador, a un paquete móvil ni a un repositorio público. Llame a la API desde su backend y deje que su front-end hable con ese backend. Todo lo documentado aquí asume una llamada de servidor a servidor.
Authorization: Bearer <keyId>.<secret>Verifique una clave en una sola llamada
GET /account es el chequeo de salud de una credencial. Responde tres preguntas a la vez: si la clave autentica, si la cuenta está habilitada y si la billetera alcanza para lo que está por pedir. Ejecútelo al arrancar y antes de cualquier proceso por lotes.
Leer la referencia de autenticación y cuentaUna integración completa, de punta a punta
Una integración funcionando son cuatro pasos. Los ejemplos de abajo los ejecutan todos contra la API real, en cURL, Node y PHP. Cambie la credencial por la suya y funcionan tal cual están.
- 1
Leer el catálogo
Recorra las grandes categorías, luego las categorías y luego las subcategorías, y lea el precio actual del artículo que está por vender. Cachee el árbol; consulte el precio en el momento.
- 2
Validar al jugador
Resuelva el identificador a un nombre dentro del juego y a una región antes de mover dinero. Ahí es donde se atrapan los errores de tipeo y las confusiones de región.
- 3
Crear el pedido
Genere un UUID, guárdelo y envíelo como order_id. Es su clave de idempotencia, y es la única forma segura de reintentar una solicitud de creación.
- 4
Confirmar la entrega
Espere el webhook de estado del pedido, y recurra a una lectura de estado para todo lo que siga en pending pasado su propio tiempo de espera.
cURL - flujo completoMostrar códigoOcultar código
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 - flujo completoMostrar códigoOcultar código
// 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 - flujo completoMostrar códigoOcultar código
<?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'];Dos detalles vale la pena copiarlos tal cual: guarde el UUID del pedido antes de la llamada de creación, no después, y vuelva a leer el precio en lugar de confiar en un número cacheado. Esos dos hábitos eliminan casi todos los cobros duplicados y todos los rechazos PRICE_INCREASED.
Webhooks: avisos de estado de pedido
Registre un endpoint HTTPS y la plataforma le envía los resultados de los pedidos a medida que ocurren, así su tienda deja de consultar en bucle y pasa a reaccionar. El aviso viene firmado, de modo que puede comprobar que el contenido salió de nosotros antes de actuar sobre él.
Eventos
| Evento | Se dispara cuando |
|---|---|
| order.completed | Se entregó una unidad del pedido. Consulta data.status para ver el estado del pedido completo. |
| order.refunded | Se reembolsó una unidad del pedido a tu billetera. Cuando se reembolsan todas, data.status pasa a refunded. |
| webhook.test | Lanzaste desde el panel una entrega de prueba firmada para comprobar tu handler. |
Encabezados de entrega
| Encabezado | Valor |
|---|---|
| Content-Type | application/json |
| X-Shop2Topup-Signature | sha256=<hex>: HMAC-SHA256 del cuerpo sin procesar de la solicitud |
| X-Shop2Topup-Event | Nombre del evento (order.completed, por ejemplo) |
| User-Agent | shop2topup-webhook/1.0 |
Contenido del aviso
Todos los avisos tienen los mismos tres campos de primer nivel — event, timestamp y data — y data replica el objeto de pedido que devuelve el endpoint de estado. Los códigos de gift card solo aparecen en un pedido completed, player_name puede ser null, y sub_transaction_summary aparece siempre que el pedido se haya dividido en unidades.
{
"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
}
}
}Verificación de la firma
Cada entrega lleva X-Shop2Topup-Signature, formado por sha256= seguido del HMAC-SHA256 hexadecimal del cuerpo crudo de la solicitud bajo su secreto de webhook. Verifíquelo contra los bytes crudos, antes de cualquier parseo de JSON, y compare en tiempo constante. Un handler que primero parsea y después verifica se puede engañar con un cuerpo re-serializado.
// 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);
},
);Entrega y reintentos
El aviso se intenta una sola vez y su endpoint tiene diez segundos para responder. No hay reintento automático ni backoff exponencial detrás, y es deliberado: el endpoint de estado es el registro autoritativo, así que la conciliación es una consulta que usted controla y no un calendario de reintentos que no puede ver. Devuelva 200 de inmediato y haga el trabajo en su propia cola.
Handlers idempotentes
Indexe su handler por order_id y haga que reprocesar un aviso no tenga efecto. Incluso con un solo intento de entrega, su propia infraestructura alguna vez le pasará el mismo mensaje a dos workers, y un pedido que acredita dos veces a un cliente es un bug mucho peor que uno que acredita tarde.
Registro y secretos
Registrar la URL de aviso, enviar una entrega de prueba firmada y rotar el secreto de firma se hacen dentro de su panel de revendedor con sesión iniciada, no por la API pública. HTTPS es obligatorio y las direcciones privadas se rechazan.
Abrir API Access en el panel de revendedorLímites de solicitudes y manejo de errores
Los límites son públicos, se aplican por cuenta y se calculan sobre una ventana deslizante. El trabajo de lectura del catálogo tiene el tope más alto; los endpoints de estado son ajustados a propósito, porque existen como respaldo de conciliación y no como bucle de consulta.
| Ruta | Solicitudes | Ventana | Se cuentan por |
|---|---|---|---|
| GET /catalog/* (las cinco) | 5000 | 60 s | por cuenta |
| POST /orders/create | 5000 | 60 s | por cuenta |
| GET /orders/:orderId | 3 | 60 s | por cuenta + ID de pedido |
| POST /orders/batch | 2 | 60 s | por cuenta |
| GET /orders | 2 | 60 s | por cuenta |
| GET /account | 60 | 60 s | por cuenta |
| POST /player/validate | cuota diaria de verificación de jugadores | 24 h | por cuenta (sin encabezados X-RateLimit) |
Los endpoints de catálogo, pedidos y cuenta llevan X-RateLimit-Limit, X-RateLimit-Remaining y X-RateLimit-Reset en cada respuesta, así que un cliente bien educado nunca tiene que adivinar en qué punto está. Vigile el contador restante y baje el ritmo antes de que lo limiten, no después. La validación de jugador es la excepción: funciona con su cuota diaria de verificaciones, no envía encabezados X-RateLimit e informa esa cuota dentro del cuerpo del 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
}
}
}Los errores que verá primero, y cómo resolverlos
Los fallos siempre devuelven un código estable junto al estado HTTP. Estos son los que encuentra una integración nueva en su primera semana; cada uno viene con el cambio que lo resuelve.
| Código | HTTP | Qué significa | Cómo resolverlo |
|---|---|---|---|
| INVALID_API_KEY | 401 | Clave de API no válidaLa clave de API enviada no coincide con ninguna clave activa del sistema. | Revisa que el ID de la clave y el secreto sean correctos. Si hace falta, genera una clave nueva desde el panel Acceso a la API.action: FIX_INPUT · retryable: false · retry_after: null |
| IP_NOT_ALLOWED | 403 | La dirección IP no está en la lista de permitidasLa solicitud vino de una dirección IP que no está en la lista de IP permitidas de tu clave de API. | Agrega la IP de tu servidor a la lista de permitidas en el panel Acceso a la API o quita las restricciones de IP.action: FIX_INPUT · retryable: false · retry_after: null |
| MISSING_REQUIRED_FIELD | 400 | Falta un campo obligatorioFalta un campo obligatorio en el cuerpo de la solicitud. En los requisitos del jugador, details.field indica qué campo falta. | Revisa la documentación del endpoint y, para los campos del jugador, GET /catalog/category/:categoryId/requirements. Luego envía todos los campos obligatorios.action: FIX_INPUT · retryable: false · retry_after: null |
| INVALID_UUID_FORMAT | 400 | Formato de UUID no válidoEl order_id no tiene un formato de UUID válido. | Usa cualquier formato de UUID válido (v1, v4, v7, etc.). Formato: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx.action: FIX_INPUT · retryable: false · retry_after: null |
| PLAYER_NOT_FOUND | 400 | Jugador no encontradoEl juego confirmó que este ID de jugador no existe. Este código solo aparece con una respuesta confirmada; si el ID no se puede verificar, recibes PLAYER_CHECK_UNAVAILABLE. | Revisa que el ID del jugador sea correcto. Comprueba si el juego necesita un zone_id.action: FIX_INPUT · retryable: false · retry_after: null |
| PLAYER_CHECK_UNAVAILABLE | 503 | Por ahora no se pueden verificar los IDs de jugador de este juegoEn este momento no es posible comprobar los IDs de jugador de este juego. El ID del jugador NO fue rechazado. | Repite la misma solicitud pasados retry_after segundos. No le digas a tu cliente que el ID es incorrecto.action: RETRY_LATER · retryable: true · retry_after: 60 |
| REGION_MISMATCH | 400 | La región de este jugador no puede recibir este productoEl jugador pertenece a una región donde este producto no se puede entregar. El error incluye player_region y player_name. | Elige el producto que corresponde a la región del jugador.action: CHOOSE_OTHER · retryable: false · retry_after: null |
| INSUFFICIENT_BALANCE | 400 | Saldo insuficiente en la billeteraTu billetera no tiene fondos suficientes para completar este pedido. | Recarga tu billetera desde la página de recargas antes de hacer el pedido.action: TOP_UP · retryable: false · retry_after: null |
| OUT_OF_STOCK | 400 | Producto agotadoEl producto solicitado está agotado en este momento. | Inténtalo más tarde o elige otro producto.action: RETRY_LATER · retryable: true · retry_after: null |
| PRODUCT_UNAVAILABLE | 409 | Este producto no está disponible temporalmenteEl producto existe, pero ahora mismo no se puede vender. | Vuelve a intentarlo más tarde o elige otro producto.action: RETRY_LATER · retryable: true · retry_after: null |
| DUPLICATE_ORDER | 409 | ID de pedido duplicadoYa existe un pedido con este UUID. Es una protección de idempotencia. | Usa un UUID nuevo cuando crees un pedido nuevo. Si te aparece al reintentar, el primer intento sí se procesó: consulta ese pedido con GET /orders/{orderId} en lugar de crear otro.action: CHECK_ORDER · retryable: false · retry_after: null |
| PRICE_INCREASED | 400 | El precio subió más de lo esperadoEl precio unitario actual es mayor que el expected_unit_price que enviaste. | Consulta el precio más reciente con GET /catalog/subcategory/:id/price y actualiza expected_unit_price.action: FIX_INPUT · retryable: false · retry_after: null |
| RATE_LIMIT_EXCEEDED | 429 | Límite de solicitudes superadoSuperaste el límite de este endpoint. Los límites por minuto (catálogo, pedidos y cuenta) envían X-RateLimit-Limit, X-RateLimit-Remaining y X-RateLimit-Reset en cada respuesta, y details repite limit, remaining (0), retry_after y window_seconds. POST /player/validate, en cambio, se mide con tu cuota diaria de verificaciones de jugador: no envía encabezados X-RateLimit y su 429 trae limit, current, remaining, reset_at y reset_in_seconds dentro de error. En ambos casos, error.retry_after y el encabezado Retry-After indican los segundos que debes esperar. | Antes de reintentar, espera los segundos que indica retry_after o el encabezado Retry-After. En los endpoints con límite por minuto, vigila X-RateLimit-Remaining para no llegar nunca al límite.action: RETRY_LATER · retryable: true · retry_after: segundos hasta el reinicio |
| INTERNAL_ERROR | 500 | Error interno del servidorOcurrió un error inesperado en el servidor. | Vuelve a enviar la solicitud pasados retry_after segundos. En POST /orders/create usa el mismo order_id, así el pedido nunca se crea dos veces. Si el error continúa, contacta a soporte.action: RETRY_LATER · retryable: true · retry_after: 30 |
Preguntas de integración, respondidas
Las preguntas que los desarrolladores hacen de verdad mientras conectan todo esto: autenticación, reintentos, idempotencia, límites, avisos y manejo de fallos.
¿Cómo autentica las solicitudes la API para revendedores?
Con un solo encabezado HTTP: Authorization: Bearer <keyId>.<secret>. El par de claves se emite desde la página API Access de su panel de revendedor, el secreto se muestra una única vez al crearlo, y el servidor lo verifica con una comprobación HMAC en cada llamada. No hay redirección OAuth, ni cookie de sesión, ni token que refrescar, así que el mismo encabezado sirve para la primera solicitud y para todas las siguientes.
¿A qué URL base y versión debe apuntar mi cliente?
A una sola URL base, y es la de producción: no hay un host de pruebas aparte contra el que desarrollar y que luego se cambie. Apunte el cliente a la URL base v1 que aparece arriba, envíe Authorization: Bearer <keyId>.<secret> en cada petición y empiece por GET /account para comprobar la clave y la forma de la cabecera antes que nada. POST /orders/create es la única llamada que mueve dinero: déjela para el final, ejecútela con su denominación más pequeña y envíe expected_unit_price mientras termina de ajustar el cliente.
¿Qué campo lleva la clave de idempotencia en POST /orders/create?
El campo order_id: un UUID que usted genera en su lado y envía en el cuerpo de la creación. Si repite una creación con un order_id que ya existe, la API responde HTTP 409 con DUPLICATE_ORDER en lugar de abrir un segundo pedido, así que un reintento nunca se convierte en un segundo cobro; lea el pedido original con GET /orders/:orderId. Genere y guarde el UUID antes del primer intento, porque un UUID creado después de un timeout es una clave nueva y no una clave de reintento, y el servidor no fusionará dos order_id que significaban lo mismo.
Mi solicitud expiró. ¿Se creó el pedido?
Averígüelo con una lectura de estado en lugar de adivinar. Llame a GET /orders/:orderId con el mismo UUID que envió: si el pedido aparece, la expiración ocurrió después de que fuera aceptado y no debe reenviarlo. Si obtiene ORDER_NOT_FOUND, no se cobró nada y es seguro repetir la solicitud de creación con ese mismo UUID.
¿Cómo verifico la firma de una llamada de webhook?
Recalcule el HMAC sobre el cuerpo crudo de la petición y compárelo con la cabecera X-Shop2Topup-Signature. La cabecera lleva sha256=<hex>: calcule HMAC-SHA256 de los bytes exactos que recibió con su secreto de webhook, añada el prefijo sha256= y compare en tiempo constante, nunca contra un objeto JSON reserializado, porque parsear y volver a serializar cambia los bytes y la comparación no coincidirá jamás. Rechace lo que no cuadre con un 401, responda 200 a una entrega válida en menos de diez segundos y haga la conciliación después en su propia cola.
¿Qué pasa cuando llego al límite de solicitudes?
Recibe un HTTP 429 con el código RATE_LIMIT_EXCEEDED y un encabezado Retry-After. El cuerpo de la respuesta repite esa misma cifra en retry_after y agrega limit, remaining y window_seconds. Espere exactamente esa cantidad de segundos y reintente una vez; no reintente de inmediato ni reparta la misma llamada entre varias claves, porque el contador es por cuenta y no por conexión.
¿Qué campos devuelve POST /player/validate?
player_name y, cuando el juego los expone, zone_id y region, dentro del objeto data de la respuesta. La creación del pedido resuelve al jugador y comprueba la región por su cuenta, y un intento fuera de región se rechaza con REGION_MISMATCH (HTTP 400) antes de cualquier cargo en la billetera, así que no es esta llamada la que protege su saldo. Llámela porque leer player_name es la única forma práctica de mostrar al comprador la cuenta que va a recargar y de detectar un identificador mal escrito antes de que confirme: el nombre siempre viene del juego, nunca de lo que el comprador escribió.
¿Cómo convierto la respuesta requirements en el cuerpo del pedido?
Indéxela por field_name: cada entrada que devuelve GET /catalog/category/:categoryId/requirements se convierte en una propiedad del objeto requirements que envía a POST /orders/create. Cada entrada trae además data_type, con lo que su formulario sabe qué renderizar y su servidor qué convertir, y las entradas single_select y multi_select incluyen sus valores permitidos: ofrezca esos en lugar de un campo de texto libre. Cachee la lista por categoría en vez de leerla en cada pedido, y vuelva a leerla cuando una creación responda MISSING_REQUIRED_FIELD.
¿Cómo debe tratar mi cliente una respuesta PRICE_INCREASED?
Relea el precio, decida y reenvíe; nunca repita el mismo cuerpo. PRICE_INCREASED (HTTP 400) significa que el precio unitario vigente está por encima del expected_unit_price que envió, así que la creación se rechazó y no se cobró nada; los detalles del error llevan expected_unit_price y current_unit_price, y un reintento ciego con la cifra antigua se rechazará siempre. Lea GET /catalog/subcategory/:itemId/price, decida si la cifra nueva le sigue sirviendo y reenvíe la creación con el expected_unit_price actualizado y el mismo order_id: un intento rechazado no lo consumió. Omitir expected_unit_price desactiva del todo la protección, que casi nunca es lo que quiere una integración automatizada.
¿Cómo aparece en la respuesta un pedido entregado en parte?
Como status partial, con el desglose en sub_transaction_summary. Ese objeto cuenta las unidades como completed, refunded o pending —esos tres siempre suman total— y sub_transactions enumera cada unidad con su propio estado. Una unidad que aún no se ha entregado ni reembolsado sigue en pending. Trate pending como no terminal y siga esperando; trate completed, partial y refunded como terminales. Los contadores failed, processing y retrying se mantienen solo por compatibilidad y siempre valen 0. Las llamadas order.completed y order.refunded llevan el mismo resumen, así que un payload con la firma verificada basta para cerrar sin una segunda lectura.
¿Qué recibe una petición desde una dirección fuera de la lista permitida?
Un HTTP 403 con el código IP_NOT_ALLOWED, devuelto antes de ejecutar ninguna lógica de negocio. La comprobación se aplica siempre que la lista de IP de la clave no esté vacía, lo que deja inservible una clave filtrada fuera de sus propias direcciones de salida. Ramifique según error.code y no según el texto del mensaje, y lea un IP_NOT_ALLOWED repentino en producción como un cambio de dirección de salida —una pasarela NAT nueva o una subred de workers nueva— y no como una clave rota. Deje la lista vacía solo si su dirección saliente realmente no es estable.
¿Cuánto tarda en completarse un pedido?
Trate la finalización como asíncrona, nunca como instantánea. POST /orders/create responde apenas el pedido se acepta y la billetera se debita, normalmente con estado pending; la entrega corre después en workers separados y el estado final le llega por webhook o en su siguiente lectura de estado. No bloquee una solicitud HTTP de cara al cliente esperando el estado final: acéptela, respóndale a su cliente y resuelva el resto por fuera.
Cree una cuenta, genere una clave y salga a producción
No hay fila de aprobación ni período de espera entre registrarse y llamar a la API. Cree la cuenta de revendedor, genere una clave en la página API Access, cargue saldo, y su primer pedido puede salir el mismo día.
¿No es usted quien programa este proyecto? El lado comercial está cubierto en la página del programa de revendedores de SHOP2TOPUP.