← Blog

Claude API Integration with a Rails App: Full Guide

2026-09-27 · 5 min read · SubToAPI Team

If you're building a Rails app and want to add Claude for chat, summarization, or content generation, the integration itself is straightforward: you make HTTPS requests to an API endpoint with a JSON body, handle the JSON (or streamed) response, and wire that into your controllers, jobs, or Turbo Streams. There's no official Claude gem, so most Rails developers either use Net::HTTP/Faraday directly or go through a proxy service that gives them a stable, key-based API.

This guide walks through both approaches: calling Claude directly from Rails, and using a lightweight service layer so your controllers stay clean. It also covers streaming responses into Turbo Streams, background job patterns for longer requests, and common pitfalls (timeouts, rate limits, request/response shape).

Setting Up the Basics

Whichever HTTP client you use, you need three things: an API key stored as a credential, a model name, and a service object that isolates the API call from your controllers.

Add your key to Rails credentials instead of .env files when possible:

EDITOR="code --wait" bin/rails credentials:edit
claude:
  api_key: sk-ant-xxxxxxxx

Then build a service object:

# app/services/claude_client.rb
class ClaudeClient
  include HTTParty
  base_uri "https://api.anthropic.com/v1"

  def initialize
    @headers = {
      "x-api-key" => Rails.application.credentials.dig(:claude, :api_key),
      "anthropic-version" => "2023-06-01",
      "content-type" => "application/json"
    }
  end

  def send_message(prompt, model: "claude-sonnet-4-5")
    self.class.post(
      "/messages",
      headers: @headers,
      body: {
        model: model,
        max_tokens: 1024,
        messages: [{ role: "user", content: prompt }]
      }.to_json
    )
  end
end

Call it from a controller:

class ChatsController < ApplicationController
  def create
    response = ClaudeClient.new.send_message(params[:prompt])
    @reply = response.parsed_response.dig("content", 0, "text")
    render turbo_stream: turbo_stream.append("chat", partial: "chats/message", locals: { text: @reply })
  end
end

This works, but a few things become painful as your app grows: managing API key rotation, tracking per-user usage, handling rate limits gracefully, and giving each part of your app (or each customer, if you're multi-tenant) its own scoped key.

Using a Managed API Layer Instead

If you don't want to manage raw API keys, rate limit handling, and usage tracking yourself, you can put SubToAPI in front of Claude. It converts your existing Claude access into a standard HTTPS API with its own sub_live_... application keys, so your Rails app talks to one stable endpoint regardless of how many team members or environments you have.

The Rails-side code barely changes — you're still POSTing JSON to a /messages-style endpoint:

# app/services/claude_client.rb
class ClaudeClient
  include HTTParty
  base_uri "https://api.subtoapi.app/v1"

  def initialize
    @headers = {
      "Authorization" => "Bearer #{Rails.application.credentials.dig(:subtoapi, :key)}",
      "content-type" => "application/json"
    }
  end

  def send_message(prompt, model: "claude-sonnet-4-5")
    self.class.post(
      "/messages",
      headers: @headers,
      body: {
        model: model,
        max_tokens: 1024,
        messages: [{ role: "user", content: prompt }]
      }.to_json
    )
  end
end

What you get in return: per-application keys you can issue for staging, production, or individual team members without ever exposing the underlying Claude account, a dashboard for usage metadata across the whole team, and team seats so multiple developers can build against the same Claude access without sharing raw credentials. Setup takes a few minutes via /signup, and the request/response format matches what's documented at /docs/messages, so you're not learning a new API shape — just pointing your existing HTTP client at a different base URL.

Streaming Into Turbo Streams

For chat-style UIs, streaming tokens as they arrive gives a much better UX than waiting for the full response. Rails' ActionController::Live combined with Turbo Streams handles this well:

class ChatsController < ApplicationController
  include ActionController::Live

  def stream
    response.headers["Content-Type"] = "text/event-stream"

    uri = URI("https://api.subtoapi.app/v1/messages")
    Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |http|
      request = Net::HTTP::Post.new(uri)
      request["Authorization"] = "Bearer #{Rails.application.credentials.dig(:subtoapi, :key)}"
      request["content-type"] = "application/json"
      request.body = {
        model: "claude-sonnet-4-5",
        max_tokens: 1024,
        stream: true,
        messages: [{ role: "user", content: params[:prompt] }]
      }.to_json

      http.request(request) do |api_response|
        api_response.read_body do |chunk|
          response.stream.write(chunk)
        end
      end
    end
  ensure
    response.stream.close
  end
end

On the frontend, parse the server-sent events and append text chunks to a Turbo Stream target as they arrive. See /docs/streaming for the exact event format and chunk structure.

Background Jobs for Longer Requests

Not every use case needs streaming. For summarization, report generation, or batch content tasks, running the request in a Sidekiq job avoids tying up a web worker:

class ClaudeGenerationJob < ApplicationJob
  queue_as :default

  def perform(record_id, prompt)
    record = Document.find(record_id)
    response = ClaudeClient.new.send_message(prompt)
    text = response.parsed_response.dig("content", 0, "text")
    record.update!(generated_content: text)
    Turbo::StreamsChannel.broadcast_replace_to(
      record, target: "document_#{record.id}", partial: "documents/content", locals: { document: record }
    )
  end
end

This pattern keeps controllers thin and gives you natural retry semantics through your job queue if a request times out or hits a rate limit.

Handling Tool Use From Rails

If your app needs Claude to call functions — looking up a record, running a calculation, querying an internal API — you define tools in the request body and handle the tool_use block in the response:

tools = [{
  name: "lookup_order",
  description: "Look up an order by ID",
  input_schema: {
    type: "object",
    properties: { order_id: { type: "string" } },
    required: ["order_id"]
  }
}]

Your Rails app inspects the response, executes the matching Ruby method, and sends the result back in a follow-up message. The full request/response cycle for this pattern is documented at /docs/tools.

Common Pitfalls

Questions

Do I need a Ruby gem to call the Claude API from Rails? No. There's no official gem, but any HTTP client (Faraday, HTTParty, Net::HTTP) works fine since it's a standard JSON-over-HTTPS API.

How do I stream Claude responses into a Rails view? Use ActionController::Live to proxy the server-sent event stream, then append incoming text chunks to a Turbo Stream target on the frontend as they arrive.

Should API calls run in the request cycle or a background job? For anything longer than a second or two, use a Sidekiq job or streaming response — synchronous calls inside a normal request risk timeouts and block the web worker. See /docs/quickstart for request patterns that work either way.

Turn your Claude access into an HTTPS API

SubToAPI gives you application API keys, streaming, tool use and usage insights on top of your existing Claude access — set up in minutes.

Start free  Read the quickstart →