Cómo manejar webhooks PSE

Recibe las entregas de webhook PSE y enruta cada estado del intent al handler correcto.


Prerequisitos

Antes de comenzar:

  • Tu endpoint de webhook es HTTPS y públicamente accesible.
  • Leíste Conceptos Clave y entiendes el ciclo de vida del intent PSE.
  • Puedes configurar Effects en tu wallet.

Siempre responde 200 OK a las entregas de webhook. Kamin reintenta en cualquier respuesta que no sea 200 con backoff exponencial hasta 20 intentos — por eso tu handler debe ser idempotente sobre (intentHandle, status).


Configuración de Effects

Un pago PSE emite tres señales que tu backend debe atender: la URL de autorización del banco (después del registro), la confirmación de que la transacción PSE terminó exitosamente, y el estado final del intent. Configura dos Effects para capturarlas — uno para los pasos de la transacción, otro para el estado final.

Ambos effects se registran en Kamin Ledger mediante de la misma manera que en la guía de Cómo registrar y escuchar un efecto de estado de intent. Esa guía cubre la autenticación, el hashing y la firma — las subsecciones siguientes describen las partes específicas de PSE: el signal, el filter y el payload del webhook que entrega cada effect.

Incluye tu dominio en el handle del effect (p. ej., pse-intent-progress@<dominio>). Sin el sufijo @<dominio>, el effect no puede registrarse contra tu wallet.


Effect 1: pasos de la transacción PSE (intent-proofs-added)

Se dispara cada vez que se agrega un nuevo proof a un intent PSE. El payload lleva el handle del intent más únicamente el proof recién agregado — no el historial completo del intent. Usa este effect para controlar el flujo de cara al cliente:

  • registered — lleva pseUrl, la URL de autorización del banco. Envíala al navegador del cliente y redirígelo a su banco para autorizar el pago.
  • prepared — lleva coreId (el mismo CUS, renombrado para uso interno). PSE procesó el pago exitosamente. Muestra al cliente una pantalla de éxito.

Crear el Effect

Hashea, firma y envía al endpoint Create Effect. Consulta Autenticación para detalles de hashing y firma.

POST https://<ledger-url>/api/v2/effects
Accept: application/json, text/plain, */*
Content-Type: application/json
x-ledger: <ledger-handle>
{
    "data": {
        "handle": "pse-intent-progress@<dominio>",
        "signal": "intent-proofs-added",
        "filter": {
            "proofs.0.signer": "pse-bridge",
            "proofs.0.custom.status": {
                "$in": ["registered", "prepared"]
            }
        },
        "action": {
            "schema": "webhook",
            "endpoint": "<endpoint-webhook>"
        },
        "access": [
            {
                "action": "any",
                "signer": { "public": "<clave-publica>" }
            }
        ]
    },
    "hash": "<sha256-hash-of-data>",
    "meta": {
        "proofs": [
            {
                "custom": { "moment": "<iso-8601-timestamp>" },
                "method": "ed25519-raw",
                "public": "<clave-publica>",
                "result": "<firma-del-hash>"
            }
        ]
    }
}

Respuesta:

{
    "hash": "<sha256-hash-of-data>",
    "data": {
        "handle": "pse-intent-progress@<dominio>",
        "signal": "intent-proofs-added",
        "filter": {
            "proofs.0.signer": "pse-bridge",
            "proofs.0.custom.status": { "$in": ["registered", "prepared"] }
        },
        "action": {
            "schema": "webhook",
            "endpoint": "<endpoint-webhook>"
        },
        "access": [
            { "action": "any", "signer": { "public": "<clave-publica>" } }
        ]
    },
    "luid": "<luid-del-effect>",
    "meta": {
        "proofs": [
            {
                "custom": {
                    "moment": "<iso-8601-timestamp>",
                    "status": "created"
                },
                "method": "ed25519-raw",
                "public": "<clave-publica>",
                "result": "<firma-del-hash>"
            },
            {
                "custom": {
                    "luid": "<luid-del-effect>",
                    "moment": "<iso-8601-timestamp>",
                    "status": "created"
                },
                "method": "ed25519-v2",
                "public": "<clave-publica-del-ledger>",
                "result": "<firma-del-ledger>"
            }
        ],
        "status": "created",
        "moment": "<iso-8601-timestamp>",
        "owners": ["<clave-publica>"]
    }
}

Por qué estos filtros:

  • proofs.0.signer: "pse-bridge" — El effect se dispara con cada proof nuevo. Un mismo status puede venir firmado por múltiples actores — tu propia firma al confirmar el intent, proofs internos del ledger, etc. La firma del PSE bridge marca el evento como originado en el rail PSE; este filtro descarta los proofs firmados por otros actores para que manejes cada evento exactamente una vez.
  • proofs.0.custom.status: { "$in": ["registered", "prepared"] } — Restringe la entrega a los dos hitos de la transacción PSE de los que este effect es responsable.

Ejemplos de llamados

Una vez creado el effect, cada proof firmado por el PSE bridge con uno de los estados configurados dispara una entrega a tu endpoint webhook. data.intent es el string del handle del intent y data.proofs contiene únicamente el proof recién agregado.

En el proof registered, los campos pseUrl y trazabilityCode son los datos accionables clave: redirige al cliente a pseUrl y persiste el trazabilityCode (CUS) vinculado al handle del intent para usarlo en las pantallas de recibo.


Effect 2: notificación de estado final (intent-updated)

Se dispara cuando un intent PSE transiciona a un estado final. El payload lleva el objeto intent completo con su historial completo de proofs. Usa este effect para manejar los resultados finales:

  • completed — Kamin acreditó pcop a tu wallet de cliente. El pago está completamente liquidado.
  • rejected — El intent falló. La lista completa de proofs te permite encontrar el proof failed y extraer el detalle del rechazo.

Crear el Effect

Hashea, firma y envía al endpoint Create Effect.

POST https://<ledger-url>/api/v2/effects
Accept: application/json, text/plain, */*
Content-Type: application/json
x-ledger: <ledger-handle>
{
    "data": {
        "handle": "pse-intent-completion@<dominio>",
        "signal": "intent-updated",
        "filter": {
            "intent.data.schema": "pse",
            "intent.meta.status": {
                "$in": ["completed", "rejected"]
            }
        },
        "action": {
            "schema": "webhook",
            "endpoint": "<endpoint-webhook>"
        },
        "access": [
            {
                "action": "any",
                "signer": { "public": "<clave-publica>" }
            }
        ]
    },
    "hash": "<sha256-hash-of-data>",
    "meta": {
        "proofs": [
            {
                "custom": { "moment": "<iso-8601-timestamp>" },
                "method": "ed25519-raw",
                "public": "<clave-publica>",
                "result": "<firma-del-hash>"
            }
        ]
    }
}

