Rails API Versioning Strategies

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.

Pick one mechanism

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.

Serializers do the lifting

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.

Be a tolerant reader

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.

Deprecation headers, not breaking changes

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.

Retiring v1

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.

What not to do

A few patterns I’ve watched teams reach for and regret:

  • Versioning by client header (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.
  • Branching inside controllers on the version param. if params[:version] == "2" ten times per action. The diff between versions is the entire point. Make it visible at the routes layer.
  • Treating breaking changes as additive. Renaming a field is breaking. Changing a type from string to int is breaking. Tightening a previously-optional field is breaking. The only additive change is a new field nobody else has to read.

Takeaways

  • URL path versioning is the boring, correct choice for public Rails APIs. Grep-able, cacheable, untouched by middleware.
  • Duplicate the controller and serializer per version. Cross-version inheritance is a debt you pay every time the older shape needs a fix.
  • Be a tolerant reader. permit drops unknown keys. Never read a request like it’s the only shape that ever existed.
  • Ship Deprecation, Sunset, and Link headers from day one of v2. Log partner identifiers per request so you can see who’s left.
  • Retire a version with instrumentation, email, and a targeted shim. Not with a hard date on a calendar.
  • Breaking changes inside an API version are not negotiable. New shape, new version, additive evolution in between.

Thanks for reading. If you’ve got thoughts, send them my way.

© 2026 Akin Gundogdu. All Rights Reserved.