Cobrar con Izipay: checkout embebido, doble confirmación e idempotencia
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.peysecure.micuentaweb.pestyle-src:static.micuentaweb.peconnect-src:api.micuentaweb.pe,static.micuentaweb.peysecure.micuentaweb.peframe-src:static.micuentaweb.peysecure.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-answery 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
modede la transacción con el modo del servidor y descarta lo que no coincide. -
orderIdcorto. Izipay rechaza conINT_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)yON 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.
orderIdpor 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.