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(...).
Dos modos: modal e inline
Kuti.open acepta dos formas de mostrar el checkout. Sin containerId abre un modal; con containerId lo incrusta en un div de tu página.
| Modo | Cómo se activa | Cuándo usarlo |
|---|---|---|
| Modal | Kuti.open({ checkoutUrl }) — default | Botón "Pagar" que abre overlay encima de tu sitio. |
| Inline | Kuti.open({ checkoutUrl, containerId: "…" }) | Checkout embebido en un paso de tu propio flujo (sin overlay). |
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.
-primary-dark)">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. Modo modal (default)
Pides el checkoutUrl a tu API y abres el overlay. No pases containerId:
<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>3. Modo inline
Pásale containerId con el id de un div de tu página. El iframe se renderiza ahí (sin modal ni overlay):
<div id="kuti-checkout" style="min-height: 640px"></div>
<script src="https://js.kuti.pe/v1/kuti.global.js"></script>
<script>
// tras obtener checkoutUrl de tu backend…
window.Kuti.open({
checkoutUrl,
containerId: "kuti-checkout",
onSuccess: ({ paymentIntentId }) => {
location.href = "/gracias?kuti_payment_id=" + encodeURIComponent(paymentIntentId);
},
onFailure: () => alert("El pago no se completó."),
onExpired: () => alert("El QR venció. Intenta de nuevo."),
onError: (err) => alert(err.message),
});
// Para limpiar el div más tarde:
// window.Kuti.close();
</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, logo, tipografía y forma de botones. Si no lo mandas, se usa la marca del panel. 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.
Siguiente
- Personalización — appearance y branding
- Crear sesión — POST /checkout-sessions
- Webhooks — confirmar en el servidor