Ruby on RailsRubyTutorialGeolocationExchange Rates
IP Geolocation and Live Currency in Ruby on Rails: A Practical Guide
ApogeoAPI5 min read
Most geo-detection gems for Rails (like geocoder) rely on MaxMind databases that need weekly updates. A simpler approach: one API call that returns the visitor's country, city, timezone, and live exchange rate — no local database to maintain.
Install
gem install httparty # or add to Gemfile
# Gemfile
gem "httparty"
A GeoService class with Rails.cache
# app/services/geo_service.rb
class GeoService
API_KEY = ENV.fetch("APOGEOAPI_KEY")
BASE_URL = "https://api.apogeoapi.com/v1"
# Returns geo data for an IP. Cached per /24 subnet for 1 hour.
def self.lookup(ip)
return {} if ip.blank? || private_ip?(ip)
cache_key = "geo:#{subnet(ip)}"
Rails.cache.fetch(cache_key, expires_in: 1.hour) do
response = HTTParty.get(
"#{BASE_URL}/ip/#{ip}",
headers: { "X-API-Key" => API_KEY },
timeout: 3
)
response.success? ? response.parsed_response : {}
end
rescue StandardError
{} # graceful degradation
end
# Returns live USD exchange rate for a currency code (e.g. "EUR").
def self.rate(currency)
cache_key = "fx:#{currency.upcase}"
Rails.cache.fetch(cache_key, expires_in: 4.hours) do
response = HTTParty.get(
"#{BASE_URL}/exchange-rates/#{currency.upcase}",
headers: { "X-API-Key" => API_KEY },
timeout: 3
)
response.success? ? response.parsed_response["usdRate"] : nil
end
rescue StandardError
nil
end
private
def self.subnet(ip)
parts = ip.split(".")
parts.length == 4 ? parts.first(3).join(".") + ".0" : ip
end
def self.private_ip?(ip)
private_ranges = [
/A127./,
/A10./,
/A172.(1[6-9]|2d|3[01])./,
/A192.168./,
/A::1z/
]
private_ranges.any? { |r| ip.match?(r) }
end
end
Key design choices:
- Subnet caching: caching by
/24subnet means everyone at the same office hits the cache — typically 10–50x fewer API calls. - Private IP guard:
127.x,10.x,192.168.xnever reach the API. In development you'll get{}instead of an error. - Graceful rescue: returns
{}ornilon any network error so your controllers don't blow up if the API is unreachable.
An ApplicationController concern
# app/controllers/concerns/geo_detection.rb
module GeoDetection
extend ActiveSupport::Concern
included do
before_action :detect_visitor_geo
helper_method :visitor_country, :visitor_currency, :visitor_currency_rate
end
private
def detect_visitor_geo
@visitor_geo ||= GeoService.lookup(real_ip)
end
def visitor_country
@visitor_geo.dig("country", "name")
end
def visitor_currency
@visitor_geo.dig("country", "currency")
end
def visitor_currency_rate
@visitor_geo.dig("country", "currencyRate")
end
def real_ip
# Cloudflare sets CF-Connecting-IP; standard proxies set X-Forwarded-For
request.env["HTTP_CF_CONNECTING_IP"] ||
request.env["HTTP_X_FORWARDED_FOR"]&.split(",")&.first&.strip ||
request.remote_ip
end
end
# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
include GeoDetection
end
Now every view has access to visitor_country, visitor_currency, and visitor_currency_rate:
<!-- app/views/layouts/application.html.erb -->
<% if visitor_country %>
<p>Shipping to: <%= visitor_country %></p>
<% end %>
A price helper for localized display
# app/helpers/pricing_helper.rb
module PricingHelper
# Display price in visitor's local currency.
# Always bill in USD — this is display only.
def localized_price(usd_amount, rate: nil, currency: nil)
rate ||= visitor_currency_rate
currency ||= visitor_currency
return number_to_currency(usd_amount) if rate.nil? || rate.zero?
local_amount = (usd_amount * rate).round(2)
symbol = currency_symbol(currency)
"#{symbol}#{format('%.2f', local_amount)} #{currency}"
end
private
def currency_symbol(code)
symbols = { "EUR" => "€", "GBP" => "£", "JPY" => "¥",
"BRL" => "R$", "ARS" => "$", "MXN" => "$",
"CAD" => "CA$", "AUD" => "A$" }
symbols.fetch(code.to_s.upcase, "$")
end
end
<!-- In a pricing view -->
<p>
Monthly plan: <%= localized_price(19.00) %>
<small>(billed as $19.00 USD)</small>
</p>
Caching in production
The code above uses Rails.cache. In production, configure Redis:
# config/environments/production.rb
config.cache_store = :redis_cache_store, {
url: ENV["REDIS_URL"],
expires_in: 1.hour
}
In development, the default :memory_store works fine. No extra config needed.
Quota math
- With 1-hour subnet caching: 1,000 free API calls/month covers ~50K daily visitors (50 users per /24, 30 days × 1K subnets/day)
- Exchange rate calls are cached 4 hours — one call per currency per 4 hours, not per user
- Free tier at apogeoapi.com: 1,000 req/month, no credit card. 14-day trial includes IP geolocation and exchange rates.
Try ApogeoAPI free
1,000 requests/month forever. 14-day full-access trial. No credit card.
Get your free API key