How We Documented 95 API Endpoints with OpenAPI and Scalar

Published: (February 23, 2026 at 06:06 PM EST)
5 min read
Source: Dev.to

Source: Dev.to

Alex Neamtu

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:

  1. A single YAML file we could lint and test.
  2. Interactive docs that render in the browser.
  3. Zero infrastructure beyond the Go binary itself.

Embed the OpenAPI spec

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)
}

Serve 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 = `

  SendRec API Reference
  
  

  
  
`

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

A subtle bug 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 back‑tick‑wrapped JSON:

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

Fix – wrap the entire description in single quotes:

# Fixed
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. This is easy to miss when 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 – organization, tagging, bulk operations
  • Settings – user account settings, notification preferences
  • Watch – public video watching, interaction, share tokens
  • Billing – subscription plans, payment processing, invoices

All of this lives in a single openapi.yaml file, embedded in the binary, served via the two tiny handlers above, and kept in sync with the codebase through automated tests.

Features

  • Videos – organize 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
  • 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
  • Grouping

The spec is a documentation artifact, not a code contract — we’d rather have clear docs than auto‑generated ones that merely mirror the code without adding context.

Trade‑off: we must keep the spec in sync manually.

  • The endpoint test catches missing paths.
  • 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.

0 views
Back to Blog

Related posts

Read more »

Making Sense Golang Worker Pattern

Spawn workers go intNumWorkers := 3 for w := 0; w < intNumWorkers; w++ { go InsertStudentDatactx, tx, insertedStudentJobs, insertedStudentjobsErrCh } go func I...