The 0fee.dev checkout widget is a 21KB IIFE (Immediately Invoked Function Expression) that merchants embed on their websites. It renders a payment form, collects payment details, and handles the transaction. For providers like Orange Money or Wave, where the customer enters a phone number and confirms via USSD, the flow stays entirely within the widget. But for providers like Stripe and PayPal, the customer must be redirected to an external page to complete the payment.
This redirect creates a fundamental UX problem. The widget is an overlay on the merchant's page. If we redirect the entire page to Stripe, the merchant's page is gone. When Stripe redirects back, we land on a callback URL -- not back in the widget overlay. The merchant's checkout flow is broken.
The solution involved popup windows, fallback tabs, polling, and the postMessage API. It was one of the most intricate frontend challenges in the entire 0fee.dev build.
The Problem in Detail
Consider this flow:
- Customer is on
merchant.com/checkout - Customer clicks "Pay with Card" in the 0fee widget overlay
- Stripe requires a redirect to
checkout.stripe.comfor 3D Secure / SCA - After payment, Stripe redirects to
api.0fee.dev/callback?session_id=xyz - The customer needs to end up back on
merchant.com/checkoutwith a success message
The widget is an iframe or overlay on the merchant's page. It cannot redirect the parent page (cross-origin security prevents this). And even if it could, redirecting the customer away from the merchant's site breaks the embedded checkout experience.
Solution: Popup Window for Redirect Providers
The approach: open the redirect URL in a new popup window. The widget stays visible on the merchant's page. The popup handles the redirect flow. When the payment completes, the popup communicates back to the 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) => {
// Try to open popup
this.popup = window.open(
redirectUrl,
'0fee-payment',
'width=500,height=700,scrollbars=yes,resizable=yes'
);
if (this.popup && !this.popup.closed) {
// Popup opened successfully
this.waitForPopupResult(transactionId, resolve, reject);
} else {
// Popup blocked -- fallback to new tab
this.handlePopupBlocked(redirectUrl, transactionId, resolve, reject);
}
});
}
private waitForPopupResult(
transactionId: string,
resolve: (result: PaymentResult) => void,
reject: (error: Error) => void,
): void {
// Listen for postMessage from the callback page
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);
// Also poll for popup closure (user closed it manually)
this.pollInterval = window.setInterval(() => {
if (this.popup?.closed) {
window.removeEventListener('message', messageHandler);
this.cleanup();
reject(new Error('Payment window was closed'));
}
}, 500);
// Timeout after 10 minutes
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;
}
}The WidgetCallback Page
When the payment provider redirects back, it hits a callback page on 0fee.dev. This page's sole purpose is to send the result back to the widget via postMessage and close itself:
html<!-- frontend/src/routes/widget-callback/+page.svelte -->
<!-- This is the page that Stripe/PayPal redirects to after payment -->
<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');
// Verify payment status with backend
const response = await fetch(
`${API_URL}/transactions/${transactionId}/status`
);
const result = await response.json();
// Send result to parent window (the merchant's page with the 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-close after 2 seconds
setTimeout(() => window.close(), 2000);
} else {
// No opener (direct navigation) -- show result and redirect
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>The window.opener check is critical. If the callback page was opened via popup (window.open), window.opener references the merchant's page. If the user navigated directly (copied the URL, or the popup was blocked and they used a new tab), window.opener is null and we show a static result page instead.
Fallback: New Tab With Polling
Popup blockers are aggressive in modern browsers. If the popup fails to open, we fall back to opening a new tab and polling the backend for the transaction status:
typescript// widget/src/redirect-handler.ts (continued)
private handlePopupBlocked(
redirectUrl: string,
transactionId: string,
resolve: (result: PaymentResult) => void,
reject: (error: Error) => void,
): void {
// Update widget state to show instructions
this.updateWidgetState('redirect_blocked');
// Open in new tab (less likely to be blocked)
window.open(redirectUrl, '_blank');
// Poll backend for status every 2 seconds
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}`));
}
// 'pending' status: keep polling
} catch (error) {
// Network error: keep polling (might be temporary)
console.warn('Status poll failed, retrying...', error);
}
}, 2000);
// Timeout after 10 minutes
setTimeout(() => {
this.cleanup();
reject(new Error('Payment timed out'));
}, 600_000);
}The polling interval of 2 seconds balances responsiveness (the widget updates quickly after payment) with server load (500ms would be too aggressive, 5 seconds would feel sluggish).
Widget States
The widget displays different UI based on the redirect state:
typescript// widget/src/states.ts
type WidgetState =
| 'idle' // Initial state, showing payment form
| 'processing' // Sending request to backend
| 'redirect_waiting' // Popup opened, waiting for result
| 'redirect_blocked' // Popup blocked, polling for result
| 'completed' // Payment successful
| 'failed' // Payment failed
| 'expired'; // Payment timed outEach state renders different content:
| State | Widget Display |
|---|---|
idle | Payment method selection, amount, pay button |
processing | Spinner with "Processing..." |
redirect_waiting | "Complete payment in the popup window" with a "Reopen" button |
redirect_blocked | "Complete payment in the new tab" with "I've completed the payment" button |
completed | Success checkmark and transaction details |
failed | Error message with "Try again" button |
expired | Timeout message with "Start over" button |
The redirect_blocked state includes a manual trigger button ("I've completed the payment") that forces an immediate status check instead of waiting for the next poll cycle.
Provider Support Matrix
Not all providers require redirects. The widget's behavior depends on the provider and payment method:
| Provider | Method | Flow | Redirect? |
|---|---|---|---|
| Stripe | Card (no 3DS) | Direct | No |
| Stripe | Card (3DS required) | Redirect | Yes (popup) |
| PayPal | PayPal | Redirect | Yes (popup) |
| Orange Money | Mobile Money | USSD push | No |
| Wave | Mobile Money | In-app notification | No |
| Hub2 | Card | Redirect | Yes (popup) |
| Hub2 | Mobile Money | USSD push | No |
| PawaPay | Mobile Money | USSD push | No |
| BUI | Card | Redirect | Yes (popup) |
| Test Provider | Any | Simulated | No |
Mobile money providers (Orange, Wave, MTN MoMo, M-Pesa) use USSD push: the customer receives a prompt on their phone, enters their PIN, and the payment completes. No browser redirect is needed. The widget polls the backend for status updates.
Card providers (Stripe, Hub2, BUI) may require redirect for 3D Secure verification. PayPal always requires redirect to the PayPal consent screen.
Security Considerations
postMessage Origin Validation
The callback page sends postMessage with '*' as the target origin because we do not know which merchant domain embedded the widget. This is safe because:
- The message contains no sensitive data (just transaction ID and status)
- The widget validates the
transactionIdmatches its pending transaction - The actual payment verification happens server-side, not based on the postMessage content
typescript// Widget-side validation
const messageHandler = (event: MessageEvent) => {
// Validate message structure
if (event.data?.type !== '0fee-payment-result') return;
// Validate transaction ID matches what we're waiting for
if (event.data?.transactionId !== pendingTransactionId) return;
// IMPORTANT: Do NOT trust the status from postMessage alone
// Verify with backend before showing success
verifyWithBackend(event.data.transactionId).then(result => {
if (result.status === 'completed') {
resolve(result);
}
});
};The widget never trusts the postMessage status directly. It always verifies with the backend. This prevents a malicious script from sending a fake "completed" message.
Popup Spoofing
A concern with popup-based flows is that a malicious script could open a fake popup that looks like Stripe's checkout page. The mitigation is that the popup URL comes from the 0fee.dev backend (which got it from Stripe's API), not from user input. The widget does not accept arbitrary redirect URLs.
What We Learned
Popup blockers are the default in 2026. Most browsers block popups by default. The fallback polling mechanism is not an edge case -- it is the primary flow for many users. Design for the fallback first.
2-second polling is the sweet spot. Faster polling creates unnecessary server load. Slower polling makes the widget feel unresponsive. Two seconds balances both concerns.
window.opener is fragile. It is null when the popup is opened from a cross-origin iframe (which the widget might be). It is null when the user copies the URL to a new tab. It is null when the popup is actually a new tab due to browser settings. Always have a fallback.
Verify server-side, not client-side. The postMessage is a signal, not a source of truth. The widget must verify the payment status with the backend before showing success. Otherwise, a window.postMessage({type: '0fee-payment-result', status: 'completed'}) in the browser console would show a fake success.
The callback page is deceptively simple and deceptively important. It is a single page that does one thing: relay the result back to the opener. But it must handle multiple scenarios (popup, tab, direct navigation), validate the payment status, and close gracefully. Do not underestimate it.
This article is part of the "How We Built 0fee.dev" series. 0fee.dev is a payment orchestrator covering 53+ providers across 200+ countries, built by Juste A. GNIMAVO and Claude from Abidjan with zero human engineers. Follow the series for the complete build story.