Try it Free

How We Documented 95 API Endpoints with OpenAPI and Scalar

SendRec has grown to 95 API endpoints covering authentication, video management, playlists, folders, tags, billing, webhooks, and public watch pages. We needed documentation that stayed in sync with the code and didn’t require a separate build step. Here’s how we set it up.

The approach: embedded YAML + Scalar

We wanted three things: a single YAML file we could lint and test, interactive docs that render in the browser, and zero infrastructure beyond the Go binary itself.

The solution is straightforward. The OpenAPI spec lives at internal/docs/openapi.yaml, and Go’s //go:embed directive bakes it into the binary at compile time:

package docs

import (
    _ "embed"
    "net/http"
)

//go:embed openapi.yaml
var specYAML []byte

func HandleSpec(w http.ResponseWriter, r *http.Request) {
    w.Header().Set("Content-Type", "application/yaml")
    _, _ = w.Write(specYAML)
}

For the interactive UI, we serve a minimal HTML page that loads Scalar from a CDN:

func HandleDocs(w http.ResponseWriter, r *http.Request) {
    w.Header().Set("Content-Type", "text/html; charset=utf-8")
    _, _ = w.Write([]byte(docsHTML))
}

const docsHTML = `<!DOCTYPE html>
<html><head>
  <title>SendRec API Reference</title>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
</head><body>
  <script id="api-reference" data-url="/api/docs/openapi.yaml"></script>
  <script src="https://cdn.jsdelivr.net/npm/@scalar/api-reference"></script>
</body></html>`

Two routes, no build step, no static file server. The docs page is gated behind an API_DOCS_ENABLED=true environment variable so self-hosters can decide whether to expose it.

Content Security Policy

Scalar loads JavaScript and CSS from cdn.jsdelivr.net, so the docs handler sets a targeted CSP header:

w.Header().Set("Content-Security-Policy",
    "default-src 'self'; "+
        "script-src 'self' https://cdn.jsdelivr.net 'unsafe-inline'; "+
        "style-src 'self' https://cdn.jsdelivr.net 'unsafe-inline'; "+
        "font-src 'self' https://cdn.jsdelivr.net data:; "+
        "img-src 'self' data:; connect-src 'self'; frame-ancestors 'self';")

This keeps the security headers tight while allowing Scalar to function. The rest of the application uses a stricter default policy.

Structuring 4,200 lines of YAML

With 95 endpoints, organization matters. We use tags to group related endpoints:

tags:
  - name: Authentication
    description: User registration, login, and token management
  - name: Videos
    description: Video CRUD, sharing, and management
  - name: Playlists
    description: Playlist management and sharing
  - name: Folders
    description: Video folder management
  - name: Tags
    description: Video tag management
  - name: Settings
    description: User account settings and notification preferences
  - name: Watch
    description: Public video watching and interaction
  - name: Billing
    description: Subscription and payment management

Each endpoint gets a tag, an operationId, and a summary. Reusable schemas live under components/schemas — we have about 75 of them covering request bodies, response objects, and error formats.

YAML gotchas

We hit one subtle bug that broke the docs page entirely. Scalar showed “Document ‘api-1’ could not be loaded” with no further details. The culprit was a description field containing backtick-wrapped JSON:

# Broken — YAML interprets `: ` inside the value as a mapping
description: Email not verified. Error body contains `{"error": "email_not_verified"}`.

# Fixed — single quotes escape the entire value
description: 'Email not verified. Error body contains {"error": "email_not_verified"}.'

The : inside "error": "email_not_verified" made the YAML parser treat everything after the colon as a nested mapping value. The fix was wrapping the entire description in single quotes. This is easy to miss when you’re writing YAML by hand, especially in inline descriptions.

Testing the spec

We have a test that verifies all critical endpoints appear in the embedded spec:

func TestSpecContainsAllEndpoints(t *testing.T) {
    spec := string(specYAML)

    endpoints := []string{
        "/api/health",
        "/api/auth/register",
        "/api/auth/login",
        "/api/videos",
        "/api/videos/limits",
        "/api/watch/{shareToken}",
        "/api/playlists",
        "/api/folders",
        // ... all 95 endpoints
    }

    for _, ep := range endpoints {
        if !strings.Contains(spec, ep) {
            t.Errorf("spec missing endpoint: %s", ep)
        }
    }
}

This catches the most common problem: adding a new route in server.go and forgetting to document it. It runs in CI alongside the rest of the test suite.

What the spec covers

The full spec documents:

  • Authentication — register, login, refresh, logout, password reset, email confirmation
  • Videos — CRUD, upload, trim, download, thumbnails, transcription, AI summaries, password protection, comments, analytics, email gates, CTAs
  • Playlists — create, list, update, delete, add/remove/reorder videos, shared watch pages
  • Folders and tags — organize videos with folders and color-coded tags
  • Batch operations — bulk delete, bulk move to folder, bulk tag
  • Settings — notification preferences, Slack webhooks, custom webhooks, branding, API keys
  • Billing — checkout, subscription status, cancellation
  • Watch pages — public video viewing, password verification, comments, milestones, oEmbed

Each endpoint includes request/response schemas, required fields, authentication requirements, and HTTP status codes.

Why not code generation?

We considered generating the spec from Go struct tags or handler annotations, but decided against it. Hand-written YAML gives us full control over descriptions, examples, and grouping. The spec is a documentation artifact, not a code contract — we’d rather have clear docs than auto-generated ones that mirror the code without adding context.

The tradeoff is that we need to keep the spec in sync manually. The endpoint test catches missing paths, and code review catches the rest.

Try it

The live API docs are at app.sendrec.eu/api/docs. SendRec is open source — the full spec and serving code are in the GitHub repo.