LaunchKit

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 :api do
  namespace :v1 do
    resource :session, only: :create      # POST /api/v1/session -> token
    resource :user, only: %i[show update] # GET/PATCH /api/v1/user (token holder)
  end
end

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:

def authenticate_api_user!
  @current_user = Api::Auth::VerifyToken.new(bearer_token).call
  head :unauthorized unless @current_user
end

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.

More on A JSON API in Rails

← All A JSON API in Rails articles