Stripe has it. Postman popularized it. Every modern API platform needs an interactive testing interface where developers can explore endpoints, craft requests, and see responses without leaving the browser. In Sessions 041 and 042, we built the 0fee.dev API Playground -- a full-featured testing tool embedded directly in the dashboard.
The result: 17 endpoints across 6 categories, dark-theme JSON syntax highlighting, a cURL generator, request history with favorites, payment method dropdowns, provider selection for fixed routing, magic test amounts, sandbox card numbers, and credit card auto-formatting. Over 1,000 lines of SolidJS in a single component.
Why Build a Playground Instead of Using Postman?
The question comes up every time. Why not just tell developers to use Postman or Insomnia?
Three reasons:
- Authentication is automatic. The playground uses the developer's existing dashboard session. No need to copy API keys into a third-party tool, manage environment variables, or worry about key rotation.
- Context is embedded. Payment method codes, provider names, magic test amounts, sandbox card numbers -- all are displayed inline. Developers do not need to cross-reference documentation.
- History persists per user. The last 20 requests are saved to localStorage. A developer can return tomorrow and re-execute yesterday's checkout session creation without reconstructing the payload.
Architecture
The API Playground is a single SolidJS component -- ApiPlayground.tsx -- that manages its own state entirely. No external state management, no API calls to store history server-side.
ApiPlayground.tsx (1,000+ lines)
├── Endpoint selector (category tabs + endpoint dropdown)
├── Request editor (path params, query params, JSON body)
├── Quick configuration panel (payment method, provider, URLs)
├── Sandbox guide (test cards, phone numbers, magic amounts)
├── Response viewer (status, timing, highlighted JSON)
├── cURL generator
├── Request history (last 20, localStorage)
└── Saved favorites (starred requests, localStorage)State Management
typescript// Core state
const [selectedCategory, setSelectedCategory] = createSignal("payments");
const [selectedEndpoint, setSelectedEndpoint] = createSignal(endpoints[0]);
const [requestBody, setRequestBody] = createSignal("{}");
const [pathParams, setPathParams] = createSignal<Record<string, string>>({});
const [queryParams, setQueryParams] = createSignal<Record<string, string>>({});
// Response state
const [response, setResponse] = createSignal<APIResponse | null>(null);
const [loading, setLoading] = createSignal(false);
const [responseTime, setResponseTime] = createSignal(0);
// History and favorites
const [history, setHistory] = createSignal<HistoryEntry[]>(
JSON.parse(localStorage.getItem("zerofee_playground_history") || "[]")
);
const [favorites, setFavorites] = createSignal<FavoriteEntry[]>(
JSON.parse(localStorage.getItem("zerofee_playground_favorites") || "[]")
);
// Quick configuration
const [paymentMethods, setPaymentMethods] = createSignal<PaymentMethod[]>([]);
const [selectedMethod, setSelectedMethod] = createSignal("");
const [selectedProvider, setSelectedProvider] = createSignal("");
const [successUrl, setSuccessUrl] = createSignal("");
const [cancelUrl, setCancelUrl] = createSignal("");The 17 Endpoints
The playground covers the core API surface a developer needs to test during integration:
| Category | Endpoints | Methods |
|---|---|---|
| Payments | Create payment, Get payment, List payments, Cancel payment | POST, GET, GET, POST |
| Checkout | Create session, Get session, Get session status | POST, GET, GET |
| Webhooks | List webhooks, Create webhook, Delete webhook, Get webhook events | GET, POST, DELETE, GET |
| Payment Links | Create link, Get link, List links | POST, GET, GET |
| Customers | Create customer, Get customer, List customers | POST, GET, GET |
| Invoices | Create invoice, Get invoice | POST, GET |
Each endpoint is defined with its path, method, description, and a template for the default request body:
typescriptinterface EndpointDefinition {
id: string;
category: string;
method: "GET" | "POST" | "PATCH" | "DELETE";
path: string;
description: string;
pathParams?: string[];
queryParams?: string[];
defaultBody?: Record<string, any>;
}
const endpoints: EndpointDefinition[] = [
{
id: "create-payment",
category: "payments",
method: "POST",
path: "/v1/payments",
description: "Create a new payment",
defaultBody: {
amount: 5000,
sourceCurrency: "XOF",
paymentMethod: "PAYIN_ORANGE_CI",
customer: { phone: "+2250700000000" },
},
},
{
id: "get-payment",
category: "payments",
method: "GET",
path: "/v1/payments/:id",
pathParams: ["id"],
description: "Retrieve a payment by ID",
},
// ... 15 more endpoints
];The JSON Highlighter Problem
Session 041 shipped with a regex-based JSON syntax highlighter. It worked beautifully for simple responses. Then it encountered a URL:
"checkout_url": "https://pay.0fee.dev/checkout/cs_abc123"The regex-based highlighter inserted spaces after colons. The output became:
"checkout_url": "https: //pay.0fee.dev/checkout/cs_abc123"Broken URLs in payment responses. Unacceptable.
The Recursive Solution
In Session 042, we rewrote the highlighter from scratch using a recursive approach. Instead of running regex over the raw JSON string, the new version parses the JSON into an object, then walks the tree and applies syntax highlighting to each node based on its type:
typescriptfunction highlightJSON(value: any, indent: number = 0): string {
const pad = " ".repeat(indent);
if (value === null) {
return `<span class="json-null">null</span>`;
}
if (typeof value === "boolean") {
return `<span class="json-boolean">${value}</span>`;
}
if (typeof value === "number") {
return `<span class="json-number">${value}</span>`;
}
if (typeof value === "string") {
// Strings are never split or modified -- URLs stay intact
const escaped = value
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">");
return `<span class="json-string">"${escaped}"</span>`;
}
if (Array.isArray(value)) {
if (value.length === 0) return "[]";
const items = value
.map((v) => `${pad} ${highlightJSON(v, indent + 1)}`)
.join(",\n");
return `[\n${items}\n${pad}]`;
}
if (typeof value === "object") {
const keys = Object.keys(value);
if (keys.length === 0) return "{}";
const entries = keys
.map((k) => {
const highlighted = highlightJSON(value[k], indent + 1);
return `${pad} <span class="json-key">"${k}"</span>: ${highlighted}`;
})
.join(",\n");
return `{\n${entries}\n${pad}}`;
}
return String(value);
}The CSS for the dark theme:
css.json-key { color: #9cdcfe; } /* Light blue */
.json-string { color: #ce9178; } /* Orange-brown */
.json-number { color: #b5cea8; } /* Light green */
.json-boolean { color: #569cd6; } /* Blue */
.json-null { color: #569cd6; } /* Blue */The key insight: by operating on parsed values rather than raw strings, URLs, file paths, and any other string content are treated as atomic units. No regex can accidentally match a colon inside a URL.
cURL Generator
Every request in the playground can be exported as a cURL command with a single click:
typescriptfunction generateCurl(): string {
const endpoint = selectedEndpoint();
const method = endpoint.method;
const url = buildFullUrl(endpoint);
let curl = `curl -X ${method} '${url}'`;
curl += ` \\\n -H 'Authorization: Bearer ${apiKey()}'`;
curl += ` \\\n -H 'Content-Type: application/json'`;
if (method === "POST" || method === "PATCH") {
const body = requestBody();
curl += ` \\\n -d '${body}'`;
}
return curl;
}The generated output:
bashcurl -X POST 'https://api.0fee.dev/v1/payments' \
-H 'Authorization: Bearer sk_test_abc123' \
-H 'Content-Type: application/json' \
-d '{"amount":5000,"sourceCurrency":"XOF","paymentMethod":"PAYIN_ORANGE_CI","customer":{"phone":"+2250700000000"}}'This is surprisingly useful. Developers testing from their terminal can copy the cURL, paste it, and compare the response with what the playground shows. It also serves as shareable documentation -- when a developer asks for help, they can share the exact cURL command that reproduces their issue.
Request History and Favorites
History
Every executed request is stored in localStorage with timestamp, endpoint, request body, and response:
typescriptinterface HistoryEntry {
id: string;
timestamp: number;
endpoint: EndpointDefinition;
body: string;
response: {
status: number;
data: any;
time: number;
};
}
function addToHistory(entry: HistoryEntry): void {
const current = history();
const updated = [entry, ...current].slice(0, 20); // Keep last 20
setHistory(updated);
localStorage.setItem(
"zerofee_playground_history",
JSON.stringify(updated)
);
}The history panel displays a scrollable list with method badges, endpoint paths, status codes, and timestamps. Clicking any entry re-populates the request editor.
Favorites
Developers can star any request in the history and give it a custom name:
typescriptinterface FavoriteEntry extends HistoryEntry {
name: string; // e.g., "Create 5000 XOF payment with Orange"
}Favorites persist across browser sessions and appear in a dedicated tab above the history.
Quick Configuration Panel
Session 042 added the quick configuration panel -- a set of dropdowns and inputs that auto-update the JSON request body. This was born from observing the workflow: developers were constantly editing the JSON body to change the payment method or provider, making typos in the process.
Payment Methods Dropdown
The dropdown fetches available payment methods from the API and groups them by operator:
typescriptonMount(async () => {
const response = await fetch("/api/v1/payin-methods", {
headers: { Authorization: `Bearer ${apiKey()}` },
});
const data = await response.json();
setPaymentMethods(data.methods);
});The methods appear grouped:
Orange
PAYIN_ORANGE_CI
PAYIN_ORANGE_SN
PAYIN_ORANGE_BF
MTN
PAYIN_MTN_CI
PAYIN_MTN_GH
PAYIN_MTN_UG
Wave
PAYIN_WAVE_CI
PAYIN_WAVE_SN
Moov
PAYIN_MOOV_CI
PAYIN_MOOV_BJSelecting a method automatically updates the paymentMethod field in the JSON body.
Provider Dropdown
For fixed routing (bypassing the intelligent routing engine), developers can select a specific provider:
auto (default)
test (sandbox)
stripe
paypal
hub2
pawapay
bui
paiementproThe test provider is particularly useful -- it processes payments instantly in sandbox mode with deterministic outcomes based on magic amounts.
Sandbox Testing Guide
Instead of a separate documentation page for sandbox testing, the playground embeds the information inline. Three sections:
Magic Amounts
| Amount | Behavior |
|---|---|
| 100 | Always succeeds |
| 200 | Always fails |
| 300 | Stays pending for 30 seconds, then succeeds |
| 400 | Stays pending for 30 seconds, then fails |
| 500 | Returns insufficient funds error |
Test Phone Numbers
+2250700000000 Universal test number (any country)
+2250700000001 Always succeeds
+2250700000002 Always fails
+2250700000003 Timeout after 30 secondsTest Card Numbers
| Card Number | Brand | Behavior |
|---|---|---|
| 4242 4242 4242 4242 | Visa | Always succeeds |
| 4000 0000 0000 0002 | Visa | Always declines |
| 5555 5555 5555 4444 | Mastercard | Always succeeds |
Each test value has a copy button. One click, pasted into the relevant field.
Credit Card Auto-Formatting
Session 042 also added credit card input formatting for the sandbox checkout page. The card number field automatically inserts spaces every 4 digits, and the expiry field formats as MM/YY:
typescriptfunction formatCardNumber(value: string): string {
const digits = value.replace(/\D/g, "").slice(0, 16);
return digits.replace(/(\d{4})(?=\d)/g, "$1 ");
}
function formatExpiry(value: string): string {
const digits = value.replace(/\D/g, "").slice(0, 4);
if (digits.length >= 3) {
return `${digits.slice(0, 2)}/${digits.slice(2)}`;
}
return digits;
}Typing 4242424242424242 produces 4242 4242 4242 4242. Typing 1226 produces 12/26. Small details, but they signal quality.
The Vite Proxy Bug
Session 042 opened with a 404 bug. Refreshing the browser on /api-playground returned a 404. Creating a payment from the playground worked fine, but the page itself would not load on refresh.
The root cause: Vite's proxy configuration.
typescript// vite.config.ts (BEFORE -- broken)
server: {
proxy: {
"/api": {
target: "http://localhost:8000",
rewrite: (path) => path.replace(/^\/api/, "/v1"),
},
},
}The pattern /api matched both /api/v1/payments (correct) and /api-playground (incorrect). The proxy was rewriting /api-playground to /v1-playground and forwarding it to the backend, which returned 404.
The fix: use a regex anchor.
typescript// vite.config.ts (AFTER -- fixed)
server: {
proxy: {
"^/api/": {
target: "http://localhost:8000",
rewrite: (path) => path.replace(/^\/api/, "/v1"),
},
},
}The ^/api/ pattern (with the trailing slash) matches only paths that start with /api/ as a directory prefix, not paths that merely start with the letters "api". This is a common Vite proxy pitfall, and the fix took longer to diagnose than to implement.
API Key Selector
The playground auto-detects API keys from the developer's account:
typescriptconst [apiKeys, setApiKeys] = createSignal<APIKey[]>([]);
const [selectedKey, setSelectedKey] = createSignal("");
onMount(async () => {
const response = await fetch("/api/v1/apps", {
headers: { Authorization: `Bearer ${sessionToken()}` },
});
const apps = await response.json();
const keys = apps.flatMap((app: any) => [
{ label: `${app.name} (sandbox)`, value: app.sandboxKey },
{ label: `${app.name} (live)`, value: app.liveKey },
]);
setApiKeys(keys);
// Default to first sandbox key
const sandbox = keys.find((k: any) => k.value.startsWith("sk_test_"));
if (sandbox) setSelectedKey(sandbox.value);
});Sandbox keys are selected by default. A developer has to explicitly switch to a live key, reducing the risk of accidental live transactions during testing.
The Result
The API Playground shipped as a single page in the 0fee.dev dashboard, accessible from the Developer section of the sidebar. It replaced the workflow of:
- Open Postman
- Create a new request
- Copy the API key from the dashboard
- Look up the endpoint in the docs
- Construct the request body from memory
- Send the request
- Interpret the response
With:
- Open the playground
- Select the endpoint
- Click Send
For a payment platform where developers need to test dozens of scenarios -- different amounts, methods, providers, currencies, error cases -- that workflow compression saves hours of integration time. The playground is not just a feature. It is a competitive advantage.
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.