API reference

Point Markline at an OpenAPI document and it generates a polished three-pane reference — resource pages, multi-language code, and a live request explorer.

Markline turns an OpenAPI 3.0/3.1 document into a full reference: a left resource nav, per-resource pages with attribute and parameter tables, dark code rails (cURL / Node / Python / Go), and an inline request explorer.

Add a spec

1

Drop in your OpenAPI document

Put it at api/openapi.json in your content root. A remote URL works too — snapshot it into that file at build time so your docs never depend on a live service.

content/
├── markline.json
└── api/
    └── openapi.json
2

Add an API tab

Give a navigation tab openapi: true — its sidebar is generated from the spec's tags and operations.

"navigation": {
  "tabs": [
    { "id": "docs", "label": "Documentation", "href": "/", "match": ["__default__"], "groups": [] },
    { "id": "api", "label": "API reference", "href": "/api-reference", "match": ["/api-reference"], "openapi": true }
  ]
}
3

Set the base URL

Point the explorer at your API. api.baseUrl overrides the spec's servers.

"api": { "baseUrl": "https://api.example.com", "playground": { "mode": "full", "proxy": "auto" } }

What you get

The reference is per-resource: one page per tag, opening on the first resource. Each page has:

  • A resource summary + an Endpoints card listing the tag's operations.
  • A "The object" section — attributes derived from the schema, with types, required/optional flags, enums, and a sample JSON.
  • One section per endpoint — method + path, parameters (path / query / body) from the schema, and a dark code rail with cURL, Node, Python and Go samples generated from the operation, plus the example response.
Resource pages live at /api-reference/<tag>; the per-operation pages remain reachable too (deep links, search). Navigation between docs and the reference is instant — it's one app, not a separate site.

Nested tags

Slash-separated tags — the convention NestJS emits from @ApiTags('store/orders') — become a nested sidebar. No config: Markline groups by shared prefix and title-cases each segment (with an acronym map, so fxFX, apiAPI).

store/products       Store
store/orders    →      ├── Products
store/refunds          ├── Orders
admin/users            └── Refunds
admin/api/keys       Admin
                       ├── Users
                       └── API
                             └── Keys
  • Parent groups are nav-only accordions (expand/collapse), opening to the active resource. Each leaf tag stays a normal resource page.
  • URLs are unchanged — the slug is still the whole tag, so store/orders lives at /api-reference/store-orders and existing links keep working.
  • Depth is arbitrary (admin/api/keys nests three levels); the page title shows the leaf (Keys) with an Admin / API breadcrumb.
  • Single-segment tags (payments) are unchanged — plain top-level entries.
This is automatic for any spec using slash tags — you don't flatten your @ApiTags to get clean resource boundaries. Per-resource MDX overlays still key off the full slug (store-orders.mdx).

Code samples

Every endpoint gets a dark code rail. By default Markline generates four samples — cURL, Node, Python, Go — from the operation itself: the Node/Python/Go ones are SDK-style (client.<resource>.<method>(…)), inferred from the tag, operationId, and the body / path / query parameters. You shape the rail two ways.

Pick the generated languages

api.codeSamples chooses which generated languages appear, and in what order. It's a subset of "curl" | "node" | "python" | "go"; omit it and all four show.

"api": {
  "baseUrl": "https://api.example.com",
  "codeSamples": ["curl", "node"]   // just cURL + Node, in that order
}
If you don't ship an SDK, the generated Node/Python/Go snippets reference a client that doesn't exist. Set "codeSamples": ["curl"] to show only cURL — or author your own per-operation samples with x-codeSamples below.

How generated samples are named

There's no separate "SDK name" setting — the generated snippets build their identifiers from your spec. A sample like acme.payments.create({ … }) is assembled from four derived tokens:

TokenDerived fromExample
clientfirst word of info.title, lowercased, alphanumerics only (else client)"Acme Payments API"acme
resourcethe operation's tag, lowercased and strippedtag Payment Methodspaymentmethods
methodHTTP verb + path shapePOSTcreate, PUT/PATCHupdate, DELETEdel, GET with {id}retrieve, GET without→list
resultsingularized tagprojectsproject

