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 /24 subnet means everyone at the same office hits the cache — typically 10–50x fewer API calls.
  • Private IP guard: 127.x, 10.x, 192.168.x never reach the API. In development you'll get {} instead of an error.
  • Graceful rescue: returns {} or nil on 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