PCSalt
YouTube GitHub
Back to Architecture
Architecture · 4 min read

API Versioning — URL, Header & Content Negotiation Strategies

Compare API versioning strategies — URL path, custom header, and content negotiation. Includes Spring Boot 4 built-in versioning support and decision framework.


You ship v1 of your API. Clients integrate. Then you need a breaking change — rename a field, restructure a response, remove an endpoint. Without versioning, you break every client at once.

API versioning lets you evolve your API without forcing all consumers to update simultaneously. But which strategy should you pick?

The three main strategies

1. URL path versioning

The most common approach. The version is part of the URL:

GET /api/v1/users
GET /api/v2/users

Pros:

  • Immediately visible — you can tell the version from the URL
  • Easy to route in load balancers and API gateways
  • Simple to test with curl or a browser
  • Cache-friendly — different URLs mean different cache entries

Cons:

  • Pollutes the URL space — the resource identity changes with each version
  • Clients must update URLs when migrating
  • Can lead to duplicate code if not structured carefully

2. Custom header versioning

The version travels in a request header:

GET /api/users
X-API-Version: 2

Or using Accept-Version:

GET /api/users
Accept-Version: v2

Pros:

  • Clean URLs — resource identity stays the same
  • Easy to default to the latest version if no header is sent

Cons:

  • Invisible in browser URLs and logs
  • Harder to test — you need to set headers explicitly
  • Some caching layers don’t vary on custom headers by default

3. Content negotiation (Accept header)

Use the standard Accept header with a vendor media type:

GET /api/users
Accept: application/vnd.myapp.v2+json

Pros:

  • Follows HTTP standards (RFC 6838)
  • Lets you version individual representations, not endpoints
  • Most “RESTful” approach

Cons:

  • Complex for clients — they need to construct media types
  • Harder to document
  • Not human-readable in logs without parsing

Spring Boot 4 built-in API versioning

Spring Boot 4 (Spring Framework 7) adds first-class API versioning support. No more custom RequestMappingHandlerMapping hacks.

Enabling versioning

package com.example.demo.config

import org.springframework.context.annotation.Configuration
import org.springframework.web.servlet.config.annotation.ApiVersionConfigurer
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer

@Configuration
class WebConfig : WebMvcConfigurer {

  override fun configureApiVersioning(configurer: ApiVersionConfigurer) {
    configurer
      .useUrlPath()
      .defaultVersion("1")
  }
}

Versioned controllers

package com.example.demo.controller

import com.example.demo.dto.UserResponseV1
import com.example.demo.dto.UserResponseV2
import com.example.demo.service.UserService
import org.springframework.web.bind.annotation.ApiVersion
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
import java.util.UUID

@RestController
@RequestMapping("/api/users")
class UserV1Controller(
  private val userService: UserService
) {

  @ApiVersion("1")
  @GetMapping("/{id}")
  fun getUser(@PathVariable id: UUID): UserResponseV1 {
    return userService.findByIdV1(id)
  }
}

@RestController
@RequestMapping("/api/users")
class UserV2Controller(
  private val userService: UserService
) {

  @ApiVersion("2")
  @GetMapping("/{id}")
  fun getUser(@PathVariable id: UUID): UserResponseV2 {
    return userService.findByIdV2(id)
  }
}

Requests to /api/v1/users/123 route to UserV1Controller. Requests to /api/v2/users/123 route to UserV2Controller.

Header-based versioning

Switch to header-based by changing the configurer:

override fun configureApiVersioning(configurer: ApiVersionConfigurer) {
  configurer
    .useRequestHeader("X-API-Version")
    .defaultVersion("1")
}

Now the same URLs work — the version comes from the header.

Content negotiation versioning

override fun configureApiVersioning(configurer: ApiVersionConfigurer) {
  configurer
    .useMediaTypeParameter("version")
    .defaultVersion("1")
}

Clients send:

