The structural shift: from paths to requests

In OpenAPI 3.1, the top-level structure revolves around paths:

# OpenAPI 3.1, path-centric
openapi: "3.1.0"
paths:
 /users/{userId}:
 get:
 operationId: getUser
 parameters:
 - name: userId
 in: path
 required: true
 schema:
 type: string
 responses:
 "200":
 content:
 application/json:
 schema:
 $ref: "#/components/schemas/User"

OpenAPI 4.0 reorganizes around requests and pathItems:

# OpenAPI 4.0, request-centric
openapi: "4.0.0"
info:
 title: User Service
 version: "2.0"

pathItems:
 user:
 parameters:
 userId:
 in: path
 schema:
 type: string
 requests:
 getUser:
 method: get
 outcomes:
 success:
 status: 200
 contentType: application/json
 body:
 schema:
 $ref: "#/components/schemas/User"
 notFound:
 status: 404
 contentType: application/problem+json
 body:
 schema:
 $ref: "#/components/schemas/Problem"

urls:
 /users/{userId}: { $ref: "#/pathItems/user" }
 /v2/users/{userId}: { $ref: "#/pathItems/user" }

Notice several things. First, pathItems are decoupled from URLs. The same user pathItem serves both /users/{userId} and /v2/users/{userId}. In 3.1, you'd duplicate the entire path definition or use $ref gymnastics. Second, responses are now outcomes, named semantic results rather than status-code-indexed entries. success and notFound are more meaningful than "200" and "404". Third, parameters belong to the pathItem, not repeated per operation.

Overlays: layering without forking

This is a long-awaited feature. Overlays let you take a base OpenAPI document and apply modifications without editing the original. Think of it like CSS overrides for your API spec.

# base.yaml, your canonical API spec
openapi: "4.0.0"
info:
 title: Payment API
 version: "1.0"
pathItems:
 charge:
 requests:
 createCharge:
 method: post
 body:
 schema:
 $ref: "#/components/schemas/ChargeRequest"
 outcomes:
 created:
 status: 201
 body:
 schema:
 $ref: "#/components/schemas/Charge"
urls:
 /charges: { $ref: "#/pathItems/charge" }
# overlay-staging.yaml, staging environment specifics
overlay: "1.0.0"
info:
 title: Staging Overlay
extends: base.yaml
actions:
 - target: "$.info"
 update:
 description: "STAGING ENVIRONMENT, test data only"
 - target: "$.servers"
 update:
 - url: https://staging-api.example.com
 description: Staging
 - target: "$.pathItems.charge.requests.createCharge"
 update:
 security:
 - apiKey: [] # Simpler auth in staging
# overlay-production.yaml
overlay: "1.0.0"
extends: base.yaml
actions:
 - target: "$.servers"
 update:
 - url: https://api.example.com
 description: Production US-East
 - url: https://eu-api.example.com
 description: Production EU-West
 - target: "$.pathItems.charge.requests.createCharge"
 update:
 security:
 - oauth2:
 scopes: ["charges:write"]
 rateLimit:
 limit: 100
 window: 60s

Build the final spec for each environment:

# Merge base + overlay
redocly bundle base.yaml --overlay overlay-production.yaml -o production-spec.yaml
redocly bundle base.yaml --overlay overlay-staging.yaml -o staging-spec.yaml

This eliminates the "copy the spec and change the server URL" pattern that plagues every multi-environment API. The base spec is your single source of truth. Overlays adapt it per environment, per partner, per version.

First-class webhooks

In 3.1, webhooks were bolted on as a top-level webhooks field, structurally identical to paths but semantically different. In 4.0, they're integrated into the request/outcome model:

webhooks:
 paymentCompleted:
 description: Fired when a payment is successfully processed
 requests:
 notify:
 method: post
 contentType: application/json
 body:
 schema:
 type: object
 required: [event, data]
 properties:
 event:
 type: string
 const: payment.completed
 data:
 $ref: "#/components/schemas/Payment"
 timestamp:
 type: string
 format: date-time
 outcomes:
 acknowledged:
 status: 200
 description: Webhook received and acknowledged
 retry:
 status: [500, 502, 503]
 description: Server error, webhook will be retried
 delivery:
 retryPolicy:
 maxRetries: 5
 backoff: exponential
 initialDelay: 30s
 signing:
 algorithm: hmac-sha256
 headerName: X-Webhook-Signature

The delivery section is new, it documents retry policies and signing mechanisms as part of the spec. In 3.1, this information lived in your docs' prose. Now it's machine-readable, and code generators can produce webhook verification code automatically.

Migration from 3.1: the practical path

A 2,400-line OpenAPI 3.1 spec migration to 4.0 follows this process:

# Step 1: Automated conversion
npx @redocly/cli transform openapi-3.1.yaml --to 4.0 -o openapi-4.0.yaml

# Step 2: Validate the output
npx @redocly/cli lint openapi-4.0.yaml --ruleset recommended

# Step 3: Manual cleanup, name your outcomes semantically
# The converter uses status codes as outcome names; rename them

The automated converter handles ~85% of the transformation. The remaining 15% is:

  1. Naming outcomes: The converter maps "200" to outcome200. Rename to success, created, notFound, etc.
  2. Extracting shared pathItems: If you had duplicated path definitions, consolidate them.
  3. Webhook delivery metadata: Add retry policies and signing info that didn't exist in 3.1.
  4. Parameter deduplication: Move shared parameters from operations to pathItems.

The total migration takes about 6 hours for a 2,400-line spec. Most of the time was spent on semantic naming.

Code generation reality check

Here's the honest state of tooling as of January 2026:

Tool 4.0 Support Notes
Redocly CLI Full Linting, bundling, overlays
Swagger UI Partial Renders but misses outcomes
OpenAPI Generator Beta Java/Python/TypeScript clients
Stoplight Full Design-first editor
Prism (mock server) Partial Basic mocking works
Postman Partial Import works, some features missing

If your workflow depends on a specific tool, check its 4.0 support before migrating. The spec is ahead of the tooling ecosystem by roughly 6 months, which is normal for a major version.

Real-world spec design patterns

After extended work with 4.0, these patterns emerge:

Pattern: Shared error outcomes

components:
 outcomes:
 validationError:
 status: 422
 contentType: application/problem+json
 body:
 schema:
 $ref: "#/components/schemas/ValidationProblem"
 unauthorized:
 status: 401
 contentType: application/problem+json
 body:
 schema:
 $ref: "#/components/schemas/AuthProblem"
 rateLimited:
 status: 429
 headers:
 Retry-After:
 schema:
 type: integer

pathItems:
 charge:
 requests:
 createCharge:
 method: post
 outcomes:
 created:
 status: 201
 body:
 schema:
 $ref: "#/components/schemas/Charge"
 validation:
 $ref: "#/components/outcomes/validationError"
 auth:
 $ref: "#/components/outcomes/unauthorized"
 throttled:
 $ref: "#/components/outcomes/rateLimited"

Shared outcomes eliminate the "copy error responses to every endpoint" problem. Define your error shapes once, reference everywhere.

Should you migrate now?

Honest assessment: for new API projects and your toolchain supports 4.0, use it. The structural improvements (overlays, decoupled pathItems, semantic outcomes) make specs easier to maintain as they grow. If you have a large existing 3.1 spec that works fine, wait 6-12 months for tooling to mature. The migration itself is straightforward but the ecosystem needs to catch up.

The biggest win is overlays. Going from maintaining four copies of a spec (dev, staging, prod, partner) to one base plus four 20-line overlay files represents significant improvement. That alone justifies the migration effort.