This guide shows how to use an exchange rate API in Ruby: a dependency-free Net::HTTP client with full error handling, the same calls with Faraday, and a Rails service that caches rates in Rails.cache and converts amounts with BigDecimal. You’ll fetch latest rates, look up a historical date and convert currencies.
Setup
- Get a key from the free sign-up.
- Store it as
FXAPI_KEYin your environment or Rails credentials.
Requests are GET https://api.fxapi.com/v1/<endpoint> with the key in the apikey header. The endpoints used here are latest, historical and convert.
Exchange rate API in Ruby with Net::HTTP
require "net/http"
require "json"
require "uri"
module FxApi
BASE = "https://api.fxapi.com/v1"
class Error < StandardError
attr_reader :status, :errors
def initialize(status, message, errors = nil)
super("#{status}: #{message}")
@status = status
@errors = errors
end
end
def self.get(path, **params)
uri = URI("#{BASE}#{path}")
uri.query = URI.encode_www_form(params)
req = Net::HTTP::Get.new(uri)
req["apikey"] = ENV.fetch("FXAPI_KEY")
res = Net::HTTP.start(uri.host, uri.port, use_ssl: true, open_timeout: 5, read_timeout: 10) do |http|
http.request(req)
end
body = begin
JSON.parse(res.body)
rescue JSON::ParserError
{}
end
case res.code.to_i
when 200 then body
when 401 then raise Error.new(401, "Invalid API key")
when 403 then raise Error.new(403, "Endpoint or option not included in your plan")
when 422 then raise Error.new(422, "Validation error", body["errors"])
when 429
raise Error.new(429, "Rate limit reached, monthly quota left: #{res['X-RateLimit-Remaining-Quota-Month']}")
else raise Error.new(res.code.to_i, res.message)
end
end
end
latest = FxApi.get("/latest", base_currency: "EUR", currencies: "USD,GBP,CHF")
puts latest.dig("meta", "last_updated_at")
puts latest.dig("data", "USD", "value")
hist = FxApi.get("/historical", date: "2024-12-31", base_currency: "USD", currencies: "EUR")
puts hist.dig("data", "EUR", "value")
Status codes to plan for:
| Status | Meaning | What to do |
|---|---|---|
| 401 | Invalid API key | Check the key; don’t retry |
| 403 | Endpoint or option not in your plan | Upgrade or use another endpoint |
| 422 | Validation error, errors keyed by parameter | Fix the input; doesn’t count against quota |
| 429 | Monthly quota used up, or Free plan’s 10 requests/minute | Serve cached rates; retry later |
Historical values are end-of-day rates in UTC; date can be any day from 1999-01-01 up to yesterday. For whole series, see the historical exchange rates API.
Currency conversion with BigDecimal
On Basic and higher plans, /v1/convert returns converted amounts:
conv = FxApi.get("/convert", value: 250, base_currency: "USD", currencies: "EUR")
puts conv.dig("data", "EUR", "value")
On the Free plan, multiply by the latest rate. Use BigDecimal, never Float, for money:
require "bigdecimal"
rate = BigDecimal(FxApi.get("/latest", base_currency: "USD", currencies: "EUR").dig("data", "EUR", "value").to_s)
eur = (BigDecimal("250.00") * rate).round(2, :banker)
bigdecimal is a bundled gem since Ruby 3.4 – add it to your Gemfile if you’re not on Rails. Different currencies need different decimal places (JPY 0, KWD 3); the rounding guide shows how to read them from /v1/currencies.
Using Faraday
require "faraday"
FX = Faraday.new(
url: "https://api.fxapi.com/v1/",
headers: { "apikey" => ENV.fetch("FXAPI_KEY") },
request: { timeout: 10, open_timeout: 5 }
) do |f|
f.response :raise_error # raises Faraday::ClientError / ServerError on 4xx / 5xx
f.response :json # parses JSON bodies (runs first on the response)
end
begin
res = FX.get("latest", base_currency: "USD") # all 190+ currencies in one call
rates = res.body["data"].transform_values { |r| r["value"] }
rescue Faraday::ClientError => e
status = e.response[:status]
if status == 422
Rails.logger.error("fxapi validation: #{e.response[:body]['errors']}")
elsif status == 429
# quota or per-minute limit reached: fall back to cached rates
end
raise
end
Faraday 2 includes the JSON response middleware. Middleware listed later sits closer to the HTTP adapter, so :json parses the body before :raise_error inspects it.
Rails: caching exchange rates with Rails.cache
# app/services/exchange_rates.rb
class ExchangeRates
TTL = 1.hour # Basic plan updates hourly; use 1.minute on Professional, 1.day on Free
def self.latest(base = "USD")
key = "fxapi/latest/#{base}"
Rails.cache.fetch(key, expires_in: TTL, race_condition_ttl: 30.seconds) do
FxApi.get("/latest", base_currency: base).tap do |data|
Rails.cache.write("#{key}/last-good", data)
end
end
rescue FxApi::Error, Net::OpenTimeout, Net::ReadTimeout => e
Rails.logger.warn("fxapi: #{e.message}")
Rails.cache.read("#{key}/last-good") || raise
end
def self.historical(date, base = "USD")
Rails.cache.fetch("fxapi/historical/#{base}/#{date}") do # past rates never change
FxApi.get("/historical", date: date.to_s, base_currency: base)
end
end
def self.convert(amount, from:, to:, digits: 2)
rate = BigDecimal(latest(from).dig("data", to, "value").to_s)
(BigDecimal(amount.to_s) * rate).round(digits, :banker)
end
end
ExchangeRates.convert("49.00", from: "USD", to: "EUR")
race_condition_ttl stops many requests from refreshing the same expired key at once, and the last-good copy keeps checkout working if fxapi is unreachable or you hit a 429. Use a shared store such as Redis or Solid Cache so all Puma workers and servers share one cached response. Store meta.last_updated_at with each order or invoice so you can always show which rate was applied – more in caching exchange rates.
Testing without spending quota
Stub HTTP calls in your test suite so specs are fast and deterministic. With WebMock:
stub_request(:get, %r{api\.fxapi\.com/v1/latest})
.to_return(
status: 200,
headers: { "Content-Type" => "application/json" },
body: { meta: { last_updated_at: "2026-10-01T23:59:59Z" },
data: { "EUR" => { code: "EUR", value: 0.9 } } }.to_json
)
expect(ExchangeRates.convert("10.00", from: "USD", to: "EUR")).to eq(BigDecimal("9.00"))
Return status: 429 to verify that the stale fallback kicks in. For end-to-end checks against the live API, the Professional and Enterprise plans include sandbox keys that return random dummy values with X-Cost: 0, so CI runs don’t use your quota.
Using the official Ruby gem
The fxapi gem exposes one method per endpoint with positional arguments and returns the raw RestClient response:
gem install fxapi
require "fxapi"
require "json"
fx = Fxapi::Endpoints.new(apikey: ENV.fetch("FXAPI_KEY"))
latest = JSON.parse(fx.latest("EUR", "USD,GBP").body)
hist = JSON.parse(fx.historical("2024-12-31", "USD", "EUR").body)
The gem sends the key as a query parameter rather than a header, so it can show up in proxy logs. If that matters for you, use the Net::HTTP or Faraday client above.
Next steps
- API docs and the OpenAPI spec.
- Compare update intervals and quotas on the pricing page.
- Multi-currency subscriptions: SaaS billing and invoicing.
The examples in this guide use fxapi, a foreign exchange rate API with a free plan of 300 requests a month – no credit card required.
Frequently asked questions
Should I use Net::HTTP or Faraday for the exchange rate API?
Net::HTTP ships with Ruby and is fine for scripts and small services. Faraday adds middleware for JSON parsing, error raising and retries, which keeps Rails code shorter.How do I avoid floating point errors when converting currencies in Ruby?
decimal_class: BigDecimal or wrap rates in BigDecimal(value.to_s), multiply, then round once to the currency’s decimal places. See currency rounding and precision.How long should Rails cache exchange rates?
expires_in: one day on Free, one hour on Basic, 60 seconds on Professional and Enterprise. Historical rates can be cached without expiry.Can I convert currencies on the Free plan?
/v1/latest. The /v1/convert endpoint, which returns converted amounts directly, requires the Basic plan or higher.Is there an official Ruby gem?
fxapi on RubyGems. It returns the raw HTTP response, so you parse the JSON yourself.