Ir al contenido
AppsSDAR

Cobrar con Izipay: checkout embebido, doble confirmación e idempotencia

actualizada el 10 min de lectura Pagos y normativa peruana

Integrar Izipay (la pasarela de micuentaweb.pe, que opera sobre la plataforma Lyra) parece terminar cuando el formulario de tarjeta aparece y un pago de prueba sale aprobado. Ahí empieza lo que decide si el sistema es confiable: qué pasa cuando cambia el monto con el formulario ya montado, cuando la confirmación llega dos veces o cuando una de las dos vías de confirmación falla sin avisar.

Esta nota sale de integrar Izipay tres veces: en SortealoPE (el primero, con casi todos los errores), en Arca y en wapi. Los fragmentos de código son de esas bases, sin llaves ni identificadores.

Paso 1: el servidor pide un formToken

El navegador nunca habla con la API de cobro. Tu backend llama a POST /api-payment/V4/Charge/CreatePayment con Basic Auth (usuario y contraseña de la API en base64) y recibe un formToken en answer.formToken. Es un cargo único: no crea ninguna suscripción por sí solo. El cuerpo mínimo que usamos en wapi:

{
  amount: Math.round(monto * 100),   // en céntimos
  currency: 'PEN',
  orderId,                           // lo generas tú
  formAction: 'REGISTER_PAY',        // cobra y guarda la tarjeta
  customer: { email, reference: empresaId }
}

El SDK del navegador (kr-payment-form.min.js más el tema classic.css y classic.js, alojados en static.micuentaweb.pe) monta el formulario en un <div class="kr-embedded" kr-form-token="...">. Si después vas a cobrar de forma recurrente, usa REGISTER_PAY y no ASK_REGISTER_PAY: el segundo deja la decisión de guardar la tarjeta a un checkbox del comprador, y sin tarjeta guardada no hay forma de crear la suscripción después. Eso lo contamos en suscripciones recurrentes en Perú.

El monto que no se actualiza

El SDK no permite cambiar el token de un formulario ya montado. Si el usuario cambia de plan o de ciclo con el formulario en pantalla, la interfaz puede mostrar el precio nuevo y el cobro real seguir con el monto viejo.

La solución fue aislar el formulario en una página propia, cargada en un iframe, que se recarga cada vez que cambia el monto. Cada carga pide un token nuevo y monta un .kr-embedded limpio. Si el formulario vive en un iframe, el resultado le llega a la página padre por postMessage.

Dos detalles del formulario embebido que cuestan tiempo: el .kr-embedded trae un ancho fijo de 266 px, así que sin width: 100% !important queda como una columna angosta; y el color del botón "Pagar" llega ya resuelto en tiempo de ejecución, por lo que sobrescribir la variable CSS del tema no alcanza, hay que forzar la propiedad final con !important.

En wapi probamos el popin del SDK (kr-popin="true") y lo revertimos el mismo día: es un modal de unos 331 px de ancho y 657 px de alto fijos, que en un teléfono real sale cortado. Lo que sí funcionó fue el formulario embebido dentro de nuestro propio modal, con el logo de la pasarela y el monto arriba. Antes de pedir 16 dígitos, el cliente tiene que ver de quién es el formulario.

Política de seguridad de contenido

Con una CSP estricta, olvidar un dominio deja el formulario en blanco sin un error claro. La lista que usa SortealoPE en producción:

  • script-src: static.micuentaweb.pe y secure.micuentaweb.pe
  • style-src: static.micuentaweb.pe
  • connect-src: api.micuentaweb.pe, static.micuentaweb.pe y secure.micuentaweb.pe
  • frame-src: static.micuentaweb.pe y secure.micuentaweb.pe

Verifica con una captura real que el formulario se ve; que el elemento exista en el DOM no prueba nada. Y no esperes automatizar el pago: los campos de tarjeta viven en iframes seguros de Izipay que ignoran las teclas sintéticas.

Dos confirmaciones, dos claves

Un pago se confirma por dos caminos. El primero es el navegador: KR.onSubmit entrega clientAnswer con orderStatus === 'PAID' y tu página reenvía la respuesta cruda y su firma a tu backend. El segundo es el IPN, el aviso servidor a servidor: Izipay (el agente se identifica como "Lyra-Network Agent") hace un POST a tu backend con kr-answer, kr-hash y kr-hash-key, aunque el usuario ya haya cerrado la pestaña. Necesitas los dos: el navegador puede no llegar nunca, y solo con el IPN no puedes confirmarle nada al usuario en pantalla.

Ambos van firmados con HMAC-SHA256, pero con claves distintas. kr-hash-key te dice cuál usar:

  • password: se firma con la contraseña de la API (camino servidor a servidor).
  • sha256_hmac: se firma con la clave HMAC aparte (camino del navegador).
export function firmaValida({ answer, hash, hashKey }) {
  const clave = hashKey === 'password' ? cfg.password : cfg.hmac;
  if (!clave) return false;
  const esperado = crypto.createHmac('sha256', clave)
    .update(answer, 'utf8').digest('hex');
  try {
    return crypto.timingSafeEqual(Buffer.from(esperado), Buffer.from(String(hash)));
  } catch { return false; }   // largos distintos
}

La firma se calcula sobre el texto exacto de kr-answer, sin volver a serializar el JSON. Y compárala en tiempo constante: en la auditoría de seguridad de Arca, la única falla del flujo de pagos fue comparar el HMAC con ===.

El IPN que fallaba en silencio

