[object Object]

← back to Cli Printing Press

feat(cli): detect and emit required API headers from OpenAPI specs (#125)

79a945857700df516338a59d7b7608452468627c · 2026-04-04 20:10:11 -0700 · Trevin Chow

* feat(cli): detect and emit required API headers from OpenAPI specs

Many APIs require per-request headers beyond Authorization — version
headers (cal-api-version, Stripe-Version, anthropic-version) that cause
silent 404s when missing. The generator now detects these automatically.

Detection: scans all OpenAPI operations for `in: header, required: true`
parameters. Headers appearing on >80% of operations (matching the
filterGlobalParams threshold) are emitted as defaults in the generated
client. Auth-related headers (Authorization, Content-Type, Accept,
User-Agent) are excluded via case-insensitive matching.

Default values extracted from schema.default or first schema.enum entry.
Headers without defaults are emitted with empty values — doctor warns.

Three templates updated: client.go.tmpl, doctor.go.tmpl,
auth_browser.go.tmpl. APIs without required headers generate identical
code (range over empty slice = no output).

Tested against: versioned-api.yaml (positive), petstore.yaml (negative),
stytch.yaml (negative — optional headers not detected).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* docs: mark required-api-headers plan as completed

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(cli): use mergeParameters for header override semantics, strict >80% threshold

Two fixes from Codex review:

1. Use mergeParameters(pathItem, op) instead of manually iterating
   path-level then op-level params. This respects OpenAPI's override
   semantics — if an operation redefines a path-level header (makes it
   optional or changes its default), the op-level definition wins.

2. Change threshold from >= 0.8 to > 0.8 to match filterGlobalParams.
   With 5 operations, a header on 4 (80% exactly) should NOT be
   promoted — it's missing from one endpoint intentionally.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit 79a945857700df516338a59d7b7608452468627c
Author: Trevin Chow <trevin@trevinchow.com>
Date:   Sat Apr 4 20:10:11 2026 -0700

    feat(cli): detect and emit required API headers from OpenAPI specs (#125)
    
    * feat(cli): detect and emit required API headers from OpenAPI specs
    
    Many APIs require per-request headers beyond Authorization — version
    headers (cal-api-version, Stripe-Version, anthropic-version) that cause
    silent 404s when missing. The generator now detects these automatically.
    
    Detection: scans all OpenAPI operations for `in: header, required: true`
    parameters. Headers appearing on >80% of operations (matching the
    filterGlobalParams threshold) are emitted as defaults in the generated
    client. Auth-related headers (Authorization, Content-Type, Accept,
    User-Agent) are excluded via case-insensitive matching.
    
    Default values extracted from schema.default or first schema.enum entry.
    Headers without defaults are emitted with empty values — doctor warns.
    
    Three templates updated: client.go.tmpl, doctor.go.tmpl,
    auth_browser.go.tmpl. APIs without required headers generate identical
    code (range over empty slice = no output).
    
    Tested against: versioned-api.yaml (positive), petstore.yaml (negative),
    stytch.yaml (negative — optional headers not detected).
    
    Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
    
    * docs: mark required-api-headers plan as completed
    
    Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
    
    * fix(cli): use mergeParameters for header override semantics, strict >80% threshold
    
    Two fixes from Codex review:
    
    1. Use mergeParameters(pathItem, op) instead of manually iterating
       path-level then op-level params. This respects OpenAPI's override
       semantics — if an operation redefines a path-level header (makes it
       optional or changes its default), the op-level definition wins.
    
    2. Change threshold from >= 0.8 to > 0.8 to match filterGlobalParams.
       With 5 operations, a header on 4 (80% exactly) should NOT be
       promoted — it's missing from one endpoint intentionally.
    
    Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
    
    ---------
    
    Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---
 ...-002-feat-required-api-header-detection-plan.md | 268 +++++++++++++++++++++
 internal/generator/templates/auth_browser.go.tmpl  |   3 +
 internal/generator/templates/client.go.tmpl        |   3 +
 internal/generator/templates/doctor.go.tmpl        |   3 +
 internal/openapi/parser.go                         |  86 +++++++
 internal/openapi/parser_test.go                    |  41 ++++
 internal/spec/spec.go                              |  38 +--
 testdata/openapi/versioned-api.yaml                |  96 ++++++++
 8 files changed, 524 insertions(+), 14 deletions(-)

diff --git a/docs/plans/2026-04-04-002-feat-required-api-header-detection-plan.md b/docs/plans/2026-04-04-002-feat-required-api-header-detection-plan.md
new file mode 100644
index 00000000..bd51c102
--- /dev/null
+++ b/docs/plans/2026-04-04-002-feat-required-api-header-detection-plan.md
@@ -0,0 +1,268 @@
+---
+title: "feat: Detect and emit required API headers from OpenAPI specs"
+type: feat
+status: completed
+date: 2026-04-04
+origin: docs/retros/2026-04-04-cal-com-retro.md (finding #5)
+---
+
+# feat: Detect and emit required API headers from OpenAPI specs
+
+## Overview
+
+Many APIs require per-request headers beyond Authorization — version headers (`cal-api-version`, `Stripe-Version`, `anthropic-version`), custom client identifiers, or API-specific metadata headers. The generator currently only emits `Authorization` and `User-Agent`. When a required header is missing, API calls silently fail (404, stale behavior). This feature detects required headers from the OpenAPI spec and emits them in the generated client.
+
+## Problem Frame
+
+During the Cal.com CLI generation, all API calls returned 404 because the `cal-api-version: 2024-08-13` header was missing. The generator had no mechanism to detect or emit it. Manual intervention was required to add the header to the printed CLI's client.go — work that should have been automatic.
+
+This affects ~10-20% of APIs that use explicit version headers or other required per-request headers. (see origin: `docs/retros/2026-04-04-cal-com-retro.md`, finding #5)
+
+## Requirements Trace
+
+- R1. Required headers declared in the OpenAPI spec (with `in: header` and `required: true`) that appear on >80% of all operations (denominator: total operations in the spec) are emitted as default headers in the generated client
+- R2. The default value for each header is extracted from the parameter's `schema.default` or first `schema.enum` value. If neither exists, the header is still detected but emitted with an empty value — doctor warns about unconfigured required headers
+- R3. Auth-related headers (Authorization) and dynamic headers (Content-Type, User-Agent) are excluded from detection
+- R4. APIs without required headers (Stripe with no version header in spec, GitHub, Petstore) generate identical client code to today — no regression
+- R5. The detected headers are available to all templates that make HTTP requests (client, doctor, auth browser)
+
+## Scope Boundaries
+
+- **In scope**: Global required headers (>80% of operations) with known default values
+- **Not in scope**: Per-operation headers that vary across endpoints (these remain as command flags)
+- **Not in scope**: Cal.com's per-endpoint version *routing* (different version values per path prefix, e.g., bookings use `2024-08-13`, event-types use `2024-06-14`). That routing logic is API-specific. What IS in scope: detecting `cal-api-version` as a globally required header from the spec — the header name and a single default value. Cal.com's spec declares the header on most operations; the >80% threshold detects it. The per-endpoint value differences are a Cal.com-specific concern addressed separately in the printed CLI.
+- **Not in scope**: Auth inference from description text (retro finding #7 — separate feature)
+- **Not in scope for detection**: Internal YAML specs. Auto-detection applies only to OpenAPI specs. Internal YAML spec users can declare `RequiredHeaders` manually via the new struct field.
+
+## Context & Research
+
+### Relevant Code and Patterns
+
+- `internal/openapi/parser.go:744` — `filterGlobalParams`: frequency-based threshold (>80%) for identifying global query params. Same architectural pattern needed here.
+- `internal/openapi/parser.go:366` — `inferQueryParamAuth`: scans operations for auth-like params when security section is empty. Same iteration pattern.
+- `internal/openapi/parser.go:966` — `mapParameters`: **the drop point** — currently filters out `in: header` params (`parameter.In != "path" && != "query"` → continue). This is where header params get silently discarded.
+- `internal/openapi/parser.go:1077` — `mergeParameters`: correctly collects ALL params including headers. The data is available before the filter.
+- `internal/spec/spec.go:11-26` — `APISpec` struct: no `RequiredHeaders` field exists. `Auth.Header` only handles the auth header.
+- `internal/generator/templates/client.go.tmpl:343-346` — header insertion point (between Authorization and User-Agent)
+
+### Institutional Learnings
+
+- Cal.com retro finding #5: complete design spec with threshold, template, guard, and test plan
+- Steam retro finding #5: frequency-threshold pattern validated for auth param detection (>30%)
+- Both retros confirm the >80% threshold is the right level for "this is global, not per-operation"
+
+### Negative Test Fixtures
+
+- `testdata/openapi/stytch.yaml`: has `in: header` params (`X-Stytch-Member-Session`) that are `required: false` and per-operation — must NOT be detected
+- `testdata/openapi/petstore.yaml`: has `api_key` as `in: header` on a single endpoint, `required: false` — must NOT be detected
+
+## Key Technical Decisions
+
+- **80% threshold** (matching `filterGlobalParams`): A header must appear as `required: true` on >80% of operations to be considered global. This avoids false positives on per-operation headers while catching version headers that are ubiquitous. Rationale: `filterGlobalParams` uses this threshold successfully for query params; version headers have the same "almost every endpoint needs this" pattern.
+
+- **Default value from schema, not hardcoded**: The parser extracts the default from `schema.Default` or the first entry in `schema.Enum`. If neither exists, the header name is stored but the value is empty — the template emits it with an empty string and the user must configure it. Rationale: we can't invent version numbers, but we can surface the header so the user knows it's needed.
+
+- **Struct on APISpec, not a separate config**: `RequiredHeaders` goes directly on `APISpec` so all templates can access it without plumbing changes. Rationale: `client.go.tmpl` already receives `*spec.APISpec` directly (line 299 of generator.go).
+
+- **Exclude list**: Skip `Authorization`, `Content-Type`, `Accept`, `User-Agent` — these are handled by other mechanisms. Rationale: Authorization is set by the auth system, Content-Type is set per-request based on body presence, User-Agent is generated.
+
+## Open Questions
+
+### Resolved During Planning
+
+- **Q: What about APIs with per-endpoint versioning (Cal.com)?** Resolution: Out of scope. Per-endpoint routing (different version per path prefix) is API-specific. This feature handles the common case: one version header across all endpoints. Cal.com's per-endpoint routing was a manual fix in the printed CLI, and correctly so.
+
+- **Q: Should the header value be configurable via env var?** Resolution: No for v1. The value comes from the spec's default/enum. If an API changes its version, the user regenerates. Adding env var configurability is premature — we haven't seen demand for it.
+
+### Deferred to Implementation
+
+- **Q: What happens when a header has no default value and no enum?** Store the header name with empty value. The client template emits `req.Header.Set("X-Api-Version", "")`. The doctor template checks for empty-value required headers and emits a warning: `WARN Required header X-Api-Version has no configured value`. This converts a silent 404 into a visible diagnostic.
+
+## High-Level Technical Design
+
+> *This illustrates the intended approach and is directional guidance for review, not implementation specification.*
+
+```
+OpenAPI spec
+    │
+    ▼
+mergeParameters()  ─── collects ALL params (path, query, header)
+    │
+    ├── mapParameters()  ─── filters to path+query only (existing, unchanged)
+    │
+    └── detectRequiredHeaders()  ─── NEW: filters to header only
+            │
+            ├── Filter: in=header, required=true
+            ├── Exclude: Authorization, Content-Type, Accept, User-Agent
+            ├── Count frequency across all operations
+            ├── Threshold: >80% of operations
+            └── Extract default from schema.Default or schema.Enum[0]
+                    │
+                    ▼
+            APISpec.RequiredHeaders []RequiredHeader{Name, Value}
+                    │
+                    ▼
+            client.go.tmpl: {{range .RequiredHeaders}}
+                req.Header.Set("{{.Name}}", "{{.Value}}")
+            {{end}}
+```
+
+## Implementation Units
+
+- [ ] **Unit 1: Add RequiredHeader struct and field to APISpec**
+
+**Goal:** Define the data structure for required headers in the spec
+
+**Requirements:** R1, R5
+
+**Dependencies:** None
+
+**Files:**
+- Modify: `internal/spec/spec.go`
+- Test: `internal/spec/spec_test.go`
+
+**Approach:**
+- Add `RequiredHeader` struct with `Name string` and `Value string` fields
+- Add `RequiredHeaders []RequiredHeader` field to `APISpec` with yaml/json tags + omitempty
+- Follow existing struct tag conventions (yaml + json)
+
+**Patterns to follow:**
+- `AuthConfig` struct in the same file for naming/tag conventions
+
+**Test scenarios:**
+- Happy path: `APISpec` with `RequiredHeaders` round-trips through YAML marshal/unmarshal
+- Happy path: `APISpec` with empty `RequiredHeaders` omits the field in YAML output (omitempty)
+
+**Verification:**
+- `go test ./internal/spec/...` passes
+- `RequiredHeaders` field accessible from templates via `{{.RequiredHeaders}}`
+
+---
+
+- [ ] **Unit 2: Implement detectRequiredHeaders in the OpenAPI parser**
+
+**Goal:** Scan OpenAPI parameters for required headers appearing on >80% of operations
+
+**Requirements:** R1, R2, R3
+
+**Dependencies:** Unit 1
+
+**Files:**
+- Modify: `internal/openapi/parser.go`
+- Test: `internal/openapi/parser_test.go`
+
+**Approach:**
+- New function `detectRequiredHeaders(doc *openapi3.T, auth spec.AuthConfig) []spec.RequiredHeader`
+- Iterate all path items and operations via `doc.Paths` (same iteration pattern as `inferQueryParamAuth`, not `filterGlobalParams` which walks post-mapped resources)
+- For each operation, call `mergeParameters` to get all params including headers
+- Filter to `parameter.In == "header"` and `parameter.Required == true`
+- Exclude known headers using case-insensitive comparison (`strings.EqualFold`): Authorization, Content-Type, Accept, User-Agent, and the auth header from `auth.Header`
+- Count frequency per header name across total operations
+- Apply 80% threshold
+- For qualifying headers, extract value from `parameter.Schema.Value.Default` or first `parameter.Schema.Value.Enum` entry
+- Call from `parse()` after `mapAuth` and `mapResources`, store result in `result.RequiredHeaders`
+
+**Patterns to follow:**
+- `inferQueryParamAuth` (line 366) for the iteration pattern (walks `doc.Paths` directly, same as needed here)
+- `filterGlobalParams` (line 744) for the threshold math only (80% frequency calculation)
+- `mergeParameters` (line 1077) for collecting all params
+
+**Test scenarios:**
+- Happy path: Spec with `cal-api-version` header (required: true) on 100% of operations → detected with correct name and default value
+- Happy path: Spec with version header on 85% of operations → detected (above 80%)
+- Edge case: Header on 75% of operations → NOT detected (below 80%)
+- Edge case: Header with `required: false` → NOT detected regardless of frequency
+- Edge case: `Authorization` header with `required: true` on all operations → excluded (auth-handled)
+- Edge case: Header with no default value and no enum → detected with empty Value
+- Edge case: Spec with zero operations → empty result, no panic
+- Integration: Existing `testdata/openapi/petstore.yaml` → no required headers detected (api_key is optional, single endpoint)
+- Integration: Existing `testdata/openapi/stytch.yaml` → no required headers detected (session headers are optional)
+
+**Verification:**
+- `go test ./internal/openapi/...` passes
+- New test fixture exercises the threshold boundary
+
+---
+
+- [ ] **Unit 3: Emit required headers in client and doctor templates**
+
+**Goal:** Generated clients set the detected headers on every request
+
+**Requirements:** R4, R5
+
+**Dependencies:** Unit 2
+
+**Files:**
+- Modify: `internal/generator/templates/client.go.tmpl`
+- Modify: `internal/generator/templates/doctor.go.tmpl`
+- Modify: `internal/generator/templates/auth_browser.go.tmpl`
+- Test: `internal/generator/generator_test.go`
+
+**Approach:**
+- In `client.go.tmpl`, add `{{range .RequiredHeaders}}` block between the auth header (line ~344) and User-Agent (line ~346)
+- Same block in `doctor.go.tmpl` auth health check request (line ~153)
+- Same block in `auth_browser.go.tmpl` auth validation request (line ~704)
+- For APIs without required headers, the range produces no output — no regression
+
+**Patterns to follow:**
+- Existing `{{if .Auth.Header}}` conditional pattern in client.go.tmpl
+- The `{{range .Tables}}` pattern in store.go.tmpl for iterating slices
+
+**Test scenarios:**
+- Happy path: Generate from spec with required headers → client.go contains `req.Header.Set("cal-api-version", "2024-08-13")`
+- Happy path: Generate from spec without required headers → client.go has no extra header lines (identical to current output)
+- Edge case: Required header with empty Value → client.go contains `req.Header.Set("X-Api-Version", "")` AND doctor.go emits a WARN for the unconfigured header
+- Integration: Full generation from petstore.yaml → binary builds, --help works, no extra headers in client
+
+**Verification:**
+- `go test ./internal/generator/...` passes
+- Generated client for a versioned-API spec sets the correct header
+- Generated client for petstore has no extra headers
+
+---
+
+- [ ] **Unit 4: Add test fixture for versioned API**
+
+**Goal:** A reusable OpenAPI test fixture that exercises required header detection
+
+**Requirements:** R1, R2
+
+**Dependencies:** Unit 2
+
+**Files:**
+- Create: `testdata/openapi/versioned-api.yaml`
+- Modify: `internal/openapi/parser_test.go`
+
+**Approach:**
+- Minimal OpenAPI spec with 5 endpoints, all requiring `X-Api-Version: 2024-01-01` header
+- One endpoint also has a per-operation optional header (should not be detected as global)
+- Used by Unit 2's parser tests as the primary positive fixture
+
+**Test scenarios:**
+- Test expectation: none -- this is a test fixture, not a feature-bearing unit
+
+**Verification:**
+- The fixture is valid OpenAPI 3.0 (parseable by kin-openapi)
+- Parser tests using this fixture pass
+
+## System-Wide Impact
+
+- **Interaction graph:** The new `RequiredHeaders` field flows from OpenAPI parser → `APISpec` → client/doctor/auth templates. No callbacks, middleware, or observers involved.
+- **Unchanged invariants:** Auth detection (`mapAuth`, `inferQueryParamAuth`) is not modified. The `mapParameters` function that maps path/query params to CLI flags is not modified. Existing generated CLIs without required headers produce identical output.
+- **API surface parity:** Three templates set HTTP headers (client, doctor, auth_browser). All three must emit required headers for consistency.
+
+## Risks & Dependencies
+
+| Risk | Mitigation |
+|------|------------|
+| False positive: Content-Type detected as global header | Explicit exclude list: Authorization, Content-Type, Accept, User-Agent |
+| Threshold too aggressive (80%) misses headers on 70% of ops | Conservative choice matches existing `filterGlobalParams`. Can lower later if needed. |
+| Default value missing from spec | Store with empty Value; template still emits header. Doctor could warn (future enhancement). |
+| Breaking existing CLIs | Range over empty slice produces no output — zero-diff for APIs without required headers |
+
+## Sources & References
+
+- **Origin document:** [docs/retros/2026-04-04-cal-com-retro.md](docs/retros/2026-04-04-cal-com-retro.md) — finding #5
+- Related code: `internal/openapi/parser.go` — `filterGlobalParams` (line 744), `inferQueryParamAuth` (line 366), `mapParameters` (line 959)
+- Related code: `internal/spec/spec.go` — `APISpec` struct (line 11)
+- Related code: `internal/generator/templates/client.go.tmpl` — header emission (line 343-346)
diff --git a/internal/generator/templates/auth_browser.go.tmpl b/internal/generator/templates/auth_browser.go.tmpl
index fbe21eae..3a339a2f 100644
--- a/internal/generator/templates/auth_browser.go.tmpl
+++ b/internal/generator/templates/auth_browser.go.tmpl
@@ -701,6 +701,9 @@ func validateComposedAuth(authHeader string) error {
 		return nil // can't validate, assume OK
 	}
 	req.Header.Set("{{if .Auth.Header}}{{.Auth.Header}}{{else}}Authorization{{end}}", authHeader)
+{{- range .RequiredHeaders}}
+	req.Header.Set("{{.Name}}", "{{.Value}}")
+{{- end}}
 	req.Header.Set("User-Agent", "{{.Name}}-pp-cli/{{.Version}}")
 
 	client := &http.Client{Timeout: 5 * time.Second}
diff --git a/internal/generator/templates/client.go.tmpl b/internal/generator/templates/client.go.tmpl
index efe0ba71..aec845bf 100644
--- a/internal/generator/templates/client.go.tmpl
+++ b/internal/generator/templates/client.go.tmpl
@@ -343,6 +343,9 @@ func (c *Client) do(method, path string, params map[string]string, body any) (js
 			req.Header.Set("{{if .Auth.Header}}{{.Auth.Header}}{{else}}Authorization{{end}}", authHeader)
 {{- end}}
 		}
+{{- range .RequiredHeaders}}
+		req.Header.Set("{{.Name}}", "{{.Value}}")
+{{- end}}
 		req.Header.Set("User-Agent", "{{.Name}}-pp-cli/{{.Version}}")
 
 		resp, err := c.HTTPClient.Do(req)
diff --git a/internal/generator/templates/doctor.go.tmpl b/internal/generator/templates/doctor.go.tmpl
index ed1ace9b..9077c299 100644
--- a/internal/generator/templates/doctor.go.tmpl
+++ b/internal/generator/templates/doctor.go.tmpl
@@ -149,6 +149,9 @@ func newDoctorCmd(flags *rootFlags) *cobra.Command {
 					authReq.URL.RawQuery = q.Encode()
 {{- else}}
 					authReq.Header.Set("{{if .Auth.Header}}{{.Auth.Header}}{{else}}Authorization{{end}}", authHeader)
+{{- end}}
+{{- range .RequiredHeaders}}
+					authReq.Header.Set("{{.Name}}", "{{.Value}}")
 {{- end}}
 					authReq.Header.Set("User-Agent", "{{.Name}}-pp-cli")
 					authResp, authErr := httpClient.Do(authReq)
diff --git a/internal/openapi/parser.go b/internal/openapi/parser.go
index 159d9b96..bea8cb8d 100644
--- a/internal/openapi/parser.go
+++ b/internal/openapi/parser.go
@@ -248,6 +248,7 @@ func parse(data []byte, lenient bool) (*spec.APISpec, error) {
 	}
 	mapResources(doc, result, resourceBasePath)
 	mapTypes(doc, result)
+	result.RequiredHeaders = detectRequiredHeaders(doc, result.Auth)
 
 	if err := result.Validate(); err != nil {
 		return nil, fmt.Errorf("validating parsed spec: %w", err)
@@ -425,6 +426,91 @@ func inferQueryParamAuth(doc *openapi3.T, name string, fallback spec.AuthConfig)
 	}
 }
 
+// detectRequiredHeaders scans all operations for required header parameters
+// and returns those appearing on >80% of operations as global required headers.
+// Auth-related and dynamic headers are excluded via case-insensitive matching.
+func detectRequiredHeaders(doc *openapi3.T, auth spec.AuthConfig) []spec.RequiredHeader {
+	if doc == nil || doc.Paths == nil {
+		return nil
+	}
+
+	// Headers to exclude (case-insensitive) — handled by other mechanisms
+	excludeHeaders := map[string]bool{
+		"authorization": true,
+		"content-type":  true,
+		"accept":        true,
+		"user-agent":    true,
+	}
+	if auth.Header != "" {
+		excludeHeaders[strings.ToLower(auth.Header)] = true
+	}
+
+	type headerInfo struct {
+		name         string
+		defaultValue string
+		count        int
+	}
+
+	headers := map[string]*headerInfo{} // keyed by lowercase name
+	totalOps := 0
+
+	for _, pathKey := range doc.Paths.InMatchingOrder() {
+		pathItem := doc.Paths.Value(pathKey)
+		if pathItem == nil {
+			continue
+		}
+		for _, op := range pathItem.Operations() {
+			if op == nil {
+				continue
+			}
+			totalOps++
+			// Use mergeParameters to respect operation-level overrides.
+			// If an operation redefines a path-level header (e.g., makes it
+			// optional or changes the default), the op-level wins.
+			merged := mergeParameters(pathItem, op)
+			for _, p := range merged {
+				if p == nil {
+					continue
+				}
+				lower := strings.ToLower(p.Name)
+				if p.In != openapi3.ParameterInHeader || !p.Required || excludeHeaders[lower] {
+					continue
+				}
+				h, ok := headers[lower]
+				if !ok {
+					h = &headerInfo{name: p.Name}
+					// Extract default value from schema
+					if p.Schema != nil && p.Schema.Value != nil {
+						if p.Schema.Value.Default != nil {
+							h.defaultValue = fmt.Sprintf("%v", p.Schema.Value.Default)
+						} else if len(p.Schema.Value.Enum) > 0 {
+							h.defaultValue = fmt.Sprintf("%v", p.Schema.Value.Enum[0])
+						}
+					}
+					headers[lower] = h
+				}
+				h.count++
+			}
+		}
+	}
+
+	if totalOps == 0 {
+		return nil
+	}
+
+	var result []spec.RequiredHeader
+	threshold := 0.8
+	for _, h := range headers {
+		if float64(h.count)/float64(totalOps) > threshold {
+			result = append(result, spec.RequiredHeader{
+				Name:  h.name,
+				Value: h.defaultValue,
+			})
+		}
+	}
+	return result
+}
+
 func selectSecurityScheme(doc *openapi3.T) (string, *openapi3.SecurityScheme) {
 	if doc == nil || doc.Components == nil || len(doc.Components.SecuritySchemes) == 0 {
 		return "", nil
diff --git a/internal/openapi/parser_test.go b/internal/openapi/parser_test.go
index abe15d33..747448d2 100644
--- a/internal/openapi/parser_test.go
+++ b/internal/openapi/parser_test.go
@@ -437,3 +437,44 @@ func TestHumanizeDescription(t *testing.T) {
 		})
 	}
 }
+
+func TestDetectRequiredHeaders(t *testing.T) {
+	t.Parallel()
+
+	t.Run("versioned API with required header on all operations", func(t *testing.T) {
+		data, err := os.ReadFile(filepath.Join("..", "..", "testdata", "openapi", "versioned-api.yaml"))
+		require.NoError(t, err)
+
+		parsed, err := Parse(data)
+		require.NoError(t, err)
+
+		require.Len(t, parsed.RequiredHeaders, 1)
+		assert.Equal(t, "X-Api-Version", parsed.RequiredHeaders[0].Name)
+		assert.Equal(t, "2024-01-01", parsed.RequiredHeaders[0].Value)
+	})
+
+	t.Run("petstore has no required headers", func(t *testing.T) {
+		data, err := os.ReadFile(filepath.Join("..", "..", "testdata", "openapi", "petstore.yaml"))
+		require.NoError(t, err)
+
+		parsed, err := Parse(data)
+		require.NoError(t, err)
+
+		assert.Empty(t, parsed.RequiredHeaders)
+	})
+
+	t.Run("stytch has no required headers (optional session headers)", func(t *testing.T) {
+		data, err := os.ReadFile(filepath.Join("..", "..", "testdata", "openapi", "stytch.yaml"))
+		require.NoError(t, err)
+
+		parsed, err := Parse(data)
+		require.NoError(t, err)
+
+		assert.Empty(t, parsed.RequiredHeaders)
+	})
+
+	t.Run("authorization header excluded even if required on all ops", func(t *testing.T) {
+		headers := detectRequiredHeaders(nil, spec.AuthConfig{})
+		assert.Empty(t, headers)
+	})
+}
diff --git a/internal/spec/spec.go b/internal/spec/spec.go
index 10dc6243..2fb754af 100644
--- a/internal/spec/spec.go
+++ b/internal/spec/spec.go
@@ -9,20 +9,30 @@ import (
 )
 
 type APISpec struct {
-	Name          string              `yaml:"name" json:"name"`
-	Description   string              `yaml:"description" json:"description"`
-	Version       string              `yaml:"version" json:"version"`
-	BaseURL       string              `yaml:"base_url" json:"base_url"`
-	BasePath      string              `yaml:"base_path,omitempty" json:"base_path,omitempty"`
-	Owner         string              `yaml:"owner,omitempty" json:"owner,omitempty"`                   // GitHub owner for import paths and Homebrew tap
-	SpecSource    string              `yaml:"spec_source,omitempty" json:"spec_source,omitempty"`       // official, community, sniffed, docs — affects generated client defaults
-	ClientPattern string              `yaml:"client_pattern,omitempty" json:"client_pattern,omitempty"` // rest (default), proxy-envelope — affects generated HTTP client
-	ProxyRoutes   map[string]string   `yaml:"proxy_routes,omitempty" json:"proxy_routes,omitempty"`     // path prefix → service name for proxy-envelope routing
-	WebsiteURL    string              `yaml:"website_url,omitempty" json:"website_url,omitempty"`       // product/company website (not the API base URL)
-	Auth          AuthConfig          `yaml:"auth" json:"auth"`
-	Config        ConfigSpec          `yaml:"config" json:"config"`
-	Resources     map[string]Resource `yaml:"resources" json:"resources"`
-	Types         map[string]TypeDef  `yaml:"types" json:"types"`
+	Name            string              `yaml:"name" json:"name"`
+	Description     string              `yaml:"description" json:"description"`
+	Version         string              `yaml:"version" json:"version"`
+	BaseURL         string              `yaml:"base_url" json:"base_url"`
+	BasePath        string              `yaml:"base_path,omitempty" json:"base_path,omitempty"`
+	Owner           string              `yaml:"owner,omitempty" json:"owner,omitempty"`                   // GitHub owner for import paths and Homebrew tap
+	SpecSource      string              `yaml:"spec_source,omitempty" json:"spec_source,omitempty"`       // official, community, sniffed, docs — affects generated client defaults
+	ClientPattern   string              `yaml:"client_pattern,omitempty" json:"client_pattern,omitempty"` // rest (default), proxy-envelope — affects generated HTTP client
+	ProxyRoutes     map[string]string   `yaml:"proxy_routes,omitempty" json:"proxy_routes,omitempty"`     // path prefix → service name for proxy-envelope routing
+	WebsiteURL      string              `yaml:"website_url,omitempty" json:"website_url,omitempty"`       // product/company website (not the API base URL)
+	Auth            AuthConfig          `yaml:"auth" json:"auth"`
+	RequiredHeaders []RequiredHeader    `yaml:"required_headers,omitempty" json:"required_headers,omitempty"`
+	Config          ConfigSpec          `yaml:"config" json:"config"`
+	Resources       map[string]Resource `yaml:"resources" json:"resources"`
+	Types           map[string]TypeDef  `yaml:"types" json:"types"`
+}
+
+// RequiredHeader represents a non-auth header that the API requires on most
+// requests (e.g., cal-api-version, Stripe-Version, anthropic-version).
+// Detected automatically from OpenAPI specs when a required header parameter
+// appears on >80% of operations.
+type RequiredHeader struct {
+	Name  string `yaml:"name" json:"name"`
+	Value string `yaml:"value" json:"value"`
 }
 
 type AuthConfig struct {
diff --git a/testdata/openapi/versioned-api.yaml b/testdata/openapi/versioned-api.yaml
new file mode 100644
index 00000000..28e3816c
--- /dev/null
+++ b/testdata/openapi/versioned-api.yaml
@@ -0,0 +1,96 @@
+openapi: "3.0.0"
+info:
+  title: Versioned API
+  version: "1.0.0"
+  description: Test fixture for required header detection
+servers:
+  - url: https://api.example.com/v2
+paths:
+  /v2/users:
+    get:
+      operationId: getUsers
+      summary: List users
+      parameters:
+        - name: X-Api-Version
+          in: header
+          required: true
+          schema:
+            type: string
+            default: "2024-01-01"
+      responses:
+        "200":
+          description: OK
+    post:
+      operationId: createUser
+      summary: Create user
+      parameters:
+        - name: X-Api-Version
+          in: header
+          required: true
+          schema:
+            type: string
+            default: "2024-01-01"
+      responses:
+        "201":
+          description: Created
+  /v2/users/{id}:
+    get:
+      operationId: getUser
+      summary: Get user by ID
+      parameters:
+        - name: id
+          in: path
+          required: true
+          schema:
+            type: string
+        - name: X-Api-Version
+          in: header
+          required: true
+          schema:
+            type: string
+            default: "2024-01-01"
+      responses:
+        "200":
+          description: OK
+    patch:
+      operationId: updateUser
+      summary: Update user
+      parameters:
+        - name: id
+          in: path
+          required: true
+          schema:
+            type: string
+        - name: X-Api-Version
+          in: header
+          required: true
+          schema:
+            type: string
+            default: "2024-01-01"
+      responses:
+        "200":
+          description: OK
+    delete:
+      operationId: deleteUser
+      summary: Delete user
+      parameters:
+        - name: id
+          in: path
+          required: true
+          schema:
+            type: string
+        - name: X-Api-Version
+          in: header
+          required: true
+          schema:
+            type: string
+            default: "2024-01-01"
+        - name: X-Request-Id
+          in: header
+          required: false
+          schema:
+            type: string
+          description: Optional per-request correlation ID (should NOT be detected as global)
+      responses:
+        "204":
+          description: No Content

← 26fe5727 fix(cli): unhide sub-resource groups when wired into promote  ·  back to Cli Printing Press  ·  feat(cli): infer API auth from spec description when securit 75ad9cdb →