Welcome back! In the previous lesson, you added bounded pagination and standardized successful JSON responses around the { data, meta } contract. The API now exposes predictable collection metadata, stable sorting, request IDs, and duration information.
In this lesson, you’ll introduce API versioning without copying the ticket feature. The canonical API will live under /api/v1, while the existing /api/tickets path remains temporarily available as a deprecated compatibility surface. Both paths will reuse the same routers, repositories, validation, authentication, authorization, workflows, and response formatting.
Why APIs Need Versions
An API contract includes more than its endpoint paths. Clients may depend on:
Request fields and validation rules.
Response structures such as { data, meta }.
Authentication requirements.
Status codes and error codes.
Pagination behavior.
Workflow transitions.
Resource names and URL layouts.
Changing these contracts in place can break existing consumers. Versioned namespaces give the server a stable place to introduce future changes while preserving older behavior during a migration period.
The canonical namespace in this project is:
text
/api/v1
Its ticket resource is available at:
text
/api/v1/tickets
The earlier path remains temporarily available:
text
/api/tickets
The old path is not a separate implementation. It is a compatibility mount that points to the same ticket router and adds migration headers.
Router-level middleware runs for every request that enters /api/v1.
X-API-Version: 1 is set before the discovery endpoint or ticket router handles the request.
Calling next() allows routing to continue while preserving the response header.
The header appears on successful discovery responses, ticket resources, validation failures, authorization failures, and other responses that pass through this router before reaching the final error handler.
This gives clients an explicit indication of the API version that handled the request.
Exposing Public Version Discovery
The canonical router provides a public discovery endpoint at GET /api/v1.
This allows clients to migrate paths without also adapting to another payload contract.
Mounting Version Routers in the Application
The final application composition is defined in src/app.ts.
import cors from "cors";import express from "express";import morgan from "morgan";import { env } from "./config/env.js";import { errorHandler } from "./middleware/error-handler.js";import { notFound } from "./middleware/not-found.js";import { requestContext } from "./middleware/request-context.js";import { apiV1Routes } from "./routes/api-v1-routes.js";import { healthRoutes } from "./routes/health-routes.js";import { legacyApiRoutes } from "./routes/legacy-api-routes.js";
The application imports the two composition routers instead of importing ticketRoutes directly.
Ticket behavior remains indirectly available through both routers.
The mounts are placed after global middleware and operational routes.
No feature handler needs to know which composition router was used.
Reusing One Ticket Router
Both composition modules import the same exported router:
import { ticketRoutes } from "./ticket-routes.js";
This means both public surfaces share:
Router-wide authentication.
Customer collection scoping.
Agent and administrator role checks.
Ticket ownership checks.
Zod schemas.
Media-type requirements.
PostgreSQL queries.
Workflow services.
Nested comments.
Method-not-allowed handling.
Success-envelope formatting.
A future bug fix in the ticket router automatically affects both mounts. There is no separate legacy implementation to update.
Generating Mount-Aware Location Headers
Location Behavior on the Compatibility Mount
Because request.baseUrl reflects the actual mount, creating through the legacy route would produce a legacy location:
POST /api/tickets
Location: /api/tickets/tkt_<generated-id>
Creating through the canonical route produces the preferred canonical location:
POST /api/v1/tickets
Location: /api/v1/tickets/tkt_<generated-id>
Clients should use the canonical surface for new integrations. The compatibility route exists to support migration, not to replace version 1 as the preferred namespace.
Versioning Without Changing Repository Logic
Repositories do not know whether a request arrived through /api/v1/tickets or /api/tickets.
For example, listTickets() still receives the same typed options:
Both paths therefore have the same security requirements:
GET /api/v1/ticketsGET /api/tickets
Both require a Bearer token.
A customer sees only their own collection through either path. Agents and administrators can access the broader collection according to the same rules.
Workflow actions remain agent- or admin-only under both namespaces:
POST /api/v1/tickets/:ticketId/closePOST /api/tickets/:ticketId/close
Versioning does not create a security bypass because both mounts reuse the same router.
Version Discovery Is Different from Ticket Discovery
The version root is public:
GET /api/v1
The ticket collection remains authenticated:
GET /api/v1/tickets
This design allows a client to learn which API version and resources exist before supplying credentials.
The legacy router does not define a matching discovery endpoint at /api. Its purpose is only to preserve the old ticket path while directing clients toward the canonical version.
Unsupported Versions
Only version 1 is registered:
app.use("/api/v1", apiV1Routes);
A request such as:
GET /api/v2
does not match a version router. It continues to the shared notFound middleware and returns the established error contract:
{ "error": { "code": "NOT_FOUND", "message": "Route GET /api/v2 was not found." }, "meta": { "requestId": "generated-request-id", "durationMs": 1 }}
The application does not silently redirect unsupported versions or treat them as version 1.
Open the Authorization tab, choose Bearer Token, and enter:
agent-token-1
Send the canonical request:
GET /api/v1/tickets?pageSize=2
Then send the compatibility request:
GET /api/tickets?pageSize=2
Both responses should contain equivalent ticket arrays under data and matching pagination fields under meta, assuming the database did not change between requests.
The canonical response should include:
X-API-Version: 1
The legacy response should include:
Deprecation: trueSunset: Thu, 31 Dec 2026 23:59:59 GMTLink: </api/v1>; rel="successor-version"
Only the compatibility route advertises deprecation.
Creating Through the Canonical API
With agent-token-1, send:
POST /api/v1/tickets
Use a JSON body such as:
{ "title": "Versioned route creation check", "description": "Created through the canonical API namespace.", "priority": "medium"}
The request should return 201 Created with the ticket under data.
Inspect the Location response header:
Location: /api/v1/tickets/<new-id>
The path should begin with /api/v1/tickets/, proving that ticket creation adapts to its mount rather than relying on a hard-coded legacy path.
Reading a Canonical Resource Through the Legacy Route
Copy the new ticket ID and request it through the compatibility path:
GET /api/tickets/<new-id>
The ticket should be returned because both mounts use the same repository and PostgreSQL data.
The response should also include the legacy migration headers. This demonstrates that versioning affects routing and response metadata, not database separation.
Checking the Same Resource Canonically
Request the ticket through its canonical location:
GET /api/v1/tickets/<new-id>
The data object should match the legacy response’s ticket representation.
The canonical response includes X-API-Version: 1, while the legacy response includes migration headers.
Observing Headers on Errors
Version and migration middleware run before the ticket feature. Their headers therefore remain available even when a nested route produces an error.
For example:
GET /api/v1/tickets/tkt_missing
should return TICKET_NOT_FOUND and include:
X-API-Version: 1
The equivalent legacy request:
GET /api/tickets/tkt_missing
should return the same error body while including:
Deprecation: trueSunset: Thu, 31 Dec 2026 23:59:59 GMTLink: </api/v1>; rel="successor-version"
The centralized error handler remains shared across both surfaces.
Using curl to Inspect the Full Response
The -i option includes response headers, which makes it useful for verifying version metadata.
The JSON body should use the same { data, meta } envelope shown in the API Simulator.
For the compatibility route, change the path to:
http://localhost:4000/api/tickets?pageSize=2
and inspect the deprecation headers.
Preserving Application Middleware Order
Versioned routing does not replace the established Express pipeline.
The final order remains:
requestContext ↓CORS ↓JSON and URL-encoded parsers ↓request logging ↓health routes ↓canonical API router ↓legacy compatibility router ↓notFound ↓errorHandler
This order matters:
Request IDs exist before version or feature routes run.
JSON is parsed before validation middleware reads request bodies.
Version and deprecation headers are set before ticket responses are generated.
Unmatched paths reach notFound.
Thrown or delegated errors reach the final four-argument handler.
Avoiding Common Versioning Mistakes
Do not copy the ticket router into a separate ticket-v1-routes.ts merely to change the URL prefix. That would duplicate behavior without creating a genuinely different contract.
Do not duplicate repository functions for version 1. PostgreSQL access is independent of the route namespace.
Do not mount authentication across all of /api/v1, because the discovery endpoint is intentionally public.
Do not hard-code creation locations. request.baseUrl keeps the resource path aligned with the current mount.
Do not remove the legacy route without migration metadata. The purpose of the compatibility surface is to keep old clients working while clearly advertising the successor.
The compatibility API temporarily preserves the same ticket operations beneath:
/api/tickets
Clients should migrate toward /api/v1, using the legacy response headers as guidance.
Recap
You’ve introduced a canonical versioned namespace without duplicating the ticket feature.
apiV1Routes sets X-API-Version: 1, provides public discovery at GET /api/v1, and mounts the existing ticket router at /tickets. legacyApiRoutes mounts the same ticket router while adding Deprecation, Sunset, and successor-version Link headers.
The application now mounts the canonical router at /api/v1 and the compatibility router at /api. Authentication, authorization, validation, repositories, workflows, pagination, and response envelopes remain owned by shared code.
Finally, request.baseUrl allows ticket creation to generate a Location header that matches the route used for the request. The API can now evolve through explicit namespaces while preserving a controlled migration path for existing clients.
import cors from "cors";import express from "express";import morgan from "morgan";import { env } from "./config/env.js";import { errorHandler } from "./middleware/error-handler.js";import { notFound } from "./middleware/not-found.js";import { requestContext } from "./middleware/request-context.js";import { apiV1Routes } from "./routes/api-v1-routes.js";import { healthRoutes } from "./routes/health-routes.js";import { legacyApiRoutes } from "./routes/legacy-api-routes.js";