ADR 012: API Versioning and Client Version Enforcement

ADR 012: API Versioning and Client Version Enforcement

Status

Accepted

Date

2026-03-11

Context

The platform ships native apps (iOS, Android via React Native/Expo) through app stores. Once published, old app versions remain in the wild indefinitely — users update on their own schedule or not at all. The API must:

  1. Support multiple client versions simultaneously
  2. Allow evolving the API without breaking old clients
  3. Provide an escape hatch to force-retire dangerously old versions

Decision

1. URL-based API versioning

All app-facing endpoints are versioned in the URL path:

/api/v1/users/{id}
/api/v1/groups
/api/v1/events

When breaking changes are unavoidable, a new version is introduced:

/api/v2/users/{id}    ← new shape
/api/v1/users/{id}    ← still works, deprecated

Old versions are maintained for a deprecation window of 3-6 months after the new version ships, then retired via the minimum version mechanism (see below).

2. Additive-only rule within a version

Within a version, the API contract is: never remove fields, never rename fields, never change types. New fields can always be added — old clients ignore unknown fields, new clients use them. This covers the vast majority of API evolution without needing a new version.

Examples of safe changes (no version bump):

  • Add a new field to a response (avatarUrl added to user)
  • Add a new optional query parameter
  • Add a new endpoint
  • Add a new enum value (client must handle unknown values gracefully)

Examples requiring a new version:

  • Remove or rename a field
  • Change a field’s type (string → object)
  • Change pagination format
  • Restructure nested objects

3. Minimum client version enforcement

Every app request includes a version header:

X-Client-Version: 1.4.2

A middleware checks this against a configurable minimum version per platform:

# config, DB, or environment — updatable without deploy
app_min_version:
  ios: "1.2.0"
  android: "1.2.0"

Responses:

  • Version meets minimum: request proceeds normally.
  • Version below minimum: HTTP 426 Upgrade Required with a body the app understands:
    {
      "error": "client_update_required",
      "message": "Please update your app to continue.",
      "store_url": "https://apps.apple.com/app/id..."
    }
  • No version header: request proceeds (for non-app clients, backward compat during rollout).

The app handles 426 by showing a blocking “Please update” screen with a link to the store. This is the escape hatch — when a critical security fix ships or an old API version is retired, bump the minimum version.

4. What we explicitly do NOT do

  • No header-based versioning (Accept: application/vnd.churchapp.v1+json) — harder to debug, no benefit for our case.
  • No contract testing framework (Pact, etc.) — overhead for a solo founder. The additive-only rule + integration tests are sufficient.
  • No per-endpoint versioning — all endpoints move together within a version.
  • No GraphQL — solves over/under-fetching, not versioning. Adds complexity we don’t need.

Consequences

  • Simple to implement: URL prefix + a middleware. No complex negotiation.
  • Safe by default: Additive-only changes mean most evolution needs no version bump.
  • Escape hatch exists: Minimum version enforcement lets us retire old versions when needed.
  • Clear deprecation path: v1 → v2 transition is explicit and time-boxed.
  • Client discipline required: The app must send X-Client-Version, handle 426, and tolerate unknown fields in responses.