So the two levers on the generated rails are info.title (the client name) and your tag names (the resource accessors). To match a real SDK exactly — custom client name, hand-written method signatures, a language Markline doesn't generate — reach for x-codeSamples.

Bring your own with x-codeSamples

To replace the generated rail on a specific operation, add the x-codeSamples extension to that operation in your OpenAPI document (not in markline.json). The older x-code-samples spelling is accepted too — Markline reads either, so specs written for Redoc/Stripe work unchanged.

"paths": {
  "/v1/payments": {
    "post": {
      "operationId": "createPayment",
      "summary": "Create a payment",
      "x-codeSamples": [
        {
          "lang": "ruby",
          "label": "Ruby",
          "source": "Acme::Payment.create(\n  amount: 1000,\n  currency: \"usd\",\n)"
        },
        {
          "lang": "php",
          "source": "$client->payments->create([\n  'amount' => 1000,\n  'currency' => 'usd',\n]);"
        }
      ]
    }
  }
}

Each entry has three fields:

sourcestringrequired

The code shown in the tab. Rendered verbatim — HTML-escaped, with no re-highlighting — so format it exactly as you want it to read, newlines (\n) and indentation included.

labelstring

The tab label. Falls back to lang, then to "Sample".

langstring

Free-form language hint (ruby, php, java, kotlin, …) — not limited to the four generated languages. Used for the tab's internal key and as the label fallback.

x-codeSamples is all-or-nothing per operation: as soon as an operation carries one custom sample, the entire generated rail for that operation is replaced, and api.codeSamples no longer applies to it. List every language you want shown — tabs appear in array order. Other operations keep their generated rails untouched.

This is the escape hatch for hand-written, copy-paste-correct examples: real SDK calls, framework-specific snippets, or languages Markline can't generate.

The playground

Write endpoints get an inline request explorer: an editable Authorization field, the request fields, a Send button, and the live response with status + latency. It's the same engine on every surface.

api.playground.mode"full" | "inline" | "explorer" | "off"

full — inline inputs + the request console + the API Explorer modal. inline — inline inputs + the request console, no modal. explorer — read-only docs + console + the API Explorer modal. off — a static cURL panel, no interactivity.

api.playground.proxy"auto" | "always" | "never"

How requests reach your API. auto (default) tries a direct browser fetch and falls back to the built-in server proxy on CORS failure; always forces the proxy; never is direct-only — required for static export.

The server proxy (/api/playground) only runs on a Node/Vercel deployment and is dropped in static export. On a static host, set proxy: "never" and enable CORS on your API, or the explorer can't reach it.

Events & webhooks

If your API emits webhooks or async events, Markline documents them alongside the endpoints that trigger them. Each resource grows an Events tab listing the events it can deliver — name, summary, payload schema, and a sample — and the two are cross-linked: an emitting endpoint shows a Triggers chip, and the event shows an Emitted by back-link to that endpoint. The tab only appears on resources that actually have events; a spec with none looks exactly as before.

Where events come from

Markline reads events from four sources and normalizes them into one model, so you can use whichever your toolchain already emits:

SourceWhere it livesUse it when
webhooksdocument root (OpenAPI 3.1)your spec is 3.1 and uses the native webhooks object
x-webhooksdocument root (Redoc extension)you're on 3.0 and want a Redoc-compatible root list
callbackson an operationthe event is a standard OpenAPI callback tied to one request
x-eventson a tag or an operation (Markline)you want the lightest annotation, especially from codegen like NestJS

For root webhooks / x-webhooks, each entry is a Path Item — Markline takes its first method operation and reads summary, description, the operation's tags, and the JSON request body as the payload. A root webhook only appears on a resource if its operation carries a matching tags entry; untagged webhooks are parsed but have no resource to attach to.

The x-events extension

x-events is the most direct way to declare events. It takes either a map of name → definition, or an array of names (references to events defined elsewhere — no inline payload):

"x-events": {
  "project.created": {
    "summary": "A new project was created.",
    "description": "Emitted once the project is provisioned and ready.",
    "payload": { "$ref": "#/components/schemas/Project" },
    "guide": "/guides/webhooks#project-created"
  }
}
"x-events": ["project.created", "project.deleted"]   // name-only references

