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.
Top comments (0)