
API Versioning in Rails: Start at /api/v1 Before You Think You Need it
TL;DR
Put the version in the URL and start at /api/v1 on day one. Give the API its own ActionController::API base that owns auth, error envelopes and transaction tagging, plus one thin base per version. Version serializers and params, not controllers. Retire v1 with Deprecation and Sunset headers, NRQL facets by client, and a 410 after removal.
Most Rails APIs happen rather than get designed: a format.json branch grows into a contract nobody wrote down. Here is the structure that fixes it: path versioning, a three-layer controller hierarchy, token auth outside the versioned tree, v2 without copying v1, and NRQL to prove when v1 can be deleted.
Also available in Português (Brasil)
The problem starts with respond_to
Most Rails APIs are not designed. They happen. A PostsController responds to HTML, someone adds a format.json branch for a mobile prototype, and six months later there are three partners calling GET /posts.json with a shape that only exists because a view needed it that way.
# app/controllers/posts_controller.rb
class PostsController < ApplicationController
def index
@posts = Post.published
respond_to do |format|
format.html
format.json { render json: @posts }
end
end
end
Nothing is wrong with this on day one. The trouble is that the JSON branch has no contract: it serializes whatever the model exposes, it runs through the same before-actions as the HTML app (session, CSRF, flash, layouts), and the moment you want to change the Post model for the web app you break a client you cannot see.
The moment you have a consumer you do not control, you have an API, and an API needs three things the HTML controller does not give you: a stable contract, its own auth, and a way to retire old shapes. This post is the setup I recommend for Rails apps that reach that point, and for new apps that would rather not have the migration later.
Put the version in the path: /api/v1
Use URL path versioning. It is the right default for Rails because the router, the controller namespace, the logs, the APM transaction names, and your CDN cache keys all see it for free. There are three options worth knowing:
| Strategy | Example | Rails fit | Observability | Verdict |
|---|---|---|---|---|
| URL path | GET /api/v1/posts | Native: namespace :v1 maps to Api::V1::PostsController | Version visible in every log line, NRQL, access log, CDN key | Default choice |
| Accept header | Accept: application/vnd.myapp.v1+json | Needs a routing constraint or a request-level dispatcher | Invisible unless you add a custom attribute; curl needs an extra flag | Use only if an API design mandate requires it |
| Query param | GET /api/posts?version=1 | Trivial to route, easy to forget | Visible, but caches and proxies treat it inconsistently | Avoid |
| Date-pinned header (Stripe style) | Stripe-Version: 2026-10-20 per account | Requires a transformation layer between versions | Excellent, but you are building an API product, not an API | Only at Stripe scale |
Header versioning is more "REST pure" because the resource URI never changes. In practice that purity costs you debuggability: a partner pastes a URL into a ticket and you cannot tell which version they hit. Path versioning makes the version part of the request identity, which is exactly what you want when you later need to count who still calls v1.
Two rules keep this honest:
v1means the contract, not the codebase. Additive changes (new fields, new endpoints, new optional params) do not bump the version. Only breaking changes do: removed fields, renamed fields, changed types, changed semantics, changed auth.- Start at
/api/v1even for the first API. A/api/postsroute with no version is av0you will have to migrate later, and every client will have hardcoded it by then.
Routes and the controller hierarchy
The API gets its own namespace, its own base controller, and its own format default. Nothing from the HTML side leaks in.
# config/routes.rb
Rails.application.routes.draw do
resources :posts # HTML app stays where it is
namespace :api, defaults: { format: :json } do
namespace :v1 do
resources :posts, only: %i[index show create update destroy]
resources :comments, only: %i[index create]
end
end
end
defaults: { format: :json } means /api/v1/posts renders JSON without .json and without respond_to. Clients that send Accept: text/html by mistake still get JSON, which is what you want from an API.
The hierarchy has three layers, and each one has a job:
# app/controllers/api/base_controller.rb
module Api
class BaseController < ActionController::API
include ActionController::HttpAuthentication::Token::ControllerMethods
before_action :authenticate!
rescue_from ActiveRecord::RecordNotFound do
render json: { error: "not_found" }, status: :not_found
end
rescue_from ActionController::ParameterMissing do |e|
render json: { error: "bad_request", detail: e.message }, status: :bad_request
end
private
def authenticate!
# see the auth section below
end
def current_api_client
@current_api_client
end
end
end
# app/controllers/api/v1/base_controller.rb
module Api
module V1
class BaseController < Api::BaseController
before_action { response.set_header("X-Api-Version", "1") }
end
end
end
# app/controllers/api/v1/posts_controller.rb
module Api
module V1
class PostsController < Api::V1::BaseController
def index
posts = Post.published.order(created_at: :desc).limit(50)
render json: PostSerializer.render(posts)
end
def show
render json: PostSerializer.render(Post.find(params[:id]))
end
end
end
end
Three decisions in that code matter:
ActionController::API, notApplicationController. You drop cookies, sessions, CSRF, flash, layouts, and the HTML before-actions. CSRF protection on a token-authenticated JSON endpoint is pure friction and a common source of 422s that nobody can explain.Api::BaseControllerowns the cross-version concerns: authentication, error envelopes, rate limiting, request IDs, pagination helpers.Api::V1::BaseControllerowns only what is specific to the v1 contract. Whenv2arrives it gets its own thin base and inherits the rest.- Serializers are explicit.
render json: @postsis the original sin of therespond_toera: it ships your schema to the world. Use Blueprinter, Alba, jbuilder, or a plain PORO. The serializer is the contract, and the contract is what you version.
Rails 7+ apps generated with --api already give you ActionController::API as the base. In a hybrid app you keep ApplicationController for HTML and add the Api tree beside it.
Authentication: tokens first, OAuth2 when you need delegation
Auth lives in Api::BaseController, not in the versioned tree. A version bump should never change how a client proves who it is; if it does, you have two APIs, not two versions.
For most first-party and partner APIs, opaque bearer tokens are enough:
# db/migrate/..._create_api_clients.rb
create_table :api_clients do |t|
t.string :name, null: false
t.string :token_digest, null: false, index: { unique: true }
t.datetime :last_used_at
t.datetime :revoked_at
t.timestamps
end
# app/models/api_client.rb
class ApiClient < ApplicationRecord
def self.authenticate(raw)
return if raw.blank?
find_by(token_digest: Digest::SHA256.hexdigest(raw), revoked_at: nil)
end
def regenerate_token!
raw = SecureRandom.base58(32)
update!(token_digest: Digest::SHA256.hexdigest(raw))
raw # shown once, never stored in plaintext
end
end
# app/controllers/api/base_controller.rb
def authenticate!
@current_api_client = authenticate_with_http_token do |token, _options|
ApiClient.authenticate(token)
end
return if @current_api_client
render json: { error: "unauthorized" }, status: :unauthorized
end
Store a digest, never the raw token. authenticate_with_http_token does a constant-time compare on the header, and the digest lookup means a leaked database dump does not leak credentials. Touch last_used_at asynchronously (a job, or a throttled update_column) so the auth path stays a single indexed read.
Reach for OAuth2 only when a third party needs to act on behalf of one of your users: a Zapier integration, a mobile app you do not ship, a marketplace. The standard Rails answer is Doorkeeper:
# Gemfile
gem "doorkeeper"
# config/routes.rb
use_doorkeeper
# app/controllers/api/base_controller.rb
before_action :doorkeeper_authorize!
def current_user
@current_user ||= User.find(doorkeeper_token.resource_owner_id)
end
Doorkeeper gives you authorization code + PKCE, client credentials, refresh tokens, and scopes. The cost is real: an authorization UI, consent screens, token rotation, and a revocation story. Do not pay it for an API whose only consumers are your own frontend and two partners with API keys. JWTs are a third option; they make sense when a separate identity provider (Cognito, Auth0) issues them and your Rails app only verifies the signature, and they make less sense as a home-grown replacement for a database-backed token you can revoke.
Whatever you pick, expose the identity to observability. current_api_client.id is what you will facet on when it is time to retire v1.
Shipping v2 without copying the tree
Do not cp -r app/controllers/api/v1 app/controllers/api/v2. A full copy feels safe and is the worst option: every bug fix now lands twice, tests double, and within a year nobody knows which copy is canonical. Three approaches exist, and only one of them scales.
| Approach | How | Breaks when | Verdict |
|---|---|---|---|
| Copy the whole tree | Api::V2::* duplicated from V1 | Every shared fix must be applied twice; drift is guaranteed | No |
| Inherit across versions | Api::V2::PostsController < Api::V1::PostsController | A v1 change silently changes v2; super chains hide behavior | Only for a one-endpoint delta, and even then prefer the next row |
| Version the contract, not the code | Thin controllers, versioned serializers and params, unchanged endpoints routed to the existing class | Rarely; it is more up-front structure | Yes |
The third approach rests on one observation: a breaking change almost always lives in the response shape or the accepted params, not in the controller logic. So version those, and let the routes map v2 to v1 controllers for everything that did not change.
# config/routes.rb
namespace :api, defaults: { format: :json } do
namespace :v1 do
resources :posts
resources :comments, only: %i[index create]
end
namespace :v2 do
resources :posts # Api::V2::PostsController, the thing that changed
# comments did not change: reuse the v1 controller under the v2 prefix
resources :comments, only: %i[index create], controller: "/api/v1/comments"
end
end
# app/serializers/api/v1/post_serializer.rb
module Api::V1
class PostSerializer < Blueprinter::Base
identifier :id
fields :title, :body, :created_at
field(:author) { |post| post.author.name } # v1 shipped a string
end
end
# app/serializers/api/v2/post_serializer.rb
module Api::V2
class PostSerializer < Blueprinter::Base
identifier :id
fields :title, :body, :created_at
association :author, blueprint: Api::V2::AuthorSerializer # v2 ships an object: breaking
end
end
# app/controllers/api/v2/posts_controller.rb
module Api::V2
class PostsController < Api::V2::BaseController
def index
posts = Posts::Query.new(current_api_client).published(limit: 50)
render json: PostSerializer.render(posts)
end
end
end
The controller body is nearly identical to v1 because it should be: it delegates to a query object or service that both versions share. What differs is Api::V2::PostSerializer, and if the accepted input changed, an Api::V2::PostParams object. Business logic stays in app/models and app/services, written once.
A few guardrails:
- Keep a request spec per version that asserts the full JSON schema (
json_schemeror a committed fixture). The serializer is the contract; the spec is what makes "v1 is frozen" true. - If an endpoint truly needs different logic per version, that is the one place inheritance is fine:
Api::V2::PostsController < Api::V1::PostsControlleroverriding a single action. Treat it as a smell to revisit, not a pattern. - Never let
v2be "v1 plus cleanup". If you are not breaking anything, add the field to v1 and skip the version.
In a hybrid app that still has the HTML PostsController serving JSON from the early days, that endpoint is effectively v0. Move it under /api/v1 with a serializer that reproduces the old shape exactly, redirect or keep the old route as an alias for one deprecation cycle, and treat v1 as the first real contract.
Retiring v1: Sunset headers and NRQL
A version you cannot delete is tech debt with a public URL. Deletion needs two things: a signal to clients that it is going away, and evidence that they stopped calling it. Monitoring with NRQL is the right way to get that evidence, with one caveat: raw URL matching is not enough. Tag the transaction.
First, make every API request carry the version and the client as custom attributes:
# app/controllers/api/base_controller.rb
before_action :tag_transaction
private
def api_version
self.class.module_parent.name.demodulize.downcase # "v1", "v2"
end
def tag_transaction
NewRelic::Agent.add_custom_attributes(
api_version: api_version,
api_client_id: current_api_client&.id,
api_client_name: current_api_client&.name
)
end
module_parent is resolved from the controller class, so a v1 controller reused under /api/v2/comments still reports v1, which is the honest answer about which code is running. If you care about the URL prefix instead, read it from request.path.
Then mark the old version as deprecated in every response, per RFC 9745 (Deprecation) and RFC 8594 (Sunset). Note the two formats: Deprecation takes a Structured Field date (@ plus a Unix timestamp), Sunset takes an HTTP date.
# app/controllers/api/v1/base_controller.rb
DEPRECATED_AT = Time.utc(2026, 10, 20)
SUNSET_AT = Time.utc(2027, 4, 1)
before_action do
response.set_header("Deprecation", "@#{DEPRECATED_AT.to_i}")
response.set_header("Sunset", SUNSET_AT.httpdate)
response.set_header("Link", '</docs/api/v2-migration>; rel="deprecation"')
end
Now the NRQL. Who is still on v1, and how much:
SELECT count(*) FROM Transaction
WHERE appName = 'my-app (Production)' AND api_version = 'v1'
FACET api_client_name
SINCE 30 days ago
Is the migration actually happening, or did the deprecation email go to spam:
SELECT count(*) FROM Transaction
WHERE appName = 'my-app (Production)' AND api_version IN ('v1', 'v2')
FACET api_version TIMESERIES 1 day
SINCE 90 days ago
Which v1 endpoints can be deleted today, because nobody has touched them:
SELECT count(*) FROM Transaction
WHERE appName = 'my-app (Production)' AND api_version = 'v1'
FACET name
SINCE 60 days ago
LIMIT MAX
Any Api::V1::* transaction missing from that result has had zero calls in 60 days. Delete those first; they are the cheapest wins and they shrink the remaining surface.
Two alerts make the process self-driving:
- A NRQL alert on
api_version = 'v1'crossing zero for 30 consecutive days. When it fires, the endpoint is dead and the PR to remove it can go up without a meeting. - A NRQL alert on
api_version = 'v1'from a client who was previously onv2, which catches a partner deploying a rollback and silently re-adopting the old contract.
The removal criteria, in order:
Sunsetdate has passed.- 30 days of zero v1 traffic in production, verified by the facet query, not by "nobody complained".
- Every v1 client has been contacted at least once, with a reply or a documented bounce.
- The v1 request specs are deleted in the same PR as the controllers and serializers, so a half-removal cannot pass CI.
After step 4, /api/v1/* should return 410 Gone with a body pointing at the migration guide, for at least one more cycle. A 404 looks like a bug; a 410 looks like a decision.
The checklist
- API routes live under
namespace :apiwithdefaults: { format: :json }, starting atv1from day one Api::BaseController < ActionController::APIowns auth, error envelopes, and transaction tagging- One thin
Api::VN::BaseControllerper version; nothing else in it unless the version contract demands it - Bearer tokens stored as digests; Doorkeeper only when third parties act on behalf of users
- Explicit serializers per version; business logic in models and services, written once
- A new version only for breaking changes; unchanged endpoints routed to the existing controller
- Request specs that pin each version's JSON schema
api_versionandapi_client_idas custom attributes on every API transactionDeprecationandSunsetheaders on the old version, with a dated migration guide- NRQL facets by client and endpoint, a zero-traffic alert, and a 410 after removal
None of this is clever. It is the boring structure that keeps a Rails API from becoming the thing you are afraid to touch. The payoff comes the day a partner asks "can we change the author field?" and the answer is a new serializer, a route block, and a dashboard that tells you when the old one can go.
Sources
- ActionController::API (Rails API docs) api.rubyonrails.org
- Using Rails for API-only Applications (Rails Guides) guides.rubyonrails.org
- ActionController::HttpAuthentication::Token (Rails API docs) api.rubyonrails.org
- Doorkeeper: OAuth 2 provider for Rails github.com
- Blueprinter: JSON serializer for Ruby github.com
- json_schemer: JSON Schema validator for Ruby github.com
- RFC 8594: The Sunset HTTP Header Field www.rfc-editor.org
- RFC 9745: The Deprecation HTTP Response Header Field www.rfc-editor.org
- New Relic Ruby agent API guide (custom attributes) docs.newrelic.com
- Stripe API versioning docs.stripe.com
FAQ
Should I use URL path or Accept header versioning in Rails?
URL path. `namespace :v1` maps straight to `Api::V1::*`, and the version shows up in logs, APM transaction names, access logs and CDN cache keys with no extra work. Header versioning keeps the URI stable but hides the version from every tool that reads URLs, including the partner pasting a link into a ticket.
When does a change require a new API version?
Only when it breaks an existing client: a removed or renamed field, a changed type, changed semantics, or changed auth. Adding fields, endpoints or optional params is additive and ships under the current version. If v2 would be "v1 plus cleanup", add the cleanup to v1 instead.
Why ActionController::API instead of ApplicationController for the API tree?
It drops cookies, sessions, CSRF, flash and layouts. A token-authenticated JSON endpoint gains nothing from CSRF protection except unexplained 422s, and the HTML app's before-actions should never run on API requests. In a hybrid app, keep `ApplicationController` for HTML and add the `Api` tree beside it.
Bearer tokens or Doorkeeper?
Opaque bearer tokens stored as SHA-256 digests cover first-party and partner APIs. Reach for Doorkeeper (OAuth2) only when a third party must act on behalf of one of your users, because it brings a consent UI, token rotation and revocation flows with it. JWTs fit when an external identity provider issues them and Rails only verifies signatures.
How do I ship v2 without duplicating the v1 controllers?
Version the contract, not the code: a thin `Api::V2::BaseController`, versioned serializers and params objects, and shared query objects or services. In the routes, point unchanged v2 endpoints at the existing v1 controller with `controller: "/api/v1/comments"`, so only the endpoints that actually changed get a new class.
How do I know when v1 can be deleted?
Tag every API transaction with `api_version` and `api_client_id`, then facet in NRQL by client and by endpoint. Send `Deprecation` and `Sunset` headers with a migration link, wait for the Sunset date plus 30 days of zero v1 traffic, contact every remaining client, and delete controllers, serializers and specs in one PR. Return 410 Gone for one more cycle.
Comments
Sign in with GitHub to comment.
No comments yet.