Saltar al contenido principal

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.

Esto es el simulador, no producción

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 saberDóndeCon qué token
Qué ha pasado, en orden, con todoGET /console/traceconsola
Cómo está una sesión ahoraGET /api/v1/ext/payment-sessions/status/<session_id>API
Qué sesiones tengoGET /console/sessionsconsola

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_idobligatorio. Sin él recibes 400.
  • session_id — filtra por una sesión.
  • type — filtra por tipo de evento. Un tipo que no exista da 400.
  • limit — entre 1 y 500, por defecto 200.
  • after_created_at y after_id — el cursor, para paginar. Repite en la siguiente llamada los valores del cursor que 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

typeCuándo se registraLo importante del payload
account.createdAl dar de alta la keyshortName
api.requestLlega una petición autenticada a /api/**method, path, correlationId
api.responseSe responde esa peticiónstatus, method, path, correlationId
state.transitionLa sesión cambia de estadofrom, to, leg — y reason solo a veces (ver abajo)
control.actionForzaste un desenlace por /control/**action, leg, outcome
webhook.attemptSe genera o se intenta entregar un webhooktiene tres formas, detalladas en la sección "Las tres formas de webhook.attempt"
webhook.deliveredTu receptor respondió 2xxattempt, status, event, idempotencyKey, routing
webhook.receivedAlgo 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 como api.request. La captura solo mira /api/**. Del plano de control queda el control.action y la state.transition que provocó.
  • correlationId empareja la petición con su respuesta. Es el mismo valor en el api.request y en el api.response del mismo intercambio.
  • reason no viene en todas las state.transition. Solo aparece en las del fase 1 ("leg": 1), y solo si forzaste el desenlace pasando un motivo. Las dos transiciones del fase 2paid → accredited y paid → accreditation_failedno 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 lee payload.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:

routingSignifica
directVa 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.
loopbackEl destino es un receptor del propio simulador. No sale de ahí.
relayEl 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:

  • statuspending, paid, failed o expired.
  • session_payment.reason y session_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 un webhook_url tuyo, o el relay.
  • Un solo evento, la captura, con "url": null y routing: "direct" → la sesión se creó sin webhook_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" con status: "error" o "timeout" → tu URL no es alcanzable desde internet, o tu servidor tardó más de dos segundos en responder.
  • routing: "relay" con status: "no-relay" → no hay ningún relay conectado para esa key. Arranca npx https://docs.abiertolab.com/relay/spidi-sim-relay.tgz y repite.
  • No hay ningún webhook.attempt → el desenlace que forzaste no emite webhook. failed y expired no emiten nada: el motivo queda en el status de 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:

  1. 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-timestamp y un punto.

  2. 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.

  3. 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 (timingSafeEqual en Node, hmac.compare_digest en 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_reference solo admite USD, EUR, COP, USDT o VES.
  • success_url y failure_url tienen 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.
Esto no reemplaza probar tu receptor

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