Version 1.0.0 · https://topup-sandbox.nexo2one.com ·
openapi.json
Recargas de saldo movil.
Esto es el sandbox. El contrato es identico al de produccion y el codigo que funcione aqui funciona alli sin cambiar una linea: lo unico que cambia es a que servidor apuntas. Las recargas no se cobran y no llegan a ningun telefono, y el saldo es ficticio.
Todas las peticiones van firmadas. No hay token portador: el secreto no viaja nunca, viaja una firma de cada peticion concreta. Un Bearer que aparece en un log o en el historial de una terminal es acceso al dinero de un cliente.
La firma vale 300 segundos y el nonce no se puede repetir dentro de esa ventana: sin lo segundo, quien capture una peticion valida puede reproducirla.
Los importes son cadenas de texto, no numeros. Es a proposito.
Para enterarte de como acaban las recargas sin preguntar en bucle, pidenos que te demos de alta un webhook: docs/cliente/WEBHOOKS.md.
Cuatro cabeceras, todas obligatorias:
| Cabecera | Que es |
|---|---|
X-Topup-Key | Tu Key ID, el que empieza por tk_. |
X-Topup-Timestamp | Segundos desde epoch. |
X-Topup-Nonce | Un valor distinto en cada peticion. Un UUID vale. |
X-Topup-Signature | hex(HMAC-SHA256(secreto, cadena_canonica)). |
La cadena canonica son cinco lineas separadas por \n:
METODO
RUTA
TIMESTAMP
NONCE
SHA256(cuerpo)
La ruta va sin dominio y sin query. El hash del cuerpo se calcula tambien cuando el cuerpo esta vacio: es el sha256 de la cadena vacia.
Donde se atasca todo el mundo: la ruta se firma sin el dominio y sin la query, y el cuerpo que se hashea tiene que ser exactamente el mismo texto que se manda. Si serializas el JSON dos veces, cualquier diferencia de formato —un espacio, el orden de las claves— invalida una peticion que por lo demas es correcta.
Copiables y comprobados: hay una prueba que verifica que estos ejemplos producen la misma firma que acepta el servidor.
import { createHash, createHmac, randomUUID } from 'node:crypto';
const BASE = 'https://topup-sandbox.nexo2one.com';
function cabeceras(keyId, secreto, metodo, ruta, cuerpo = '') {
const timestamp = String(Math.floor(Date.now() / 1000));
const nonce = randomUUID();
// El orden y los saltos de linea son exactos. Un espacio de mas, y no cuadra.
const canonica = [
metodo.toUpperCase(),
ruta, // sin dominio y sin query
timestamp,
nonce,
createHash('sha256').update(cuerpo, 'utf8').digest('hex'), // del cuerpo vacio tambien
].join('\n');
return {
'content-type': 'application/json',
'x-topup-key': keyId,
'x-topup-timestamp': timestamp,
'x-topup-nonce': nonce,
'x-topup-signature': createHmac('sha256', secreto).update(canonica, 'utf8').digest('hex'),
};
}
// Consultar el saldo
await fetch(BASE + '/v1/balance', {
headers: cabeceras(keyId, secreto, 'GET', '/v1/balance'),
});
// Crear una recarga. La Idempotency-Key es obligatoria y la eliges tu:
// si repites la llamada con la misma clave, te devolvemos la misma orden.
const cuerpo = JSON.stringify({ sku: 'CU-226475', destino: '5355512345' });
await fetch(BASE + '/v1/topups', {
method: 'POST',
headers: {
...cabeceras(keyId, secreto, 'POST', '/v1/topups', cuerpo),
'idempotency-key': 'mi-pedido-00042',
},
// El mismo texto que se hasheo para firmar: si aqui se re-serializa el objeto,
// cualquier diferencia de formato invalida la firma.
body: cuerpo,
});import hashlib, hmac, json, time, uuid, requests
BASE = "https://topup-sandbox.nexo2one.com"
def cabeceras(key_id, secreto, metodo, ruta, cuerpo=""):
timestamp = str(int(time.time()))
nonce = str(uuid.uuid4())
canonica = "\n".join([
metodo.upper(),
ruta,
timestamp,
nonce,
hashlib.sha256(cuerpo.encode()).hexdigest(),
])
firma = hmac.new(secreto.encode(), canonica.encode(), hashlib.sha256).hexdigest()
return {
"content-type": "application/json",
"x-topup-key": key_id,
"x-topup-timestamp": timestamp,
"x-topup-nonce": nonce,
"x-topup-signature": firma,
}
# Consultar el saldo
requests.get(BASE + "/v1/balance", headers=cabeceras(key_id, secreto, "GET", "/v1/balance"))
# Crear una recarga
cuerpo = json.dumps({"sku": "CU-226475", "destino": "5355512345"})
h = cabeceras(key_id, secreto, "POST", "/v1/topups", cuerpo)
h["idempotency-key"] = "mi-pedido-00042"
requests.post(BASE + "/v1/topups", headers=h, data=cuerpo)<?php
const BASE = 'https://topup-sandbox.nexo2one.com';
function cabeceras(string $keyId, string $secreto, string $metodo, string $ruta, string $cuerpo = ''): array {
$timestamp = (string) time();
$nonce = bin2hex(random_bytes(16));
$canonica = implode("\n", [
strtoupper($metodo),
$ruta,
$timestamp,
$nonce,
hash('sha256', $cuerpo),
]);
$firma = hash_hmac('sha256', $canonica, $secreto);
return [
'content-type: application/json',
"x-topup-key: {$keyId}",
"x-topup-timestamp: {$timestamp}",
"x-topup-nonce: {$nonce}",
"x-topup-signature: {$firma}",
];
}
$ch = curl_init(BASE . '/v1/balance');
curl_setopt($ch, CURLOPT_HTTPHEADER, cabeceras($keyId, $secreto, 'GET', '/v1/balance'));
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$respuesta = curl_exec($ch);
/v1/products
Catalogo disponible
Lo que se puede vender ahora mismo, con tu precio.
200 | El catalogo. |
401 | Firma invalida o ausente. |
429 | Limite por minuto. |
/v1/balance
Tu saldo
Tres cifras, y la que importa para poder pedir es disponible.
reservado es lo comprometido en recargas que todavia estan en vuelo: el saldo se aparta antes de mandar nada al operador, y no se libera hasta saber que paso.
200 | El saldo. |
401 | Firma invalida o ausente. |
/v1/topups
Tus ultimas recargas
Las cien mas recientes, de la mas nueva a la mas vieja.
200 | Las recargas. |
401 | Firma invalida o ausente. |
/v1/topups
Crear una recarga
Idempotency-Key es obligatoria, no recomendada. La eliges tu, y si repites la llamada con la misma clave te devolvemos la orden que ya existe con un 200 en vez de crear otra. Es lo que te protege de cobrar dos veces cuando una respuesta se pierde por el camino.
Si repites la clave cambiando el sku o el destino, contestamos 409: eso no es un reintento, es otra cosa con la misma etiqueta.
La respuesta llega casi siempre en pendiente. Enterarse de como acaba es para lo que estan los webhooks.
Cabeceras propias: Idempotency-Key (obligatoria)
200 | La misma `Idempotency-Key` con los mismos datos: te devolvemos la orden que ya existia. |
201 | Recarga creada. |
400 | Falta la Idempotency-Key, o el cuerpo no vale. |
403 | Cuenta suspendida. |
409 | Esa Idempotency-Key ya se uso con otros datos. |
422 | Sin saldo, producto no disponible o limite de importe. |
429 | Limite de recargas por minuto. |
/v1/topups/{id}
Una recarga
Esta es la fuente de verdad. Un webhook adelanta lo que esto te diria; no lo sustituye. Si los dos se contradicen, manda esto.
Una recarga de otro cliente se responde igual que una que no existe.
200 | La recarga. |
404 | No existe esa recarga, o no es tuya. |
Todos los errores llegan con la misma forma:
{"error":{"codigo","mensaje","peticion_id"}}.
Programa contra el codigo, nunca contra el mensaje: el mensaje esta
escrito para una persona y puede cambiar. El peticion_id es lo que nos
tienes que dar si nos escribes.
| Codigo | HTTP | Que ha pasado |
|---|---|---|
FIRMA_INVALIDA | 401 | La firma no cuadra con la peticion, o faltan cabeceras de firma. |
TIMESTAMP_FUERA_DE_VENTANA | 401 | El reloj se ha ido mas de 300 s. Sincronizalo. |
NONCE_REPETIDO | 401 | Ese nonce ya se uso. Genera uno nuevo por peticion. |
IP_NO_AUTORIZADA | 403 | Tu IP no esta en la lista blanca. |
CREDENCIAL_DESCONOCIDA | 401 | Key ID que no existe o retirada. |
CLIENTE_DESCONECTADO | 403 | La cuenta esta suspendida. |
IDEMPOTENCY_KEY_REQUERIDA | 400 | Falta la cabecera Idempotency-Key en POST /v1/topups. |
IDEMPOTENCY_KEY_CONFLICTO | 409 | Esa clave ya se uso con otro sku o destino. |
CUERPO_INVALIDO | 400 | El cuerpo no es JSON o le faltan campos. |
SALDO_INSUFICIENTE | 422 | No hay saldo disponible para esa recarga. |
PRODUCTO_NO_DISPONIBLE | 404 | Ese sku no existe o no esta activo. |
RECARGA_NO_ENCONTRADA | 404 | No existe esa recarga, o no es tuya. |
LIMITE_EXCEDIDO | 429 | Demasiadas peticiones. Mira la cabecera Retry-After. |
Cada respuesta trae X-RateLimit-Limit y X-RateLimit-Remaining:
no hace falta descubrir el limite a base de recibir 429. Cuando se pasa,
la respuesta es 429 con Retry-After en segundos. Respetalo.
Consultar y crear recargas gastan cupos distintos, asi que mirar el saldo no te quita capacidad de pedir.
Para enterarte de como acaban las recargas sin preguntar en bucle, pidenos que te demos de alta una URL. El contrato completo —firma, reintentos y deduplicacion— esta en el documento de webhooks que te entregamos con el alta.