Back to 0fee
0fee

The API Playground: Interactive API Testing

How we built the 0fee.dev API Playground with 17 endpoints, JSON highlighting, cURL export, and request history. By Juste A. Gnimavo and Claude.

Thales & Claude | March 25, 2026 11 min 0fee
api-playgrounddeveloper-experiencesolidjstesting

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:

  1. 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.
  1. 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.
  1. 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:

CategoryEndpointsMethods
PaymentsCreate payment, Get payment, List payments, Cancel paymentPOST, GET, GET, POST
CheckoutCreate session, Get session, Get session statusPOST, GET, GET
WebhooksList webhooks, Create webhook, Delete webhook, Get webhook eventsGET, POST, DELETE, GET
Payment LinksCreate link, Get link, List linksPOST, GET, GET
CustomersCreate customer, Get customer, List customersPOST, GET, GET
InvoicesCreate invoice, Get invoicePOST, 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, "&amp;")
      .replace(/</g, "&lt;")
      .replace(/>/g, "&gt;");
    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_BJ

Selecting 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
paiementpro

The 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

AmountBehavior
100Always succeeds
200Always fails
300Stays pending for 30 seconds, then succeeds
400Stays pending for 30 seconds, then fails
500Returns insufficient funds error

Test Phone Numbers

+2250700000000    Universal test number (any country)
+2250700000001    Always succeeds
+2250700000002    Always fails
+2250700000003    Timeout after 30 seconds

Test Card Numbers

Card NumberBrandBehavior
4242 4242 4242 4242VisaAlways succeeds
4000 0000 0000 0002VisaAlways declines
5555 5555 5555 4444MastercardAlways 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:

  1. Open Postman
  2. Create a new request
  3. Copy the API key from the dashboard
  4. Look up the endpoint in the docs
  5. Construct the request body from memory
  6. Send the request
  7. Interpret the response

With:

  1. Open the playground
  2. Select the endpoint
  3. 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.

Share this article:

Responses

Write a response
0/2000
Loading responses...

Related Articles