Cómo cobrar un pago PSE

Envía un intent PSE firmado al endpoint Create Intent, redirige al cliente a su banco y escucha el estado final del intent para confirmar o rechazar el pago.


Prerequisitos

Antes de comenzar:

  • Tienes un código de servicio PSE válido asignado por Kamin.
  • Tienes un endpoint HTTPS de webhook públicamente accesible. Consulta Manejar Webhooks para el contrato completo.
  • Tienes una URL de retorno públicamente accesible donde PSE redirige al navegador del cliente después de la autorización.
  • Entiendes el ciclo de vida del Intent PSE.

Crear el Intent

PSE distingue dos modelos de integración: comercio directo (la entidad que realiza la venta es la misma que recibe el pago) y agregador (operas en nombre de un sub-comercio final). Ambos modelos comparten el mismo payload base — las diferencias se concentran en target.custom y custom.subMerchant. Consulta Conceptos Clave para el contexto completo de cada modelo.

Definición de campos

NombreTipoDescripción
handle*String (64)Identificador único del intent, usado para garantizar idempotencia
schema*StringSchema del intent. Solo acepta:
pse
action*StringAcción del intent. Solo acepta:
transfer
amount*NumberMonto del cobro en centavos (COP × 100). No puede ser cero
source.handle*StringHandle del rail origen. Solo acepta:
pse
source.custom.identificationType*EnumTipo de documento del cliente que paga. Acepta:
CedulaDeCiudadania
CedulaDeExtranjeria
Pasaporte
TarjetaDeIdentidad
TarjetaDeExtranjeria
DocumentoDeIdentificacionExtranjero
RegistroCivilDeNacimiento
NIT
Si userType es company, debe ser NIT
source.custom.identificationNumber*String (15)Número de documento del cliente. Solo dígitos
source.custom.fullName*String (64)Nombre completo del cliente. Sin pipes ni comillas dobles
source.custom.phoneNumber*String (10)Celular colombiano. Solo dígitos
source.custom.address*String (64)Dirección física del cliente
source.custom.email*String (120)Correo del cliente. PSE lo usa para OTP y búsqueda de registro
source.custom.userType*EnumTipo de cliente. Acepta:
person
company
Determina el flujo del portal bancario al que se dirige al cliente
source.custom.bankCode*String (9)Código de institución financiera PSE elegido por el cliente. Ver Obtener Bancos Activos
symbol.handle*StringSímbolo del ledger. Solo acepta:
pcop
target.handle*StringHandle de tu wallet del cliente, por ejemplo:
clientA.kamin.one
Identifica al comercio que recibe el cobro
target.custom.identificationType*EnumTipo de documento del comercio final. Acepta:
CedulaDeCiudadania
CedulaDeExtranjeria
Pasaporte
TarjetaDeIdentidad
TarjetaDeExtranjeria
DocumentoDeIdentificacionExtranjero
RegistroCivilDeNacimiento
NIT
Típicamente NIT
target.custom.identificationNumber*String (15)Número de documento del comercio final
target.custom.name*String (64)Razón social del comercio final. Los agregadores la omiten — se reemplaza por custom.subMerchant.name
target.custom.ciiuCategory*Number (6)Código CIIU del comercio final
target.custom.serviceCode*String (10)Código de servicio PSE único asignado a tu comercio. Kamin te lo provee durante el onboarding y debes incluirlo en cada intent. Los agregadores envían su propio código asignado
custom.ticketId*NumberNúmero de factura del cobro. PSE lo usa como clave de idempotencia — no puede coexistir con otra transacción APROBADA o PENDIENTE con el mismo valor
custom.paymentDescription*String (80)Descripción del pago mostrada al cliente. Sin pipes ni comillas dobles.
Por defecto el handle del intent si se omite
custom.returnUrl*String (512)URL donde PSE redirige al navegador del cliente después de la autorización. Puedes añadir los parámetros de consulta que necesites para identificar la transacción en tu backend
custom.vatValueNumberIVA en centavos. Informativo para PSE.
Por defecto 0

Esta tabla cubre el caso de comercio directo, donde el comercio final es el mismo que recibe el cobro. Si operas como agregador y el comercio final es un sub-comercio, consulta la sección siguiente.

Agregador

Si operas como agregador, omite target.custom.name y agrega un objeto subMerchant bajo custom con la identidad del sub-comercio. PSE usa los datos de subMerchant en lugar de los de target.custom como comercio final de la transacción. Recuerda enviar tu target.custom.serviceCode asignado en lugar del default de Kamin.

