Ir al contenido

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>:

HTMLhtml
<script src="https://js.kuti.pe/v1/kuti.global.js"></script>
Sírvelo desde js.kuti.pe, no lo copies a tu CDN. Así te llegan fixes sin redeployar.

Cómo funciona

Tu servidor crea el cobro → tu página muestra el QR → el cliente paga → tú confirmas en backend.

Cliente
Tu sitio
Tu backend
KUTI
1. Clic en Pagar
2. Pedir cobro
3. POST /checkout-sessions
4. checkout_url
5. checkoutUrl
6. Modal o embed con QR
7. Paga (QR / banca KUTI)
8. onSuccess
9. webhook payment.succeeded
La secret key no sale del servidor. Antes de marcar un pedido como pagado, confírmalo con la API o el webhook.

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.

cURLbash
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.

Node (Express)js
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:

HTMLhtml
<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:

Inlinehtml
<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ónTipoReq.Qué hace
checkoutUrlstringSí*La URL de la sesión (data.checkout_url). Lo normal.
clientSecretstringSí*Si preferís armar la URL a mano.
containerIdstringNoId del div → modo inline. Sin esto, modal.
appearanceobjectNoColor y logo. Ver Personalización.
onSuccessfnNoEl pago salió bien en el iframe.
onFailurefnNoCobro en FAILED o CANCELLED.
onExpiredfnNoSe venció sin pagar.
onClosefnNoCerró el modal (X, fuera o Esc). No aplica en inline.
onErrorfnNoAlgo falló al cargar (no es el estado del cobro).

*Necesitas checkoutUrl o clientSecret (uno de los dos).

Kuti.close()js
// 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 / onFailure / onExpiredjs
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
}
onSuccess no es la verdad absoluta: alguien podría dispararlo a mano. Antes de entregar, pregunta a KUTI desde tu backend (GET /payment-intents/{id}) o espera payment.succeeded.

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.