Webhooks
KUTI avisa a tu servidor cuando pasa algo: un cobro pagado, anulado o vencido. Tú registras una URL HTTPS y recibes el evento firmado.
Flujo: registras un endpoint → ocurre un evento → KUTI hace POST a tu URL → respondes 2xx. Si falla, reintenta; puedes forzar un reenvío desde la API o el dashboard.
Headers de cada entrega
| Header | Qué es |
|---|---|
| X-Kuti-Id | Id del evento (evt_…) |
| X-Kuti-Timestamp | Epoch en segundos |
| X-Kuti-Signature | v1=<hex HMAC-SHA256> |
Firma: HMAC-SHA256(signing_secret, "<timestamp>.<body>"). Rechaza si |now − timestamp| > 5 minutos.
const crypto = require("crypto");
function verifyKutiWebhook(rawBody, headers, signingSecret) {
const id = headers["x-kuti-id"];
const ts = headers["x-kuti-timestamp"];
const sig = headers["x-kuti-signature"]; // §§SLOT_4§§
if (!id || !ts || !sig) return false;
const age = Math.abs(Date.now() / 1000 - Number(ts));
if (age > 300) return false; // 5 min
const expected =
"v1=" +
crypto
.createHmac("sha256", signingSecret)
.update(`${ts}.${rawBody}`)
.digest("hex");
try {
return crypto.timingSafeEqual(
Buffer.from(sig),
Buffer.from(expected)
);
} catch {
return false;
}
}Envelope
Todas las entregas usan el mismo envelope. data.<recurso> es el objeto del GET de ese recurso (p. ej. data.payment_intent == GET /payment-intents/{id}, sin fee_preview ni settlement). GET /events/{id} devuelve este mismo body.
{
"id": "evt_…",
"type": "payment.succeeded",
"merchant_id": "mer_…",
"created_at": "2026-09-04T10:55:12.000Z",
"data": {
"<recurso>": { }
}
}| Campo | Qué es |
|---|---|
| id | Id estable del evento (evt_…). Úsalo para ser idempotente. |
| type | Tipo canónico (dot-case). No cambia una vez publicado. |
| merchant_id | Negocio dueño del evento. |
| created_at | UTC ISO-8601 con Z. Momento en que ocurrió. |
| data.<recurso> | payment_intent | customer | checkout_session | balance_transaction | payout |
Tipos de evento (v1)
| Evento | Cuándo | data |
|---|---|---|
| payment.created | Creaste el cobro (QR / código / link). | payment_intent |
| payment.succeeded | El cliente pagó. Marca el pedido. | payment_intent |
| payment.failed | El pago fue rechazado. | payment_intent |
| payment.expired | Pasó expires_at sin pagar. | payment_intent |
| payment.cancelled | Anulaste el cobro. | payment_intent |
| checkout.session.completed | El pagador terminó el checkout embebido. | checkout_session |
| customer.created | Se creó un cliente (API o al crear el cobro). | customer |
| customer.updated | Editaste el cliente. | customer |
| customer.deleted | Archivaste el cliente. | customer |
| balance.transaction_created | El cobro pagado entró al ledger (PENDING). | balance_transaction |
| balance.available | Ese dinero ya se puede pagar a tu CCI. | balance_transaction |
| payout.created | Armamos un depósito a tu cuenta. | payout |
| payout.paid | El depósito llegó a tu CCI. | payout |
| payout.failed | El depósito falló. | payout |
Al crear el endpoint, events es un array de esos tipos, o ["*"] para todos. Integración mínima: ["payment.succeeded", "payment.failed"].
payment.created
Cobro recién creado, esperando pago. Trae payment_method (QR y/o código) y client_secret / checkout_url.
{
"id": "evt_01J8Z3K4M5N6P7Q8R9S0T1U2V1",
"type": "payment.created",
"merchant_id": "mer_01J8Z3K4M5N6P7Q8R9S0T1U2V3",
"created_at": "2026-09-04T10:48:59.556906Z",
"data": {
"payment_intent": {
"id": "pi_01J8Z3K4M5N6P7Q8R9S0T1U2V3",
"merchant_id": "mer_01J8Z3K4M5N6P7Q8R9S0T1U2V3",
"livemode": true,
"customer": {
"id": "cus_01J8Z3K4M5N6P7Q8R9S0T1U2V3",
"type": "INDIVIDUAL",
"first_name": "María",
"last_name": "López",
"name": "María López",
"document_type": "DNI",
"document_value": "45678912",
"email": "maria@example.com"
},
"amount": { "amount": "50.00", "currency": "PEN" },
"status": "PENDING",
"payment_method_types": ["INTEROPERABLE_QR", "BANK_TRANSFER"],
"payment_method": {
"qr": {
"type": "INTEROPERABLE_QR",
"payload": "00020101021226…",
"expires_at": "2026-09-05T23:59:59Z"
},
"payment_code": {
"type": "BANK_TRANSFER",
"code": "41041172",
"expires_at": "2026-09-05T23:59:59Z"
}
},
"checkout_url": "https://pay.kuti.pe/c/A3F9K2P7QM",
"client_secret": "pi_01J8…_secret_ab12cd34ef56gh78ij90kl12",
"description": "Pedido #1042",
"external_reference": "order-1042",
"requires_customer_info": false,
"created_at": "2026-09-04T10:48:59.556906Z"
}
}
}payment.succeeded
El cliente pagó. Este es el evento para marcar el pedido. paid_with dice con qué método; payment_id es el pago (pay_…). Ya no viene client_secret ni payment_method.
{
"id": "evt_01J8Z3K4M5N6P7Q8R9S0T1U2V2",
"type": "payment.succeeded",
"merchant_id": "mer_01J8Z3K4M5N6P7Q8R9S0T1U2V3",
"created_at": "2026-09-04T10:55:12.000Z",
"data": {
"payment_intent": {
"id": "pi_01J8Z3K4M5N6P7Q8R9S0T1U2V3",
"merchant_id": "mer_01J8Z3K4M5N6P7Q8R9S0T1U2V3",
"livemode": true,
"customer": {
"id": "cus_01J8Z3K4M5N6P7Q8R9S0T1U2V3",
"type": "INDIVIDUAL",
"first_name": "María",
"last_name": "López",
"name": "María López",
"document_type": "DNI",
"document_value": "45678912",
"email": "maria@example.com"
},
"amount": { "amount": "50.00", "currency": "PEN" },
"status": "SUCCEEDED",
"payment_method_types": ["INTEROPERABLE_QR", "BANK_TRANSFER"],
"paid_with": {
"method_type": "INTEROPERABLE_QR",
"paid_at": "2026-09-04T10:55:12.000Z"
},
"payment_id": "pay_01J8Z3K4M5N6P7Q8R9S0T1U2V3",
"checkout_url": "https://pay.kuti.pe/c/A3F9K2P7QM",
"description": "Pedido #1042",
"external_reference": "order-1042",
"requires_customer_info": false,
"created_at": "2026-09-04T10:48:59.556906Z"
}
}
}payment.failed
El cobro quedó FAILED (el pago fue rechazado). Usa external_reference o id para hallar el pedido. Si el cliente reintenta y paga, te llega payment.succeeded aparte.
{
"id": "evt_01J8Z3K4M5N6P7Q8R9S0T1U2V3",
"type": "payment.failed",
"merchant_id": "mer_01J8Z3K4M5N6P7Q8R9S0T1U2V3",
"created_at": "2026-09-04T10:56:02.000Z",
"data": {
"payment_intent": {
"id": "pi_01J8Z3K4M5N6P7Q8R9S0T1U2V3",
"merchant_id": "mer_01J8Z3K4M5N6P7Q8R9S0T1U2V3",
"livemode": true,
"customer": {
"id": "cus_01J8Z3K4M5N6P7Q8R9S0T1U2V3",
"type": "INDIVIDUAL",
"first_name": "María",
"last_name": "López",
"name": "María López",
"document_type": "DNI",
"document_value": "45678912",
"email": "maria@example.com"
},
"amount": { "amount": "50.00", "currency": "PEN" },
"status": "FAILED",
"payment_method_types": ["INTEROPERABLE_QR", "BANK_TRANSFER"],
"payment_method": {
"qr": {
"type": "INTEROPERABLE_QR",
"payload": "00020101021226…",
"expires_at": "2026-09-05T23:59:59Z"
},
"payment_code": {
"type": "BANK_TRANSFER",
"code": "41041172",
"expires_at": "2026-09-05T23:59:59Z"
}
},
"checkout_url": "https://pay.kuti.pe/c/A3F9K2P7QM",
"description": "Pedido #1042",
"external_reference": "order-1042",
"requires_customer_info": false,
"created_at": "2026-09-04T10:48:59.556906Z"
}
}
}payment.expired
Pasó expires_at sin pago. Un cobro sin expires_at no expira. Si el dinero llega después, KUTI puede recuperarlo a SUCCEEDED (te llega payment.succeeded).
{
"id": "evt_01J8Z3K4M5N6P7Q8R9S0T1U2V4",
"type": "payment.expired",
"merchant_id": "mer_01J8Z3K4M5N6P7Q8R9S0T1U2V3",
"created_at": "2026-09-05T23:59:59.000Z",
"data": {
"payment_intent": {
"id": "pi_01J8Z3K4M5N6P7Q8R9S0T1U2V3",
"merchant_id": "mer_01J8Z3K4M5N6P7Q8R9S0T1U2V3",
"livemode": true,
"customer": {
"id": "cus_01J8Z3K4M5N6P7Q8R9S0T1U2V3",
"type": "INDIVIDUAL",
"first_name": "María",
"last_name": "López",
"name": "María López",
"document_type": "DNI",
"document_value": "45678912",
"email": "maria@example.com"
},
"amount": { "amount": "50.00", "currency": "PEN" },
"status": "EXPIRED",
"payment_method_types": ["INTEROPERABLE_QR", "BANK_TRANSFER"],
"checkout_url": "https://pay.kuti.pe/c/A3F9K2P7QM",
"expires_at": "2026-09-05T23:59:59Z",
"description": "Pedido #1042",
"external_reference": "order-1042",
"requires_customer_info": false,
"created_at": "2026-09-04T10:48:59.556906Z"
}
}
}payment.cancelled
Anulaste el cobro (POST /payment-intents/{id}/cancel). El QR y el código dejan de cobrar.
{
"id": "evt_01J8Z3K4M5N6P7Q8R9S0T1U2V5",
"type": "payment.cancelled",
"merchant_id": "mer_01J8Z3K4M5N6P7Q8R9S0T1U2V3",
"created_at": "2026-09-04T11:10:00.000Z",
"data": {
"payment_intent": {
"id": "pi_01J8Z3K4M5N6P7Q8R9S0T1U2V3",
"merchant_id": "mer_01J8Z3K4M5N6P7Q8R9S0T1U2V3",
"livemode": true,
"customer": {
"id": "cus_01J8Z3K4M5N6P7Q8R9S0T1U2V3",
"type": "INDIVIDUAL",
"first_name": "María",
"last_name": "López",
"name": "María López",
"document_type": "DNI",
"document_value": "45678912",
"email": "maria@example.com"
},
"amount": { "amount": "50.00", "currency": "PEN" },
"status": "CANCELLED",
"payment_method_types": ["INTEROPERABLE_QR", "BANK_TRANSFER"],
"checkout_url": "https://pay.kuti.pe/c/A3F9K2P7QM",
"description": "Pedido #1042",
"external_reference": "order-1042",
"requires_customer_info": false,
"created_at": "2026-09-04T10:48:59.556906Z"
}
}
}checkout.session.completed
El pagador terminó el checkout embebido (Checkout.js). El cobro pagado llega aparte como payment.succeeded — usa payment_intent_id para cruzarlos.
{
"id": "evt_01J8Z3K4M5N6P7Q8R9S0T1U2V6",
"type": "checkout.session.completed",
"merchant_id": "mer_01J8Z3K4M5N6P7Q8R9S0T1U2V3",
"created_at": "2026-09-04T10:55:12.000Z",
"data": {
"checkout_session": {
"id": "cs_01J8Z3K4M5N6P7Q8R9S0T1U2V3",
"merchant_id": "mer_01J8Z3K4M5N6P7Q8R9S0T1U2V3",
"customer_id": "cus_01J8Z3K4M5N6P7Q8R9S0T1U2V3",
"payment_intent_id": "pi_01J8Z3K4M5N6P7Q8R9S0T1U2V3",
"amount": { "amount": "50.00", "currency": "PEN" },
"status": "COMPLETED",
"description": "Pedido #1042",
"success_url": "https://tienda.pe/pago/ok",
"created_at": "2026-09-04T10:48:59.556906Z"
}
}
}customer.created
Se creó un cliente: POST /customers o al crear un cobro con datos nuevos. customer.updated y customer.deleted usan el mismo data.customer (updated = datos nuevos; deleted = el que se archivó). No incluye payment_intents_count (eso solo sale en GET /customers/{id}).
{
"id": "evt_01J8Z3K4M5N6P7Q8R9S0T1U2V7",
"type": "customer.created",
"merchant_id": "mer_01J8Z3K4M5N6P7Q8R9S0T1U2V3",
"created_at": "2026-09-04T10:48:59.556906Z",
"data": {
"customer": {
"id": "cus_01J8Z3K4M5N6P7Q8R9S0T1U2V3",
"merchant_id": "mer_01J8Z3K4M5N6P7Q8R9S0T1U2V3",
"external_id": "cust_erp_4821",
"type": "INDIVIDUAL",
"first_name": "María",
"last_name": "López",
"document": {
"country": "PE",
"type": "DNI",
"value": "45678912"
},
"email": "maria@example.com",
"phone": "+51987654321",
"created_at": "2026-09-04T10:48:59.556906Z"
}
}
}balance.transaction_created
El cobro pagado entró al ledger. status = PENDING hasta que el dinero quede disponible; entonces llega balance.available con el mismo objeto y status = AVAILABLE. source_id es el pago (pay_…).
{
"id": "evt_01J8Z3K4M5N6P7Q8R9S0T1U2V8",
"type": "balance.transaction_created",
"merchant_id": "mer_01J8Z3K4M5N6P7Q8R9S0T1U2V3",
"created_at": "2026-09-04T10:55:12.000Z",
"data": {
"balance_transaction": {
"id": "btxn_01J8Z3K4M5N6P7Q8R9S0T1U2V3",
"type": "PAYMENT",
"status": "PENDING",
"currency": "PEN",
"payment_method": "INTEROPERABLE_QR",
"source_type": "PAYMENT",
"source_id": "pay_01J8Z3K4M5N6P7Q8R9S0T1U2V3",
"gross": { "amount": "50.00", "currency": "PEN" },
"fee": { "amount": "-1.77", "currency": "PEN" },
"fee_tax": { "amount": "-0.27", "currency": "PEN" },
"net": { "amount": "48.23", "currency": "PEN" },
"available_on": "2026-09-06T00:00:00Z",
"available_confirmed": false,
"created_at": "2026-09-04T10:55:12.000Z"
}
}
}payout.paid
El depósito a tu CCI se completó. payout.created y payout.failed usan el mismo data.payout (status PENDING o FAILED; si falló vienen failure_code y failure_message).
{
"id": "evt_01J8Z3K4M5N6P7Q8R9S0T1U2V9",
"type": "payout.paid",
"merchant_id": "mer_01J8Z3K4M5N6P7Q8R9S0T1U2V3",
"created_at": "2026-09-05T10:15:00.000Z",
"data": {
"payout": {
"id": "pyt_01J8Z3K4M5N6P7Q8R9S0T1U2V3",
"source": "PROVIDER",
"amount": { "amount": "1250.50", "currency": "PEN" },
"gross_amount": { "amount": "1280.00", "currency": "PEN" },
"fee_amount": { "amount": "-29.50", "currency": "PEN" },
"destination_bank": "BCP",
"destination_account_masked": "••••••••9012",
"status": "PAID",
"payout_account_id": "pacc_01J8Z3K4M5N6P7Q8R9S0T1U2V3",
"statement_descriptor": "KUTI",
"arrival_expected_on": "2026-09-05",
"initiated_at": "2026-09-04T18:00:00Z",
"paid_at": "2026-09-05T10:15:00Z",
"created_at": "2026-09-04T18:00:00Z"
}
}
}Buenas prácticas
- Responde 2xx rápido; procesa en cola si hace falta.
- Usa X-Kuti-Id (o el id del evento) para ser idempotente.
- Verifica siempre la firma con el body crudo (raw body).
- HTTPS obligatorio en la URL del endpoint.
- Para saber si pagó: mira type === "payment.succeeded" y data.payment_intent.external_reference (o id).