How We Documented 95 API Endpoints with OpenAPI and Scalar
Source: Dev.to
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.
- 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.
