The Developer Console is the central hub for integration in the 0fee.dev dashboard. It is where developers go to learn how the API works, test their integration, preview the checkout widget, and download starter templates. Over Sessions 074 and 076, we consolidated the console from five tabs to four, added rich status endpoints, built an interactive status checker, created a live widget preview, and shipped a downloadable HTML checkout template.
This article covers the architecture of the Developer Console, the design decisions behind each tab, and the implementation details that make the integration experience smooth.
The Consolidation Problem
Before Session 074, the Developer Console had five tabs:
- Quick Start
- Hosted Checkout
- Widget
- Direct Integration
- API Reference
The problem: Quick Start and Hosted Checkout were nearly identical. Both explained how to create a checkout session via POST /v1/payments, both showed the same code examples, both linked to the same API endpoint. The only difference was the framing -- one said "start here" and the other said "redirect your customer." Developers were confused about which tab to use.
The consolidation merged these into a single Quick Start tab with a "How It Works" flow diagram, reducing the tab count to four:
| Tab | Purpose |
|---|---|
| Quick Start | Create your first payment, status checking, how the flow works |
| Direct Integration | Server-to-server integration, downloadable HTML template |
| Widget | Checkout.js widget configuration, live preview, framework code |
| API Reference | Link to full documentation, key endpoint summaries |
The final tab order was reordered in Session 076 to match the developer learning path: Quick Start, Direct Integration, Widget, API Reference.
Rich Status Endpoints
Session 074 enhanced the status endpoints to return full session and transaction data. Previously, the status endpoints returned minimal responses:
json// BEFORE: GET /v1/checkout/:id/status
{
"status": "completed"
}This was useless for debugging. A developer polling for status had no context about what completed, when, or for how much. The enhanced endpoints return everything:
json// AFTER: GET /v1/checkout/:id/status
{
"status": "completed",
"session_id": "cs_abc123def456",
"amount": 5000,
"currency": "XOF",
"customer": {
"phone": "+2250709757296",
"email": null,
"name": null
},
"payment_reference": "ORDER-42",
"invoice_reference": "INV-2025-0001",
"checkout_url": "https://pay.0fee.dev/checkout/cs_abc123def456",
"success_url": "https://merchant.com/success",
"cancel_url": "https://merchant.com/cancel",
"expires_at": "2025-12-27T15:30:00Z",
"created_at": "2025-12-27T14:30:00Z"
}The transaction status endpoint received the same treatment:
json// AFTER: GET /v1/payments/public/:id/status
{
"status": "completed",
"source_amount": 5000,
"source_currency": "XOF",
"destination_amount": 5000,
"destination_currency": "XOF",
"payment_method": "PAYIN_ORANGE_CI",
"payment_reference": "ORDER-42",
"invoice_reference": "INV-2025-0001",
"customer": {
"phone": "+2250709757296"
}
}These endpoints are public (no authentication required) because they are called from the client-side checkout page. The data they expose is limited to what the customer would already know -- their own phone number, the amount they paid, and the references the merchant chose to share.
Backend Implementation
python# backend/routes/checkout_hosted.py
@router.get("/v1/checkout/{session_id}/status")
async def get_checkout_status(session_id: str, db: AsyncSession = Depends(get_db)):
"""Return rich status data for a checkout session."""
session = await db.execute(
select(CheckoutSession).where(CheckoutSession.id == session_id)
)
session = session.scalar_one_or_none()
if not session:
raise HTTPException(status_code=404, detail="Session not found")
return {
"status": session.status,
"session_id": session.id,
"amount": session.amount,
"currency": session.currency,
"customer": {
"phone": session.customer_phone,
"email": session.customer_email,
"name": session.customer_name,
},
"payment_reference": session.payment_reference,
"invoice_reference": session.invoice_reference,
"checkout_url": f"https://pay.0fee.dev/checkout/{session.id}",
"success_url": session.success_url,
"cancel_url": session.cancel_url,
"expires_at": session.expires_at.isoformat() if session.expires_at else None,
"created_at": session.created_at.isoformat(),
}The Interactive Status Checker
Instead of showing static code examples for status checking, the Developer Console provides live input fields that make actual API calls:
typescript// State for the status checker
const [sessionIdInput, setSessionIdInput] = createSignal("");
const [transactionIdInput, setTransactionIdInput] = createSignal("");
const [sessionStatus, setSessionStatus] = createSignal<any>(null);
const [transactionStatus, setTransactionStatus] = createSignal<any>(null);
const [statusLoading, setStatusLoading] = createSignal(false);
async function checkSessionStatus() {
const id = sessionIdInput();
if (!id) return;
setStatusLoading(true);
try {
const response = await fetch(`/api/v1/checkout/${id}/status`);
const data = await response.json();
setSessionStatus(data);
} catch (error) {
setSessionStatus({ error: "Failed to fetch status" });
} finally {
setStatusLoading(false);
}
}
async function checkTransactionStatus() {
const id = transactionIdInput();
if (!id) return;
setStatusLoading(true);
try {
const response = await fetch(`/api/v1/payments/public/${id}/status`);
const data = await response.json();
setTransactionStatus(data);
} catch (error) {
setTransactionStatus({ error: "Failed to fetch status" });
} finally {
setStatusLoading(false);
}
}The UI renders two checker panels side by side:
+----------------------------------+ +----------------------------------+
| Session Status Checker | | Transaction Status Checker |
| | | |
| Session ID: [cs_____________] | | Transaction ID: [txn__________] |
| [Check Status] | | [Check Status] |
| | | |
| { | | { |
| "status": "completed", | | "status": "completed", |
| "session_id": "cs_abc123", | | "source_amount": 5000, |
| "amount": 5000, | | "source_currency": "XOF", |
| "currency": "XOF", | | "payment_method": "ORANGE_CI",|
| ... | | ... |
| } | | } |
+----------------------------------+ +----------------------------------+The JSON response is displayed with the same recursive syntax highlighter from the API Playground. Developers can paste a session or transaction ID from their logs, click Check Status, and see the full response instantly. No cURL required.
The "How It Works" Flow Diagram
The Quick Start tab opens with a 3-step flow diagram that explains the payment lifecycle:
+-------------------+ +-------------------+ +-------------------+
| | | | | |
| 1. Create | | 2. Customer | | 3. Webhook |
| Payment | --> | Pays | --> | Notification |
| | | | | |
| POST /v1/ | | Redirect to | | POST to your |
| payments | | checkout URL | | webhook URL |
| | | | | |
+-------------------+ +-------------------+ +-------------------+Each step includes a brief description and a link to the relevant documentation section. The diagram replaced a wall of text that explained the same flow in prose. Visual communication wins.
The Widget Tab
The Widget tab is where the Developer Console becomes interactive. It does not just describe the checkout widget -- it lets developers configure and launch it.
Widget Configuration
Three input fields control the widget parameters:
typescriptconst [widgetAmount, setWidgetAmount] = createSignal(5000);
const [widgetCurrency, setWidgetCurrency] = createSignal("XOF");
const [widgetReference, setWidgetReference] = createSignal("TEST-001");
const [widgetScriptLoaded, setWidgetScriptLoaded] = createSignal(false);
const [activeFramework, setActiveFramework] = createSignal("vanilla");
const [widgetResult, setWidgetResult] = createSignal<any>(null);The configuration panel:
Widget Configuration
+--------------------------------------------------+
| Amount: [5000 ] |
| Currency: [XOF v] |
| Reference: [TEST-001 ] |
| |
| [Try Widget Live] |
+--------------------------------------------------+"Try Widget Live"
Clicking the button loads the checkout.js script on-demand and opens the actual checkout modal:
typescriptasync function loadWidgetScript(): Promise<void> {
if (widgetScriptLoaded()) return;
return new Promise((resolve, reject) => {
const script = document.createElement("script");
script.src = "https://pay.0fee.dev/checkout.js";
script.onload = () => {
setWidgetScriptLoaded(true);
resolve();
};
script.onerror = reject;
document.head.appendChild(script);
});
}
async function tryWidgetLive(): Promise<void> {
await loadWidgetScript();
// @ts-ignore -- ZeroFeeCheckout is loaded dynamically
const checkout = new ZeroFeeCheckout({
apiKey: sandboxKey(),
amount: widgetAmount(),
currency: widgetCurrency(),
reference: widgetReference(),
onSuccess: (result: any) => {
setWidgetResult({ type: "success", data: result });
},
onError: (error: any) => {
setWidgetResult({ type: "error", data: error });
},
onClose: () => {
setWidgetResult({ type: "closed" });
},
});
checkout.open();
}This is not a mockup. Clicking "Try Widget Live" opens the real checkout modal, connected to the sandbox environment, processing test payments. Developers see exactly what their customers will see -- the payment method selector, the phone number input, the processing spinner, the success confirmation.
Framework Tabs
Below the live preview, framework-specific integration code is displayed in tabs:
Vanilla JS:
html<script src="https://pay.0fee.dev/checkout.js"></script>
<script>
const checkout = new ZeroFeeCheckout({
apiKey: "sk_test_your_key_here",
amount: 5000,
currency: "XOF",
reference: "ORDER-42",
onSuccess: function(result) {
console.log("Payment succeeded:", result);
window.location.href = "/success";
},
onError: function(error) {
console.error("Payment failed:", error);
},
onClose: function() {
console.log("Widget closed");
}
});
document.getElementById("pay-btn").onclick = function() {
checkout.open();
};
</script>React:
jsximport { useEffect, useRef } from "react";
function CheckoutButton({ amount, currency, reference }) {
const checkoutRef = useRef(null);
useEffect(() => {
const script = document.createElement("script");
script.src = "https://pay.0fee.dev/checkout.js";
script.onload = () => {
checkoutRef.current = new ZeroFeeCheckout({
apiKey: "sk_test_your_key_here",
amount,
currency,
reference,
onSuccess: (result) => console.log("Success:", result),
onError: (error) => console.error("Error:", error),
});
};
document.head.appendChild(script);
}, []);
return (
<button onClick={() => checkoutRef.current?.open()}>
Pay {amount} {currency}
</button>
);
}The code examples are not static strings. They are generated dynamically from the widget configuration state:
typescriptfunction getVanillaCode(): string {
return `<script src="https://pay.0fee.dev/checkout.js"></script>
<script>
const checkout = new ZeroFeeCheckout({
apiKey: "${sandboxKey()}",
amount: ${widgetAmount()},
currency: "${widgetCurrency()}",
reference: "${widgetReference()}",
onSuccess: function(result) {
console.log("Payment succeeded:", result);
},
onError: function(error) {
console.error("Payment failed:", error);
}
});
</script>`;
}If a developer changes the amount to 10000, the code examples update to show amount: 10000. If they change the currency to EUR, the code shows currency: "EUR". Copy-paste-ready.
The Downloadable HTML Template
The Direct Integration tab includes a "Download Template" button that generates a complete, self-contained HTML file (~920 lines) implementing the full checkout flow. This is the fastest path from "I signed up" to "I have a working checkout page."
The Template Generator
typescript// frontend/src/utils/checkoutTemplate.ts
export function generateCheckoutTemplate(apiKey: string): string {
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Checkout - Powered by 0fee.dev</title>
<style>
/* Complete embedded CSS -- no external dependencies */
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, sans-serif; }
/* ... ~200 lines of styles */
</style>
</head>
<body>
<div id="checkout">
<!-- Step 1: Amount and method selection -->
<!-- Step 2: Customer details -->
<!-- Step 3: Processing -->
<!-- Step 4: Result -->
</div>
<script>
const API_KEY = "${apiKey}";
const API_URL = "https://api.0fee.dev/v1";
/* ... ~400 lines of checkout logic */
</script>
</body>
</html>`;
}The template includes:
| Feature | Implementation |
|---|---|
| Payment method selector | Grid of available methods, fetched from the API |
| Phone number input | With country code prefix |
| Card input fields | Auto-formatting for number, expiry, CVV |
| Processing spinner | Animated, with status polling |
| Success/failure display | With transaction ID and reference |
| Responsive design | Works on mobile and desktop |
| Dark mode | Respects prefers-color-scheme |
| Error handling | Network errors, validation errors, timeout handling |
All ~920 lines in a single HTML file. No build tools, no npm, no framework. A developer can download it, replace the API key, and host it anywhere -- static hosting, S3, even a local file.
The Demo Key Endpoint
To make the download work in sandbox mode without requiring the developer to find their API key, Session 076 added a dedicated endpoint:
python# backend/routes/checkout.py
@router.get("/v1/checkout/demo-key")
async def get_demo_key():
"""Return a sandbox API key for demo purposes."""
return {"key": config.PLATFORM_DEMO_SANDBOX_KEY}The template download fetches this key and embeds it in the HTML file. The developer gets a working checkout page immediately, without any configuration.
Widget Callbacks Reference
The Widget tab documents the three callback functions that drive the integration:
typescriptinterface WidgetCallbacks {
onSuccess: (result: {
transactionId: string;
status: "completed";
amount: number;
currency: string;
reference: string;
}) => void;
onError: (error: {
code: string;
message: string;
transactionId?: string;
}) => void;
onClose: () => void;
}| Callback | When it fires | What to do |
|---|---|---|
onSuccess | Payment completed successfully | Redirect to success page, update UI, verify server-side |
onError | Payment failed or was declined | Show error message, offer retry |
onClose | Customer closed the widget without completing | Resume shopping, offer alternative |
The documentation emphasizes that onSuccess should always be verified server-side via the webhook or status endpoint. Client-side callbacks can be spoofed.
Widget Parameters Reference
| Parameter | Type | Required | Description |
|---|---|---|---|
apiKey | string | Yes | Your sandbox or live API key |
amount | number | Yes | Amount in smallest currency unit |
currency | string | Yes | ISO 4217 currency code |
reference | string | No | Your internal reference for the payment |
customer.phone | string | No | Pre-fill customer phone number |
customer.email | string | No | Pre-fill customer email |
customer.name | string | No | Pre-fill customer name |
locale | string | No | Language code (en, fr). Defaults to browser language |
theme | string | No | "light" or "dark". Defaults to system preference |
Lessons Learned
Consolidate duplicate tabs early. The five-tab Developer Console confused developers who could not tell the difference between Quick Start and Hosted Checkout. The consolidation to four tabs was obvious in hindsight.
Rich status endpoints cost nothing but save hours. Adding 10 fields to a status response took 15 minutes of backend work. It saves every developer integrating the API from making additional calls to get context they need for debugging.
Live previews beat documentation. A developer who can click "Try Widget Live" and see the actual checkout modal understands the widget in 10 seconds. The same understanding from reading documentation takes 10 minutes.
Downloadable templates are the fastest onboarding. The ~920-line HTML template is the most direct path from signup to working checkout. No framework, no build tools, no dependencies. Download, open, pay.
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.