Integración de GreenPay Express Checkout con Node.js: Arquitectura de Pagos con QR
El reto de los pagos con QR en LatAm
En Latinoamérica, los pagos con QR se han convertido en un método estándar para transacciones en punto de venta. Los clientes esperan escanear un código y completar el pago sin ingresar datos manualmente. Pero los QR estáticos, esos que imprimís y pegás en el mostrador, tienen una limitación fundamental: siempre apuntan al mismo destino. No pueden codificar un producto específico, ajustarse a precios variables ni expirar tras su uso.
Un cliente se acercó con un problema específico. Necesitaba que los clientes escanearan un QR en un punto físico, llegaran a una página de pago con el producto y precio correctos cargados en tiempo real desde su CMS, y completaran la transacción a través de GreenPay Express Checkout. El QR tenía que ser dinámico: cada uno vinculado a un contexto de compra específico, válido solo por una ventana limitada, e imposible de reutilizar tras el pago.
Esta es la arquitectura que construimos para resolverlo.
Resumen de la arquitectura
El sistema tiene cuatro componentes:
- Frontend en Next.js: Renderiza la página de pago que el cliente ve al escanear el QR. Usa server-side rendering para el load inicial, así el cliente ve los datos del producto de inmediato, no un spinner de carga.
- Backend en Node.js/Express: Maneja la generación de QR, validación de hashes, comunicación con el CMS y orquesta el flujo de Express Checkout de GreenPay.
- API de GreenPay: El procesador de pagos. Usamos su endpoint de Express Checkout para crear una sesión de pago y sus webhooks para confirmar el estado de la transacción.
- CMS: El sistema de gestión de contenidos del cliente, que contiene el catálogo de productos y las definiciones de tarifas. El backend lo consulta para poblar el contexto de pago cuando se escanea un QR.
El flujo del QR
Cada pago con QR sigue una secuencia fija:
- Creación del intento de compra: El backend genera un hash criptográficamente aleatorio y lo guarda junto con el ID del producto, el precio y un timestamp en la base de datos. Este hash es el payload del QR.
- Generación del QR: El backend codifica el hash en una URL que apunta a la ruta de la página de pago, por ejemplo
https://pay.example.com/checkout/{hash}. - El cliente escanea: El cliente abre la cámara, escanea el QR y el navegador navega a la página de checkout.
- Validación del hash: El backend recibe el hash, verifica que exista en la base de datos, confirma que no haya expirado y que no haya sido usado ya para un pago completado.
- Recuperación del producto: El backend consulta el CMS para obtener los detalles del producto vinculado a este intento de compra y los devuelve al frontend.
- Sesión de GreenPay: El backend llama a la API de Express Checkout de GreenPay para crear una sesión de pago con el monto correcto.
- Pago y confirmación: El cliente completa el pago en la página alojada por GreenPay. GreenPay envía un webhook al backend, que marca el intento de compra como pagado e invalida el hash.
Consideraciones de seguridad
Generación del hash
El hash debe ser impredecible. Usamos crypto.randomBytes(32) en Node.js y codificamos el resultado en hex. Esto da un string de 64 caracteres hexadecimales con 256 bits de entropía, más que suficiente para prevenir ataques de fuerza bruta o adivinación.
const crypto = require('crypto');
function generatePurchaseHash() {
return crypto.randomBytes(32).toString('hex');
}
Validación y expiración
Cada hash tiene un tiempo de vida. Por defecto usamos 15 minutos. Cuando el backend recibe un hash, verifica:
- Que el hash exista en la base de datos
- Que el tiempo actual esté dentro de la ventana de validez
- Que no exista un pago confirmado para este hash
Si cualquier check falla, el cliente ve una página de error explicando que el QR es inválido o expirado. No se expone ningún dato del producto.
Idempotencia
El mismo hash puede escanearse múltiples veces dentro de su ventana de validez, por ejemplo si el cliente cierra la pestaña y la vuelve a abrir. El backend maneja esto sin problema, devolviendo el mismo contexto de compra. Una vez que GreenPay confirma el pago, el hash se marca como consumido y los escaneos posteriores fallan la validación.
Verificación de webhooks
Los webhooks de GreenPay incluyen un header de firma. El backend verifica esta firma usando el secreto del webhook antes de procesar el callback. Sin verificación, un atacante podría falsificar un webhook y marcar órdenes como pagadas sin que haya un pago real.
Detalles de implementación
Nota: Los fragmentos de código a continuación son ejemplos ilustrativos del patrón, no el código de producción real de este proyecto. Los usamos para explicar la arquitectura, no para exponer specifics de implementación.
Crear un intento de compra
Cuando el sistema necesita un QR nuevo, llama al backend para crear un intento de compra. El patrón general se ve así:
// Ejemplo: patrón ilustrativo, no código de producción
async function createPurchaseIntent(req, res) {
const { productId } = req.body;
// Obtener producto del CMS
const product = await cmsClient.getProduct(productId);
if (!product) {
return res.status(404).json({ error: 'Product not found' });
}
const hash = generatePurchaseHash();
const expiresAt = new Date(Date.now() + 15 * 60 * 1000);
await db.purchaseIntent.create({
hash,
productId,
amount: product.price,
currency: product.currency,
status: 'pending',
expiresAt,
});
const checkoutUrl = buildCheckoutUrl(hash);
const qrImage = await generateQRCode(checkoutUrl);
res.json({ hash, qrImage, checkoutUrl });
}
Validar el hash y devolver los datos del producto
Cuando el cliente escanea el QR y la página de Next.js carga, solicita el contexto de compra al backend:
// Ejemplo: patrón ilustrativo, no código de producción
async function getPurchaseContext(req, res) {
const { hash } = req.params;
const intent = await db.purchaseIntent.findByHash(hash);
if (!intent) {
return res.status(404).json({ error: 'Invalid QR code' });
}
if (intent.status === 'paid') {
return res.status(410).json({ error: 'This QR code has already been used' });
}
if (new Date() > intent.expiresAt) {
return res.status(410).json({ error: 'This QR code has expired' });
}
const product = await cmsClient.getProduct(intent.productId);
res.json({ product: { name: product.name, amount: intent.amount, currency: intent.currency } });
}
Crear la sesión de GreenPay Express Checkout
Una vez que el cliente confirma que quiere pagar, el backend crea una sesión en GreenPay:
// Ejemplo: patrón ilustrativo, no código de producción
async function createCheckoutSession(req, res) {
const { hash } = req.params;
const intent = await db.purchaseIntent.findByHash(hash);
if (!intent || intent.status !== 'pending' || new Date() > intent.expiresAt) {
return res.status(400).json({ error: 'Cannot initiate checkout' });
}
const greenpayResponse = await greenpayClient.createSession({
amount: intent.amount,
currency: intent.currency,
reference: hash,
callbackUrl: buildWebhookUrl(),
});
res.json({ checkoutUrl: greenpayResponse.checkoutUrl });
}
Manejar el webhook
// Ejemplo: patrón ilustrativo, no código de producción
async function handleGreenpayWebhook(req, res) {
const signature = req.headers['x-greenpay-signature'];
const rawBody = req.rawBody;
if (!verifySignature(rawBody, signature, getWebhookSecret())) {
return res.status(401).json({ error: 'Invalid signature' });
}
const event = JSON.parse(rawBody);
if (event.status === 'approved') {
await db.purchaseIntent.markAsPaid(event.reference);
}
res.status(200).json({ received: true });
}
Lecciones aprendidas
Probá la entrega de webhooks desde el principio. La entrega de webhooks de GreenPay depende de la configuración de red y los firewalls. Si estás trabajando localmente durante el desarrollo, usá un tunnel como ngrok para recibir los webhooks. No asumas que los webhooks en producción van a funcionar sin probar el flujo completo.
Manejá la latencia del CMS con cuidado. El CMS puede tardar más de lo esperado en responder. Si la página de pago espera los datos del CMS durante el render inicial, el cliente ve una página en blanco. Lo resolvimos haciendo que Next.js renderice del lado del servidor con un estado de carga y se hidrate con los datos del producto en el cliente si el CMS tarda.
Mantén los hashes de corta duración por defecto. Una expiración de 15 minutos es generosa para la mayoría de escenarios de punto de venta. Ventanas más largas aumentan el riesgo de que un hash se comparta o sea interceptado. Que la expiración sea configurable según el caso de uso, pero mantén el default ajustado.
Registrá cada transición de estado. Cuando un hash pasa de pending a paid, loguealo. Cuando llega un webhook, loguealo. Cuando la validación falla, loguealo. Los bugs de pago son notoriamente difíciles de reproducir, y unos buenos logs son la diferencia entre un fix de 10 minutos y una investigación de 3 horas.
Los rate limits de GreenPay importan a escala. Si generás cientos de QR en una ventana corta, podés chocar con los rate limits del endpoint de Express Checkout. Implementá una pequeña cola o cacheá las sesiones de pago cuando el mismo producto se compra repetidamente.
Cierre
Los pagos con QR dinámicos son una buena opción para el comercio en LatAm. La combinación de Next.js, Express y GreenPay te da un sistema que opera en tiempo real, es seguro y reutilizable entre distintas líneas de producto. La arquitectura descrita arriba ha estado corriendo en producción sin problemas desde su despliegue.
Si estás trabajando en una integración de pagos y querés hablar sobre la arquitectura, con gusto compartimos lo que hemos aprendido. ¿Necesitás ayuda con una integración de pagos? Hablá con nuestro equipo de ingeniería en byxel.io.
¿Necesitas ayuda con tu proyecto?
Hablemos de cómo podemos ayudarte a construir software confiable.
Contáctanos