En SortealoPE solo se probó el camino del navegador antes de desplegar. El IPN real devolvía 400 por firma inválida desde el primer día y nadie lo notó, porque los pagos se activaban igual por la otra vía. El problema aparece después, en el primer cobro de renovación, que llega solo por IPN y sin navegador.

Si solo probaste la confirmación del navegador, no probaste la integración. Prueba el IPN real y deja un log legible cuando la firma no coincide.

En el catch de firma inválida registramos kr-hash-key y Object.keys(req.body). Es lo que permite diagnosticar sin volver a desplegar. Un caso que ese log ayuda a descubrir: si en el Back Office de Izipay dejas campos rellenos en la sección "API formulario V1, V2", el POST llega con otros campos y un endpoint que espera kr-answer lo rechaza con 400. Para la API REST, esos campos van vacíos.

Otras tres cosas de configuración del IPN:

  • Las reglas de notificación son por tienda y hay que activar tres: "al final del pago", "al crear una suscripción" y, sobre todo, "al autorizar por lote". Los cobros de una recurrencia se autorizan en lote de madrugada; sin esa regla el cobro mensual ocurre y tu sistema nunca se entera.
  • Izipay publica el rango 194.50.38.0/24 (puerto 443) para abrir en el firewall. En el cPanel compartido donde corremos Arca no hizo falta tocar nada: el log de acceso mostró un POST real de esa red con respuesta 200.
  • La URL de notificación es por tienda, y por eso usamos una tienda por marca: con una tienda compartida habría que repartir los IPN de todos los productos desde un solo punto.

Idempotencia: el mismo pago, dos veces

El navegador y el IPN confirman el mismo pago, y además Izipay reintenta el IPN si el primer intento falló. Sin protección, el plan se activa más de una vez por un solo cobro. La regla es una tabla con UNIQUE(order_id) y un INSERT que no hace nada si ya existe; el cumplimiento del pedido solo corre cuando se insertó una fila:

const { rowCount } = await db.query(
  `INSERT INTO pagos_procesados (order_id, usuario_id, monto, origen, modo, uuid_trans)
   VALUES ($1,$2,$3,$4,$5,$6) ON CONFLICT DO NOTHING`, [...]);
if (!rowCount) return;   // ya lo procesó el otro camino
await activarPlan(userId, ciclo);

En wapi la inserción y la activación van dentro de una misma transacción. Y el IPN responde después de procesar: si la base falla a mitad, devuelve 500 para que Izipay reintente. Antes respondía 200 de entrada y un error dejaba el pago sin acreditar para siempre. Como el procesamiento es idempotente, el reintento no duplica nada. La excepción es un IPN sin empresa reconocible: ahí se registra el error y se responde 200, porque reintentar no lo va a arreglar.

Lo que decide el servidor

El navegador manda qué plan o ciclo eligió, nunca cuánto cobrar. El backend busca el precio en su catálogo antes de pedir el token. Lo que muestra la pantalla es informativo.

Tres detalles más de esa lógica:

  • El monto que se acredita es el que cobró la pasarela. En wapi, la acreditación usa el monto del kr-answer y solo cae al de nuestra fila pendiente si falta. Quien manda es lo que Izipay realmente cobró.
  • Un pago en modo prueba no acredita nada real. Si una llave de test queda puesta en producción, regalarías meses de plan. Por eso el IPN compara el mode de la transacción con el modo del servidor y descarta lo que no coincide.
  • orderId corto. Izipay rechaza con INT_013 "invalid orderId" a partir de unos 63-65 caracteres. Un UUID más un nombre de ciclo ya lo supera. Usamos una letra de ciclo, el UUID y un timestamp en base36, que se parsea de vuelta con una expresión regular.

Pruebas: lo que se queda vivo

Mantén llaves de test y de producción en variables separadas (IZIPAY_PASSWORD, IZIPAY_PUBLIC_KEY y IZIPAY_HMAC frente a las IZIPAY_TEST_*), elegidas por NODE_ENV. Pasar a cobros reales es cambiar valores en el panel del hosting, sin tocar código.

Una trampa de las pruebas con recurrencia: una suscripción creada en modo TEST sigue viva y cobra cuotas sola. Cada cuota dispara un IPN a producción, que solo tiene la clave de producción, así que la firma de test no valida, responde 400 y Izipay manda un correo de error con el aviso de modo PRUEBA cada pocos minutos. No afecta pagos reales, pero es ruido que parece una caída. Al terminar de probar la recurrencia, cancela siempre las suscripciones de test. Tampoco pruebes en una cuenta real: activar un plan suma días, y cuatro pruebas seguidas en SortealoPE sumaron 665 días de vigencia.

Para validar una tienda ante el soporte de Izipay con un cobro real sin perder dinero, mira validar una pasarela con un cobro de un sol.

Checklist antes de dar la integración por terminada

  • Un formulario por monto: si el monto cambia, iframe y token nuevos.
  • CSP con los dominios de la pasarela, verificada con captura.
  • Firma validada con la clave que indique kr-hash-key, en tiempo constante.
  • IPN real probado en producción, con log de firma inválida.
  • Reglas de notificación activas, incluida "al autorizar por lote".
  • UNIQUE(order_id) y ON CONFLICT DO NOTHING; el IPN responde tras procesar.
  • Precio resuelto en el servidor; el modo de la transacción se compara con el del servidor.
  • orderId por debajo de ~60 caracteres.
  • Suscripciones de test canceladas al cerrar las pruebas.

La misma disciplina de cerrar lo que nadie mira está en candados en las puertas de atrás: un IPN es, al fin y al cabo, un endpoint público que activa servicio.