TL;DR
- Use URL versioning for major changes (v1 → v2), header versioning for minor changes—each has different caching implications
- Always support N-1 versions minimum; implement force update only for critical security fixes, not feature pushes
- A/B test new API versions with 10-20% rollout first—hash-based user bucketing ensures consistent experience per user
Best for: Mobile developers, backend teams supporting mobile clients, anyone managing multi-version API ecosystems
Skip if: Your app has mandatory auto-updates or you control all client deployments
Read time: 18 minutes
API versioning is critical for mobile applications where users don’t always update to the latest version. While API contract testing ensures compatibility between frontend and backend, versioning strategy handles the complexity of supporting multiple client versions in production. This guide covers versioning strategies, backward compatibility, forced updates, and testing multiple API versions simultaneously.
For comprehensive API testing fundamentals, see API Testing Mastery. Mobile teams should explore Mobile Testing 2025 for platform-specific strategies, and Appium 2.0 Architecture for automated mobile testing approaches that complement API versioning tests.
Versioning Strategies
URL Versioning
// Clear, explicit versioning in URL path
val BASE_URL_V1 = "https://api.example.com/v1/"
val BASE_URL_V2 = "https://api.example.com/v2/"
interface ApiServiceV1 {
@GET("users/{id}")
suspend fun getUser(@Path("id") id: String): UserV1
}
interface ApiServiceV2 {
@GET("users/{id}")
suspend fun getUser(@Path("id") id: String): UserV2
}
Header Versioning
class VersionInterceptor(private val apiVersion: String) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request().newBuilder()
.addHeader("API-Version", apiVersion)
.build()
return chain.proceed(request)
}
}
// Usage
val client = OkHttpClient.Builder()
.addInterceptor(VersionInterceptor("2.0"))
.build()
Content Negotiation
@Headers("Accept: application/vnd.example.v2+json")
@GET("users/{id}")
suspend fun getUserV2(@Path("id") id: String): UserV2
Backward Compatibility Testing
Multi-Version Test Suite
Testing multiple API versions requires comprehensive coverage across all supported client versions. As discussed in API testing mastery, a well-structured test suite validates both functional and non-functional requirements.
class MultiVersionApiTest {
@Test
fun testUserEndpointV1() = runTest {
val apiV1 = createApiClient(version = "v1")
val user = apiV1.getUser("123")
// V1 response structure
assertNotNull(user.id)
assertNotNull(user.name)
assertNotNull(user.email)
}
@Test
fun testUserEndpointV2() = runTest {
val apiV2 = createApiClient(version = "v2")
val user = apiV2.getUser("123")
// V2 adds additional fields
assertNotNull(user.id)
assertNotNull(user.fullName) // renamed from 'name'
assertNotNull(user.email)
assertNotNull(user.phoneNumber) // new field
assertNotNull(user.preferences) // new nested object
}
@Test
fun testDeprecationWarnings() = runTest {
val apiV1 = createApiClient(version = "v1")
val response = apiV1.getUserRaw("123")
// Check for deprecation header
val deprecationWarning = response.headers()["Deprecation-Warning"]
assertTrue(deprecationWarning?.contains("This API version is deprecated") == true)
}
}
Compatibility Layer
// Adapter pattern for version compatibility
interface UserRepository {
suspend fun getUser(id: String): User
}
class UserRepositoryV1(private val api: ApiServiceV1) : UserRepository {
override suspend fun getUser(id: String): User {
val userV1 = api.getUser(id)
return User(
id = userV1.id,
fullName = userV1.name, // map old 'name' to 'fullName'
email = userV1.email,
phoneNumber = null, // not available in V1
preferences = UserPreferences() // default for V1
)
}
}
class UserRepositoryV2(private val api: ApiServiceV2) : UserRepository {
override suspend fun getUser(id: String): User {
return api.getUser(id) // direct mapping
}
}
// Factory
class RepositoryFactory {
fun createUserRepository(apiVersion: String): UserRepository {
return when (apiVersion) {
"v1" -> UserRepositoryV1(apiV1)
"v2" -> UserRepositoryV2(apiV2)
else -> throw UnsupportedVersionException()
}
}
}
@Test
fun testCompatibilityLayer() = runTest {
val repoV1 = RepositoryFactory().createUserRepository("v1")
val repoV2 = RepositoryFactory().createUserRepository("v2")
val userFromV1 = repoV1.getUser("123")
val userFromV2 = repoV2.getUser("123")
// Both return same User model
assertEquals(userFromV1.id, userFromV2.id)
assertEquals(userFromV1.fullName, userFromV2.fullName)
}
Force Update Strategy
Version Check Endpoint
data class VersionResponse(
val minimumVersion: String,
val latestVersion: String,
val forceUpdate: Boolean,
val message: String
)
interface VersionCheckService {
@GET("version/check")
suspend fun checkVersion(
@Query("platform") platform: String,
@Query("currentVersion") currentVersion: String
): VersionResponse
}
class VersionChecker(private val versionService: VersionCheckService) {
suspend fun checkForUpdate(): UpdateStatus {
val currentVersion = BuildConfig.VERSION_NAME
val platform = "android"
val response = versionService.checkVersion(platform, currentVersion)
return when {
response.forceUpdate -> UpdateStatus.ForceUpdate(response.message)
isNewerVersion(currentVersion, response.latestVersion) ->
UpdateStatus.OptionalUpdate(response.latestVersion)
else -> UpdateStatus.UpToDate
}
}
private fun isNewerVersion(current: String, latest: String): Boolean {
val currentParts = current.split(".").map { it.toInt() }
val latestParts = latest.split(".").map { it.toInt() }
for (i in 0 until maxOf(currentParts.size, latestParts.size)) {
val currentPart = currentParts.getOrElse(i) { 0 }
val latestPart = latestParts.getOrElse(i) { 0 }
if (latestPart > currentPart) return true
if (latestPart < currentPart) return false
}
return false
}
}
@Test
fun testVersionComparison() {
val checker = VersionChecker(mockService)
assertTrue(checker.isNewerVersion("1.0.0", "1.0.1"))
assertTrue(checker.isNewerVersion("1.0.0", "2.0.0"))
assertFalse(checker.isNewerVersion("1.0.1", "1.0.0"))
assertFalse(checker.isNewerVersion("1.0.0", "1.0.0"))
}
@Test
fun testForceUpdateDetection() = runTest {
mockService.respondWith(VersionResponse(
minimumVersion = "2.0.0",
latestVersion = "2.1.0",
forceUpdate = true,
message = "Please update to continue"
))
val status = VersionChecker(mockService).checkForUpdate()
assertTrue(status is UpdateStatus.ForceUpdate)
assertEquals("Please update to continue", (status as UpdateStatus.ForceUpdate).message)
}
Force Update UI
class ForceUpdateActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val message = intent.getStringExtra("message") ?: "Update required"
AlertDialog.Builder(this)
.setTitle("Update Required")
.setMessage(message)
.setCancelable(false)
.setPositiveButton("Update") { _, _ ->
openPlayStore()
}
.show()
}
private fun openPlayStore() {
val appPackageName = packageName
try {
startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("market://details?id=$appPackageName")))
} catch (e: ActivityNotFoundException) {
startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("https://play.google.com/store/apps/details?id=$appPackageName")))
}
}
}
Graceful Degradation
Feature Flags Based on API Version
class FeatureManager(private val apiVersion: String) {
fun isFeatureAvailable(feature: Feature): Boolean {
return when (feature) {
Feature.USER_PREFERENCES -> apiVersion >= "2.0"
Feature.PUSH_NOTIFICATIONS -> apiVersion >= "1.5"
Feature.DARK_MODE -> apiVersion >= "2.1"
else -> true
}
}
}
@Composable
fun UserProfile(featureManager: FeatureManager) {
Column {
Text("User Profile")
if (featureManager.isFeatureAvailable(Feature.USER_PREFERENCES)) {
PreferencesSection()
}
if (featureManager.isFeatureAvailable(Feature.DARK_MODE)) {
ThemeToggle()
}
}
}
@Test
fun testFeatureAvailability() {
val managerV1 = FeatureManager("1.0")
val managerV2 = FeatureManager("2.0")
assertFalse(managerV1.isFeatureAvailable(Feature.USER_PREFERENCES))
assertTrue(managerV2.isFeatureAvailable(Feature.USER_PREFERENCES))
}
A/B Testing Different API Versions
A/B testing API versions in mobile applications allows gradual rollout and performance comparison before full deployment.
Gradual Rollout Strategy
class ApiVersionSelector(
private val userId: String,
private val rolloutPercentage: Int
) {
fun selectApiVersion(): String {
val userHash = userId.hashCode().absoluteValue
val bucket = userHash % 100
return if (bucket < rolloutPercentage) {
"v2" // New version
} else {
"v1" // Old version
}
}
}
@Test
fun testGradualRollout() {
// 20% rollout
val selector = ApiVersionSelector("user123", rolloutPercentage = 20)
val versions = (1..1000).map {
ApiVersionSelector("user$it", 20).selectApiVersion()
}
val v2Count = versions.count { it == "v2" }
val percentage = (v2Count.toDouble() / versions.size) * 100
// Should be approximately 20%
assertTrue(percentage in 15.0..25.0)
}
Analytics for Version Performance
In microservices architectures, tracking metrics across API versions becomes even more critical for identifying performance bottlenecks and compatibility issues.
class ApiMetrics {
fun trackApiCall(version: String, endpoint: String, success: Boolean, latency: Long) {
analytics.logEvent("api_call") {
param("version", version)
param("endpoint", endpoint)
param("success", success)
param("latency_ms", latency)
}
}
}
class VersionAwareInterceptor(private val metrics: ApiMetrics) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request()
val version = extractVersion(request)
val startTime = System.currentTimeMillis()
val response = chain.proceed(request)
val latency = System.currentTimeMillis() - startTime
val success = response.isSuccessful
metrics.trackApiCall(version, request.url.encodedPath, success, latency)
return response
}
}
Deprecation Strategy
Gradual Deprecation Process
data class DeprecationInfo(
val deprecatedAt: String,
val sunsetDate: String,
val migrationGuide: String
)
@GET("users/{id}")
suspend fun getUser(
@Path("id") id: String,
@Header("API-Version") version: String = "1.0"
): Response<User>
// Server returns deprecation headers:
// Deprecation-Warning: API v1.0 is deprecated. Please migrate to v2.0
// Sunset: 2024-12-31
// Migration-Guide: https://docs.example.com/migration/v1-to-v2
Client-Side Deprecation Handling
class DeprecationMonitor : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val response = chain.proceed(chain.request())
val deprecationWarning = response.header("Deprecation-Warning")
val sunsetDate = response.header("Sunset")
if (deprecationWarning != null) {
log.warn("API Deprecation: $deprecationWarning (Sunset: $sunsetDate)")
analytics.logEvent("api_deprecation_warning") {
param("warning", deprecationWarning)
param("sunset_date", sunsetDate ?: "unknown")
}
}
return response
}
}
Best Practices
- Version in URL for major changes - Clear, cache-friendly
- Use headers for minor changes - Flexible, backward compatible
- Always support N-1 versions - Give users time to update
- Implement force update mechanism - Critical security/bug fixes
- Use feature flags - Gradual rollout, A/B testing
- Monitor version distribution - Know when to sunset old versions
- Communicate deprecation clearly - Advance notice, migration guides
AI-Assisted API Versioning
What AI Does Well
- Version compatibility analysis: Detecting breaking changes between API versions automatically
- Migration script generation: Creating adapter code to map old responses to new models
- Test case generation: Producing compatibility tests for multiple API versions
- Deprecation impact assessment: Analyzing which clients would be affected by version sunset
Where Humans Are Needed
- Business decision on timelines: When to force updates vs. extend support is a product decision
- User communication: Crafting deprecation messages that don’t alarm users
- Edge case handling: Real-world client behavior often surprises automated analysis
- Security exception decisions: Determining what warrants an immediate force update
Useful Prompts
"Compare these two API response schemas and identify breaking changes
that would affect mobile clients. Consider field renames, type changes,
and removed fields: [schema v1] vs [schema v2]"
"Generate a Kotlin adapter class that maps UserV1 to UserV2 model,
handling missing fields with sensible defaults and logging deprecation
warnings."
"Create a test suite that validates backward compatibility between
API v1 and v2, ensuring all v1 functionality still works for clients
that haven't upgraded."
"Analyze this API changelog and recommend a deprecation timeline
with force update thresholds based on the severity of changes."
Decision Framework
When to Invest in Versioning Infrastructure
| Factor | High Priority | Low Priority |
|---|---|---|
| Client diversity | Multiple app versions in production | Controlled enterprise deployment |
| Update frequency | Users update monthly or slower | Auto-update enabled |
| API change rate | Frequent breaking changes | Stable, additive-only changes |
| User base | Global users, varied connectivity | Reliable network, quick rollouts |
When NOT to Invest Heavily
- Greenfield projects: API will change rapidly anyway during initial development
- Internal tools: You control all clients and can force-sync updates
- Stateless APIs: Simple CRUD with no complex state transitions
- Short-lived products: MVPs or experiments with limited lifespan
Measuring Success
| Metric | Before | Target | How to Track |
|---|---|---|---|
| Version adoption rate | < 50% on latest | > 80% on latest within 30 days | Analytics dashboard |
| Force update triggers | 5+ per year | < 2 per year | Release notes, incident reports |
| Deprecation cycle time | 3+ months | 6 weeks from announce to sunset | Versioning calendar |
| Breaking change incidents | Monthly | Quarterly or less | Post-mortems |
| Client error rate on old versions | > 5% | < 1% | API monitoring |
Warning Signs
- Version fragmentation: 5+ active versions indicates poor deprecation discipline
- Frequent force updates: Users will disable app if updates feel aggressive
- Silent failures: Old clients getting unexpected responses without proper errors
- No deprecation monitoring: Flying blind on who uses what version
Conclusion
API versioning for mobile requires:
- Multi-version testing strategy
- Backward compatibility layers
- Force update mechanisms
- Graceful degradation
- A/B testing infrastructure
- Clear deprecation timelines
Proper versioning ensures smooth transitions while maintaining support for existing users, balancing innovation with stability.
Official Resources
See Also
- API Testing Mastery: From REST to Contract Testing - Comprehensive API testing strategies including versioning patterns
- Mobile Testing 2025: iOS, Android and Beyond - Complete mobile testing guide for iOS and Android platforms
- Appium 2.0: Architecture and Cloud Integration - Mobile test automation for API versioning validation
- REST Assured: Java-Based API Testing Framework - Programmatic API testing for version compatibility tests
- CI/CD Pipeline Optimization for QA Teams - Automate multi-version API testing in your pipelines
