Conducir el ciclo en el simulador
En producción no puedes decidir que un pago salga mal: el pago es real. En el simulador de SPIDI sí, y esa es la razón de que exista. Aquí conduces la sesión al desenlace que quieras, tantas veces como quieras, y compruebas qué hace tu código con cada uno.
Necesitas tus dos tokens: Credenciales.
POST /control/sessions/<id>/outcome, POST /control/reset y las claves de pago mágicas de la página de pago (000000 y compañía) existen solo en el simulador. En producción no hay forma de forzar un desenlace: lo decide el banco. Si tu código de integración llama a /control/**, falla el día que salgas del sandbox — úsalo desde tus pruebas, nunca desde el camino que va a producción.
1. El acuerdo
El acuerdo define cómo recibes el dinero. Se crea una vez y lo reutilizas en todas las sesiones. El concepto está en Acuerdo de liquidación.
curl -X POST https://sim-productos.abiertolab.com/api/v1/ext/agreements \
-H "Authorization: Bearer <tu-token-de-api>" \
-H "Content-Type: application/json" \
-d '{
"title": "Acuerdo de pruebas",
"description": "Sin distribución de fondos",
"split": false,
"payment_methods": { "immediate_debit": true, "crypto": false, "mobile_payment": true },
"default_bank_account_id": "uuid_banco_001"
}'
La respuesta trae data.agreement_id (agr_…). Guárdalo.
2. La sesión
curl -X POST https://sim-productos.abiertolab.com/api/v1/ext/payment-sessions/buttons \
-H "Authorization: Bearer <tu-token-de-api>" \
-H "Content-Type: application/json" \
-d '{
"agreement_id": "<agreement_id>",
"amount_reference": 50,
"currency_reference": "USD",
"identifier_label": "Pedido",
"identifier": "ORD-0001",
"description": "Pago de prueba",
"success_url": "https://tu-app.example/pago-ok",
"failure_url": "https://tu-app.example/pago-ko",
"webhook_url": "https://tu-app.example/webhooks/spidi"
}'
La respuesta trae data.session_id (ses_…) y data.payment_url. La sesión nace en pending.
Los ocho campos de arriba son obligatorios: agreement_id, amount_reference, currency_reference, identifier_label, identifier, description, success_url, failure_url. Si falta alguno recibes un 400. El webhook_url es opcional, pero sin él no recibes ningún aviso.
Ese ejemplo pide 50 USD y el crédito que vas a ver es de unos 50 bolívares, no los varios miles que darían al cambio. El simulador valida la moneda —acepta USD, EUR, COP, USDT y VES, y rechaza cualquier otra con un 400— pero no aplica ninguna tasa: trata el amount_reference como si ya viniera en bolívares.
Es a propósito, y es la misma frontera de siempre: el simulador es autoridad sobre la forma, no sobre los números. La tasa la fija el BCV y se mueve a diario; congelarla aquí sería inventar una cifra que a los dos días miente.
Qué significa para tus pruebas: puedes comprobar que tu código lee bien amount_ves_credited, que resta la comisión y que concilia — pero no uses el sandbox para verificar tu aritmética de conversión. Para eso, fija tú la tasa en tus propias pruebas unitarias.
3. Forzar el desenlace
El plano de control lleva la sesión adonde tú digas. Va con el token de API (el mismo con el que creaste la sesión) y solo toca tus datos.
curl -X POST https://sim-productos.abiertolab.com/control/sessions/<session_id>/outcome \
-H "Authorization: Bearer <tu-token-de-api>" \
-H "Content-Type: application/json" \
-d '{"leg":1,"outcome":"paid"}'
Responde { "success": true, "data": { … } } con la sesión ya actualizada.
El pago tiene dos fases: el débito (fase 1) y la acreditación al receptor (fase 2). Por qué son dos y qué implica está en Transacción a dos fases y en Ciclo de vida de la sesión. Esto es lo que acepta cada uno:
leg | outcome | Se admite desde | Qué pasa | Webhook que emite |
|---|---|---|---|---|
1 | paid | pending | El status pasa a paid | payment_session.paid |
1 | failed | pending, y solo en Botón | El status pasa a failed | ninguno |
1 | expired | pending | El status pasa a expired | ninguno |
2 | accredited | paid | La sesión queda acreditada; el status sigue paid | payment_session.accredited |
2 | accreditation_failed | paid | El status sigue paid; no queda rastro en la sesión | payment_session.accreditation_to_recipient_failed |
Tres cosas que conviene saber antes de pelearte con ellas:
failedno aplica a la Solicitud de pago. Una Solicitud o se paga o vence. Si lo intentas, la llamada falla.- Una transición inválida responde
500, con el motivo en el campomessage(por ejemploleg1 inválido desde estado paid, oleg2 requiere status paid). Es feo, pero te dice exactamente qué pasó. - Forzar
paidpor el plano de control no programa la acreditación. La fase 2 queda esperando solo cuando el pago entra por la página de pago. Si fuerzaspaidcon/controly esperas sentado elpayment_session.accredited, no llega nunca: fuerza también la fase 2.
Para dejar tu cuenta como recién creada entre pruebas:
curl -X POST https://sim-productos.abiertolab.com/control/reset \
-H "Authorization: Bearer <tu-token-de-api>"
Borra solo tus sesiones, acuerdos y eventos.
4. Pagar como paga una persona
En vez de forzar, puedes abrir la payment_url de la sesión en un navegador. Es la página de pago del simulador: te deja elegir método de pago entre los que tu acuerdo declaró en payment_methods —pago móvil, débito inmediato o cripto, y solo esos—, pide los datos que ese método necesita, arranca un temporizador de 30 segundos y pide una clave de pago. El método que uses viaja después en payment_method, así que puedes cruzar lo que declaraste con lo que te llegó. Al confirmar redirige a tu success_url o a tu failure_url, igual que en una integración real.
Las claves de pago son mágicas, y cada una provoca un desenlace distinto:
| Clave | Resultado | Motivo que queda en session_payment |
|---|---|---|
000000 | paid | — |
111111 | failed | Fondos insuficientes |
222222 | failed | Clave de pago incorrecta |
333333 | failed | Límite diario del banco excedido |
| dejar vencer el temporizador | expired | La sesión de pago expiró |
Cualquier otra clave se toma como incorrecta.
Antes de confirmar, la página te deja elegir cómo termina la segunda fase: acreditación exitosa o acreditación fallida. Confirmas, el pago queda paid al instante, y unos segundos después el simulador aplica solo el desenlace que elegiste y te entrega el payment_session.accredited o el payment_session.accreditation_to_recipient_failed correspondiente — sin que llames a /control/**. Esa espera es deliberada: pagado no es recibido, y aquí lo ves llegar aparte.
Si después de eso fuerzas la fase 2 por el plano de control, tu orden explícita gana: el simulador cancela la acreditación que tenía programada, para no entregarte dos avisos contradictorios del mismo pago.
5. Recibir el webhook en tu servidor
Aquí está la parte que más gente se salta y más caro sale.
El simulador trae receptores por defecto en /console/hooks/<account_id>/paid, /accredited y /fail (los ves en GET /console/keys). Los dos primeros responden 200 siempre; el tercero responde 500 a propósito, para que veas los reintentos y la rendición en la traza.
Hay un cuarto, /captura, que acepta cualquier cosa y responde 200. Todo lo que llega a cualquiera de los cuatro queda en la traza como webhook.received, con el cuerpo crudo, las cabeceras y la firma — útil cuando quieres ver qué forma tiene un aviso antes de escribir el código que lo procesa.
Son cómodos para ver la mecánica, y no prueban absolutamente nada de tu código: son del propio simulador, así que estás contestándote a ti mismo. Tu verificación de firma no se ejecuta, tu deduplicación no se ejecuta, tu manejo de errores no se ejecuta. En la traza esos envíos aparecen con routing: "loopback", que es la señal de que no estás probando lo que crees.
Para que el aviso llegue a tu código hay dos vías.
Vía A — una URL pública
Pon en webhook_url una URL alcanzable desde internet: tu servicio ya desplegado, o un túnel (por ejemplo ngrok) apuntando a tu puerto local. En la traza esos envíos aparecen con routing: "direct".
Vía B — el relay, si tu receptor vive en localhost
El simulador hosteado no puede alcanzar tu localhost, pero tu máquina sí puede alcanzarlo a él. El relay usa eso: abre un WebSocket saliente contra el simulador, se queda escuchando, y cuando baja un webhook lo entrega con un POST a tu servidor local.
Creas la sesión con tu URL local tal cual:
{ "webhook_url": "http://localhost:4020/webhooks/spidi" }
Y dejas el relay corriendo en otra terminal:
npx https://docs.abiertolab.com/relay/spidi-sim-relay.tgz --key <tu-token-de-api> --mock wss://sim-productos.example
--keyes el token de API (no el de consola): así el simulador sabe a qué cuenta pertenece ese relay.--mockes la URL del simulador con esquema WebSocket. Con el simulador hosteado —que es tu caso— es el mismo host dehttps://sim-productos.abiertolab.comcon esquemawss://. Elws://localhost:…solo aplica si levantas el simulador en tu máquina, que es cosa de desarrollo del propio simulador, no de integrar.
El simulador clasifica automáticamente como relay cualquier webhook_url que apunte a localhost, 127.0.0.1 o a una red privada (10.x, 192.168.x, 172.16-31.x). No hay nada más que configurar. En la traza esos envíos aparecen con routing: "relay".
Solo puede haber un relay conectado por key: si abres un segundo, el primero se cierra.
Qué hace tu receptor
Verificar la firma —que se calcula sobre spidi-timestamp + "." + el cuerpo crudo—, deduplicar por idempotency-key y responder 2xx rápido. Está desarrollado en Manejar notificaciones. Si algo de eso no sale, el catálogo de síntomas está en Observar y depurar.
Un ciclo completo, de arriba abajo
POST /console/register→ tus tokens.POST /api/v1/ext/agreements→agreement_id.- Arranca tu receptor y, si es local, el relay.
POST /api/v1/ext/payment-sessions/buttonscon tuwebhook_url→session_id.POST /control/sessions/<session_id>/outcomecon{"leg":1,"outcome":"paid"}→ te llegapayment_session.paid.POST /control/sessions/<session_id>/outcomecon{"leg":2,"outcome":"accredited"}→ te llegapayment_session.accredited.GET /api/v1/ext/payment-sessions/status/<session_id>→status: "paid".- Repite el paso 5 con
failedy conexpireden sesiones nuevas, y el paso 6 conaccreditation_failed. Los cinco desenlaces cubiertos.