RUBY ON RAILS · 19 MIN READ ·

Rails API Versioning: URL Namespaces, Header Routing, and Graceful Deprecation

Rails API versioning done right: URL namespaces, Accept header routing, controller inheritance, and Sunset headers for graceful deprecation. Full examples.

Rails API Versioning: URL Namespaces, Header Routing, and Graceful Deprecation

The client called on a Tuesday afternoon. Their mobile app was live in three hundred thousand hands and they had just discovered that the date format we returned for created_at in the orders API was wrong — not wrong by our definition, but wrong by the iOS team’s definition, which turned out to be ISO 8601 with milliseconds where we had shipped plain ISO 8601 without. A fix in our serializer would break the existing app. Not shipping the fix would keep breaking the new version.

We had no versioning. Every client was pinned to a single endpoint. The fix took four months to negotiate, coordinate, and finally ship — client release cycle, phased rollout, feature flags on the mobile side, and six weeks of running both serializer formats simultaneously via a request header that one of us added at 11pm on a Friday.

After nineteen years of Rails I have shipped APIs both ways: with versioning from day one and without. The cost of retrofitting versioning is always higher than the cost of adding it upfront. Here is how I do it.

Why URL-Based Rails API Versioning Is the Right Default

There are three main approaches to Rails API versioning: URL path (/api/v1/), Accept header, and custom header (X-API-Version). I have used all three. URL-based versioning is the right default for almost every API I build, and the arguments for the alternatives rarely survive contact with production.

URL versioning is explicit, cacheable, and debuggable. A URL like https://api.example.com/v2/orders/123 tells everyone — your logs, your load balancer, your browser developer tools, your client’s curl one-liner — exactly which version they are hitting. Accept header versioning buries that information in a request header that most logging configurations will not capture by default, and that almost every frontend developer will forget to set correctly at least once.

The objection I hear most often is “URLs should be permanent; changing them to add a version is wrong.” That concern is valid for human-readable web pages. APIs are contracts between machines. When you break the contract, the version number in the URL is a feature, not pollution.

Custom header versioning (X-API-Version: 2) shares all of the debugging problems of Accept header versioning without the semantic legitimacy. I have never seen it work better than either alternative in practice.

Setting Up URL-Based API Versioning in Rails

The routing structure for a versioned Rails API:

# config/routes.rb
Rails.application.routes.draw do
  namespace :api do
    namespace :v1 do
      resources :orders, only: [:index, :show, :create]
      resources :customers, only: [:index, :show]
    end

    namespace :v2 do
      resources :orders, only: [:index, :show, :create, :update]
      resources :customers
    end
  end
end

This produces routes like GET /api/v1/orders and GET /api/v2/orders/123. The corresponding controller paths:

app/controllers/api/v1/orders_controller.rb  →  Api::V1::OrdersController
app/controllers/api/v2/orders_controller.rb  →  Api::V2::OrdersController

Create a shared base controller that handles authentication and common error handling, then a thin base for each version:

# app/controllers/api/base_controller.rb
module Api
  class BaseController < ApplicationController
    protect_from_forgery with: :null_session
    before_action :authenticate_api_token!
    respond_to :json

    rescue_from ActiveRecord::RecordNotFound do |e|
      render json: { error: "Not found", message: e.message }, status: :not_found
    end

    rescue_from ActiveRecord::RecordInvalid do |e|
      render json: { error: "Validation failed", errors: e.record.errors.full_messages },
             status: :unprocessable_entity
    end

    private

    def authenticate_api_token!
      token = request.headers["Authorization"]&.delete_prefix("Bearer ")
      @current_api_user = ApiToken.active.find_by(token: token)&.user
      render json: { error: "Unauthorized" }, status: :unauthorized unless @current_api_user
    end
  end
end
# app/controllers/api/v1/base_controller.rb
module Api
  module V1
    class BaseController < Api::BaseController
    end
  end
end
# app/controllers/api/v2/base_controller.rb
module Api
  module V2
    class BaseController < Api::BaseController
    end
  end
end

Each version’s controllers inherit from their version’s base, which inherits from the shared Api::BaseController. Authentication, error handling, and any cross-cutting concerns live in the shared base and apply to all versions automatically.

Sharing Logic Across API Versions with Controller Inheritance

The most common mistake in Rails API versioning is copying the V1 controller wholesale into the V2 directory and editing it. Now you have two copies to maintain, two places for bugs to hide, and a diff that will confuse the next engineer who touches it.

Better approach: V2 overrides only what changed.

# app/controllers/api/v1/orders_controller.rb
module Api
  module V1
    class OrdersController < V1::BaseController
      def index
        orders = current_api_user.orders.order(created_at: :desc).page(params[:page])
        render json: orders.map { |o| serialize_order(o) }
      end

      def show
        order = current_api_user.orders.find(params[:id])
        render json: serialize_order(order)
      end

      def create
        order = current_api_user.orders.create!(order_params)
        render json: serialize_order(order), status: :created
      end

      private

      def serialize_order(order)
        {
          id: order.id,
          status: order.status,
          total: order.total.to_f,
          created_at: order.created_at.iso8601       # V1: no milliseconds
        }
      end

      def order_params
        params.require(:order).permit(:customer_id, :notes)
      end
    end
  end