Each definition field is optional:

summarystring

One-line description, shown as the event's heading.

descriptionstring

Longer prose shown under the summary.

payloadJSON Schema | $ref

The delivered payload. Inline schema or a $ref into components.schemas — resolved at render to produce the attribute table and the sample JSON. (schema and a JSON requestBody are accepted as fallbacks.)

guidestring

Link to a docs page or anchor that explains the event in depth, rendered as a follow-on link. (guideHref / href are accepted too.)

Tag level vs. operation level changes how the event is wired:

  • On an operation (paths.<path>.<method>.x-events), the event inherits that operation's tags, so it groups onto those resources — and because an endpoint triggers it, the endpoint gets a Triggers chip and the event an Emitted by back-link.
  • On a tag (tags[].x-events), the event attaches to that resource only, with no emitter back-link. Use this for events not tied to a single endpoint — delivered by an external processor, a batch job, or a state change.

Markline merges the two per resource and de-duplicates by event name, so an event declared on both a tag and an operation appears once.

The sample spec at api/openapi.json in this repo is wired up: createProject emits project.created, createDeployment emits deployment.succeeded / deployment.failed, and a root x-webhooks entry adds project.archived to the Projects resource. Open the Projects reference to see the Events tab.

From NestJS

NestJS has no @ApiCallbacks, but @ApiExtension is a clean fit — and it's why x-events exists. Annotate the handler that causes the event; Markline aggregates it onto the resource automatically (no document-root wiring):

import { ApiExtension, ApiExtraModels, ApiTags, getSchemaPath } from '@nestjs/swagger';
 
@ApiTags('cards')
@ApiExtraModels(CardCreatedEvent)   // emits the payload into components.schemas
@Controller('cards')
export class CardsController {
  @Post()
  @ApiExtension('x-events', {
    'card.created': {
      summary: 'A new card was issued',
      payload: { $ref: getSchemaPath(CardCreatedEvent) },
      guide: '/guides/webhooks#card-created',
    },
  })
  create() { /* … */ }
}
  • @ApiExtraModels(CardCreatedEvent) makes getSchemaPath(...) resolve — without it the $ref points at a schema that was never emitted.
  • The event inherits @ApiTags('cards'), so it lands on the Cards resource and POST /cards gets the Triggers chip.
  • @ApiExtension requires an x--prefixed key and an object value — both hold. Placed on the controller class it applies to every route; on a handler, just that operation.

Reuse a shared set with a small composed decorator:

import { applyDecorators } from '@nestjs/common';
import { ApiExtension } from '@nestjs/swagger';
 
export const Emits = (events: Record<string, unknown>) =>
  applyDecorators(ApiExtension('x-events', events));
 
// @Emits({ 'card.created': { summary: 'A new card was issued', payload: { $ref: getSchemaPath(CardCreatedEvent) } } })
For an event with no single triggering endpoint (one a processor delivers), attach it to the most relevant operation, or add it under a tag's x-events by post-processing the generated document before you write openapi.json — NestJS has no decorator for tag objects.

MDX overlays

Layer authored MDX on top of the generated reference — full components (callouts, tables, tabs) included:

api/
├── openapi.json
├── introduction.mdx              # replaces the whole reference landing
├── sections/<tag>.mdx            # rich intro for a resource (overrides the tag description)
└── operations/<operationId>.mdx  # extra prose on one operation page
  • sections/<tag>.mdx renders as the resource intro. The filename is the slugified tag — Payment Methodspayment-methods.mdx.
  • operations/<operationId>.mdx renders between the endpoint path and the auto-generated parameters.

AI & page actions

Every resource section carries the same affordances as the docs: Copy for LLM (copies the section as Markdown), View as Markdown, and — when Ask AI is enabled — Ask about this section, which opens the docked assistant with that section as context.

Versions

The reference header shows a version selector. With a single spec it surfaces the spec's info.version (a semver 1.4.2v1 · 1.4.2; a date 2025-06-01 → shown once, verbatim).

With multiple versions configured, each version ships its own <id>/api/openapi.json, served at /api-reference/<id> — the selector switches the spec in place and every link stays under that version's prefix. See Versions & i18n for the folder layout.