URL path vs header vs content negotiation API versioning in Rails, with deprecation headers, sunset dates, and tolerant readers.
It was a Saturday afternoon at the combat-sports tournament platform I CTO’d in London. A federation event was being broadcast publicly. Federations and commentators were watching the standings page in real time. We were also serving the same standings to partner federations through a public REST API shipped on day one as /api/v1/.... I was three years into building the platform.
The standings projector started rebalancing every thirty seconds. The page froze at 14:32 local. Within two minutes, three PagerDuty pages and the federation tech contact on Slack. Root cause was a stale container image on one consumer pod. We pinned image SHAs by Monday. But the part that stayed with me was a different problem. We were about to deprecate /api/v1/standings and replace its shape, and partners were hitting it with cron jobs we couldn’t see, no telemetry on who they were or what shape they expected. Twelve minutes of stale standings was bad. Killing a partner endpoint at the wrong moment would be worse.
Versioning isn’t an API design preference. It’s the social contract between you and the people running cron jobs you can’t reach.
Three options come up every time. URL path (/api/v1/...). A header (Accept-Version: 2 or a custom X-API-Version). Content negotiation (Accept: application/vnd.akin.v2+json). Pick one and commit. Mixing them is how you ship a documentation bug instead of an API.
I default to URL path versioning for public APIs. It’s grep-able. Testable from curl. CDN cache keys don’t lie about it. Header versioning is cleaner in theory and worse in practice, every time I’ve watched a team try.
# config/routes.rb
Rails.application.routes.draw do
namespace :api, defaults: { format: :json } do
namespace :v1 do
resources :standings, only: [:index, :show]
resources :athletes, only: [:index, :show]
end
namespace :v2 do
resources :standings, only: [:index, :show]
resources :athletes, only: [:index, :show]
end
end
end
The controllers live in their own namespaces. Api::V1::StandingsController, Api::V2::StandingsController. They are not subclasses of each other. They look almost identical for the first six months. That is fine. Duplication beats premature abstraction across a contract boundary you can’t change.
The work isn’t in the routes. It’s in the serializer. The same domain model serves both versions. A clean cut at the edge keeps Active Record honest.
# app/serializers/api/v1/standing_serializer.rb
module Api::V1
class StandingSerializer
def initialize(standing)
@standing = standing
end
def as_json
{
athlete_id: @standing.athlete_id,
rank: @standing.rank,
points: @standing.points.to_i
}
end
end
end
# app/serializers/api/v2/standing_serializer.rb
module Api::V2
class StandingSerializer
def initialize(standing)
@standing = standing
end
def as_json
{
athlete: {
id: @standing.athlete_id,
slug: @standing.athlete.slug
},
rank: @standing.rank,
points: @standing.points.to_f,
last_updated_at: @standing.updated_at.iso8601
}
end
end
end
Two serializers. One model. The v1 contract is frozen forever. The v2 shape is allowed to grow as long as it grows additively.
The other half of the contract is the requests you accept. The Postel rule still holds. Strict on what you emit, generous on what you take. For Rails APIs that means strong parameters that ignore unknown keys instead of rejecting them, and never reading a field like it’s required when the client might be on an older shape.
# app/controllers/api/v2/athletes_controller.rb
module Api::V2
class AthletesController < BaseController
def update
athlete = current_org.athletes.find(params[:id])
athlete.update!(athlete_params)
render json: Api::V2::AthleteSerializer.new(athlete).as_json
end
private
def athlete_params
params.require(:athlete).permit(
:name,
:slug,
:weight_class,
:nationality_code,
medical_card: [:expires_at, :status]
)
end
end
end
permit drops anything it doesn’t know about. The client can post v3-shaped fields against v2 and the request still succeeds. That’s the point. A tolerant reader survives client drift. A strict reader breaks on the first new key a partner adds to the JSON they were planning to send anyway.
When v2 ships, v1 doesn’t disappear. Not on launch day, not the next month. You announce, you instrument, you wait. The web has had two real headers for this since RFC 8594 and the Deprecation draft, and both are cheap to set.
# app/controllers/api/v1/base_controller.rb
module Api::V1
class BaseController < ApplicationController
SUNSET_DATE = (Time.now.utc + 90.days).freeze
before_action :set_deprecation_headers
private
def set_deprecation_headers
response.headers["Deprecation"] = "true"
response.headers["Sunset"] = SUNSET_DATE.httpdate
response.headers["Link"] =
'<https://docs.akin.dev/api/v2>; rel="successor-version"'
end
end
end
Three headers. Every v1 response carries them. Partners running healthy clients will surface them in their logs within a release cycle. The ones who don’t read response headers are exactly the partners you’re going to call manually anyway.
The other half is server-side. Log every v1 request with the partner identifier extracted from the API key. Build a small dashboard that answers one question: which partners called v1 in the last seven days. That’s the whole observability story you need to retire a version safely.
# app/controllers/api/v1/base_controller.rb (continued)
def append_info_to_payload(payload)
super
payload[:api_version] = "v1"
payload[:partner_id] = current_partner&.id
end
Lograge picks the payload up. Datadog scrapes the structured logs. A saved query on api_version:v1 gives the list. No new infrastructure.
Back to the federation platform story. We had three partners still hitting v1 standings four months after v2 shipped. Two responded to email within a day and switched in a week. The third was a federation whose IT contact had changed twice and whose cron job had been running unattended for over a year. Hard-kill on the sunset date and break their integration during a live event, or carry v1 forever as a courtesy.
We did neither. We shipped a v1 shim that returned a 410 Gone with a JSON body explaining the migration and a Link header to v2, but only on requests from that partner’s API key, only outside of their live broadcast windows. They noticed within a day. Migrated within three weeks. The shim came out the week after.
Deprecation is a process, not a switch. Ship the headers. Instrument. Email. Call. Then flip the route. Anyone who tells you a clean cut on a calendar date is professional discipline has never had a federation contact who left the job eighteen months ago.
A “safe and instant” version retirement is a fiction. You do it in steps. The headers, the dashboards, the email, the shim, the kill. Skip a step and somebody’s cron job stops paying you on a Tuesday morning.
A few patterns I’ve watched teams reach for and regret:
X-Client-Version) instead of API version. Now your API behaviour depends on whether the client lied about itself. CDN caching becomes a puzzle. Don’t.if params[:version] == "2" ten times per action. The diff between versions is the entire point. Make it visible at the routes layer.permit drops unknown keys. Never read a request like it’s the only shape that ever existed.Deprecation, Sunset, and Link headers from day one of v2. Log partner identifiers per request so you can see who’s left.Thanks for reading. If you’ve got thoughts, send them my way.