Building a Rails API That Clients Actually Enjoy Working With
by Eric Hanson, Backend Developer at Clean Systems Consulting
The gap between "works" and "good to integrate against"
An API can return correct data, be available, and have reasonable response times — and still be miserable to work with. The misery comes from inconsistency, from errors that don't explain what went wrong, from pagination schemes that change mid-collection, and from response shapes that require clients to handle six different structures for conceptually similar resources.
These aren't performance problems. They're design problems, and they compound: every inconsistency an API client encounters becomes a special case in their codebase, a question in Slack, or a support ticket.
Consistent response envelope
Pick a response structure and apply it everywhere. The most common approach:
{
"data": { ... },
"meta": { ... },
"errors": [ ... ]
}
data contains the primary payload. meta contains pagination, counts, or other contextual metadata. errors is present only on failure. A client that understands this structure can handle any response without reading documentation for each endpoint.
In Rails, implement this with a base serializer or a response helper that wraps every response:
module Api
class BaseController < ApplicationController
private
def render_success(data, meta: {}, status: :ok)
render json: { data: data, meta: meta }, status: status
end
def render_error(errors, status: :unprocessable_entity)
errors = Array(errors).map { |e| { message: e } }
render json: { errors: errors }, status: status
end
def render_not_found(message = "Resource not found")
render_error(message, status: :not_found)
end
end
end
Every controller action calls render_success or render_error. No controller renders json: { user: ... } directly. Consistency is enforced structurally, not by convention.
Error responses that explain themselves
The single most useful thing you can do for API clients is return errors that tell them what went wrong and how to fix it. The minimum a useful error response contains:
{
"errors": [
{
"code": "validation_failed",
"message": "Email is already taken",
"field": "email"
}
]
}
code is a machine-readable identifier clients can branch on. message is human-readable. field is present for validation errors to tell clients which input caused the problem.
A machine-readable code is the detail most APIs skip. Without it, clients parse the message string to decide what to do — fragile, locale-dependent, and broken whenever you change the wording. With a stable code, clients write if error.code == "email_taken" and your message text can change freely.
Map your exception hierarchy to error codes in the base controller:
rescue_from MyApp::Errors::ValidationError do |e|
render_error(
{ code: "validation_failed", message: e.message, field: e.field },
status: :unprocessable_entity
)
end
rescue_from MyApp::Errors::AuthorizationError do |e|
render_error(
{ code: "unauthorized", message: "You don't have permission to perform this action" },
status: :forbidden
)
end
rescue_from ActiveRecord::RecordNotFound do
render_not_found
end
Clients receive consistent error shapes with stable codes regardless of which controller raised the error.
Versioning from day one
Version the API before the first external client hits it. The cost of adding namespace :v1 to routes before any client exists is negligible. The cost of versioning after external clients are in production is high.
The URL path approach (/api/v1/orders) is the most explicit and the easiest for clients to work with — they see the version in every request and can test against both versions simultaneously. Header-based versioning (Accept: application/vnd.myapp.v1+json) is cleaner in theory and harder in practice — harder to test in a browser, harder to proxy-cache, harder to debug from logs.
The route structure that scales:
Rails.application.routes.draw do
namespace :api do
namespace :v1 do
resources :orders, only: [:index, :show, :create, :update]
resources :users, only: [:show, :update]
end
end
end
Controllers in app/controllers/api/v1/. When v2 is needed, v1 controllers remain unchanged:
module Api
module V1
class OrdersController < Api::BaseController
# v1 implementation unchanged when v2 ships
end
end
end
Serialization with explicit field control
Never let to_json or as_json on an AR model determine what your API returns. Model attributes change. Associations get added. Internal fields get exposed accidentally. A serializer layer that explicitly defines what each response contains is not optional:
class OrderSerializer
def initialize(order, include_user: false)
@order = order
@include_user = include_user
end
def as_json
data = {
id: order.id,
status: order.status,
total: order.total_in_cents,
currency: order.currency,
created_at: order.created_at.iso8601,
line_items: order.line_items.map { |li| LineItemSerializer.new(li).as_json }
}
data[:user] = UserSerializer.new(order.user).as_json if include_user
data
end
private
attr_reader :order, :include_user
end
Two things in this implementation worth noting:
Monetary values are in cents (integers), not floats. Floats are not safe for money. 19.99 as a float is 19.990000000000002 in some environments. Return integers with a documented unit, or a string decimal if the client ecosystem requires it.
Timestamps are ISO 8601 strings. Not Unix timestamps (require conversion), not Rails' default format (locale-dependent). ISO 8601 is unambiguous, universally parseable, and timezone-explicit: 2026-04-17T14:30:00Z.
For larger APIs, alba, blueprinter, or panko provide production-grade serialization with better performance than hand-rolled serializers. panko in particular uses C extensions for serialization and outperforms pure Ruby serializers by a significant margin at high throughput.
Pagination that clients can predict
Offset-based pagination (page=2&per_page=25) is familiar and easy to implement. It breaks on large datasets where records are being inserted during pagination — page 2 may skip or repeat records from page 1 if rows were added after the first request. For APIs where data doesn't change between pages, offset pagination is fine.
Cursor-based pagination avoids the skipping problem. Each page response includes a cursor — an opaque value encoding position in the result set — that the next request passes to resume:
{
"data": [ ... ],
"meta": {
"next_cursor": "eyJpZCI6MTAwfQ==",
"has_more": true
}
}
The cursor encodes the last record's position (typically a primary key or created_at). The query uses it as a WHERE id > ? rather than OFFSET n:
def index
cursor = decode_cursor(params[:cursor])
per_page = params.fetch(:per_page, 25).to_i.clamp(1, 100)
orders = Order.order(:id)
orders = orders.where("id > ?", cursor) if cursor
orders = orders.limit(per_page + 1) # fetch one extra to check has_more
has_more = orders.length > per_page
results = orders.first(per_page)
render_success(
results.map { |o| OrderSerializer.new(o).as_json },
meta: {
next_cursor: has_more ? encode_cursor(results.last.id) : nil,
has_more: has_more
}
)
end
The per_page + 1 trick checks for more pages without a separate COUNT query — fetch one extra record; if it exists, there's a next page.
encode_cursor / decode_cursor Base64-encode the position value. Opaque cursors prevent clients from constructing arbitrary cursors and simplify future changes to the cursor format.
Rate limiting and request headers clients need
Include these headers on every response — clients need them regardless of whether you're hitting limits:
X-RateLimit-Limit: 1000
X-RateLimit-Remaining: 847
X-RateLimit-Reset: 1713358800
Retry-After: 30 (on 429 responses only)
X-RateLimit-Reset is a Unix timestamp, not a duration. Clients build retry logic against the reset time, not a relative interval that requires knowing when the response was received.
The Rack middleware approach with rack-attack:
# config/initializers/rack_attack.rb
Rack::Attack.throttle("api/requests", limit: 1000, period: 1.hour) do |request|
request.env["HTTP_AUTHORIZATION"]&.then { |t| Digest::SHA1.hexdigest(t) }
end
Rack::Attack.throttled_responder = ->(env) {
[
429,
{
"Content-Type" => "application/json",
"Retry-After" => "30",
"X-RateLimit-Limit" => "1000",
"X-RateLimit-Remaining" => "0"
},
[{ errors: [{ code: "rate_limited", message: "Too many requests" }] }.to_json]
]
}
Rate limit the authentication credential, not the IP address. IP-based rate limiting breaks clients behind corporate NAT, mobile networks, and load balancers.
Idempotency for mutating requests
POST requests that create resources are not idempotent by default — submitting the same request twice creates two records. For clients handling network errors and retries, this is a real problem.
An idempotency key lets clients safely retry:
before_action :check_idempotency_key, only: [:create]
def check_idempotency_key
key = request.headers["Idempotency-Key"]
return unless key.present?
cached = IdempotencyCache.fetch(key)
if cached
render json: cached[:response], status: cached[:status]
else
@idempotency_key = key
end
end
def create
result = CreateOrder.call(order_params)
response_body = OrderSerializer.new(result.value).as_json
IdempotencyCache.store(@idempotency_key, { response: response_body, status: 201 }) if @idempotency_key
render_success(response_body, status: :created)
end
The cache stores the response for 24 hours. A client that retries with the same key gets the same response. The resource is created exactly once.
The OpenAPI spec as the contract
Document the API with an OpenAPI (formerly Swagger) spec. Not because tooling requires it, but because the spec becomes the contract clients write against and the test fixture you validate against. rswag generates the spec from RSpec request specs:
# spec/requests/api/v1/orders_spec.rb
path "/api/v1/orders" do
post "Create an order" do
consumes "application/json"
produces "application/json"
parameter name: :body, in: :body, schema: {
type: :object,
properties: {
order: {
type: :object,
properties: {
payment_method: { type: :string }
}
}
}
}
response "201", "order created" do
schema "$ref" => "#/components/schemas/Order"
run_test!
end
response "422", "validation failed" do
schema "$ref" => "#/components/schemas/Error"
run_test!
end
end
end
run_test! executes the request and validates the response shape against the schema. The spec and the tests stay in sync. The generated JSON spec feeds API documentation tools (Redoc, Stoplight) without a separate documentation writing step.
An API with a published OpenAPI spec that matches production behavior is one clients can integrate against confidently. That confidence is what "enjoyable to work with" actually means in practice.