GET /api/users/123
Accept: application/json;version=2

What changes between versions?

Not every change needs a new version. Here’s the decision framework:

Breaking changes (need new version)

  • Removing a field from a response
  • Renaming a field
  • Changing a field’s type (string to number)
  • Removing an endpoint
  • Changing the meaning of a parameter
  • Changing authentication requirements

Non-breaking changes (no new version needed)

  • Adding a new field to a response
  • Adding a new endpoint
  • Adding an optional query parameter
  • Adding a new enum value (if clients handle unknowns)
  • Performance improvements
  • Bug fixes

The rule

Adding is safe. Removing or changing is breaking.

If clients follow the robustness principle (ignore unknown fields), you can evolve a lot without versioning.

Implementation patterns

Shared service layer

Don’t duplicate business logic for each version. Version the DTOs and controllers, share the service:

@Service
class UserService(
  private val userRepository: UserRepository
) {

  fun findByIdV1(id: UUID): UserResponseV1 {
    val user = userRepository.findById(id)
      .orElseThrow { UserNotFoundException(id) }
    return user.toV1Response()
  }

  fun findByIdV2(id: UUID): UserResponseV2 {
    val user = userRepository.findById(id)
      .orElseThrow { UserNotFoundException(id) }
    return user.toV2Response()
  }
}

Versioned DTOs

// V1 — original response
data class UserResponseV1(
  val id: UUID,
  val name: String,
  val email: String
)

// V2 — split name into firstName/lastName, add role
data class UserResponseV2(
  val id: UUID,
  val firstName: String,
  val lastName: String,
  val email: String,
  val role: String
)

Mapping functions

fun User.toV1Response() = UserResponseV1(
  id = id!!,
  name = "$firstName $lastName",
  email = email
)

fun User.toV2Response() = UserResponseV2(
  id = id!!,
  firstName = firstName,
  lastName = lastName,
  email = email,
  role = role.name
)

Deprecation strategy

When you release v2, don’t immediately kill v1:

  1. Release v2 — announce it, document the migration path
  2. Deprecate v1 — add Sunset and Deprecation headers to v1 responses
  3. Monitor v1 usage — track which clients still use it
  4. Set a sunset date — give clients a deadline (3–6 months is typical)
  5. Remove v1 — after the sunset date, return 410 Gone

Sunset headers

@ApiVersion("1")
@GetMapping("/{id}")
fun getUser(@PathVariable id: UUID): ResponseEntity<UserResponseV1> {
  val user = userService.findByIdV1(id)
  return ResponseEntity.ok()
    .header("Sunset", "Sat, 01 Jul 2026 00:00:00 GMT")
    .header("Deprecation", "true")
    .header("Link", "</api/v2/users>; rel=\"successor-version\"")
    .body(user)
}

Which strategy to pick?

CriteriaURL PathHeaderContent Negotiation
SimplicityBestGoodComplex
VisibilityURL shows versionHiddenHidden
CachingEasyNeeds Vary headerNeeds Vary header
API Gateway supportUniversalMostSome
REST purityLeast pureBetterBest

Default choice: URL path versioning. It’s the most widely understood, easiest to implement, and works everywhere. GitHub, Stripe, and Google use it.

Use header versioning when: you have a small number of API consumers you control, and you want clean URLs.

Use content negotiation when: you’re building a hypermedia API or need per-resource versioning.

Common mistakes

Versioning too early. Don’t version until you have a breaking change. Start with v1 and stay there as long as possible.

Versioning too granularly. Version the whole API, not individual endpoints. Mixed versions (v1 for users, v3 for orders) create confusion.

Keeping old versions forever. Every active version is maintenance burden. Sunset aggressively.

Not documenting the migration path. When you release v2, publish a migration guide showing exactly what changed and how to update.

API versioning is a necessary cost of API evolution. Pick the simplest strategy that works for your use case, version only when needed, and retire old versions on a schedule.