Checkout.js
Cobra con QR sin sacar al cliente de tu sitio: modal encima de la página, o el checkout incrustado en un div.
Checkout.js es un script chico que abre pay.kuti.pe en un iframe. Tu backend crea el cobro con la secret key; el browser solo recibe un checkoutUrl y llama a window.Kuti.open(...).
Instalación
Pon esto antes de </body>:
<script src="https://js.kuti.pe/v1/kuti.global.js"></script>Cómo funciona
Tu servidor crea el cobro → tu página muestra el QR → el cliente paga → tú confirmas en backend.
1. Crear la sesión (backend)
No crees el cobro desde el browser ni uses un monto que mande el cliente. En el servidor resuelves el precio (por productId, por ejemplo) y llamas a KUTI con tu secret key.
curl -s https://api.kuti.pe/v1/checkout-sessions \
-H 'Authorization: Bearer kuti_live_…' \
-H 'Content-Type: application/json' \
-H 'Idempotency-Key: 550e8400-e29b-41d4-a716-446655440030' \
-d '{
"amount": { "amount": "249.90", "currency": "PEN" },
"payment_method_types": ["INTEROPERABLE_QR", "BANK_TRANSFER"],
"description": "Zapatillas Running Aero Talla 42",
"external_reference": "order-1042"
}'Te devuelve checkout_url (ya trae el client_secret). Pásalo al frontend como checkoutUrl. Si quieres, también payment_intent_id para la página de gracias.
app.post("/api/checkout", async (req, res) => {
const product = catalog[req.body.productId]; // precio real en tu catálogo
if (!product) return res.status(404).json({ error: "unknown_product" });
const r = await fetch("https://api.kuti.pe/v1/checkout-sessions", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.KUTI_SECRET_KEY}`,
"Content-Type": "application/json",
"Idempotency-Key": crypto.randomUUID(),
},
body: JSON.stringify({
amount: { amount: product.amount, currency: "PEN" },
payment_method_types: ["INTEROPERABLE_QR", "BANK_TRANSFER"],
description: product.name,
external_reference: `order-${Date.now()}`,
customer: req.body.customer, // opcional
}),
});
const json = await r.json();
res.status(201).json({
checkoutUrl: json.data.checkout_url,
paymentIntentId: json.data.payment_intent_id,
});
});2. Abrir el checkout (frontend)
Pides el checkoutUrl a tu API y abres el modal:
<button id="pagar">Pagar S/ 249.90</button>
<script src="https://js.kuti.pe/v1/kuti.global.js"></script>
<script>
document.getElementById("pagar").addEventListener("click", async () => {
const res = await fetch("/api/checkout", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ productId: "tenis-running-42" }),
});
const { checkoutUrl, paymentIntentId } = await res.json();
window.Kuti.open({
checkoutUrl,
onSuccess: () => {
window.Kuti.close();
location.href = "/gracias?kuti_payment_id=" + encodeURIComponent(paymentIntentId);
},
onFailure: () => alert("El pago no se completó."),
onExpired: () => alert("El QR venció. Intenta de nuevo."),
onClose: () => console.log("Modal cerrado"),
onError: (err) => alert(err.message),
});
});
</script>Modo inline
Si no quieres modal, pásale containerId y el checkout se mete en ese div:
<div id="kuti-checkout" style="min-height: 640px"></div>
<script>
window.Kuti.open({
checkoutUrl,
containerId: "kuti-checkout",
onSuccess: ({ paymentIntentId }) => {
location.href = "/gracias?kuti_payment_id=" + encodeURIComponent(paymentIntentId);
},
});
</script>Ejemplos
Si prefieres partir de código que ya corre, clona este repo (iremos sumando más stacks):
Kuti.open(options)
| Opción | Tipo | Req. | Qué hace |
|---|---|---|---|
| checkoutUrl | string | Sí* | La URL de la sesión (data.checkout_url). Lo normal. |
| clientSecret | string | Sí* | Si preferís armar la URL a mano. |
| containerId | string | No | Id del div → modo inline. Sin esto, modal. |
| appearance | object | No | Color y logo. Ver Personalización. |
| onSuccess | fn | No | El pago salió bien en el iframe. |
| onFailure | fn | No | Cobro en FAILED o CANCELLED. |
| onExpired | fn | No | Se venció sin pagar. |
| onClose | fn | No | Cerró el modal (X, fuera o Esc). No aplica en inline. |
| onError | fn | No | Algo falló al cargar (no es el estado del cobro). |
*Necesitas checkoutUrl o clientSecret (uno de los dos).
// Cierra el modal o vacía el div inline.
// No dispara onClose (eso es solo si el usuario lo cierra).
window.Kuti.close();Callbacks
onSuccess: ({ paymentIntentId }) => { /* pi_… */ },
onFailure: ({ paymentIntentId, status }) => {
// status: §§SLOT_0§§ | §§SLOT_1§§
},
onExpired: ({ paymentIntentId }) => {
// normalmente creas otra sesión y vuelves a open()
},
onError: ({ code, message }) => {
// MISSING_CHECKOUT_URL | CONTAINER_NOT_FOUND | CHECKOUT_LOAD_FAILED
}Confirmar el pago
- Lo mínimo: en /gracias tu API pregunta si ese payment_intent_id está SUCCEEDED.
- En producción suma el webhook payment.succeeded (por si el comprador cierra la pestaña).
- El id en la URL no es secreto. Lo que importa es que solo tu backend, con la secret key, pueda decir "sí, pagó".
Preguntas frecuentes
¿React / Vue / Angular? Sí. Cargas el script y llamas a Kuti.open en el click. En Next App Router, el componente del botón va con "use client".
¿Y si cierran el modal? El cobro sigue PENDING hasta que expire o lo anules. Pueden volver a abrir con el mismo checkoutUrl mientras sirva.
¿Hace falta publishable key en el browser? No. El checkoutUrl ya alcanza para esa sesión. La secret key nunca va al frontend.