Not every developer wants to embed a checkout widget on their site. Some prefer a redirect flow: send the customer to a hosted checkout page, let the payment platform handle the entire UI, and receive the customer back with a payment confirmation. Stripe's Checkout, PayPal's payment pages, and Shopify's checkout all follow this model.
0fee.dev's hosted checkout pages are server-rendered Jinja2 templates served by the FastAPI backend. They support 15 languages, dark and light modes, multiple payment methods, and both sandbox and live environments. This article covers the template architecture, the multi-step flow, PaiementPro SDK integration, and the sandbox mode with its built-in test guide.
Why Server-Rendered Templates
The hosted checkout is served by the FastAPI backend, not the SolidJS frontend. This is intentional:
| Approach | Pros | Cons |
|---|---|---|
| SolidJS SPA | Consistent with dashboard | Requires SPA build, client-side routing |
| Jinja2 templates | Fast first paint, no JS required, SEO-friendly | Separate from SolidJS codebase |
For a checkout page, the priority is speed. The customer should see the payment form immediately, without waiting for a JavaScript bundle to download, parse, and render. A server-rendered page achieves this with a single HTTP response.
The Checkout Template
The checkout page is a single Jinja2 template (backend/templates/checkout.html) that handles the entire flow through client-side JavaScript:
html<!DOCTYPE html>
<html lang="{{ lang }}" {% if rtl %}dir="rtl"{% endif %}>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ t.checkout_title }} - 0fee.dev</title>
<style>
/* ~500 lines of embedded CSS */
:root {
--primary: #10b981;
--primary-hover: #059669;
--bg: #ffffff;
--text: #1f2937;
--border: #e5e7eb;
}
.dark {
--bg: #0f172a;
--text: #f1f5f9;
--border: #334155;
}
/* RTL support for Arabic */
[dir="rtl"] .checkout-form { direction: rtl; }
[dir="rtl"] .phone-input { flex-direction: row-reverse; }
</style>
</head>
<body class="{{ 'dark' if dark_mode else '' }}">
{% if sandbox_mode %}
<div class="sandbox-banner">
<span class="shimmer-text">SANDBOX MODE</span>
<span>{{ t.sandbox_notice }}</span>
</div>
{% endif %}
<div class="checkout-container">
<div class="checkout-card">
<!-- Header with amount and merchant info -->
<div class="checkout-header">
<div class="amount-display">
<span class="amount">{{ formatted_amount }}</span>
<span class="currency">{{ currency }}</span>
</div>
<span class="merchant-name">{{ merchant_name }}</span>
<span class="reference">Ref: {{ reference }}</span>
</div>
<!-- Multi-step form -->
<div id="step-country" class="step active">
<!-- Country and payment method selection -->
</div>
<div id="step-phone" class="step">
<!-- Phone number input -->
</div>
<div id="step-otp" class="step">
<!-- OTP verification -->
</div>
<div id="step-processing" class="step">
<!-- Processing indicator -->
</div>
<div id="step-result" class="step">
<!-- Success or error -->
</div>
</div>
{% if sandbox_mode %}
<div id="test-guide" class="test-guide-panel">
<!-- Test cards, phone numbers, magic amounts -->
</div>
{% endif %}
</div>
<!-- Language selector -->
<div class="language-selector">
<select id="lang-select">
{% for code, name in languages %}
<option value="{{ code }}" {{ 'selected' if code == lang }}>
{{ name }}
</option>
{% endfor %}
</select>
</div>
</body>
</html>Multi-Language Support (15 Languages)
The checkout page supports 15 languages with translations loaded server-side:
python# backend/locales/translations.py
TRANSLATIONS = {
"en": {
"checkout_title": "Checkout",
"select_country": "Select your country",
"select_method": "Choose payment method",
"enter_phone": "Enter your phone number",
"enter_otp": "Enter verification code",
"processing": "Processing your payment...",
"success": "Payment successful!",
"failed": "Payment failed",
"retry": "Try again",
"sandbox_notice": "Test mode -- no real money will be charged",
},
"fr": {
"checkout_title": "Paiement",
"select_country": "Choisissez votre pays",
"select_method": "Choisissez un moyen de paiement",
"enter_phone": "Entrez votre numero de telephone",
"enter_otp": "Entrez le code de verification",
"processing": "Traitement de votre paiement...",
"success": "Paiement reussi !",
"failed": "Paiement echoue",
"retry": "Reessayer",
"sandbox_notice": "Mode test -- aucun argent reel ne sera debite",
},
"ar": {
"checkout_title": "الدفع",
"select_country": "اختر بلدك",
"select_method": "اختر طريقة الدفع",
# ... Arabic translations with RTL support
},
# ... 12 more languages
}Languages supported: EN, FR, ES, PT, DE, IT, NL, AR, ZH, JA, KO, TR, RU, SW, HA.
Arabic triggers RTL (Right-to-Left) layout through the dir="rtl" attribute on the HTML element. The CSS includes specific RTL overrides for form elements, phone inputs, and button layouts.
The language selector at the bottom of the page reloads the checkout with the selected language:
javascriptdocument.getElementById("lang-select").addEventListener("change", function() {
const url = new URL(window.location);
url.pathname = url.pathname.replace(
/^\/checkout\/[a-z]{2}\//,
`/checkout/${this.value}/`
);
window.location.href = url.toString();
});The Multi-Step Flow
The hosted checkout uses a multi-step form similar to the widget, but server-rendered with progressive enhancement:
Step 1: Country + Method Selection
Step 2: Phone Number Input
Step 3: OTP Verification (if required)
Step 4: Processing
Step 5: Result (Success/Error)Step Transitions
javascriptfunction showStep(stepId) {
document.querySelectorAll(".step").forEach(s => {
s.classList.remove("active");
});
document.getElementById(`step-${stepId}`).classList.add("active");
}
// Country selection -> Method selection
document.getElementById("country-select").addEventListener("change", function() {
const country = this.value;
loadMethodsForCountry(country);
});
// Method selection -> Phone input
document.querySelectorAll(".method-btn").forEach(btn => {
btn.addEventListener("click", function() {
selectedMethod = this.dataset.method;
updatePhonePrefix(selectedCountry);
showStep("phone");
});
});
// Phone submit -> Payment initiation
document.getElementById("pay-btn").addEventListener("click", async function() {
const phone = dialCode + document.getElementById("phone-input").value;
showStep("processing");
await initiatePayment(phone);
});PaiementPro SDK Integration
For PaiementPro payments, the hosted checkout integrates PaiementPro's JavaScript SDK directly:
html{% if provider == "paiementpro" %}
<script src="https://cdn.paiementpro.net/js/paiementpro.js"></script>
<script>
async function initiatePaiementProPayment(phone) {
const pp = new PaiementPro({
merchantId: "{{ paiementpro_merchant_id }}",
amount: {{ amount }},
currency: "{{ currency }}",
channel: "{{ paiementpro_channel }}",
reference: "{{ reference }}",
customerPhone: phone,
returnURL: "{{ return_url }}",
notifyURL: "{{ webhook_url }}",
});
pp.on("success", function(data) {
showStep("result");
showSuccess(data);
});
pp.on("error", function(data) {
showStep("result");
showError(data.message || "Payment failed");
});
pp.submit();
}
</script>
{% endif %}The PaiementPro SDK handles the payment processing, but the checkout page provides the surrounding UI (amount display, language selection, dark mode).
Dark and Light Mode
The checkout page supports both themes, determined by the checkout session configuration or user preference:
javascript// Check system preference
const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
// Check session configuration
const sessionDark = "{{ dark_mode }}" === "True";
if (prefersDark || sessionDark) {
document.body.classList.add("dark");
}
// Toggle button
document.getElementById("theme-toggle")?.addEventListener("click", function() {
document.body.classList.toggle("dark");
});The CSS uses CSS custom properties for theming:
css.checkout-card {
background: var(--bg);
color: var(--text);
border: 1px solid var(--border);
border-radius: 24px;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.1);
}
.dark .checkout-card {
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
}Sandbox Mode
When the checkout session is created with a sandbox API key, the hosted checkout renders in sandbox mode with additional features:
Visual Indicators
- A shimmer-animated "SANDBOX MODE" banner at the top
- Amber-colored border on the checkout card
- "No real money" notice
Test Guide Panel
A toggleable panel on the right side of the checkout (added in Session 059):
html{% if sandbox_mode %}
<div id="test-guide" class="test-guide-panel">
<div class="panel-header">
<h3>Test Guide</h3>
<button id="toggle-guide" class="toggle-btn green">
Close
</button>
</div>
<div class="panel-section">
<h4>Test Cards</h4>
<table>
<tr><td><code>4242424242424242</code></td><td>Success</td></tr>
<tr><td><code>4000000000000002</code></td><td>Decline</td></tr>
</table>
</div>
<div class="panel-section">
<h4>Test Phone Numbers</h4>
<table>
<tr><td><code>+11111111111</code></td><td>Success</td></tr>
<tr><td><code>+22222222222</code></td><td>Failure</td></tr>
<tr><td><code>+44444444444</code></td><td>OTP (123456)</td></tr>
</table>
</div>
<div class="panel-section">
<h4>Magic Amounts</h4>
<table>
<tr><td><code>10000</code></td><td>Success</td></tr>
<tr><td><code>99999</code></td><td>Failure</td></tr>
<tr><td><code>88888</code></td><td>OTP required</td></tr>
</table>
</div>
</div>
{% endif %}The test guide uses glassmorphism styling with a dark background, providing all the information a developer needs to test payments without leaving the checkout page.
Sandbox Phone Defaults
In sandbox mode, the default country is set to US (+1) with a helpful hint:
pythonif sandbox_mode:
context["default_country"] = "US"
context["default_phone_prefix"] = "+1"
context["phone_placeholder"] = "1111111111"
context["phone_hint"] = "Use +11111111111 for successful payment"The Backend Route
The checkout page is served by a FastAPI route that loads session data and renders the template:
python@router.get("/checkout/{lang}/{session_id}")
async def checkout_page(
request: Request,
lang: str,
session_id: str,
):
"""Render hosted checkout page."""
session = await get_checkout_session(session_id)
if not session:
raise HTTPException(status_code=404, detail="Session not found")
# Determine sandbox mode from API key
sandbox_mode = session["api_key"].startswith("sk_sand_")
# Load translations
translations = TRANSLATIONS.get(lang, TRANSLATIONS["en"])
# Load payment methods for the session
methods = await get_methods_for_country(session["country"])
context = {
"request": request,
"session": session,
"t": translations,
"lang": lang,
"rtl": lang == "ar",
"dark_mode": session.get("dark_mode", False),
"sandbox_mode": sandbox_mode,
"amount": session["amount"],
"currency": session["currency"],
"formatted_amount": format_amount(session["amount"], session["currency"]),
"merchant_name": session["app_name"],
"reference": session["reference"],
"methods": methods,
"languages": SUPPORTED_LANGUAGES,
"return_url": f"{BASE_URL}/v1/payments/{session['transaction_id']}/return",
}
if sandbox_mode:
context["default_country"] = "US"
context["default_phone_prefix"] = "+1"
context["phone_placeholder"] = "1111111111"
context["phone_hint"] = "Use +11111111111 for successful payment"
return templates.TemplateResponse("checkout.html", context)Template vs. Widget: When to Use Which
| Feature | Checkout Widget | Hosted Checkout |
|---|---|---|
| Integration | Script tag on merchant site | Redirect to 0fee URL |
| UI control | Merchant controls surrounding page | 0fee controls everything |
| PCI compliance | Merchant page sees card form | Card data on 0fee's page |
| Bundle size | 21KB JavaScript | Zero JavaScript for merchant |
| Customization | Limited (amount, currency, callbacks) | Language, theme, country presets |
| Mobile experience | Modal overlay | Full page |
What We Learned
Building the hosted checkout taught us three things:
- Server-rendered pages beat SPAs for payment forms. The first paint is faster, there is no JavaScript bundle to download, and the page works even with JavaScript disabled (for the initial render). Progressive enhancement adds interactivity without blocking the initial display.
- 15 languages need RTL support. Adding Arabic was not just about translating strings -- it required reversing the layout direction, adjusting form element positioning, and testing every visual component in RTL mode.
- Sandbox and live should use the same template. The
sandbox_modeflag enables additional features (test guide, banner, default values) without duplicating the template. One template, two modes -- this keeps the visual experience consistent between testing and production.
The hosted checkout evolved from a basic Jinja2 page in Session 004 to a full multi-language, dark-mode, sandbox-aware checkout experience by Session 059. It serves as the primary checkout path for developers who prefer the redirect flow over the embedded widget.
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.