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:
- Support multiple client versions simultaneously
- Allow evolving the API without breaking old clients
- 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 (
avatarUrladded 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 Requiredwith 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, handle426, and tolerate unknown fields in responses.