Respuesta:

{
    "hash": "<sha256-hash-of-data>",
    "data": {
        "handle": "pse-intent-completion@<dominio>",
        "signal": "intent-updated",
        "filter": {
            "intent.data.schema": "pse",
            "intent.meta.status": { "$in": ["completed", "rejected"] }
        },
        "action": {
            "schema": "webhook",
            "endpoint": "<endpoint-webhook>"
        },
        "access": [
            { "action": "any", "signer": { "public": "<clave-publica>" } }
        ]
    },
    "luid": "<luid-del-effect>",
    "meta": {
        "proofs": [
            {
                "custom": {
                    "moment": "<iso-8601-timestamp>",
                    "status": "created"
                },
                "method": "ed25519-raw",
                "public": "<clave-publica>",
                "result": "<firma-del-hash>"
            },
            {
                "custom": {
                    "luid": "<luid-del-effect>",
                    "moment": "<iso-8601-timestamp>",
                    "status": "created"
                },
                "method": "ed25519-v2",
                "public": "<clave-publica-del-ledger>",
                "result": "<firma-del-ledger>"
            }
        ],
        "status": "created",
        "moment": "<iso-8601-timestamp>",
        "owners": ["<clave-publica>"]
    }
}

Por qué estos filtros:

  • intent.data.schema: "pse" — Limita la entrega únicamente a intents PSE. Tu wallet puede procesar otros schemas para otros rails.
  • intent.meta.status: { "$in": ["completed", "rejected"] } — Restringe a estados finales. Las transiciones anteriores ya están cubiertas por el effect intent-proofs-added.

Ejemplos de llamados

Una vez creado el effect, cada transición final de un intent PSE dispara una entrega a tu endpoint webhook. data.intent es el objeto intent completo con su historial completo de proofs; los proofs viven bajo data.intent.meta.proofs.


Manejar cada estado

registered — Redirigir al cliente

El proof registered lleva pseUrl (la URL de autorización del banco) y trazabilityCode (el CUS PSE). Envíalos al navegador en espera y redirige al cliente a pseUrl. Persiste trazabilityCode vinculado al handle del intent — lo necesitarás para las pantallas de recibo después de la redirección bancaria.

{
    "custom": {
        "status": "registered",
        "moment": "<iso-8601-timestamp>",
        "pseUrl": "<url-de-autorizacion-del-banco>",
        "trazabilityCode": "<cus>"
    }
}

prepared — Mostrar éxito

El proof prepared confirma que la transacción está procesada en PSE. Lleva el trazabilityCode y su alias coreId (mismo valor CUS). Marca la orden como exitosa y muestra al cliente una pantalla de éxito.

{
    "custom": {
        "status": "prepared",
        "moment": "<iso-8601-timestamp>",
        "coreId": "<cus>",
        "trazabilityCode": "<cus>"
    }
}

completed — Liquidación confirmada

El proof completed significa que Kamin Ledger acreditó pcop a tu wallet del cliente. El pago está completamente liquidado. En la mayoría de integraciones, la pantalla de éxito del cliente ya se está mostrando desde preparedcompleted es relevante para la reconciliación de back-office.

El tiempo entre prepared y completed depende de los ciclos de liquidación de PSE. El cambio de estado se dispara con el crédito que ocurre al cierre del ciclo en el que Kamin recibe los fondos — no inmediatamente después de prepared. Planea la reconciliación de back-office considerando este retraso en lugar de esperar los dos webhooks consecutivos.

rejected — Mostrar fallo

rejected es el estado final del intent — el marcador de que el cobro falló. Confirmalo con intent.meta.status === "rejected" o buscando el proof rejected firmado por el PSE bridge en data.intent.meta.proofs.

Para obtener el motivo del fallo, revisa la lista de proofs buscando el proof anterior donde signer === "pse-bridge" y custom.status === "failed" — ese proof lleva los campos detail y reason. El proof rejected por sí mismo solo marca la transición final de estado.

{
    "custom": {
        "status": "failed",
        "moment": "<iso-8601-timestamp>",
        "detail": "<codigo-error-pse>",
        "reason": "bridge.entry-rejected"
    }
}

Siguiente paso

Con esto ya tienes los Effects configurados y tus handlers listos para recibir los webhooks de Kamin ante cada cambio de estado de un intent PSE. En la siguiente sesión continuaremos con Cómo cobrar un pago PSE, donde construirás el intent PSE firmado, redirigirás al cliente a su banco y cerrarás el ciclo con los webhooks que acabas de configurar.


Historial de cambios
1.0.0
  • Agregado Version inicial