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
| Nombre | Tipo | Descripción |
|---|---|---|
| handle* | String (64) | Identificador único del intent, usado para garantizar idempotencia |
| schema* | String | Schema del intent. Solo acepta: pse |
| action* | String | Acción del intent. Solo acepta: transfer |
| amount* | Number | Monto del cobro en centavos (COP × 100). No puede ser cero |
| source.handle* | String | Handle del rail origen. Solo acepta: pse |
| source.custom.identificationType* | Enum | Tipo 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* | Enum | Tipo 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* | String | Símbolo del ledger. Solo acepta: pcop |
| target.handle* | String | Handle de tu wallet del cliente, por ejemplo: clientA.kamin.one Identifica al comercio que recibe el cobro |
| target.custom.identificationType* | Enum | Tipo 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* | Number | Nú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.vatValue | Number | IVA 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.
| Nombre | Tipo | Descripción |
|---|---|---|
| custom.subMerchant.identificationType* | Enum | Tipo 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/intentsAccept: 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}/proofsAuthorization: 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.detailsiempre es un códigoFAIL_*. - Rechazo en el procesamiento de la transacción — Tras tu proof
confirmed, PSE reporta que la transacción no se autorizó.custom.detailesFAILED(la transacción falló sin causa escalable — el valor por defecto del bridge) oNOT_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-exceeded | FAIL_EXCEEDEDLIMIT | El cliente supera el límite transaccional autorizado por el banco |
| bridge.account-inactive | FAIL_DISABLEDUSEREMAIL | El correo del cliente está deshabilitado en PSE |
| bridge.account-not-found | FAIL_ENTITYNITEXISTSORDISABLEDFAIL_BANKNOTEXISTSORDISABLEDFAIL_SERVICENOTEXISTSORNOTCONFIGURED | El NIT, banco o código de servicio no existe o está deshabilitado |
| bridge.entry-rejected | FAIL_TRANSACTIONNOTALLOWEDFAIL_INVALIDPARAMETERSFAIL_CANNOTGETCURRENTCYCLEFAIL_INVALIDAMOUNTORVATAMOUNT | Parámetros inválidos, monto/IVA inválidos |
Rechazos en el procesamiento de la transacción (tras el proof confirmed):
Razón (reason) | detail | Significado |
|---|---|---|
| bridge.entry-rejected | NOT_AUTHORIZED | El cliente no autorizó la transacción en el portal de su banco |
| bridge.unexpected-error | FAILED | La 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
- Agregado• Version inicial