Every API program starts with a style guide document, and most end with it unread in a wiki while specs quietly grow duplicate operationIds, 200 responses with no body, GET requests that take secrets in the query string, and a dozen subtly different error shapes. Linting turns the guide into something that actually runs: a machine-readable ruleset that fails the pull request. Spectral is the standard OpenAPI linter for exactly this job, and the value is not its built-in rules, it is encoding your team's conventions once and enforcing them forever.

Run it locally and in CI

Spectral takes a ruleset file (YAML or JS) and lints one or more documents:

npm install --save-dev @stoplight/spectral-cli
npx spectral lint openapi/openapi.yaml --ruleset openapi/.spectral.yaml

A minimal CI step fails the build on errors while letting warnings pass:

# .github/workflows/openapi-lint.yml (excerpt)
- name: Lint OpenAPI
  run: npx spectral lint openapi/openapi.yaml --ruleset openapi/.spectral.yaml --fail-severity=error

--fail-severity=error is the key split: conventions you are still rolling out are warn and do not block, while hard contracts are error and do. Add the same command to a pre-commit hook so authors get feedback before they open the PR.

A practical ruleset

Start by extending the OpenAPI recommended rules, then turn the style guide into explicit rules:

# openapi/.spectral.yaml
extends:
  - spectral:oas
rules:
  # ---- Documentation completeness (warnings during rollout) ----
  operation-operationId: error
  operation-summary: warn
  operation-description: warn
  info-contact: error
  info-description: error

  # ---- operationId conventions ----
  operation-id-camel-case:
    message: operationId must be lowerCamelCase and verb-first.
    severity: error
    given: $.paths[*][*]
    then:
      field: operationId
      function: pattern
      functionOptions:
        match: '^(list|get|create|update|patch|delete|archive|cancel|approve|export)[A-Z][A-Za-z0-9]*$'

  operation-id-unique:
    message: operationId must be unique across the document.
    severity: error
    given: $.paths[*][*]
    then:
      field: operationId
      function: enumeration
      # uniqueness is enforced by the built-in operation-operationId-unique;
      # this entry is illustrative of naming policy.

  # ---- Tag hygiene ----
  tags-declared-at-root:
    message: Every tag used by an operation must be declared in the root tags array.
    severity: error
    given: $.paths[*][*].tags[*]
    then:
      function: in
      functionOptions:
        values:
          - Users
          - Orders
          - Billing
          - Webhooks

  # ---- Error and response contracts ----
  error-response-shape:
    message: Every 4xx/5xx response must reference the ProblemDetail schema.
    severity: error
    given: $.paths[*][*].responses[?@property.match(/^(4|5)/)]
    then:
      field: content.application/json.schema.$ref
      function: truthy

  no-200-without-body:
    message: A 200 response must define a response body or be explicitly 204.
    severity: warn
    given: $.paths[*][*].responses.200
    then:
      field: content
      function: truthy

  # ---- Security ----
  no-secrets-in-query:
    message: Never pass tokens or passwords as query parameters; use a security scheme.
    severity: error
    given: $.paths[*][*].parameters[*]
    then:
      field: name
      function: pattern
      functionOptions:
        notMatch: '(?i)(token|password|secret|apikey|api_key)'

  # ---- Consistency ----
  array-responses-wrapped:
    message: Prefer an object envelope for top-level arrays (pagination consistency).
    severity: warn
    given: $.paths[*][get].responses.200.content.application/json.schema
    then:
      field: type
      function: pattern
      functionOptions:
        notMatch: '^array$'

The exact JSONPath in a couple of these is illustrative; Spectral's given uses JSONPath-plus, and for cross-field logic you write a custom function rather than overloading the built-in ones. The pattern to internalize is: a rule has a given selector for where it applies, a then assertion (built-in function or your own), and a severity.

Custom functions for cross-field rules

Many real conventions cannot be expressed as "this field matches a regex," for example "every write operation that returns 202 must also document a Location header," or "every enum used in a response must have an unknown-value policy note." Custom functions live in a functions/ directory next to the ruleset:

// openapi/functions/hasLocationHeaderFor202.js
module.exports = (response, _options, context) => {
  if (context.path[context.path.length - 1] !== "202") return;
  const headers = response.headers || {};
  if (!headers.Location) {
    return [
      {
        message: "A 202 Accepted response must document a Location header.",
        path: [...context.path, "headers"],
      },
    ];
  }
};

Register it in the ruleset and attach it:

functions: [hasLocationHeaderFor202]
rules:
  async-202-needs-location:
    severity: error
    given: $.paths[*][*].responses
    then:
      function: hasLocationHeaderFor202

Custom functions are plain JavaScript receiving the matched node and a context; return an array of results (empty means the rule passed). This is where you encode the genuinely domain-specific contracts from your architecture reviews, such as "money fields use amount_minor plus currency" or "every paginated list has cursor parameters."

Errors, warnings, and the rollout path

Turning on twenty errors at once on a legacy spec produces thousands of violations and the team disables the linter. Roll out in stages:

  1. Run with everything as warn and capture the baseline count.
  2. Freeze new violations by failing CI only on changed files (or by generating an allowlist snapshot that only shrinks).
  3. Promote rules to error as the affected areas are cleaned.
  4. Reserve error for things that cause real breakage: duplicate ids, invalid references, missing error bodies, secrets in the URL, security-scheme gaps.

Spectral supports exceptions per path via the except map or document-level overrides, which is preferable to deleting a rule for one legacy endpoint; the exception documents the debt and keeps the rule active everywhere else.

Lint more than the spec

The same ruleset should run on specs generated from code and specs authored by hand, so a reverse-engineered document is held to the same bar as a hand-written one. Two adjacent checks belong in the same pipeline:

  • Structural validity and resolution: every $ref resolves, the document passes the OpenAPI schema (Spectral covers much of this).
  • Breaking-change detection: a separate diff step compares the spec against the released version and blocks unannounced removals. Linting governs internal style; the diff governs external compatibility. They are complementary and both belong in CI.

You can also lint examples against their schemas and validate that operationIds referenced by mocks, tests, and agent tool configs still exist, which catches a renamed id that would otherwise break generated clients silently.

What this buys codegen and AI callers

Lint rules are what make the conventions in the rest of this series non-optional: unique verb-first operationIds produce usable SDK method names; declared tags produce stable namespaces; consistent error bodies mean generated error handling works the same way on every endpoint; documented security schemes mean an AI agent authenticates correctly instead of inventing a header. An agent or code generator is only as consistent as the spec it reads, and a linter is how you guarantee that consistency at scale.

Checklist

  1. Add Spectral with a committed ruleset; run it locally, in pre-commit, and in CI with --fail-severity=error.
  2. Extend the recommended OpenAPI rules and encode your style guide as explicit rules with messages that say how to fix the issue.
  3. Cover operationId uniqueness and casing, declared tags, response bodies, error shape, and no secrets in query parameters.
  4. Write custom functions for cross-field contracts (202 needs Location, money needs currency, pagination needs cursors).
  5. Roll out legacy areas as warnings with a shrinking allowlist; promote real-breakage rules to errors.
  6. Use path-level exceptions for known debt instead of disabling rules.
  7. Run the same ruleset on hand-written and code-generated specs, and pair linting with a breaking-change diff.

Get these in place and your API style stops being a document people agree with and ignore, and becomes a gate the build enforces on every change.

You can author the ruleset, lint and fix a spec, and generate a conformant client in one local-first workspace, right in your browser. For the external-compatibility gate that pairs with internal linting, see detecting breaking API changes with OpenAPI diffs in CI.