Most of the time you want NookDocs to auto-generate API pages from your OpenAPI spec. But sometimes you need to document something the spec can't express — webhook semantics, rate-limit edge cases, partial responses, SSE streams, WebSocket handshakes. For those, author the page in MDX directly using the API primitive components.
When to write manually
No spec exists
Legacy API you're adopting that has no OpenAPI definition. Ship docs first, reverse-engineer the spec later.
Semantics the spec can't express
Retry behaviour, idempotency keys, rate-limit burst vs sustained, eventual consistency windows. Prose > schema.
Custom response formats
Server-sent events, newline-delimited JSON, WebSocket frames. <ResponseExample> accepts any body string.
Specific endpoints with special UX
Your POST /payments has a 3-step flow with customer redirects. Manual page can walk through each step with <Steps> + <RequestExample>.
The five components
<ApiMethod>componentpathRenders the method + path header at the top of an endpoint page (GET /users/{id}). Colour-coded by method — GET green, POST blue, DELETE red, etc.
<ParamField>componentpathOne row per request parameter — path, query, header, or body field. Props: path (field name), type (TS-ish type), required, default. Children = description.
<ResponseField>componentpathSame shape as ParamField but for response body fields. Renders in the response panel.
<RequestExample>componentpathTabbed code card for the request — cURL, fetch, SDK, whatever you want. Same layout as the auto-generated card.
<ResponseExample>componentpathTabbed code card for response examples. One tab per status code or scenario. Status-coloured like auto-gen.
Minimal example
Complete page documenting a POST /payments endpoint from scratch:
---
title: Create payment
description: Charge a card or create a pending bank transfer.
---
<ApiMethod method="POST" path="/v1/payments" />
## Request body
<ParamField path="amount" type="integer" required>
Amount in smallest currency unit (cents for USD). Min `100`.
</ParamField>
<ParamField path="currency" type="'usd' | 'eur' | 'gbp'" required>
ISO 4217 currency code. Only these three are supported today.
</ParamField>
<ParamField path="source" type="string" required>
Payment source token from the Elements SDK (`tok_...`) or a saved
customer card (`card_...`).
</ParamField>
<ParamField path="description" type="string">
Appears on the customer's statement. Max 22 characters.
</ParamField>
<ParamField path="idempotencyKey" type="string">
Unique key to dedupe retries. Same key within 24 hours returns the
original response without re-charging.
</ParamField>
<RequestExample>
```bash cURL
curl https://api.acme.com/v1/payments \
-H "Authorization: Bearer sk_live_..." \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{
"amount": 1000,
"currency": "usd",
"source": "tok_visa",
"description": "Monthly subscription"
}'
```
```typescript TypeScript
import Acme from "@acme/sdk";
const acme = new Acme({ apiKey: process.env.ACME_KEY });
const payment = await acme.payments.create({
amount: 1000,
currency: "usd",
source: "tok_visa",
description: "Monthly subscription",
}, {
idempotencyKey: crypto.randomUUID(),
});
```
</RequestExample>
## Response
<ResponseField name="id" type="string">
Unique payment identifier. Always starts with `pay_`.
</ResponseField>
<ResponseField name="status" type="'succeeded' | 'pending' | 'failed'">
`succeeded` — money moved. `pending` — waiting on bank rails (up to 3 business days). `failed` — reason in `failureReason`.
</ResponseField>
<ResponseField name="amount" type="integer">
Same amount you passed in the request.
</ResponseField>
<ResponseField name="createdAt" type="string (ISO 8601)">
Server timestamp when the payment was created.
</ResponseField>
<ResponseExample>
```json 200 OK
{
"id": "pay_abc123",
"status": "succeeded",
"amount": 1000,
"currency": "usd",
"createdAt": "2026-04-20T10:30:00Z"
}
```
```json 400 Bad Request
{
"code": "invalid_amount",
"message": "Amount must be at least 100 for USD.",
"statusCode": 400
}
```
```json 429 Too Many Requests
{
"code": "rate_limited",
"message": "Too many requests. Retry after 60 seconds.",
"statusCode": 429
}
```
</ResponseExample>The page renders with the same layout as auto-generated pages — method header, params table, request code card, response field table, response example tabs.
When to use method + path
<ApiMethod> renders a one-line header. If you need the full endpoint treatment (Try-It playground, request tabs, response tabs, status-coloured), you can bind a manual page to an auto-gen'd operation via the openapi frontmatter:
---
title: Create payment — detailed walkthrough
openapi: "POST /v1/payments"
---
This page still renders the Try-It panel + auto-gen'd tabs for
POST /v1/payments. Your prose renders between the method bar and
the generated reference — Callouts, plan banners, setup notes,
any component works here.
## When to use this endpoint
(Your custom narrative, full MDX freedom.)This hybrid mode is described in the OpenAPI overview. The manual-page-only approach (this page) skips the spec entirely.
Organising manual endpoints
Put them under a sensible path that mirrors your API shape:
docs/
api/
payments/
create.mdx # <-- manual page
capture.mdx # <-- manual page
refund.mdx # <-- manual pageAdd to nav as a group:
{
"group": "Payments",
"pages": [
"docs/api/payments/create",
"docs/api/payments/capture",
"docs/api/payments/refund"
]
}Status badges (GET/POST/etc) appear automatically in the sidebar when the page starts with <ApiMethod>.
Authentication sections
Shared auth blocks belong in a snippet. Today use inline <Snippet> children; Phase 4 file-based snippets will let you include from _snippets/auth.mdx:
<Snippet>
All endpoints require a `Bearer <api_key>` in the `Authorization`
header. Test-mode keys start with `sk_test_`; live keys with `sk_live_`.
</Snippet>See reusable snippets for the full pattern.
Paginated responses
Document cursor pagination with a dedicated field block:
<ResponseField name="data" type="Payment[]">
Current page of results. Always ordered newest first.
</ResponseField>
<ResponseField name="hasMore" type="boolean">
Whether more results exist. When `true`, include the last item's `id`
as the `startingAfter` query parameter to fetch the next page.
</ResponseField>
<ResponseField name="startingAfter" type="string">
Echoes the cursor from the request, or `null` for the first page.
</ResponseField>Include a working paginated example in <RequestExample> showing two sequential calls.
Streaming responses (SSE / NDJSON)
<ResponseExample> accepts any body string — use it for non-JSON formats:
<ResponseExample>
```text Server-Sent Events
event: message
data: {"id":"msg_1","content":"Hello"}
event: message
data: {"id":"msg_2","content":"world"}
event: done
data: {"finishReason":"stop"}
```
</ResponseExample>Document the content-type (text/event-stream / application/x-ndjson) in prose above the example.
Page frontmatter for discovery
Set description so the LLM search + Copy-for-LLMs header has a one-sentence summary. Set icon to give the sidebar entry a glyph. Set hidden: true for internal-only endpoints that you still want routable.
---
title: Rotate API key
description: Generate a new API key and invalidate the old one after a 5-minute grace period.
icon: key-round
---Related
Complex types — composition keywords for spec-driven schemas
Multiple responses — status-code tabs behaviour (manual pages get this for free via
<ResponseExample>)SDK examples —
x-codeSamplesextension for spec-driven pagesReusable snippets — share auth + intro blocks across manual pages
OpenAPI overview — auto-gen + hybrid modes