Comunicación con punto de venta
Tu terminal habla con SPIDI en dos fases: pairing (una sola vez) y luego operación firmada con HMAC (en cada petición).
Fase 1 — Pairing (vinculación)
Ocurre una sola vez en la vida del dispositivo, y son dos llamadas.
1) El admin registra el serial y genera un OTP. Con tu token:
curl -X POST https://dev.api.spuntodeventa.com/api/v1/merchants/MERCHANT-001/pos-terminals/98202003219630/pairing \
-H "Authorization: Bearer <tu-token>" \
-H "Content-Type: application/json" \
-d '{ "serial": "98202003219630" }'
Sí, el serial va dos veces: en la ruta y en el cuerpo. Así lo exige el contrato. La respuesta trae el código que el técnico va a teclear en el aparato:
{ "data": { "code": "889904" } }
2) El POS canjea el OTP y recibe su Secret Key. Esta llamada es pública —el terminal todavía no tiene con qué firmar:
curl -X POST https://dev.api.spuntodeventa.com/api/v1/pos-terminals/98202003219630/pairing/activate \
-H "Content-Type: application/json" \
-d '{ "serial": "98202003219630", "code": "889904" }'
{ "data": { "secret_key": "sk_live_…" } }
El servidor valida el código, genera la Secret Key y la asocia al serial; el POS la guarda en memoria segura. A partir de aquí, la Secret Key nunca vuelve a viajar por internet.
Fase 2 — Firma HMAC en cada petición
Ya vinculado, toda petición del POS se firma con HMAC-SHA256 a partir de cuatro elementos:
| # | Elemento | Detalle |
|---|---|---|
| 1 | Body | El JSON del cuerpo, tal cual se envía. En GET o sin cuerpo, cadena vacía "" |
| 2 | Timestamp | ISO 8601 en UTC, p. ej. 2026-07-29T14:03:11Z. El mismo que envías en la cabecera |
| 3 | URL | La URL completa, con esquema y host, incluyendo el query string |
| 4 | Secret Key | La clave persistida en el pairing |
message = body + "\n" + timestamp + "\n" + url
signature = HMAC_SHA256(message, secret_key) → hexadecimal
Cabeceras obligatorias en toda petición del terminal:
| Cabecera | Valor |
|---|---|
x-pos-signature | La firma HMAC-SHA256 en hexadecimal |
x-pos-timestamp | El mismo timestamp ISO 8601 usado en la firma |
Vector de prueba
Antes de pelearte con el servidor, comprueba que tu implementación arma la cadena igual que esta doc. Con esta clave de juguete:
secret_key = sk_test_3f8a1c9e5b2d4a6f8e0c1b3d5f7a9c2e
Un GET (cuerpo vacío, así que el mensaje empieza por un salto de línea):
timestamp = 2026-07-29T14:03:11Z
url = https://dev.api.spuntodeventa.com/api/v1/pos-terminals/98202003219630/transactions?status=PENDING
body = ""
x-pos-signature = 8469bfcc3e38bae7c5cfe57d8de0830d58720ee9cc8198e5fc4639236b33c8bf
Un POST con cuerpo:
timestamp = 2026-07-29T14:05:02Z
url = https://dev.api.spuntodeventa.com/api/v1/pos-terminals/98202003219630/transactions/ORD-123456/commit
body = {"authorizationCode":"171599","processCode":"002000","commitAt":"2026-07-29T14:05:00Z","amountReference":10.5}
x-pos-signature = 03ff4aabc6098b6917b864300dda7b3a1dc91f5ee68f57b900c06a16c6950759
Si te salen esos dos hexadecimales, tu ensamblado del mensaje y tu codificación coinciden con las de aquí. Ojo con el cuerpo: se firma exactamente la cadena que mandas, así que serialízalo una sola vez y firma esa misma variable — no vuelvas a serializar el objeto para enviarlo, o un espacio de diferencia te tumba la firma.
En Node:
const crypto = require("crypto");
function firmar(body, timestamp, url, secretKey) {
const message = `${body}\n${timestamp}\n${url}`;
return crypto.createHmac("sha256", secretKey).update(message).digest("hex");
}
Operaciones del terminal
Casi todas van firmadas, pero no todas: la consulta de un cierre concreto pide token, no firma.
| Operación | Método | Endpoint | Body | Autenticación |
|---|---|---|---|---|
| Listar pagos | GET | /api/v1/pos-terminals/{serialNumber}/transactions?status=PENDING | "" | firma |
| Consultar una orden | GET | /api/v1/pos-terminals/{serialNumber}/transactions/{orderId} | "" | firma |
| Confirmar pago | POST | /api/v1/pos-terminals/{serialNumber}/transactions/{orderId}/commit | JSON | firma |
| Confirmar anulación | POST | /api/v1/pos-terminals/{serialNumber}/transactions/{orderId}/cancellation/commit | "" | firma |
| Crear cierre de lote | POST | /api/v1/pos-terminals/{serialNumber}/settlements | JSON | firma |
| Listar cierres | GET | /api/v1/pos-terminals/{serialNumber}/settlements | "" | firma |
| Consultar un cierre | GET | /api/v1/pos-terminals/{serialNumber}/settlements/{batchNumber} | "" | Bearer |
El GET de pagos aparece con cuerpo obligatorio. No lo tiene: el servidor de sandbox responde a esa ruta sin cuerpo, y en un GET se firma cadena vacía. Es una errata del OpenAPI.
La cabecera x-pos-timestamp no está declarada. El contrato solo describe x-pos-signature, pero el timestamp es obligatorio: sin él la firma no se puede verificar. Envíalo siempre.
Las dos están reportadas a SPIDI.
Por qué es seguro
- Sin contraseñas en el dispositivo: nada que anotar en un papel.
- Integridad: si alguien altera el monto, la URL o el timestamp, la firma falla y el servidor rechaza.
- Anti-replay: el
x-pos-timestamppermite descartar peticiones viejas o duplicadas. - Aislamiento: la llave del terminal no sirve para tocar tu cuenta, y tu token no sirve para firmar como el terminal. Robar el aparato no te compromete el comercio.
La API todavía no expone una operación para desvincular un POS o revocar su Secret Key. Si necesitas dar de baja un aparato, escríbele a SPIDI. Está pedido como capacidad pendiente.