# Ruby on Rails and the CurrencyCore API

> Call /v1/convert and /v1/rates from Rails 7 with Faraday, credentials, explicit timeouts, a named error class, and a date-keyed cache.

Canonical: https://currency-core.com/docs/guides/ruby-rails/
Updated: 2026-11-11
Published: 2026-11-11

---
There is no official Ruby SDK, so Faraday or `Net::HTTP` is the supported path. Rails 7 and Faraday 2 keep this small: one initializer owns the connection, one error class carries the API code and status, and one cache key follows the daily snapshot. `timeout` is Faraday's read timeout; `open_timeout` covers the TCP and TLS handshake. Setting both stops a stalled response from occupying a Puma thread indefinitely.

Add Faraday to the Gemfile. Version 2 defaults to the `Net::HTTP` adapter and includes JSON parsing:

```ruby
gem "faraday", "~> 2.9"
```

Store the key in Rails credentials, with an environment variable as the fallback. The initializer runs once per process:

```ruby
# config/initializers/currency_core.rb
module CurrencyCore
  API_KEY = Rails.application.credentials.dig(:currencycore, :api_key) ||
            ENV.fetch("CURRENCYCORE_API_KEY")

  CONNECTION = Faraday.new(url: "https://api.currency-core.com/v1/") do |f|
    f.options.open_timeout = 3
    f.options.timeout = 10
    f.headers["Authorization"] = "Bearer #{API_KEY}"
    f.headers["Accept"] = "application/json"
    f.response :json
  end
end
```

The error class keeps the upstream status and code, so the controller can distinguish input errors from plan gates and rate limits:

```ruby
module CurrencyCore
  class Error < StandardError
    attr_reader :status, :code

    def initialize(status:, code:, message:)
      @status = status
      @code = code
      super(message)
    end
  end

  module Client
    module_function

    def convert(from:, to:, amount:, date: nil)
      response = CONNECTION.get("convert") do |req|
        req.params["from"] = from
        req.params["to"] = Array(to).join(",")
        req.params["amount"] = amount.to_s
        req.params["date"] = date if date
      end

      parse(response)
    rescue Faraday::TimeoutError, Faraday::ConnectionFailed => e
      raise Error.new(status: 504, code: "upstream_timeout", message: e.message)
    end

    def rates(base:, date: Date.current.iso8601)
      key = "currencycore:rates:#{base}:#{date}"
      ttl = date < Date.current.iso8601 ? 30.days : 10.minutes

      Rails.cache.fetch(key, expires_in: ttl) do
        response = CONNECTION.get("rates/#{base}") do |req|
          req.params["date"] = date
        end

        parse(response)
      end
    end

    def parse(response)
      body = response.body
      unless response.success?
        error = body.is_a?(Hash) ? body["error"] : nil
        raise Error.new(
          status: response.status,
          code: error ? error["code"] : "http_error",
          message: error ? error["message"] : "HTTP #{response.status}"
        )
      end

      body
    end
  end
end
```

The controller returns the upstream status rather than a generic `500`:

```ruby
class CurrencyController < ApplicationController
  def convert
    render json: CurrencyCore::Client.convert(
      from: params.fetch(:from, "USD"),
      to: params.fetch(:to, "INR"),
      amount: params.fetch(:amount, "100"),
      date: params[:date]
    )
  rescue CurrencyCore::Error => e
    render json: { error: { code: e.code, message: e.message } }, status: e.status
  end
end
```

A conversion returns one entry per target. A rates response returns a map with the base at `1`:

```json
{
  "from": "USD",
  "amount": 100,
  "date": "2026-11-10",
  "results": [{ "to": "INR", "result": 8345.0, "rate": 83.45 }]
}
```

```json
{ "base": "EUR", "date": "2026-11-10", "rates": { "EUR": 1, "USD": 1.0861 } }
```

Free allows 1,000 requests per month at 60 per minute. Test keys use the same data and quota, and live keys require a paid plan. Rates are polled hourly, and the fixings are daily.

## Production notes

If you use `Net::HTTP` instead of Faraday, pass timeouts to `Net::HTTP.start`:

```ruby
Net::HTTP.start(uri.host, uri.port, use_ssl: true,
                open_timeout: 3, read_timeout: 10, write_timeout: 10) do |http|
  # ...
end
```

`open_timeout` bounds connection setup. `read_timeout` applies to each socket read, not the whole exchange, so a slow trickle can exceed your wall-clock budget. Faraday's `timeout` maps to the read timeout; add a request deadline if total latency matters.

`Rails.cache` with `:memory_store` is per process; use Redis or Memcached across workers. Historical dates are stable for normal caching and can live 30 days; keep the observation date. The latest snapshot moves about once per business day, so 10 minutes is enough.

The API sends no `ETag`, `Last-Modified`, or quota headers. Track usage yourself, retry only `429` and `5xx`, and honor `Retry-After`. A history `403` usually means the date is outside the Free 30-day window.

## Next steps

- [API reference](/docs/api/) and [authentication](/docs/authentication/)
- [Error codes](/docs/errors/) and [all guides](/docs/guides/)
- [Caching guide](/docs/guides/caching/)
