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.

Scale Your Backend - Need an Experienced Backend Developer?

We provide backend engineers who join your team as contractors to help build, improve, and scale your backend systems.

We focus on clean backend design, clear documentation, and systems that remain reliable as products grow. Our goal is to strengthen your team and deliver backend systems that are easy to operate and maintain.

We work from our own development environments and support teams across US, EU, and APAC timezones. Our workflow emphasizes documentation and asynchronous collaboration to keep development efficient and focused.

  • Production Backend Experience. Experience building and maintaining backend systems, APIs, and databases used in production.
  • Scalable Architecture. Design backend systems that stay reliable as your product and traffic grow.
  • Contractor Friendly. Flexible engagement for short projects, long-term support, or extra help during releases.
  • Focus on Backend Reliability. Improve API performance, database stability, and overall backend reliability.
  • Documentation-Driven Development. Development guided by clear documentation so teams stay aligned and work efficiently.
  • Domain-Driven Design. Design backend systems around real business processes and product needs.

Tell us about your project

Our offices

  • Copenhagen
    1 Carlsberg Gate
    1260, København, Denmark
  • Magelang
    12 Jalan Bligo
    56485, Magelang, Indonesia

More articles

Why “Don’t Touch This Code” Is a Huge Engineering Red Flag

Hearing “don’t touch this code” might seem like harmless advice, but it often signals deep problems in a codebase and the team culture around it.

Read more

What Really Happens Inside a Java HashMap

HashMap is the most-used data structure in Java and one of the least understood internally. The hash function, bucket structure, tree conversion, and resize behavior all have practical consequences for performance and correctness.

Read more

Chicago Has a Thriving Tech Scene — and a Fintech Sector That Absorbs All the Senior Backend Talent

Chicago's tech community is active and growing. Its fintech and trading infrastructure sector quietly employs most of the senior backend engineers that community depends on.

Read more

How Seoul Tech Startups Are Filling Senior Backend Gaps Without Competing With the Big Players

Competing with Samsung and Kakao for backend engineers is a losing game for most startups. The ones shipping consistently have stopped playing it.

Read more