TOPUP — API B2B sandbox

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.

Autenticacion

Cuatro cabeceras, todas obligatorias:

CabeceraQue es
X-Topup-KeyTu Key ID, el que empieza por tk_.
X-Topup-TimestampSegundos desde epoch.
X-Topup-NonceUn valor distinto en cada peticion. Un UUID vale.
X-Topup-Signaturehex(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.

Ejemplos de firma

Copiables y comprobados: hay una prueba que verifica que estos ejemplos producen la misma firma que acepta el servidor.

JavaScript (Node 18+)

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,
});

Python 3

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 8

<?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);

Endpoints

GET /v1/products

Catalogo disponible

Lo que se puede vender ahora mismo, con tu precio.

200El catalogo.
401Firma invalida o ausente.
429Limite por minuto.
GET /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.

200El saldo.
401Firma invalida o ausente.
GET /v1/topups

Tus ultimas recargas

Las cien mas recientes, de la mas nueva a la mas vieja.

200Las recargas.
401Firma invalida o ausente.
POST /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)

200La misma `Idempotency-Key` con los mismos datos: te devolvemos la orden que ya existia.
201Recarga creada.
400Falta la Idempotency-Key, o el cuerpo no vale.
403Cuenta suspendida.
409Esa Idempotency-Key ya se uso con otros datos.
422Sin saldo, producto no disponible o limite de importe.
429Limite de recargas por minuto.
GET /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.

200La recarga.
404No existe esa recarga, o no es tuya.

Errores

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.

CodigoHTTPQue ha pasado
FIRMA_INVALIDA401La firma no cuadra con la peticion, o faltan cabeceras de firma.
TIMESTAMP_FUERA_DE_VENTANA401El reloj se ha ido mas de 300 s. Sincronizalo.
NONCE_REPETIDO401Ese nonce ya se uso. Genera uno nuevo por peticion.
IP_NO_AUTORIZADA403Tu IP no esta en la lista blanca.
CREDENCIAL_DESCONOCIDA401Key ID que no existe o retirada.
CLIENTE_DESCONECTADO403La cuenta esta suspendida.
IDEMPOTENCY_KEY_REQUERIDA400Falta la cabecera Idempotency-Key en POST /v1/topups.
IDEMPOTENCY_KEY_CONFLICTO409Esa clave ya se uso con otro sku o destino.
CUERPO_INVALIDO400El cuerpo no es JSON o le faltan campos.
SALDO_INSUFICIENTE422No hay saldo disponible para esa recarga.
PRODUCTO_NO_DISPONIBLE404Ese sku no existe o no esta activo.
RECARGA_NO_ENCONTRADA404No existe esa recarga, o no es tuya.
LIMITE_EXCEDIDO429Demasiadas peticiones. Mira la cabecera Retry-After.

Limites

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.

Webhooks

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.