El widget de checkout de 0fee.dev es un IIFE (Immediately Invoked Function Expression) de 21KB que los comerciantes incrustan en sus sitios web. Renderiza un formulario de pago, recopila detalles de pago y maneja la transaccion. Para proveedores como Orange Money o Wave, donde el cliente introduce un numero de telefono y confirma via USSD, el flujo permanece enteramente dentro del widget. Pero para proveedores como Stripe y PayPal, el cliente debe ser redirigido a una pagina externa para completar el pago.
Esta redireccion crea un problema fundamental de UX. El widget es una superposicion en la pagina del comerciante. Si redirigimos toda la pagina a Stripe, la pagina del comerciante desaparece. Cuando Stripe redirige de vuelta, aterrizamos en una URL de callback -- no de vuelta en la superposicion del widget. El flujo de checkout del comerciante se rompe.
La solucion involucro ventanas popup, pestanas de respaldo, polling y la API postMessage. Fue uno de los desafios de frontend mas intrincados en toda la construccion de 0fee.dev.
El problema en detalle
Considera este flujo:
- El cliente esta en
merchant.com/checkout - El cliente hace clic en "Pagar con tarjeta" en la superposicion del widget de 0fee
- Stripe requiere una redireccion a
checkout.stripe.compara 3D Secure / SCA - Despues del pago, Stripe redirige a
api.0fee.dev/callback?session_id=xyz - El cliente necesita terminar de vuelta en
merchant.com/checkoutcon un mensaje de exito
El widget es un iframe o superposicion en la pagina del comerciante. No puede redirigir la pagina padre (la seguridad de origen cruzado lo previene). E incluso si pudiera, redirigir al cliente lejos del sitio del comerciante rompe la experiencia de checkout incrustado.
Solucion: ventana popup para proveedores con redireccion
El enfoque: abrir la URL de redireccion en una nueva ventana popup. El widget permanece visible en la pagina del comerciante. El popup maneja el flujo de redireccion. Cuando el pago se completa, el popup comunica de vuelta al widget via postMessage.
typescript// widget/src/redirect-handler.ts
class RedirectHandler {
private popup: Window | null = null;
private pollInterval: number | null = null;
async handleRedirect(redirectUrl: string, transactionId: string): Promise<PaymentResult> {
return new Promise((resolve, reject) => {
// Intentar abrir popup
this.popup = window.open(
redirectUrl,
'0fee-payment',
'width=500,height=700,scrollbars=yes,resizable=yes'
);
if (this.popup && !this.popup.closed) {
// Popup abierto exitosamente
this.waitForPopupResult(transactionId, resolve, reject);
} else {
// Popup bloqueado -- respaldo a nueva pestaña
this.handlePopupBlocked(redirectUrl, transactionId, resolve, reject);
}
});
}
private waitForPopupResult(
transactionId: string,
resolve: (result: PaymentResult) => void,
reject: (error: Error) => void,
): void {
// Escuchar postMessage de la pagina de callback
const messageHandler = (event: MessageEvent) => {
if (event.data?.type === '0fee-payment-result' &&
event.data?.transactionId === transactionId) {
window.removeEventListener('message', messageHandler);
this.cleanup();
if (event.data.status === 'completed') {
resolve(event.data);
} else {
reject(new Error(event.data.error || 'Payment failed'));
}
}
};
window.addEventListener('message', messageHandler);
// Tambien consultar cierre del popup (usuario lo cerro manualmente)
this.pollInterval = window.setInterval(() => {
if (this.popup?.closed) {
window.removeEventListener('message', messageHandler);
this.cleanup();
reject(new Error('Payment window was closed'));
}
}, 500);
// Timeout despues de 10 minutos
setTimeout(() => {
window.removeEventListener('message', messageHandler);
this.cleanup();
reject(new Error('Payment timed out'));
}, 600_000);
}
private cleanup(): void {
if (this.pollInterval) {
clearInterval(this.pollInterval);
this.pollInterval = null;
}
if (this.popup && !this.popup.closed) {
this.popup.close();
}
this.popup = null;
}
}La pagina WidgetCallback
Cuando el proveedor de pago redirige de vuelta, llega a una pagina de callback en 0fee.dev. El unico proposito de esta pagina es enviar el resultado de vuelta al widget via postMessage y cerrarse:
html<!-- frontend/src/routes/widget-callback/+page.svelte -->
<!-- Esta es la pagina a la que Stripe/PayPal redirige despues del pago -->
<script>
import { onMount } from 'svelte';
import { page } from '$app/stores';
let status = 'processing';
let message = 'Completing your payment...';
onMount(async () => {
const params = new URLSearchParams($page.url.search);
const transactionId = params.get('transaction_id');
const paymentStatus = params.get('status');
// Verificar estado del pago con el backend
const response = await fetch(
`${API_URL}/transactions/${transactionId}/status`
);
const result = await response.json();
// Enviar resultado a la ventana padre (la pagina del comerciante con el widget)
if (window.opener) {
window.opener.postMessage({
type: '0fee-payment-result',
transactionId: transactionId,
status: result.status,
amount: result.source_amount,
currency: result.source_currency,
}, '*');
status = 'complete';
message = 'Payment complete. This window will close automatically.';
// Auto-cerrar despues de 2 segundos
setTimeout(() => window.close(), 2000);
} else {
// Sin opener (navegacion directa) -- mostrar resultado y redirigir
status = result.status;
message = result.status === 'completed'
? 'Payment successful! You can close this tab.'
: 'Payment was not completed.';
}
});
</script>
<div class="callback-page">
<div class="status-card">
{#if status === 'processing'}
<div class="spinner"></div>
{:else if status === 'completed'}
<div class="checkmark">✓</div>
{:else}
<div class="cross">✗</div>
{/if}
<p>{message}</p>
</div>
</div>La verificacion de window.opener es critica. Si la pagina de callback fue abierta via popup (window.open), window.opener referencia la pagina del comerciante. Si el usuario navego directamente (copio la URL, o el popup fue bloqueado y uso una nueva pestaña), window.opener es nulo y mostramos una pagina de resultado estatica en su lugar.
Respaldo: nueva pestaña con polling
Los bloqueadores de popups son agresivos en los navegadores modernos. Si el popup no se abre, recurrimos a abrir una nueva pestaña y consultar el backend por el estado de la transaccion:
typescript// widget/src/redirect-handler.ts (continuacion)
private handlePopupBlocked(
redirectUrl: string,
transactionId: string,
resolve: (result: PaymentResult) => void,
reject: (error: Error) => void,
): void {
// Actualizar estado del widget para mostrar instrucciones
this.updateWidgetState('redirect_blocked');
// Abrir en nueva pestaña (menos probable de ser bloqueada)
window.open(redirectUrl, '_blank');
// Consultar backend por estado cada 2 segundos
this.pollInterval = window.setInterval(async () => {
try {
const response = await fetch(
`${API_URL}/transactions/${transactionId}/status`
);
const result = await response.json();
if (result.status === 'completed') {
this.cleanup();
resolve(result);
} else if (result.status === 'failed' || result.status === 'expired') {
this.cleanup();
reject(new Error(`Payment ${result.status}`));
}
// Estado 'pending': seguir consultando
} catch (error) {
// Error de red: seguir consultando (puede ser temporal)
console.warn('Status poll failed, retrying...', error);
}
}, 2000);
// Timeout despues de 10 minutos
setTimeout(() => {
this.cleanup();
reject(new Error('Payment timed out'));
}, 600_000);
}El intervalo de polling de 2 segundos equilibra la capacidad de respuesta (el widget se actualiza rapidamente despues del pago) con la carga del servidor (500ms seria demasiado agresivo, 5 segundos se sentiria lento).
Estados del widget
El widget muestra diferente interfaz basada en el estado de redireccion:
typescript// widget/src/states.ts
type WidgetState =
| 'idle' // Estado inicial, mostrando formulario de pago
| 'processing' // Enviando solicitud al backend
| 'redirect_waiting' // Popup abierto, esperando resultado
| 'redirect_blocked' // Popup bloqueado, consultando resultado
| 'completed' // Pago exitoso
| 'failed' // Pago fallido
| 'expired'; // Pago expiradoCada estado renderiza contenido diferente:
| Estado | Visualizacion del widget |
|---|---|
idle | Seleccion de metodo de pago, monto, boton de pagar |
processing | Indicador con "Procesando..." |
redirect_waiting | "Complete el pago en la ventana popup" con boton "Reabrir" |
redirect_blocked | "Complete el pago en la nueva pestaña" con boton "He completado el pago" |
completed | Marca de verificacion de exito y detalles de transaccion |
failed | Mensaje de error con boton "Intentar de nuevo" |
expired | Mensaje de timeout con boton "Comenzar de nuevo" |
El estado redirect_blocked incluye un boton de activacion manual ("He completado el pago") que fuerza una verificacion inmediata de estado en lugar de esperar el siguiente ciclo de polling.
Matriz de soporte de proveedores
No todos los proveedores requieren redirecciones. El comportamiento del widget depende del proveedor y metodo de pago:
| Proveedor | Metodo | Flujo | ¿Redireccion? |
|---|---|---|---|
| Stripe | Tarjeta (sin 3DS) | Directo | No |
| Stripe | Tarjeta (3DS requerido) | Redireccion | Si (popup) |
| PayPal | PayPal | Redireccion | Si (popup) |
| Orange Money | Dinero movil | Push USSD | No |
| Wave | Dinero movil | Notificacion en app | No |
| Hub2 | Tarjeta | Redireccion | Si (popup) |
| Hub2 | Dinero movil | Push USSD | No |
| PawaPay | Dinero movil | Push USSD | No |
| BUI | Tarjeta | Redireccion | Si (popup) |
| Proveedor de prueba | Cualquiera | Simulado | No |
Los proveedores de dinero movil (Orange, Wave, MTN MoMo, M-Pesa) usan push USSD: el cliente recibe un aviso en su telefono, introduce su PIN y el pago se completa. No se necesita redireccion del navegador. El widget consulta al backend por actualizaciones de estado.
Los proveedores de tarjeta (Stripe, Hub2, BUI) pueden requerir redireccion para verificacion 3D Secure. PayPal siempre requiere redireccion a la pantalla de consentimiento de PayPal.
Consideraciones de seguridad
Validacion de origen de postMessage
La pagina de callback envia postMessage con '*' como origen objetivo porque no sabemos cual dominio de comerciante incrusto el widget. Esto es seguro porque:
- El mensaje no contiene datos sensibles (solo ID de transaccion y estado)
- El widget valida que el
transactionIdcoincida con su transaccion pendiente - La verificacion real del pago ocurre del lado del servidor, no basada en el contenido de postMessage
typescript// Validacion del lado del widget
const messageHandler = (event: MessageEvent) => {
// Validar estructura del mensaje
if (event.data?.type !== '0fee-payment-result') return;
// Validar que el ID de transaccion coincida con lo que esperamos
if (event.data?.transactionId !== pendingTransactionId) return;
// IMPORTANTE: NO confiar en el estado del postMessage solo
// Verificar con el backend antes de mostrar exito
verifyWithBackend(event.data.transactionId).then(result => {
if (result.status === 'completed') {
resolve(result);
}
});
};El widget nunca confia en el estado del postMessage directamente. Siempre verifica con el backend. Esto previene que un script malicioso envie un mensaje falso de "completado".
Suplantacion de popup
Una preocupacion con flujos basados en popup es que un script malicioso podria abrir un popup falso que se vea como la pagina de checkout de Stripe. La mitigacion es que la URL del popup proviene del backend de 0fee.dev (que la obtuvo de la API de Stripe), no de la entrada del usuario. El widget no acepta URLs de redireccion arbitrarias.
Lo que aprendimos
Los bloqueadores de popups son el estandar en 2026. La mayoria de los navegadores bloquean popups por defecto. El mecanismo de polling de respaldo no es un caso limite -- es el flujo principal para muchos usuarios. Diseña para el respaldo primero.
El polling de 2 segundos es el punto ideal. Polling mas rapido crea carga de servidor innecesaria. Polling mas lento hace que el widget se sienta sin respuesta. Dos segundos equilibra ambas preocupaciones.
window.opener es fragil. Es nulo cuando el popup se abre desde un iframe de origen cruzado (que el widget podria ser). Es nulo cuando el usuario copia la URL a una nueva pestaña. Es nulo cuando el popup es realmente una nueva pestaña debido a la configuracion del navegador. Siempre ten un respaldo.
Verifica del lado del servidor, no del cliente. El postMessage es una senal, no una fuente de verdad. El widget debe verificar el estado del pago con el backend antes de mostrar exito. De lo contrario, un window.postMessage({type: '0fee-payment-result', status: 'completed'}) en la consola del navegador mostraria un exito falso.
La pagina de callback es enganosamente simple y enganosamente importante. Es una sola pagina que hace una cosa: retransmitir el resultado al abridor. Pero debe manejar multiples escenarios (popup, pestaña, navegacion directa), validar el estado del pago y cerrarse adecuadamente. No la subestimes.
Este articulo es parte de la serie "Como construimos 0fee.dev". 0fee.dev es un orquestador de pagos que cubre mas de 53 proveedores en mas de 200 paises, construido por Juste A. GNIMAVO y Claude desde Abiyan sin ningun ingeniero humano. Sigue la serie para conocer la historia completa de construccion.