NombreTipoDescripción
custom.subMerchant.identificationType*EnumTipo de documento del sub-comercio. Acepta:
NIT
CedulaDeCiudadania
IdentificacionComercioExtranjero. PSE rechaza si no se envía
custom.subMerchant.identificationNumber*String (15)NIT o número de documento del sub-comercio. Solo dígitos para NIT o CedulaDeCiudadania
custom.subMerchant.name*String (64)Razón social del sub-comercio. PSE rechaza si no se envía
custom.subMerchant.ciiuCategory*Number (6)Código CIIU del sub-comercio

Ejemplo de solicitud

Hashea, firma y envía al endpoint Create Intent. El cuerpo cambia según el modelo: la pestaña Comercio directo muestra el caso por defecto y la pestaña Agregador muestra los campos adicionales. Consulta Autenticación para detalles de hashing y firma.

POST https://<ledger-url>/api/v2/intents
Accept: application/json, text/plain, */*
Content-Type: application/json
x-ledger: <ledger-handle>
{
    "data": {
        "handle": "pse-<order-id>-<timestamp>",
        "schema": "pse",
        "claims": [
            {
                "action": "transfer",
                "amount": <amount-in-cents>,
                "source": {
                    "handle": "pse",
                    "custom": {
                        "identificationType": "<customer-id-type>",
                        "identificationNumber": "<customer-id-number>",
                        "fullName": "<customer-name>",
                        "phoneNumber": "<customer-phone>",
                        "address": "<customer-address>",
                        "email": "<customer-email>",
                        "userType": "person",
                        "bankCode": "<selected-bank-code>"
                    }
                },
                "symbol": { "handle": "pcop" },
                "target": {
                    "handle": "<client-wallet-handle>",
                    "custom": {
                        "identificationType": "NIT",
                        "identificationNumber": "<merchant-nit>",
                        "name": "<merchant-name>",
                        "ciiuCategory": 9887,
                        "serviceCode": "<service-code>"
                    }
                }
            }
        ],
        "custom": {
            "ticketId": <timestamp>,
            "vatValue": 0,
            "returnUrl": "<customer-return-url>",
            "paymentDescription": "Pago PSE - Order <order-id>"
        },
        "access": [
            { "action": "any", "signer": { "$record": "owner" } },
            { "action": "read", "bearer": { "$signer": { "$record": "owner" } } }
        ],
        "config": { "commit": "auto" }
    },
    "hash": "<sha256-hash-of-data>",
    "meta": {
        "proofs": [
            {
                "custom": { "status": "pending" },
                "method": "ed25519-raw",
                "public": "<public-key>",
                "result": "<signature-of-hash>"
            }
        ]
    }
}

Respuesta:

{
    "hash": "<sha256-hash-of-data>",
    "data": {
        "handle": "pse-<order-id>-<timestamp>",
        "schema": "pse",
        "claims": ["..."]
    },
    "meta": {
        "status": "pending",
        "moment": "<iso-8601-timestamp>",
        "proofs": [
            {
                "custom": {
                    "moment": "<iso-8601-timestamp>",
                    "status": "pending"
                },
                "digest": "<digest-del-proof>",
                "method": "ed25519-raw",
                "public": "<public-key>",
                "result": "<signature-of-hash>"
            }
        ],
        "owners": ["<public-key>"]
    }
}

Persiste el handle del intent vinculado a la orden. Kamin registra la transacción con PSE y entrega el webhook registered.


Obtener la URL PSE y redirigir

Una vez creado el intent, Kamin lo registra con PSE y te entrega la URL de autorización del banco a través del webhook intent-proofs-added. El proof registered lleva pseUrl (la URL a la que rediriges al cliente) y trazabilityCode (el CUS, que usarás en las pantallas de recibo). Configura el effect correspondiente siguiendo Manejar Webhooks — Effect 1.

Cuando recibas la entrega del webhook, extrae los dos campos del proof:

const proofs = payload.data.proofs ?? []

const registered = proofs.find(
    (p: Proof) => p.signer === 'pse-bridge' && p.custom?.status === 'registered'
)

if (registered) {
    const { pseUrl, trazabilityCode } = registered.custom
    // enviar pseUrl y trazabilityCode al navegador del cliente
}

Redirige el navegador a pseUrl. Persiste trazabilityCode vinculado al handle del intent — lo necesitarás para las pantallas de recibo después de la redirección bancaria.


Enviar la señal de confirmación

Cuando el cliente regresa a tu returnUrl, avisa a Kamin que el flujo bancario terminó para que cierre la transacción y transicione el intent a prepared. La señal es un proof confirmed agregado al intent. Si lo omites, Kamin cae de vuelta a consultar el estado final a PSE periódicamente — el intent puede tardar hasta 21 minutos en cerrarse.

Calcula el digest sobre el hash del intent más el custom del proof, firma con tu par de llaves PSE, y envía solo el proof. Consulta Autenticación para detalles de digest y firma.

POST https://<ledger-url>/api/v2/intents/{intentHandle}/proofs
Authorization: Bearer <jwt-token>
Content-Type: application/json
x-ledger: <ledger-handle>
{
    "custom": {
        "moment": "<iso-8601-timestamp>",
        "status": "confirmed"
    },
    "method": "ed25519-v2",
    "public": "<public-key>",
    "digest": "<proof-digest>",
    "result": "<signature-of-digest>"
}

Respuesta:

{
    "hash": "<sha256-hash-of-data>",
    "data": {
        "handle": "pse-<order-id>-<timestamp>",
        "schema": "pse",
        "claims": ["..."]
    },
    "meta": {
        "status": "pending",
        "moment": "<iso-8601-timestamp>",
        "proofs": ["<existing-proofs-plus-confirmed>"],
        "owners": ["<public-key>"]
    }
}

Envía este proof exactamente una vez por intent. Protégete contra doble montaje, navegación atrás/adelante y recarga de página con una verificación de deduplicación del lado del servidor con intentHandle.


Escuchar el estado final del Intent

Este effect entrega dos resultados finales: éxito (un proof prepared tras el proof confirmed) y rechazo (un evento intent-updated con status: rejected). El rechazo puede ocurrir en dos momentos distintos del ciclo de vida del intent, y cada momento tiene su propio conjunto disjunto de valores en custom.detail:

  • Rechazo en la creación — PSE rechaza el intent al registrarlo, antes de entregar pseUrl. El cliente nunca es redirigido al banco. custom.detail siempre es un código FAIL_*.
  • Rechazo en el procesamiento de la transacción — Tras tu proof confirmed, PSE reporta que la transacción no se autorizó. custom.detail es FAILED (la transacción falló sin causa escalable — el valor por defecto del bridge) o NOT_AUTHORIZED (el cliente no autorizó en el portal de su banco).

La lógica de extracción es la misma en ambos casos: busca el proof failed firmado por pse-bridge y lee custom.detail + custom.reason. Qué fase disparó: si no llegó ningún proof registered antes del failed, el rechazo ocurrió en la creación; si registered llegó primero, ocurrió durante el procesamiento de la transacción.

Éxito — proof prepared:

if (proof.custom?.status === 'prepared') {
    const { coreId, trazabilityCode } = proof.custom
    // coreId es el número de recibo PSE — mostrar pantalla de éxito
}

Fallo — intent rejected:

const proofs = payload.data.intent.meta.proofs ?? []

const failed = proofs.find(
    (p: Proof) => p.signer === 'pse-bridge' && p.custom?.status === 'failed'
)
const errorDetail = failed?.custom?.detail  // e.g. "FAIL_EXCEEDEDLIMIT" (en creación) o "NOT_AUTHORIZED" (en procesamiento)
const errorReason = failed?.custom?.reason  // e.g. "bridge.entry-rejected"

El campo detail lleva el código de retorno de PSE y reason lleva la categoría de error de Kamin. Usa ambos para clasificar el fallo y decidir qué mostrar al cliente.

Rechazos en la creación (antes del proof registered):

Razón (reason)Códigos PSE (detail)Significado
bridge.account-limit-exceededFAIL_EXCEEDEDLIMITEl cliente supera el límite transaccional autorizado por el banco
bridge.account-inactiveFAIL_DISABLEDUSEREMAILEl correo del cliente está deshabilitado en PSE
bridge.account-not-foundFAIL_ENTITYNITEXISTSORDISABLED
FAIL_BANKNOTEXISTSORDISABLED
FAIL_SERVICENOTEXISTSORNOTCONFIGURED
El NIT, banco o código de servicio no existe o está deshabilitado
bridge.entry-rejectedFAIL_TRANSACTIONNOTALLOWED
FAIL_INVALIDPARAMETERS
FAIL_CANNOTGETCURRENTCYCLE
FAIL_INVALIDAMOUNTORVATAMOUNT
Parámetros inválidos, monto/IVA inválidos

Rechazos en el procesamiento de la transacción (tras el proof confirmed):

Razón (reason)detailSignificado
bridge.entry-rejectedNOT_AUTHORIZEDEl cliente no autorizó la transacción en el portal de su banco
bridge.unexpected-errorFAILEDLa transacción falló sin causa específica — el default del bridge cuando no hay código PSE mapeable; no requiere escalación

Consulta la Referencia de Errores — PSE para las mismas tablas en la referencia autoritativa y Manejar Webhooks para el recorrido del historial de proofs.


Siguiente paso

Con esto ya tienes el flujo de cobro PSE completo: creación del intent, redirección al banco, proof de confirmación y cierre del ciclo vía los webhooks. En la siguiente sesión continuaremos con Requisitos de Certificación, donde prepararás las pantallas de UX que PSE exige para aprobar la puesta en producción.


Historial de cambios
1.0.0
  • Agregado Version inicial