end
# app/controllers/api/v2/orders_controller.rb
module Api
  module V2
    class OrdersController < V1::OrdersController  # inherit from V1
      def update
        order = current_api_user.orders.find(params[:id])
        order.update!(order_params)
        render json: serialize_order(order)
      end

      private

      def serialize_order(order)
        super.merge(
          created_at: order.created_at.iso8601(3),  # V2: millisecond precision
          updated_at: order.updated_at.iso8601(3),
          line_items: order.line_items.map { |li|
            { id: li.id, sku: li.sku, quantity: li.quantity }
          }
        )
      end

      def order_params
        super.merge(params.require(:order).permit(:status, :priority))
      end
    end
  end
end

V2’s OrdersController inherits from V1::OrdersController, overrides serialize_order to add millisecond timestamps and line items, and adds the update action. Every other action — index, show, create, authentication, error handling — comes from V1 for free. When you fix a bug in V1’s index, V2 gets the fix automatically unless V2 has overridden it.

This pattern only works when V2 is a strict superset of V1. If V2 changes behaviour in a way that would break V1 clients if applied to V1, the controllers need to diverge. Accept the duplication and treat it as the cost of the breaking change — that cost is exactly what you are charging to V1 clients’ account by preserving backward compatibility.

Accept Header Versioning: When It Makes Sense

Accept header versioning (Accept: application/vnd.myapp.v2+json) is the REST-purist’s choice. It keeps URLs stable across versions and routes by content negotiation rather than path structure.

To implement it in Rails, you need a routing constraint:

# app/constraints/api_version_constraint.rb
class ApiVersionConstraint
  def initialize(version:, default: false)
    @version = version
    @default = default
  end

  def matches?(request)
    @default || request.headers["Accept"].to_s.include?("application/vnd.myapp.v#{@version}+json")
  end
end
# config/routes.rb
Rails.application.routes.draw do
  namespace :api, defaults: { format: :json } do
    scope module: :v2, constraints: ApiVersionConstraint.new(version: 2) do
      resources :orders
    end

    scope module: :v1, constraints: ApiVersionConstraint.new(version: 1, default: true) do
      resources :orders
    end
  end
end

I use this pattern when I am building an API for clients I control — a mobile app and a web SPA maintained by my own team, where I can audit that the Accept header is always set correctly. For public APIs where I cannot audit every client integration, I default to URL versioning. The Accept header is invisible to most curl invocations and missing from most API playground defaults, which means third-party developers will hit the default version regardless of intent.

Serializer Versioning

The controller inheritance pattern above embeds serialization in the controller, which works for small APIs. For anything more than a handful of resources, extract serializers into their own classes.

Using the alba gem (fast, minimal, no DSL magic):

# app/serializers/api/v1/order_serializer.rb
module Api
  module V1
    class OrderSerializer
      include Alba::Resource

      attributes :id, :status
      attribute(:total) { |o| o.total.to_f }
      attribute(:created_at) { |o| o.created_at.iso8601 }
    end
  end
end
# app/serializers/api/v2/order_serializer.rb
module Api
  module V2
    class OrderSerializer < V1::OrderSerializer
      attribute(:created_at) { |o| o.created_at.iso8601(3) }
      attribute(:updated_at) { |o| o.updated_at.iso8601(3) }

      association :line_items, serializer: V2::LineItemSerializer
    end
  end
end

The V2 controller then becomes:

module Api
  module V2
    class OrdersController < V1::OrdersController
      def show
        order = current_api_user.orders.find(params[:id])
        render json: V2::OrderSerializer.new(order).serialize
      end
    end
  end
end

Clean separation of concerns: the controller handles authentication, authorization, and input validation. The serializer handles output shape. Each version overrides only the attributes that changed.

Deprecating Old API Versions with Sunset Headers

Removing an API version is the hard part. The technical work takes a day; the client coordination takes months. Here is the scaffolding I use to make it manageable.

The IETF Sunset header (Sunset: Sat, 31 Dec 2026 23:59:59 GMT) signals to API clients that an endpoint will be decommissioned on a specific date. Paired with a Deprecation header and a Link pointing to migration documentation, it creates a machine-readable deprecation trail that well-behaved API clients can surface to their developers automatically.

Add it to V1’s base controller once the shutdown date is decided:

# app/controllers/api/v1/base_controller.rb
module Api
  module V1
    class BaseController < Api::BaseController
      SUNSET_DATE = Time.utc(2026, 12, 31, 23, 59, 59).freeze

      before_action :add_deprecation_headers

      private

      def add_deprecation_headers
        response.headers["Sunset"] = SUNSET_DATE.httpdate
        response.headers["Deprecation"] = "true"
        response.headers["Link"] = \
          '<https://api.example.com/docs/migration-v1-v2>; rel="deprecation"'
      end
    end
  end
end

Log V1 usage to track which clients are still hitting it:

before_action :log_v1_usage

