Observar y depurar en el simulador
Cuando una integración no funciona, casi siempre es porque estás mirando tu lado y el fallo está en el medio. El simulador de SPIDI te enseña el medio: qué petición le llegó, qué respondió, qué webhook intentó entregarte, a dónde, cuántas veces y con qué resultado.
Necesitas tus dos tokens: Credenciales.
Toda la superficie /console/** — la traza incluida — existe solo en el simulador. En producción observas con tus propios registros y con GET /api/v1/ext/payment-sessions/status/<session_id>, que sí es contrato. Aprovecha la traza para dejar tu instrumentación bien puesta antes de salir del sandbox, porque después no la vas a tener.
Las dos ventanas
| Qué quieres saber | Dónde | Con qué token |
|---|---|---|
| Qué ha pasado, en orden, con todo | GET /console/trace | consola |
| Cómo está una sesión ahora | GET /api/v1/ext/payment-sessions/status/<session_id> | API |
| Qué sesiones tengo | GET /console/sessions | consola |
La traza
curl "https://sim-productos.abiertolab.com/console/trace?account_id=<tu-account-id>" \
-H "Authorization: Bearer <tu-token-de-consola>"
Devuelve { "events": [ … ], "cursor": { … } }, en orden cronológico ascendente. Cada evento tiene esta forma:
{
"id": "evt_0a1b2c3d4e5f6071",
"createdAt": 1754300000000,
"accountId": "acc_9f8e7d6c5b4a3021",
"sessionId": "ses_1f2e3d4c5b6a7890",
"type": "webhook.attempt",
"payload": { }
}
Parámetros de consulta:
account_id— obligatorio. Sin él recibes400.session_id— filtra por una sesión.type— filtra por tipo de evento. Un tipo que no exista da400.limit— entre 1 y 500, por defecto 200.after_created_atyafter_id— el cursor, para paginar. Repite en la siguiente llamada los valores delcursorque te devolvió la anterior.
Y los errores que puedes recibir: 401 si el token de consola es inválido o falta, 403 si esa key no es de tu cuenta.
Qué cuenta cada tipo de evento
type | Cuándo se registra | Lo importante del payload |
|---|---|---|
account.created | Al dar de alta la key | shortName |
api.request | Llega una petición autenticada a /api/** | method, path, correlationId |
api.response | Se responde esa petición | status, method, path, correlationId |
state.transition | La sesión cambia de estado | from, to, leg — y reason solo a veces (ver abajo) |
control.action | Forzaste un desenlace por /control/** | action, leg, outcome |
webhook.attempt | Se genera o se intenta entregar un webhook | tiene tres formas, detalladas en la sección "Las tres formas de webhook.attempt" |
webhook.delivered | Tu receptor respondió 2xx | attempt, status, event, idempotencyKey, routing |
webhook.received | Algo entró por un receptor del simulador (/console/hooks/<account_id>/<kind>) | kind, raw, body, headers, signature, idempotencyKey |
Tres detalles que ahorran confusión:
- Las llamadas a
/control/**no aparecen comoapi.request. La captura solo mira/api/**. Del plano de control queda elcontrol.actiony lastate.transitionque provocó. correlationIdempareja la petición con su respuesta. Es el mismo valor en elapi.requesty en elapi.responsedel mismo intercambio.reasonno viene en todas lasstate.transition. Solo aparece en las del fase 1 ("leg": 1), y solo si forzaste el desenlace pasando un motivo. Las dos transiciones del fase 2 —paid → accreditedypaid → accreditation_failed— no traen la clave en absoluto, así que no la busques ahí para explicar por qué falló una acreditación: el simulador no registra un motivo para la fase 2. Si tu código leepayload.reason, trátalo como ausente por defecto, no como un fallo sin motivo.
Las tres formas de webhook.attempt
Este es el evento que resuelve la mayoría de los problemas, y tiene tres formas según el momento:
1. La captura. Se registra siempre, incluso cuando no hay a dónde entregar nada:
{
"capture": true,
"event": "payment_session.paid",
"url": "http://localhost:4020/webhooks/spidi",
"routing": "relay",
"payload": { "event": "payment_session.paid", "data": { } },
"signature": "9c1f…",
"idempotencyKey": "0f3a…"
}
Trae el cuerpo exacto que se firmó y la firma que se calculó. Es lo que te deja depurar la verificación sin recibir nada.
Si la sesión se creó sin webhook_url, esta captura sale con "url": null y "routing": "direct", y no le sigue ningún evento más: no hay a dónde entregar, así que no se intenta nada.
2. Cada intento de entrega, uno por cada envío:
{ "attempt": 1, "url": "…", "routing": "relay", "status": 500, "ok": false }
El status puede ser un número HTTP o una de estas tres palabras: timeout (tu servidor tardó más de dos segundos), error (no se pudo ni conectar) o no-relay (el destino era local pero no hay ningún relay conectado).
3. La rendición, cuando se agotaron los intentos:
{ "gaveUp": true, "attempts": 3, "event": "payment_session.paid", "idempotencyKey": "0f3a…" }
Ojo con los dos contadores, porque se parecen y no son lo mismo: attempt (singular) es el número de un intento concreto y empieza en 1; attempts (plural) es el total gastado y solo existe en la rendición, donde siempre es mayor que cero. Ningún evento de la traza lleva attempts: 0: cuando no hay a dónde entregar, lo que ves es la captura sola, sin ningún intento detrás.
El campo routing
Es lo primero que hay que mirar cuando un webhook no aparece. Tiene tres valores:
routing | Significa |
|---|---|
direct | Va por internet a la URL que pusiste. También es lo que verás si la sesión no tiene webhook_url: se captura el evento con "url": null y no se envía nada. |
loopback | El destino es un receptor del propio simulador. No sale de ahí. |
relay | El destino es local o privado, y viaja por el WebSocket del relay. |
El estado de una sesión
curl https://sim-productos.abiertolab.com/api/v1/ext/payment-sessions/status/<session_id> \
-H "Authorization: Bearer <tu-token-de-api>"
En data te interesan:
status—pending,paid,failedoexpired.session_payment.reasonysession_payment.user_message— el motivo del fallo, cuando lo hubo.session_payment.payment_details— quién pagó, si pagó por la página.
La acreditación no se ve en el status, pero sí en esta misma respuesta. Una sesión acreditada sigue diciendo status: "paid", así que si la buscas ahí no la vas a encontrar nunca. Está un nivel más abajo, en session_payment.receiver_credits: mientras venga null, la fase 2 no ha ocurrido. → Transacción a dos fases.
Para listar todas tus sesiones de un vistazo, con su status, su accredited y su webhook_url:
curl "https://sim-productos.abiertolab.com/console/sessions?account_id=<tu-account-id>" \
-H "Authorization: Bearer <tu-token-de-consola>"
Admite &status=paid para filtrar.
Catálogo de síntomas
El webhook no llega
Busca el webhook.attempt de esa sesión y mira el routing:
curl "https://sim-productos.abiertolab.com/console/trace?account_id=<tu-account-id>&session_id=<session_id>&type=webhook.attempt" \
-H "Authorization: Bearer <tu-token-de-consola>"
routing: "loopback"→ el webhook fue a un receptor del propio simulador. Te estás contestando a ti mismo: el envío se entregó perfectamente y tu código no se enteró. Necesitas unwebhook_urltuyo, o el relay.- Un solo evento, la captura, con
"url": nullyrouting: "direct"→ la sesión se creó sinwebhook_url. No hay a dónde entregar, así que no se intentó nada y detrás de esa captura no hay ningún otro evento. Es el caso más fácil de confundir con "no llega": llegar, no llegó, pero el simulador nunca lo mandó. routing: "direct"constatus: "error"o"timeout"→ tu URL no es alcanzable desde internet, o tu servidor tardó más de dos segundos en responder.routing: "relay"constatus: "no-relay"→ no hay ningún relay conectado para esa key. Arrancanpx https://docs.abiertolab.com/relay/spidi-sim-relay.tgzy repite.- No hay ningún
webhook.attempt→ el desenlace que forzaste no emite webhook.failedyexpiredno emiten nada: el motivo queda en elstatusde la sesión, no en un aviso.
Cómo poner un receptor que sí sea tuyo está en Conducir el ciclo.
La firma no valida
La firma es un HMAC-SHA256 de spidi-timestamp + "." + el cuerpo crudo, con tu webhook_secret, en hexadecimal, comparado en tiempo constante. Los errores clásicos son siempre los mismos:
-
Dejarse el timestamp fuera. Es el más silencioso, porque el código parece correcto: se calcula un HMAC del cuerpo, se compara, y nunca coincide. La cadena firmada empieza por el valor del header
spidi-timestampy un punto. -
Firmar el cuerpo re-serializado. Si parseas el JSON y vuelves a serializarlo para calcular el HMAC, cambian los espacios o el orden de las claves, y el hash ya no coincide. Hay que firmar los bytes que llegaron, tal cual. En Express eso significa quedarse el
rawBody; en la mayoría de los frameworks, leer el cuerpo como buffer o texto antes de que el parser de JSON lo toque. -
Comparar con
==. Una comparación normal de cadenas se corta en el primer byte distinto, y eso filtra información sobre la firma correcta. Se compara con una función de tiempo constante (timingSafeEqualen Node,hmac.compare_digesten Python), y comprobando antes que las dos cadenas midan lo mismo.
Para depurarlo sin adivinar: el webhook.attempt de captura trae el payload que se firmó y la signature que salió. Compara ese signature con el que calcula tu código sobre ese mismo cuerpo. Si tu cálculo no coincide con el de la captura, el problema es tu HMAC; si coincide con la captura pero no con lo que recibió tu servidor, el problema es que estás firmando algo distinto de lo que llegó por el cable.
La implementación completa está en Manejar notificaciones.
El POST de sesión devuelve 400 y no dice qué campo falta
Son ocho campos obligatorios, y basta con que falte uno:
agreement_id, amount_reference, currency_reference, identifier_label, identifier, description, success_url, failure_url.
Los que más se olvidan son identifier_label, identifier y description, porque no parecen esenciales para un pago. Lo son para el contrato.
Y dos causas menos evidentes del mismo 400:
currency_referencesolo admiteUSD,EUR,COP,USDToVES.success_urlyfailure_urltienen que ser URLs absolutas con esquema (https://…).
Comprueba en la traza que la petición llegó a llegar:
curl "https://sim-productos.abiertolab.com/console/trace?account_id=<tu-account-id>&type=api.response" \
-H "Authorization: Bearer <tu-token-de-consola>"
Si no aparece ningún api.response con status: 400, es que ni siquiera te autenticaste: sin token válido no se registra nada.
El webhook llega repetido
Es el comportamiento correcto, no un fallo. Si tu receptor no responde 2xx a tiempo, el simulador reintenta —hoy, hasta tres envíos, con esperas crecientes— y todos los reintentos llevan la misma idempotency-key. En producción los números pueden ser otros; lo que no cambia es que el mismo aviso puede llegarte más de una vez con la misma clave.
Lo que tiene que hacer tu código es deduplicar por idempotency-key: guardar la clave la primera vez, y si vuelve a llegar, responder 200 sin volver a procesar. Un pago acreditado dos veces en tu contabilidad es un problema mucho más caro que un aviso perdido.
En la traza lo distingues así: varios webhook.attempt con attempt: 1, 2, 3 y el mismo idempotencyKey, seguidos de un webhook.delivered si al final respondiste 2xx, o de un gaveUp: true si no.
Si quieres provocarlo a propósito, usa como webhook_url el receptor fail de tus default_webhooks (GET /console/keys): responde 500 siempre, y verás la secuencia completa de reintentos y la rendición.
Ver un aviso por dentro antes de escribir el código que lo procesa
Los eventos de arriba cuentan lo que el simulador envía. webhook.received cuenta lo que entra por uno de sus receptores, y sirve para una pregunta distinta: ¿qué forma tiene exactamente el aviso que voy a recibir?
El receptor captura existe para eso. Acepta cualquier POST, responde 200 y deja el resultado en tu traza:
curl -X POST "https://sim-productos.abiertolab.com/console/hooks/<tu-account-id>/captura" -H 'content-type: application/json' -d '{"lo":"que sea"}'
curl "https://sim-productos.abiertolab.com/console/trace?account_id=<tu-account-id>&type=webhook.received" -H "authorization: Bearer <tu-console-token>"
Del evento, los campos que importan son raw —el cuerpo tal cual llegó— y el spidi-timestamp de headers: con esos dos se reconstruye la cadena firmada (timestamp + "." + raw), que es lo único con lo que se puede comprobar una firma; body es el mismo contenido ya parseado, cómodo para leer pero inservible para verificar. headers trae todo lo que vino por el cable, y signature e idempotencyKey salen ya extraídos.
Dos usos concretos:
- Antes de escribir tu receptor, para conocer la forma real del aviso en vez de deducirla de un ejemplo.
- Cuando tu firma no cuadra, para comparar byte a byte lo que tu servidor recibió con lo que dice haber recibido.
Que un aviso quede capturado aquí solo demuestra que algo llegó. No ejecuta tu verificación de firma, ni tu deduplicación, ni tu manejo de errores. La lección del routing: loopback sigue en pie: el ciclo se cierra contra tu servidor.
Todo responde 401
Cruzaste los tokens. El de API abre /api/v1/ext/** y /control/**; el de consola abre /console/**. No son intercambiables. Y recuerda que POST /console/login rota el token de consola: el anterior deja de valer.
Forzar un desenlace responde 500
La transición no es válida desde el estado actual. El campo message te dice cuál: leg1 inválido desde estado paid (esa sesión ya se pagó), leg2 requiere status paid (todavía no hay pago que acreditar), Solicitud no admite failed (una Solicitud de pago o se paga o vence). Mira el status real con GET /api/v1/ext/payment-sessions/status/<session_id> antes de insistir.
Empezar de cero
Cuando la traza se vuelva ruido, límpiala:
curl -X POST https://sim-productos.abiertolab.com/control/reset \
-H "Authorization: Bearer <tu-token-de-api>"
Borra tus sesiones, tus acuerdos y tus eventos. No toca a nadie más.
→ Conducir el ciclo · Manejar notificaciones · Estados y errores