Every payment platform needs an embeddable checkout experience. Stripe has Stripe.js at 28KB. PayPal has their SDK. 0fee.dev has a 21KB IIFE (Immediately Invoked Function Expression) that a developer drops onto their page with a single script tag. It handles country selection, payment method filtering, phone number input with dial codes, OTP validation, processing states, and success/error callbacks -- all without requiring the developer to build any payment UI.
This article covers the Vite library build configuration, the widget's multi-step flow, customer information collection, the IIFE vs. ES module dual output, and the bug fixes that made it production-ready.
The Integration Experience
From the developer's perspective, the checkout widget is three lines of code:
html<script src="https://cdn.0fee.dev/checkout.js"></script>
<script>
const checkout = new ZeroFee.Checkout({
apiKey: "pk_live_...",
amount: 5000,
currency: "XOF",
onSuccess: (result) => console.log("Paid!", result),
onError: (error) => console.error("Failed:", error),
});
checkout.open();
</script>The checkout.open() call renders a modal overlay with the complete payment flow. The customer selects their country, chooses a payment method, enters their phone number, confirms via OTP if required, and sees a success or error state. The developer's onSuccess callback fires with the transaction details.
Vite Library Build
The widget is built with Vite in library mode, producing both an IIFE bundle (for <script> tags) and an ES module (for modern bundlers):
typescript// checkout/vite.config.ts
import { defineConfig } from "vite";
export default defineConfig({
build: {
lib: {
entry: "src/checkout.ts",
name: "ZeroFee",
formats: ["iife", "es"],
fileName: (format) =>
format === "iife" ? "checkout.js" : "checkout.esm.js",
},
rollupOptions: {
output: {
// IIFE: single file, no external dependencies
inlineDynamicImports: true,
},
},
minify: "terser",
terserOptions: {
compress: {
drop_console: true,
},
},
},
});The IIFE format is critical. Unlike ES modules, an IIFE can be loaded with a plain <script> tag and immediately exposes the ZeroFee global object. No import statements, no module system, no bundler required. This makes integration as simple as adding a script tag to any HTML page.
| Output | Format | Size | Use Case |
|---|---|---|---|
checkout.js | IIFE | 21KB | Script tag on any website |
checkout.esm.js | ES Module | 19KB | Import in React/Vue/Svelte apps |
The Widget Flow
The checkout widget follows a multi-step flow:
Customer Info -> Country -> Method -> Phone -> OTP -> Processing -> ResultStep 1: Customer Information
typescriptrenderCustomerStep(): string {
return `
<div class="zf-step">
<h3 class="zf-title">Customer Information</h3>
<label class="zf-label">Full Name *</label>
<input
type="text"
class="zf-field"
id="zf-customer-name"
placeholder="John Doe"
required
/>
<label class="zf-label">Email (optional)</label>
<input
type="email"
class="zf-field"
id="zf-customer-email"
placeholder="[email protected]"
/>
<label class="zf-label">Country *</label>
<select class="zf-select" id="zf-country">
${this.countries.map(c =>
`<option value="${c.code}">${c.flag} ${c.name}</option>`
).join("")}
</select>
<button class="zf-btn-primary" id="zf-next-step">
Continue
</button>
</div>
`;
}Session 077 redesigned this step to combine customer info and country selection into a single form, reducing the number of steps. The full name is required; email is optional. The country dropdown is populated dynamically from the /v1/countries API with a fallback to a hardcoded list.
Step 2: Payment Method Selection
typescriptrenderMethodStep(): string {
const methods = this.getMethodsForCountry(this.selectedCountry);
return `
<div class="zf-step">
<h3 class="zf-title">Payment Method</h3>
<div class="zf-methods">
${methods.map(m => `
<button
class="zf-method-btn"
data-method="${m.code}"
>
<span class="zf-method-icon">${m.icon}</span>
<span class="zf-method-name">${m.name}</span>
</button>
`).join("")}
</div>
</div>
`;
}Methods are filtered based on the selected country. A customer in Ivory Coast sees Orange Money, MTN, Wave, and Moov. A customer in the US sees Card and PayPal.
Step 3: Phone Number Input
typescriptrenderPhoneStep(): string {
const country = this.getCountry(this.selectedCountry);
return `
<div class="zf-step">
<h3 class="zf-title">Phone Number</h3>
<div class="zf-phone-input">
<span class="zf-dial-code">${country.dialCode}</span>
<input
type="tel"
class="zf-field"
id="zf-phone"
placeholder="${country.phonePlaceholder}"
maxlength="12"
/>
</div>
<button class="zf-btn-primary" id="zf-pay">
Pay ${this.formatAmount()}
</button>
</div>
`;
}The dial code is prepopulated from the country selection. The phone input validates format based on the country's expected phone number length.
Step 4: OTP Input (Conditional)
typescriptrenderOTPStep(): string {
return `
<div class="zf-step">
<h3 class="zf-title">Enter OTP Code</h3>
<p class="zf-subtitle">
A verification code has been sent to your phone
</p>
<div class="zf-otp-inputs">
${Array(6).fill(0).map((_, i) => `
<input
type="text"
class="zf-otp-digit"
maxlength="1"
data-index="${i}"
/>
`).join("")}
</div>
<button class="zf-btn-primary" id="zf-verify-otp">
Verify
</button>
</div>
`;
}The OTP step only appears when the provider returns payment_flow.type === "otp". It renders six individual digit inputs that auto-advance on keypress.
Step 5: Processing
typescriptrenderProcessingStep(): string {
return `
<div class="zf-step zf-processing">
<div class="zf-spinner"></div>
<h3 class="zf-title">Processing Payment</h3>
<p class="zf-subtitle">
${this.getProcessingMessage()}
</p>
</div>
`;
}
getProcessingMessage(): string {
if (this.paymentFlow === "ussd_push") {
return "Check your phone for the payment prompt";
}
return "Please wait while we process your payment...";
}For USSD push payments, the processing step displays a message telling the customer to check their phone. The widget polls the transaction status endpoint until the payment completes, fails, or times out.
Step 6: Success/Error States
typescriptrenderSuccessStep(): string {
return `
<div class="zf-step zf-success">
<div class="zf-check-icon">
<svg><!-- checkmark SVG --></svg>
</div>
<h3 class="zf-title">Payment Successful</h3>
<p class="zf-amount">${this.formatAmount()}</p>
<p class="zf-reference">Ref: ${this.transactionId}</p>
</div>
`;
}
renderErrorStep(): string {
return `
<div class="zf-step zf-error">
<div class="zf-error-icon">
<svg><!-- X icon SVG --></svg>
</div>
<h3 class="zf-title">Payment Failed</h3>
<p class="zf-subtitle">${this.errorMessage}</p>
<button class="zf-btn-primary" id="zf-retry">
Try Again
</button>
</div>
`;
}Payment Processing
The core payment logic handles the API call and status polling:
typescriptasync processPayment(): Promise<void> {
this.showStep("processing");
try {
const phone = `${this.dialCode}${this.phoneNumber}`;
const response = await fetch(`${this.apiUrl}/v1/payments`, {
method: "POST",
headers: {
"Authorization": `Bearer ${this.options.apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
amount: this.options.amount,
currency: this.options.currency,
payment_method: this.selectedMethod,
customer: {
phone,
fullName: this.customerName,
email: this.customerEmail || undefined,
},
return_url: window.location.href,
}),
});
const data = await response.json();
if (data.data?.redirect_url) {
// Redirect flow (Wave, PayPal, Stripe)
window.location.href = data.data.redirect_url;
return;
}
if (data.data?.payment_flow?.type === "otp") {
this.transactionId = data.data.id;
this.showStep("otp");
return;
}
// USSD push -- poll for status
this.transactionId = data.data.id;
await this.pollPaymentStatus();
} catch (error) {
this.errorMessage = error.message || "Payment failed";
this.showStep("error");
this.options.onError?.(error);
}
}
async pollPaymentStatus(): Promise<void> {
const maxAttempts = 60; // 5 minutes at 5-second intervals
let attempts = 0;
while (attempts < maxAttempts) {
await new Promise(resolve => setTimeout(resolve, 5000));
const response = await fetch(
`${this.apiUrl}/v1/payments/${this.transactionId}`,
{ headers: { "Authorization": `Bearer ${this.options.apiKey}` } }
);
const data = await response.json();
if (data.data?.status === "completed") {
this.showStep("success");
this.options.onSuccess?.(data.data);
return;
}
if (data.data?.status === "failed") {
this.errorMessage = "Payment was declined";
this.showStep("error");
this.options.onError?.(data.data);
return;
}
attempts++;
}
// Timeout
this.errorMessage = "Payment timed out";
this.showStep("error");
}Embedded CSS
The widget includes its own CSS, embedded directly in the JavaScript bundle to avoid external stylesheet dependencies:
typescript// styles/checkout-styles.ts
export const CHECKOUT_STYLES = `
.zf-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 99999;
}
.zf-modal {
background: white;
border-radius: 16px;
width: 400px;
max-height: 90vh;
overflow-y: auto;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
}
.zf-field {
width: 100%;
padding: 12px 16px;
border: 1px solid #e2e8f0;
border-radius: 8px;
font-size: 14px;
outline: none;
transition: border-color 0.2s;
}
.zf-field:focus {
border-color: #10b981;
box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.1);
}
.zf-btn-primary {
width: 100%;
padding: 12px;
background: #10b981;
color: white;
border: none;
border-radius: 8px;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
}
/* ... ~200 more CSS rules */
`;All styles are prefixed with zf- (ZeroFee) to avoid conflicts with the host page's CSS. The overlay uses z-index: 99999 to ensure it appears above any other elements.
API URL Detection
A subtle but critical bug: the widget needed to detect the correct API URL based on where it was loaded:
typescriptprivate getApiUrl(): string {
// Check if running on localhost (development)
if (window.location.hostname === "localhost"
|| window.location.hostname === "127.0.0.1") {
return "http://localhost:8000";
}
// Production: use the configured API URL
return this.options.apiUrl || "https://api.0fee.dev";
}Without this detection, the widget loaded on localhost:3000 would try to call https://api.0fee.dev -- resulting in CORS errors during development. The fix checks the hostname and falls back to localhost:8000 for local development.
Dynamic Countries Loading
Session 077 added dynamic country loading from the API:
typescriptasync loadCountries(): Promise<void> {
try {
const response = await fetch(`${this.apiUrl}/v1/countries`);
const data = await response.json();
this.countries = data.data || data;
} catch {
// Fallback to hardcoded list
this.countries = DEFAULT_COUNTRIES;
}
}The fallback ensures the widget works even if the countries API is unreachable. The hardcoded list covers the 10 most common countries (CI, SN, CM, BJ, BF, ML, TG, GH, NG, KE).
Bug Fixes
Infinite Recursion (Session 077)
A getter that referenced itself caused an infinite loop:
typescript// Bug: this.currency calls the getter, which calls this.currency...
get currency(): string {
return this.currency; // Infinite recursion
}
// Fix: read from the options object
get currency(): string {
return this.options.currency;
}Wrong Build File (Session 077)
The ES module build was accidentally copied instead of the IIFE:
javascript// ES module (checkout.esm.js) -- won't work in a <script> tag
export class Checkout { ... }
// IIFE (checkout.js) -- works in a <script> tag
var ZeroFee = (function() { ... })();The build script was updated to explicitly copy the IIFE output to the public directory.
What We Learned
Building the checkout widget taught us three things:
- IIFE format is essential for embeddable widgets. ES modules require
type="module"on the script tag and do not expose global objects. An IIFE works everywhere, on every page, with zero configuration from the developer.
- Embedded CSS with namespaced selectors prevents conflicts. By prefixing every class with
zf-and embedding the CSS in JavaScript, we avoid requiring the developer to add a stylesheet link. The widget is truly self-contained.
- Multi-step flows need careful state management. The widget tracks the current step, customer data, selected country, selected method, phone number, OTP code, and transaction ID. Each step transition must validate the current state and prepare the next step's data.
The checkout widget was built in Session 002 and refined through Sessions 006, 077, and beyond. At 21KB minified, it handles the complete payment flow for any country and payment method in 0fee's ecosystem.
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.