def log_v1_usage
  Rails.logger.warn(
    event: "api_v1_usage",
    path: request.path,
    client_ip: request.remote_ip,
    user_agent: request.user_agent,
    api_user_id: @current_api_user&.id,
    days_until_sunset: ((SUNSET_DATE - Time.current) / 86_400).ceil
  )
end

With structured logs flowing into your observability stack — see the OpenTelemetry and Rails 8 guide for the setup — you can build a dashboard showing V1 request volume over time and identify which API users have not migrated. Email them proactively. Push the sunset date if major clients need more time. Remove V1 entirely once the logs show zero traffic for two weeks minimum; one month preferred.

Testing Versioned Rails APIs

Test each version’s controller independently in request specs. Do not share specs across versions — each spec file documents the contract for that specific version and should break if that contract changes.

# spec/requests/api/v2/orders_spec.rb
RSpec.describe "GET /api/v2/orders/:id", type: :request do
  let(:user)  { create(:user) }
  let(:token) { create(:api_token, user: user) }
  let(:order) { create(:order, customer: user, line_items: create_list(:line_item, 2)) }

  before do
    get "/api/v2/orders/#{order.id}",
        headers: { "Authorization" => "Bearer #{token.token}" }
  end

  it "returns the order with millisecond timestamps and line items" do
    expect(response).to have_http_status(:ok)
    body = JSON.parse(response.body)
    expect(body["created_at"]).to match(/\.\d{3}Z$/)
    expect(body["line_items"]).to be_an(Array).and have(2).items
  end
end
# spec/requests/api/v1/orders_spec.rb
RSpec.describe "GET /api/v1/orders/:id", type: :request do
  let(:user)  { create(:user) }
  let(:token) { create(:api_token, user: user) }
  let(:order) { create(:order, customer: user) }

  before do
    get "/api/v1/orders/#{order.id}",
        headers: { "Authorization" => "Bearer #{token.token}" }
  end

  it "returns the order without milliseconds and without line_items key" do
    expect(response).to have_http_status(:ok)
    body = JSON.parse(response.body)
    expect(body["created_at"]).not_to match(/\.\d{3}Z$/)
    expect(body).not_to have_key("line_items")
  end
end

The RSpec and Factory Bot patterns post covers recording and replaying HTTP fixtures for external dependencies — useful when your API calls a third-party service that differs between versions.

When to Skip Versioning

Not every API needs versioning. If you control all clients — a Rails app talking to its own API layer, an internal microservice with coordinated deployments — versioning adds ceremony without value. Just change the endpoint. Deploy the client and server together.

Versioning is for APIs where you cannot coordinate client updates with server deploys. Public APIs. Mobile apps with release cycles outside your control. Third-party integrations where the client code lives in someone else’s repository. If all of those are absent, skip the namespace overhead and keep the routes flat.

FAQ

Should I add Rails API versioning from day one?

If you are building a public API or an API consumed by mobile apps you do not control, yes — add the /v1/ prefix before you ship the first endpoint. The cost of adding it later, when clients are in production, is the date-format war story from the opening of this post. If you are building an internal API with coordinated deployments, versioning is optional overhead.

What is the best Rails API versioning gem?

There is no gem that handles Rails API versioning better than the built-in routing namespace. Third-party gems like versionist exist but add abstraction where Rails’ routing DSL is already expressive enough. The routing constraint pattern for header-based versioning is twenty lines of plain Ruby. I have not found a gem that improves on the approach enough to justify the dependency.

How do I handle breaking changes without incrementing the version?

You don’t. Any change that breaks existing clients is a new version by definition. Adding fields and new optional parameters is backward-compatible and does not require a version bump. Changing a field’s type, removing a field, or changing error response shapes are breaking changes that require a new version. When in doubt, ship both the old and new field simultaneously in the current version, deprecate the old field in documentation, and remove it in the next version.

How does Rails API versioning interact with Kamal deployments?

Not at all, which is exactly what you want. Versioning is a routing and controller concern; the deploy mechanism is invisible to it. A zero-downtime Kamal 2 deploy that rolls through containers one at a time will serve V1 and V2 traffic correctly throughout, because both version namespaces exist in the same deployed codebase. You only need to coordinate deploys when you are removing a version — and even then, the coordination is with API clients, not with the deployment pipeline.

Maintaining a Rails API that’s become hard to extend because versioning was an afterthought? TTB Software helps teams design clean API contracts, implement versioning strategies that don’t create maintenance nightmares, and migrate clients without disruption. We have been doing this for nineteen years.

#rails-api-versioning #rails-api-namespace-routing #accept-header-api-versioning-rails #rails-api-deprecation-sunset-header #rails-api-v2-migration #rails-versioned-api-controller

Related Articles

Last section. Then please call.

It's a phone call. That's the worst it can get.

No discovery deck. No 45-minute "qualification" call. 30 minutes, your problem, my opinion. If we're a fit, you'll know by minute 12.

Direct line — answered by Roger
+31 6 5123 6132
Mon–Fri, 09:00–18:00 CET · Currently available

OR
info@ttb.software