API documentation is not a feature. It is the product. For a payment orchestration platform, the API is the primary interface. Every merchant, every developer, every integration partner interacts with 0fee.dev through HTTP endpoints. If the documentation is incomplete, ambiguous, or hard to navigate, the platform is unusable -- regardless of how well the backend works.
In Session 019 (December 12, 2025), we transformed the documentation page from a sparse 400-line placeholder into a comprehensive 1,006-line interactive reference covering 90+ endpoints across 18 route modules. This article breaks down the architecture, the content strategy, and the technical details of building documentation that developers actually want to read.
The Before State
Before Session 019, Docs.tsx was a minimal page. It listed maybe a dozen endpoints with basic descriptions and a few code snippets. No sidebar navigation. No language tabs. No syntax highlighting. No error codes reference. No search. A developer trying to integrate the Currency API or the Credits API would find nothing.
The backend had grown to 18 route modules with 90+ endpoints, but the documentation had not kept pace. This is the classic documentation debt problem -- every new feature ships without updated docs because the docs page is hard to maintain.
The After State
| Metric | Before | After |
|---|---|---|
| Lines of code | ~400 | 1,006 |
| Endpoints documented | ~12 | 90+ |
| Route modules covered | 4 | 18 |
| Language tabs | 0 | 3 (TypeScript, Python, cURL) |
| Navigation | None | Scroll-spy sidebar |
| Error codes reference | No | Yes |
| Interactive examples | No | Yes, with copy buttons |
Architecture
The documentation page is a single SolidJS component with three main sections:
Docs.tsx (1,006 lines)
├── Sidebar (scroll-spy navigation)
│ ├── Section links (18 sections)
│ └── Active section indicator
├── Content (scrollable main area)
│ ├── Introduction
│ ├── Authentication
│ ├── 18 API sections
│ │ ├── Section header
│ │ ├── Endpoint cards
│ │ │ ├── Method badge
│ │ │ ├── Path
│ │ │ ├── Description
│ │ │ ├── Language tabs (TS / Python / cURL)
│ │ │ ├── Request/response examples
│ │ │ └── Copy button
│ │ └── Parameter tables
│ └── Error codes reference
└── State management
├── Active section (scroll position)
└── Selected language (per-section)The 18 Route Modules
The documentation covers every route module in the backend:
| Module | Endpoints | Description |
|---|---|---|
| auth.py | 5 | Registration, login, OTP verification, token refresh |
| payments.py | 8 | Create, get, list, cancel, refund, status, search |
| webhooks.py | 6 | CRUD operations, event listing, test delivery |
| checkout.py | 5 | Session creation, retrieval, status, hosted page |
| analytics.py | 5 | Overview, volume charts, transaction stats, customer stats |
| billing.py | 4 | Summary, invoices, account status, usage |
| credits.py | 6 | Balance, history, top-up, fee estimation, tiers, usage |
| currency.py | 5 | List currencies, exchange rates, convert, regions, supported |
| invoices.py | 6 | Create, get, list, update, delete, HTML export |
| payment_links.py | 5 | Create, get, list, update, deactivate |
| customers.py | 5 | Create, get, list, update, payment history |
| profile.py | 4 | Get profile, update, sessions, API usage |
| payin_methods.py | 3 | List methods, get method details, methods by country |
| countries.py | 3 | List countries, get country, providers by country |
| apps.py | 5 | Create app, get, list, update, rotate keys |
| oauth.py | 4 | Authorize, callback, token, revoke |
| pay.py | 3 | Render payment link page, process, status |
| health.py | 3 | Health check, version, status |
Total: 90+ endpoints, each documented with method, path, description, parameters, request body, and response example.
Scroll-Spy Sidebar
The sidebar tracks the user's scroll position and highlights the currently visible section. This is essential for a long documentation page -- without it, developers lose their place.
typescriptconst [activeSection, setActiveSection] = createSignal("introduction");
// Section definitions for the sidebar
const sections = [
{ id: "introduction", label: "Introduction" },
{ id: "authentication", label: "Authentication" },
{ id: "payments", label: "Payments" },
{ id: "checkout", label: "Checkout" },
{ id: "webhooks", label: "Webhooks" },
{ id: "analytics", label: "Analytics" },
{ id: "billing", label: "Billing" },
{ id: "credits", label: "Credits" },
{ id: "currency", label: "Currency" },
{ id: "invoices", label: "Invoices" },
{ id: "payment-links", label: "Payment Links" },
{ id: "customers", label: "Customers" },
{ id: "profile", label: "Profile" },
{ id: "payin-methods", label: "Payment Methods" },
{ id: "countries", label: "Countries" },
{ id: "apps", label: "Apps" },
{ id: "oauth", label: "OAuth" },
{ id: "errors", label: "Error Codes" },
];
onMount(() => {
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
setActiveSection(entry.target.id);
}
}
},
{
rootMargin: "-20% 0px -80% 0px",
threshold: 0,
}
);
sections.forEach(({ id }) => {
const element = document.getElementById(id);
if (element) observer.observe(element);
});
onCleanup(() => observer.disconnect());
});The rootMargin values are chosen to trigger the active state when a section header is in the upper portion of the viewport. The -20% 0px -80% 0px means the intersection is detected when the element is in the top 20% of the visible area. This creates natural "snapping" behavior as the user scrolls.
The sidebar renders as a fixed column:
tsx<nav class="sidebar">
<For each={sections}>
{(section) => (
<a
href={`#${section.id}`}
class={activeSection() === section.id ? "active" : ""}
onClick={(e) => {
e.preventDefault();
document.getElementById(section.id)?.scrollIntoView({
behavior: "smooth",
});
}}
>
{section.label}
</a>
)}
</For>
</nav>Language Tabs
Every endpoint includes code examples in three languages: TypeScript (using the 0fee SDK), Python (using the 0fee SDK), and cURL (raw HTTP). Developers choose their preferred language, and the selection persists across sections.
typescriptconst [selectedLang, setSelectedLang] = createSignal<"typescript" | "python" | "curl">("typescript");A single endpoint documented in all three languages:
TypeScript:
typescriptimport { ZeroFee } from "@zerofee/sdk";
const zf = new ZeroFee({ apiKey: "sk_test_..." });
const payment = await zf.payments.create({
amount: 5000,
sourceCurrency: "XOF",
paymentMethod: "PAYIN_ORANGE_CI",
customer: { phone: "+2250709757296" },
});
console.log(payment.id); // txn_abc123
console.log(payment.checkoutUrl); // https://pay.0fee.dev/...Python:
pythonfrom zerofee import ZeroFee
zf = ZeroFee(api_key="sk_test_...")
payment = zf.payments.create(
amount=5000,
source_currency="XOF",
payment_method="PAYIN_ORANGE_CI",
customer={"phone": "+2250709757296"},
)
print(payment.id) # txn_abc123
print(payment.checkout_url) # https://pay.0fee.dev/...cURL:
bashcurl -X POST https://api.0fee.dev/v1/payments \
-H "Authorization: Bearer sk_test_..." \
-H "Content-Type: application/json" \
-d '{
"amount": 5000,
"sourceCurrency": "XOF",
"paymentMethod": "PAYIN_ORANGE_CI",
"customer": {"phone": "+2250709757296"}
}'Syntax Highlighting
Code examples use a custom syntax highlighter that applies colors based on the language:
typescriptfunction highlightCode(code: string, language: string): string {
if (language === "typescript" || language === "python") {
return code
.replace(
/\b(import|from|const|let|var|await|async|function|return|if|else|print)\b/g,
'<span class="keyword">$1</span>'
)
.replace(
/(["'`])(?:(?!\1).)*\1/g,
'<span class="string">$&</span>'
)
.replace(
/\/\/.*/g,
'<span class="comment">$&</span>'
)
.replace(
/#.*/g,
'<span class="comment">$&</span>'
);
}
if (language === "curl") {
return code
.replace(
/\b(curl)\b/g,
'<span class="keyword">$1</span>'
)
.replace(
/(-[A-Za-z]+)/g,
'<span class="flag">$1</span>'
)
.replace(
/(["'])(?:(?!\1).)*\1/g,
'<span class="string">$&</span>'
);
}
return code;
}This is a simple regex-based highlighter -- not a full parser. It handles the common cases (keywords, strings, comments, flags) well enough for documentation purposes. The API Playground's JSON highlighter uses the recursive approach because JSON has nested structures that regex cannot handle correctly. For code snippets in documentation, regex is sufficient.
Endpoint Method Badges
Each endpoint displays a colored badge indicating the HTTP method:
tsxfunction MethodBadge(props: { method: string }) {
const colors: Record<string, string> = {
GET: "bg-blue-500",
POST: "bg-green-500",
PATCH: "bg-yellow-500",
DELETE: "bg-red-500",
PUT: "bg-orange-500",
};
return (
<span class={`method-badge ${colors[props.method] || "bg-gray-500"}`}>
{props.method}
</span>
);
}The color coding is standard across the industry:
| Method | Color | Semantic |
|---|---|---|
| GET | Blue | Read data |
| POST | Green | Create data |
| PATCH | Yellow | Update data |
| DELETE | Red | Remove data |
Developers scan the badge color before reading the path, which accelerates navigation through the endpoint list.
Interactive Copy Buttons
Every code example has a copy button that copies the code to the clipboard:
typescriptfunction CopyButton(props: { text: string }) {
const [copied, setCopied] = createSignal(false);
async function handleCopy() {
await navigator.clipboard.writeText(props.text);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
return (
<button class="copy-btn" onClick={handleCopy}>
{copied() ? "Copied!" : "Copy"}
</button>
);
}The "Copied!" feedback disappears after 2 seconds. Small detail, large usability impact. Without the feedback, developers click the button and are not sure if anything happened.
The 7 New Sections
Session 019 added 7 API sections that were missing entirely from the documentation:
Analytics API
GET /v1/analytics/overview Dashboard overview stats
GET /v1/analytics/volume Transaction volume chart data
GET /v1/analytics/transactions Transaction statistics (by status, method, provider)
GET /v1/analytics/customers Customer acquisition and retention stats
GET /v1/analytics/revenue Revenue breakdown by currency and methodBilling API
GET /v1/billing/summary Billing summary (current period charges, credits used)
GET /v1/billing/invoices List billing invoices
GET /v1/billing/status Account billing status (active, suspended, grace period)
GET /v1/billing/usage Detailed usage breakdownCredits API
GET /v1/credits/balance Current credit balance
GET /v1/credits/history Credit transaction history (top-ups, deductions)
POST /v1/credits/topup Add credits to account
GET /v1/credits/fee-estimate Estimate fee for a given amount and method
GET /v1/credits/tiers Credit tier pricing and thresholds
GET /v1/credits/usage Credit usage statisticsCurrency API
GET /v1/currencies List supported currencies
GET /v1/currencies/rates Current exchange rates
POST /v1/currencies/convert Convert amount between currencies
GET /v1/currencies/regions Currencies grouped by region
GET /v1/currencies/:code Currency details (symbol, decimals, countries)Invoices API
POST /v1/invoices Create an invoice
GET /v1/invoices/:id Get invoice by ID
GET /v1/invoices List invoices (with filters)
PATCH /v1/invoices/:id Update invoice
DELETE /v1/invoices/:id Delete invoice
GET /v1/invoices/:id/html Export invoice as HTML (for PDF generation)Payment Links API
POST /v1/payment-links Create a payment link
GET /v1/payment-links/:id Get payment link
GET /v1/payment-links List payment links
PATCH /v1/payment-links/:id Update payment link
POST /v1/payment-links/:id/deactivate Deactivate a payment linkProfile API
GET /v1/profile Get current user profile
PATCH /v1/profile Update profile (name, email, company)
GET /v1/profile/sessions List active sessions
GET /v1/profile/usage API usage statisticsEach section includes request and response examples in all three languages, parameter tables with types and descriptions, and notes on pagination, filtering, and error handling.
Error Codes Reference
The documentation ends with a comprehensive error codes table:
| Code | HTTP Status | Description |
|---|---|---|
auth_required | 401 | Missing or invalid API key |
auth_expired | 401 | API key or session has expired |
forbidden | 403 | Insufficient permissions for this operation |
not_found | 404 | Resource does not exist |
validation_error | 400 | Request body failed validation |
duplicate | 409 | Resource already exists (e.g., duplicate idempotency key) |
insufficient_credits | 402 | Not enough credits to process the payment |
provider_error | 502 | Payment provider returned an error |
provider_timeout | 504 | Payment provider did not respond in time |
rate_limited | 429 | Too many requests. Retry after the indicated delay |
suspended | 403 | Account is suspended due to unpaid billing |
currency_unsupported | 400 | The requested currency is not supported |
method_unavailable | 400 | The payment method is not available in the target country |
amount_too_low | 400 | Amount is below the minimum for the payment method |
amount_too_high | 400 | Amount exceeds the maximum for the payment method |
Each error code includes the HTTP status, a machine-readable code string, and a human-readable description. The machine-readable codes are stable and can be used in error handling logic:
typescripttry {
const payment = await zf.payments.create({ amount: 10, sourceCurrency: "XOF" });
} catch (error) {
switch (error.code) {
case "insufficient_credits":
// Redirect to top-up page
break;
case "amount_too_low":
// Show minimum amount to user
break;
case "provider_error":
// Retry with a different provider
break;
default:
// Generic error handling
break;
}
}The Content Strategy
Writing documentation for 90+ endpoints is a volume problem. The strategy was:
- Start with the backend routes. Every FastAPI route has a docstring, parameter types, and response models. These are the source of truth.
- Group by domain, not by module. Developers think in terms of "I want to create a payment" or "I want to check analytics," not "I need the payments.py module." The documentation groups by business domain.
- Show the happy path first. Every endpoint example shows a successful request and response. Error cases are documented separately in the error codes reference.
- Three languages, same structure. TypeScript and Python examples use the SDK. cURL examples show raw HTTP. All three produce identical results. A developer can switch languages and see the same operation expressed differently.
- Parameter tables are mandatory. Every endpoint lists its parameters with type, required/optional, and description. No "see the source code for details."
The SDK Reference Connection
The documentation page links to SDK-specific methods for each endpoint. When viewing the Payments section, developers see:
SDK Methods:
TypeScript: zf.payments.create(params)
Python: zf.payments.create(**params)
Go: client.Payments.Create(ctx, params)
Ruby: client.payments.create(params)
PHP: $client->payments->create($params)
Java: client.payments().create(params)
C#: client.Payments.CreateAsync(params)This bridges the gap between "I see the REST endpoint" and "How do I call it from my language."
Build Verification
After the rewrite, the frontend build completed successfully:
65 modules compiled
Build time: 5.86s
JS output: 427.70 kB (107.28 kB gzip)
CSS output: 101.92 kB (14.68 kB gzip)No TypeScript errors. The 1,006-line component compiles cleanly despite its size because SolidJS components are just functions -- there is no class hierarchy or lifecycle complexity that would cause type issues at scale.
Lessons Learned
Documentation is the first thing developers judge. Before trying the API, before reading the pricing page, developers open the docs. If the docs look incomplete, they assume the API is incomplete. The transformation from 400 to 1,006 lines was not just a documentation improvement -- it was a credibility improvement.
Scroll-spy is not optional for long pages. A 90+ endpoint reference page without navigation is a wall of text. The scroll-spy sidebar turns it into a browsable reference where developers can jump to any section in one click.
Three-language examples triple the maintenance cost but are worth it. When an endpoint changes, three code examples need updating. But developers overwhelmingly prefer seeing code in their language over mentally translating from a language they do not use.
A single-component architecture works at 1,000 lines. The documentation page is one SolidJS component with one file. No sub-components, no separate data files, no abstractions. For a page that is essentially a structured document with interactive elements, a single component is easier to maintain than a component tree.
Error codes are an API feature. Documenting error codes is not a documentation task -- it is an API design task. Every error code is a contract. insufficient_credits tells the developer exactly what happened and what to do about it. internal_server_error tells them nothing.
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.