Tutorial/Ruby on Rails exchange rates

Exchange Rate API for Ruby on Rails

Wrap currency conversion in one Rails service, cache successful observations, and keep source and freshness metadata beside every result.

ERexchangerate.dev·Sep 20, 2026·7 min read

A Rails controller should not know how the upstream rate API authenticates or how long its responses are cached. Put that decision in one service object and return a small, explicit result to the rest of the application.

Key points
Use Net::HTTP from the Ruby standard library; no SDK is required.
Store the API key in Rails credentials or an environment variable.
Cache by currency pair and amount policy, not by controller action.
Raise explicit errors for timeouts, rate limits, and unavailable data.

Create a small conversion service

ruby · app/services/exchange_rate_client.rbcopy
require "net/http"
require "json"

class ExchangeRateClient
  Error = Class.new(StandardError)

  def convert(from:, to:, amount:)
    from = from.to_s.upcase
    to = to.to_s.upcase
    raise Error, "invalid currency code" unless [from, to].all? { |code| code.match?(/A[A-Z]{3}z/) }

    key = ["fx-convert", from, to, amount.to_d.to_s("F")]
    Rails.cache.fetch(key, expires_in: 60.seconds) do
      uri = URI("https://api.exchangerate.dev/v1/convert/#{from}/#{to}/#{amount}")
      request = Net::HTTP::Get.new(uri)
      request["Authorization"] = "Bearer #{ENV.fetch("EXCHANGERATE_API_KEY")}" 

      response = Net::HTTP.start(uri.host, uri.port, use_ssl: true,
        open_timeout: 2, read_timeout: 5) { |http| http.request(request) }
      raise Error, "exchange-rate API returned #{response.code}" unless response.is_a?(Net::HTTPSuccess)
      JSON.parse(response.body)
    end
  rescue Net::OpenTimeout, Net::ReadTimeout => error
    raise Error, "exchange-rate API timed out: #{error.class}"
  end
end

Keep the controller thin

ruby · conversions_controller.rbcopy
class ConversionsController < ApplicationController
  def show
    amount = BigDecimal(params.require(:amount))
    result = ExchangeRateClient.new.convert(
      from: params.require(:from).upcase,
      to: params.require(:to).upcase,
      amount: amount
    )
    render json: result.slice("from", "to", "amount", "rate", "converted",
      "source", "market_session", "data_updated_at")
  rescue ExchangeRateClient::Error, ArgumentError => error
    render json: { error: error.message }, status: :bad_gateway
  end
end

Store evidence when the conversion becomes a record

A transient display can use a cached result. An invoice, ledger entry, or report should store the original amount, both currency codes, full-precision rate, converted amount, source, and data_updated_at. That makes the number reproducible after the live rate changes.

Use background jobs for recurring refreshes

If many users need the same base rates, fetch once in a scheduled Active Job and serve the cached snapshot. Do not let every request to a product page trigger an upstream currency request.

Do not silently turn failure into zero
A timeout or unavailable rate is an error state. Keep a bounded last-good value with its original timestamp, or stop the operation and ask the caller to retry.
ER
exchangerate.dev
Practical integration guides for developers working with currency data.

Keep reading

GuideCurrency Converter API for DevelopersRead Use caseExchange rates for accounting softwareRead GuideHistorical exchange-rate time seriesRead