Code Examples

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.


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:

gem "faraday", "~> 2.9"

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

# 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:

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:

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:

{
  "from": "USD",
  "amount": 100,
  "date": "2026-11-10",
  "results": [{ "to": "INR", "result": 8345.0, "rate": 83.45 }]
}
{ "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:

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