Rails API versioning, and the error shape it forces you to pick
September 22, 2026
An API answers two questions on its first day and pays for both later: where the version number
lives, and what a failure looks like on the wire. Rails helps with neither. It gives you a routing
scope and a head method, and the conventions that turn those into an API other people can build
against are yours to write down.
What namespace :v1 actually does
The whole of Rails API versioning in this boilerplate is four lines of config/routes.rb:
namespace is not a versioning feature. Read its definition in
ActionDispatch::Routing::Mapper::Scoping and it does three assignments from the same word:
options[:module] ||= name, then as = name, then path = name. One :v1 therefore produces the
path segment /v1, the constant prefix Api::V1, and the route helper prefix api_v1_. Nesting
two of them stacks all three, which is why Api::V1::UsersController#show is reachable at
/api/v1/user and nowhere else.
Nothing else in Rails knows the word version. There is no default version, no deprecation header,
no constraint helper that reads a version out of a request, and no warning when a controller under
v1 starts returning a field v1 never returned. The framework gives you a namespace; the
promise that the namespace means something is enforced by review or not at all. The rest of
the JSON API is built on that assumption, so it is worth being
explicit that the assumption is the only thing holding.
Three places to put the version, and which one to pick
A version can sit in the URL (/api/v1/user), in a request header (Accept:
application/vnd.myapp.v2+json, or a custom header), or in a query parameter (/api/user?version=2).
The three are not equivalent and the choice is close to irreversible once a client ships.
Pick the URL segment. A versioned URL is visible in a log line, a curl in a bug report and a
router config, which means the version of a broken request is never a question anybody has to ask.
The cost is real and it is the whole case against: the same user is now two resources,
/api/v1/user and /api/v2/user, so a cache key, a rate-limit bucket and an access log all split
in two.
What would change the answer is scale you cannot coordinate. Stripe versions by date in a
Stripe-Version request header; GitHub does the same with X-GitHub-Api-Version, and answers
410 Gone to a version it no longer supports. Both have more integrations than they can ask to
change a URL, so pinning a client to a date and letting the path stay stable is worth the loss of
visibility. With a handful of known clients, you are buying that complexity for nobody.
The query parameter is the one to rule out. A parameter is the easiest thing in the chain to drop:
a proxy rewrites it, a client library appends its own params and forgets yours, a redirect loses the
query string. It also shares a namespace with real filters, so ?version=2 sits next to ?page=2
and neither reads as the more important of the two.
One Rails detail decides how much work the header strategy is. ActionController::API::MODULES
lists StrongParameters, RateLimiting, Renderers::All and ConditionalGet, and does not list
ActionController::MimeResponds. An API controller has no respond_to block, so a version carried
in Accept cannot be switched on inside the action the way a format is. You write a routing
constraint object answering matches?(request), or a before_action that parses the header itself.
That is a class you own and test, against a segment you get for free.
What breaks the day Api::V2 exists
Three things in this codebase are versioned by accident rather than on purpose, and all three
surface the moment a second version is real.
The base controller is Api::V1::BaseController. Every endpoint inherits its authenticate_api_user!
callback and its gated_by :api, which is correct today and wrong the first time v2 changes how
authentication works, because a Api::V2::UsersController < Api::V1::BaseController is now v2 code
depending on a v1 constant. The move is to lift the shared half into Api::BaseController and leave
only what is genuinely version-specific below it, and the cheap moment to do that is before v2
exists, not during.
The serializer is Api::V1::UserSerializer, a plain class whose as_json returns seven keys:
id, email_address, name, company_name, role, confirmed, onboarded. Copy it to v2 and
a bug fix is two edits forever. Subclass it and the next field added for v2 appears in a v1 response
that a client already parses. Copy it anyway. A serializer's job is to be frozen, and duplication is
the cheapest way to freeze one.
The specs are the quiet one. spec/requests/api/v1/users_spec.rb writes let(:path) { "/api/v1/user" }
and passes it to two shared examples, so the suite is green about v1 and silent about v2. Worse, the
shared example that looks like it covers the new version does not. "a token-authenticated endpoint"
ends its happy path on expect(response).not_to have_http_status(:unauthorized), which a 500 also
satisfies. Wire up a v2 endpoint that raises on every request, include that shared example, and the
build stays green while the endpoint is broken in production. The assertion is deliberately loose
because its subject is authentication rather than the action, and reusing it as proof the endpoint
works is the mistake it invites.
The 401 with nothing in it
Authentication for the whole namespace is five lines of Api::V1::BaseController:
head sets the status, sets a Content-Type from the request format because 401 is a content-bearing
status, and assigns self.response_body = "". So a rejected request gets a 401 with a Content-Type
header, zero bytes of body, and no WWW-Authenticate. RFC 9110 section 15.5.2 is explicit about
that last one: "The server generating a 401 response MUST send a WWW-Authenticate header field
(Section 11.6.1) containing at least one challenge applicable to the target resource." Nothing here
sends one, and head already takes the headers as options, so the fix is
head :unauthorized, www_authenticate: %(Bearer realm="api") and a line in the spec.
The empty body is the better decision of the two, and worth defending. Api::Auth::VerifyToken
returns nil for a blank token, a tampered signature, an expired exp and a sub pointing at a
deleted user, and a body that told them apart would be an oracle: it tells an attacker which of
their guesses was closer.
Verifying the bearer token
covers why a single rescue JWT::DecodeError collapses those cases in the first place. The cost
lands on the legitimate client, which cannot distinguish "your token expired, get a new one" from
"your token was never valid, stop retrying", and will therefore either retry forever or re-login on
every 401.
What is not defensible is that the same API answers 401 two different ways.
Api::V1::SessionsController#create renders { error: "Invalid email address or password." } with
status: :unauthorized, so the login endpoint has a JSON body and every other endpoint has none. A
client writing one error handler for this API has to branch on whether the response is parseable,
which is a thing nobody discovers until JSON.parse raises in the field.
Two 404s with two different bodies
gated_by :api sits at the top of the base controller and makes the whole namespace disappear when
the admin switches the API feature off. The concern calls prepend_before_action, so the check runs
ahead of authenticate_api_user! and a caller with no token gets 404 rather than 401:
a disabled feature answering 404
works through why prepend is the load-bearing word there. The body is head :not_found, which
means, again, zero bytes.
Compare that to the other 404 the same API can produce. An unhandled exception in production is
rendered by ActionDispatch::PublicExceptions, which builds { status: status, error:
Rack::Utils::HTTP_STATUS_CODES.fetch(status, ...) } and calls to_json on it when
request.formats.first is JSON. An ActiveRecord::RecordNotFound therefore comes back as
{"status":404,"error":"Not Found"}, a parseable object, while a switched-off feature comes back as
404 with Content-Type: application/json and an empty body.
Same status code, same content type, two incompatible shapes, and the client library that handled
one of them correctly is the one that crashes on the other. The honest fix is a rescue_from in the
base controller that renders the same object the feature gate renders, so every 404 the namespace
emits has one shape. The cost of doing it is that you now own an error format, which is a public
contract and the first thing v2 will want to change.
The missing record this API never has to answer
Both routes are singular resources. resource :session takes no id, resource :user takes no id,
and current_user comes out of the token rather than out of the path, so no client-supplied id
reaches User.find and ActiveRecord::RecordNotFound cannot be raised by either controller. That
is why there is no rescue_from in the base controller: the case has not happened yet. The first
endpoint with an id in its path is the one that has to choose the shape, and by then the choice is
constrained by whatever v1 clients already parse.
What this page does not cover
Deprecation scheduling, which is the half of versioning that actually costs money. Nothing in this
codebase emits a Sunset or Deprecation header, nothing records which client last called v1, and
retiring a version you cannot measure is a guess with a support queue attached.
CORS, too. There is no rack-cors in the Gemfile and no Access-Control-Allow-Origin anywhere in
app/ or config/, which is fine for a server-to-server or native client and is a blocker on the
day a browser front end calls this API from another origin.
An API answers two questions on its first day and pays for both later: where the version number lives, and what a failure looks like on the wire. Rails helps with neither. It gives you a routing scope and a
headmethod, and the conventions that turn those into an API other people can build against are yours to write down.What
namespace :v1actually doesThe whole of Rails API versioning in this boilerplate is four lines of
config/routes.rb:namespaceis not a versioning feature. Read its definition inActionDispatch::Routing::Mapper::Scopingand it does three assignments from the same word:options[:module] ||= name, thenas = name, thenpath = name. One:v1therefore produces the path segment/v1, the constant prefixApi::V1, and the route helper prefixapi_v1_. Nesting two of them stacks all three, which is whyApi::V1::UsersController#showis reachable at/api/v1/userand nowhere else.Nothing else in Rails knows the word version. There is no default version, no deprecation header, no constraint helper that reads a version out of a request, and no warning when a controller under
v1starts returning a fieldv1never returned. The framework gives you a namespace; the promise that the namespace means something is enforced by review or not at all. The rest of the JSON API is built on that assumption, so it is worth being explicit that the assumption is the only thing holding.Three places to put the version, and which one to pick
A version can sit in the URL (
/api/v1/user), in a request header (Accept: application/vnd.myapp.v2+json, or a custom header), or in a query parameter (/api/user?version=2). The three are not equivalent and the choice is close to irreversible once a client ships.Pick the URL segment. A versioned URL is visible in a log line, a
curlin a bug report and a router config, which means the version of a broken request is never a question anybody has to ask. The cost is real and it is the whole case against: the same user is now two resources,/api/v1/userand/api/v2/user, so a cache key, a rate-limit bucket and an access log all split in two.What would change the answer is scale you cannot coordinate. Stripe versions by date in a
Stripe-Versionrequest header; GitHub does the same withX-GitHub-Api-Version, and answers410 Goneto a version it no longer supports. Both have more integrations than they can ask to change a URL, so pinning a client to a date and letting the path stay stable is worth the loss of visibility. With a handful of known clients, you are buying that complexity for nobody.The query parameter is the one to rule out. A parameter is the easiest thing in the chain to drop: a proxy rewrites it, a client library appends its own params and forgets yours, a redirect loses the query string. It also shares a namespace with real filters, so
?version=2sits next to?page=2and neither reads as the more important of the two.One Rails detail decides how much work the header strategy is.
ActionController::API::MODULESlistsStrongParameters,RateLimiting,Renderers::AllandConditionalGet, and does not listActionController::MimeResponds. An API controller has norespond_toblock, so a version carried inAcceptcannot be switched on inside the action the way a format is. You write a routing constraint object answeringmatches?(request), or abefore_actionthat parses the header itself. That is a class you own and test, against a segment you get for free.What breaks the day
Api::V2existsThree things in this codebase are versioned by accident rather than on purpose, and all three surface the moment a second version is real.
The base controller is
Api::V1::BaseController. Every endpoint inherits itsauthenticate_api_user!callback and itsgated_by :api, which is correct today and wrong the first time v2 changes how authentication works, because aApi::V2::UsersController < Api::V1::BaseControlleris now v2 code depending on a v1 constant. The move is to lift the shared half intoApi::BaseControllerand leave only what is genuinely version-specific below it, and the cheap moment to do that is before v2 exists, not during.The serializer is
Api::V1::UserSerializer, a plain class whoseas_jsonreturns seven keys:id,email_address,name,company_name,role,confirmed,onboarded. Copy it to v2 and a bug fix is two edits forever. Subclass it and the next field added for v2 appears in a v1 response that a client already parses. Copy it anyway. A serializer's job is to be frozen, and duplication is the cheapest way to freeze one.The specs are the quiet one.
spec/requests/api/v1/users_spec.rbwriteslet(:path) { "/api/v1/user" }and passes it to two shared examples, so the suite is green about v1 and silent about v2. Worse, the shared example that looks like it covers the new version does not."a token-authenticated endpoint"ends its happy path onexpect(response).not_to have_http_status(:unauthorized), which a 500 also satisfies. Wire up a v2 endpoint that raises on every request, include that shared example, and the build stays green while the endpoint is broken in production. The assertion is deliberately loose because its subject is authentication rather than the action, and reusing it as proof the endpoint works is the mistake it invites.The 401 with nothing in it
Authentication for the whole namespace is five lines of
Api::V1::BaseController:headsets the status, sets a Content-Type from the request format because 401 is a content-bearing status, and assignsself.response_body = "". So a rejected request gets a 401 with a Content-Type header, zero bytes of body, and noWWW-Authenticate. RFC 9110 section 15.5.2 is explicit about that last one: "The server generating a 401 response MUST send a WWW-Authenticate header field (Section 11.6.1) containing at least one challenge applicable to the target resource." Nothing here sends one, andheadalready takes the headers as options, so the fix ishead :unauthorized, www_authenticate: %(Bearer realm="api")and a line in the spec.The empty body is the better decision of the two, and worth defending.
Api::Auth::VerifyTokenreturnsnilfor a blank token, a tampered signature, an expiredexpand asubpointing at a deleted user, and a body that told them apart would be an oracle: it tells an attacker which of their guesses was closer. Verifying the bearer token covers why a singlerescue JWT::DecodeErrorcollapses those cases in the first place. The cost lands on the legitimate client, which cannot distinguish "your token expired, get a new one" from "your token was never valid, stop retrying", and will therefore either retry forever or re-login on every 401.What is not defensible is that the same API answers 401 two different ways.
Api::V1::SessionsController#createrenders{ error: "Invalid email address or password." }withstatus: :unauthorized, so the login endpoint has a JSON body and every other endpoint has none. A client writing one error handler for this API has to branch on whether the response is parseable, which is a thing nobody discovers untilJSON.parseraises in the field.Two 404s with two different bodies
gated_by :apisits at the top of the base controller and makes the whole namespace disappear when the admin switches the API feature off. The concern callsprepend_before_action, so the check runs ahead ofauthenticate_api_user!and a caller with no token gets 404 rather than 401: a disabled feature answering 404 works through whyprependis the load-bearing word there. The body ishead :not_found, which means, again, zero bytes.Compare that to the other 404 the same API can produce. An unhandled exception in production is rendered by
ActionDispatch::PublicExceptions, which builds{ status: status, error: Rack::Utils::HTTP_STATUS_CODES.fetch(status, ...) }and callsto_jsonon it whenrequest.formats.firstis JSON. AnActiveRecord::RecordNotFoundtherefore comes back as{"status":404,"error":"Not Found"}, a parseable object, while a switched-off feature comes back as 404 withContent-Type: application/jsonand an empty body.Same status code, same content type, two incompatible shapes, and the client library that handled one of them correctly is the one that crashes on the other. The honest fix is a
rescue_fromin the base controller that renders the same object the feature gate renders, so every 404 the namespace emits has one shape. The cost of doing it is that you now own an error format, which is a public contract and the first thing v2 will want to change.The missing record this API never has to answer
Both routes are singular resources.
resource :sessiontakes no id,resource :usertakes no id, andcurrent_usercomes out of the token rather than out of the path, so no client-supplied id reachesUser.findandActiveRecord::RecordNotFoundcannot be raised by either controller. That is why there is norescue_fromin the base controller: the case has not happened yet. The first endpoint with an id in its path is the one that has to choose the shape, and by then the choice is constrained by whatever v1 clients already parse.What this page does not cover
Deprecation scheduling, which is the half of versioning that actually costs money. Nothing in this codebase emits a
SunsetorDeprecationheader, nothing records which client last called v1, and retiring a version you cannot measure is a guess with a support queue attached.CORS, too. There is no
rack-corsin the Gemfile and noAccess-Control-Allow-Originanywhere inapp/orconfig/, which is fine for a server-to-server or native client and is a blocker on the day a browser front end calls this API from another origin.