Back to 0fee
0fee

Currency Conversion Across 25+ Currencies

How 0fee.dev handles currency conversion across 25+ currencies with free APIs, caching, and locale detection. By Juste A. Gnimavo and Claude.

Thales & Claude | March 25, 2026 11 min 0fee
currencyconversioninternationalization

A payment orchestrator serving 200+ countries must handle currency conversion as a first-class concern. When a merchant in Abidjan charges 10,000 XOF and wants to see their revenue in USD, or when a Kenyan developer prices their API in KES but pays platform fees in USD, the conversion layer must be fast, accurate, and resilient.

0fee.dev supports 25+ currencies across Africa, Europe, Asia, and the Americas. Here is how we built the conversion system.

Source/Destination Currency Separation

The foundational design decision was separating source and destination currencies at the transaction level. Every transaction records both:

pythonclass Transaction(Base):
    __tablename__ = "transactions"

    # What the customer pays
    source_amount: Mapped[Decimal]
    source_currency: Mapped[str]  # e.g., "XOF"

    # What the merchant receives (may be same or different)
    destination_amount: Mapped[Decimal]
    destination_currency: Mapped[str]  # e.g., "USD"

    # Platform accounting (always USD)
    amount_usd: Mapped[Decimal]
    platform_fee_usd: Mapped[Decimal]

    # Rate at time of transaction
    exchange_rate: Mapped[Decimal]
    rate_source: Mapped[str]  # "fawazahmed0" or "fallback"
    rate_timestamp: Mapped[datetime]

This separation matters because the customer's currency and the merchant's preferred currency are often different. A merchant in Senegal might accept XOF from local customers but track revenue in EUR. The transaction stores both sides of the conversion, plus the rate used, for full auditability.

The Currency API: fawazahmed0/currency-api

For exchange rates, we use the free fawazahmed0/currency-api -- an open-source project that aggregates rates from multiple sources and publishes them daily via CDN.

pythonimport httpx
from decimal import Decimal

CURRENCY_API_BASE = "https://cdn.jsdelivr.net/npm/@fawazahmed0/currency-api@latest/v1"

async def fetch_exchange_rate(
    from_currency: str,
    to_currency: str
) -> Decimal:
    """Fetch current exchange rate from free API."""
    from_code = from_currency.lower()
    to_code = to_currency.lower()

    url = f"{CURRENCY_API_BASE}/currencies/{from_code}.json"

    async with httpx.AsyncClient(timeout=10) as client:
        response = await client.get(url)
        response.raise_for_status()
        data = response.json()

    rate = data[from_code].get(to_code)
    if rate is None:
        raise ValueError(f"Rate not found: {from_currency} -> {to_currency}")

    return Decimal(str(rate))

Why a free API instead of a paid service like Open Exchange Rates or XE?

  1. Cost. At scale, paid currency APIs charge per request. The free API costs nothing.
  2. Accuracy. For our use case, daily rates are sufficient. We are not a forex trading platform.
  3. Reliability. The CDN-backed delivery means near-100% uptime. If the primary URL fails, a fallback exists.
  4. No API keys. One less secret to manage, one less vendor dependency.

Supported Currencies

Here are the 25+ currencies 0fee.dev supports:

CurrencyCodeRegionType
US DollarUSDGlobalStandard
EuroEUREuropeStandard
British PoundGBPUKStandard
West African CFA FrancXOFUEMOA (8 countries)Zero-decimal
Central African CFA FrancXAFCEMAC (6 countries)Zero-decimal
Nigerian NairaNGNNigeriaStandard
Ghanaian CediGHSGhanaStandard
Kenyan ShillingKESKenyaStandard
South African RandZARSouth AfricaStandard
Tanzanian ShillingTZSTanzaniaZero-decimal
Ugandan ShillingUGXUgandaZero-decimal
Rwandan FrancRWFRwandaZero-decimal
Guinean FrancGNFGuineaZero-decimal
Burundian FrancBIFBurundiZero-decimal
Djiboutian FrancDJFDjiboutiZero-decimal
Comorian FrancKMFComorosZero-decimal
Moroccan DirhamMADMoroccoStandard
Egyptian PoundEGPEgyptStandard
Indian RupeeINRIndiaStandard
Japanese YenJPYJapanZero-decimal
South Korean WonKRWSouth KoreaZero-decimal
Vietnamese DongVNDVietnamZero-decimal
Brazilian RealBRLBrazilStandard
Canadian DollarCADCanadaStandard
Australian DollarAUDAustraliaStandard
Chinese YuanCNYChinaStandard
CFP FrancXPFFrench PacificZero-decimal

The "zero-decimal" distinction is critical for correct amount handling (covered in detail in article 039).

24-Hour Cache with localStorage Fallback

Exchange rates don't change by the second for our purposes. We cache rates for 24 hours, with a multi-layer caching strategy:

pythonfrom datetime import datetime, timedelta
from typing import Optional

# Server-side in-memory cache
_rate_cache: dict[str, dict] = {}
CACHE_TTL = timedelta(hours=24)

async def get_exchange_rate(
    from_currency: str,
    to_currency: str
) -> Decimal:
    """Get exchange rate with 24h caching."""
    cache_key = f"{from_currency}_{to_currency}"

    # Check cache
    cached = _rate_cache.get(cache_key)
    if cached and datetime.utcnow() - cached["timestamp"] < CACHE_TTL:
        return cached["rate"]

    try:
        rate = await fetch_exchange_rate(from_currency, to_currency)
        _rate_cache[cache_key] = {
            "rate": rate,
            "timestamp": datetime.utcnow()
        }
        return rate
    except Exception:
        # Fallback to cached rate even if expired
        if cached:
            return cached["rate"]
        # Last resort: hardcoded rates
        return get_hardcoded_rate(from_currency, to_currency)

On the frontend, we add a localStorage layer for dashboard currency conversion:

typescriptinterface CachedRate {
  rate: number;
  timestamp: number;
}

const CACHE_KEY = '0fee_exchange_rates';
const CACHE_TTL = 24 * 60 * 60 * 1000; // 24 hours

function getCachedRates(): Record<string, CachedRate> {
  try {
    const stored = localStorage.getItem(CACHE_KEY);
    return stored ? JSON.parse(stored) : {};
  } catch {
    return {};
  }
}

function setCachedRate(pair: string, rate: number): void {
  const cache = getCachedRates();
  cache[pair] = { rate, timestamp: Date.now() };
  try {
    localStorage.setItem(CACHE_KEY, JSON.stringify(cache));
  } catch {
    // localStorage full or unavailable -- silent fail
  }
}

async function getExchangeRate(from: string, to: string): Promise<number> {
  if (from === to) return 1;

  const pair = `${from}_${to}`;
  const cached = getCachedRates()[pair];

  if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
    return cached.rate;
  }

  try {
    const response = await fetch(`/api/currency/rate?from=${from}&to=${to}`);
    const data = await response.json();
    setCachedRate(pair, data.rate);
    return data.rate;
  } catch {
    // Fallback to expired cache
    if (cached) return cached.rate;
    throw new Error(`Cannot get rate for ${from} -> ${to}`);
  }
}

The fallback chain is: memory cache -> localStorage -> API call -> expired cache -> hardcoded rates. A merchant should never see a "rate unavailable" error.

Hardcoded Fallback Rates

As the absolute last resort, we maintain hardcoded rates that are manually updated quarterly:

pythonHARDCODED_RATES: dict[str, Decimal] = {
    "XOF_USD": Decimal("0.0016"),
    "XAF_USD": Decimal("0.0016"),
    "NGN_USD": Decimal("0.00063"),
    "GHS_USD": Decimal("0.064"),
    "KES_USD": Decimal("0.0077"),
    "ZAR_USD": Decimal("0.054"),
    "EUR_USD": Decimal("1.08"),
    "GBP_USD": Decimal("1.26"),
    "USD_USD": Decimal("1.00"),
}

def get_hardcoded_rate(from_currency: str, to_currency: str) -> Decimal:
    """Last-resort hardcoded rates."""
    key = f"{from_currency}_{to_currency}"
    rate = HARDCODED_RATES.get(key)
    if rate:
        return rate

    # Try inverse
    inverse_key = f"{to_currency}_{from_currency}"
    inverse_rate = HARDCODED_RATES.get(inverse_key)
    if inverse_rate:
        return Decimal("1") / inverse_rate

    # Try via USD
    to_usd = HARDCODED_RATES.get(f"{from_currency}_USD")
    from_usd = HARDCODED_RATES.get(f"{to_currency}_USD")
    if to_usd and from_usd:
        return to_usd / from_usd

    raise ValueError(f"No rate available: {from_currency} -> {to_currency}")

Currency Selector in Dashboard Header

The dashboard header includes a currency selector that lets merchants view all monetary values in their preferred currency:

svelte<script lang="ts">
  import { ChevronDown } from 'lucide-svelte';

  let { selectedCurrency, onSelect } = $props<{
    selectedCurrency: string;
    onSelect: (currency: string) => void;
  }>();

  let open = $state(false);

  const currencies = [
    { code: 'USD', symbol: '$', name: 'US Dollar' },
    { code: 'EUR', symbol: '\u20ac', name: 'Euro' },
    { code: 'GBP', symbol: '\u00a3', name: 'British Pound' },
    { code: 'XOF', symbol: 'CFA', name: 'CFA Franc (West)' },
    { code: 'XAF', symbol: 'FCFA', name: 'CFA Franc (Central)' },
    { code: 'NGN', symbol: '\u20a6', name: 'Nigerian Naira' },
    { code: 'GHS', symbol: 'GH\u20b5', name: 'Ghanaian Cedi' },
    { code: 'KES', symbol: 'KSh', name: 'Kenyan Shilling' },
    { code: 'ZAR', symbol: 'R', name: 'South African Rand' },
  ];

  let selected = $derived(
    currencies.find(c => c.code === selectedCurrency) ?? currencies[0]
  );
</script>

<div class="relative">
  <button
    onclick={() => open = !open}
    class="flex items-center gap-1.5 px-3 py-1.5 rounded-lg border
           border-gray-200 dark:border-gray-700 hover:bg-gray-50
           dark:hover:bg-gray-800 transition-colors text-sm"
  >
    <span class="font-medium">{selected.symbol}</span>
    <span class="text-gray-500">{selected.code}</span>
    <ChevronDown class="w-3.5 h-3.5 text-gray-400" />
  </button>

  {#if open}
    <div class="absolute right-0 mt-1 w-56 bg-white dark:bg-gray-800
                rounded-lg shadow-lg border border-gray-200 dark:border-gray-700
                z-50 max-h-64 overflow-y-auto">
      {#each currencies as currency}
        <button
          onclick={() => { onSelect(currency.code); open = false; }}
          class="w-full px-3 py-2 text-left text-sm hover:bg-gray-50
                 dark:hover:bg-gray-700 flex items-center gap-2
                 {currency.code === selectedCurrency ? 'bg-blue-50 dark:bg-blue-900/20' : ''}"
        >
          <span class="font-medium w-10">{currency.symbol}</span>
          <span>{currency.code}</span>
          <span class="text-gray-400 text-xs ml-auto">{currency.name}</span>
        </button>
      {/each}
    </div>
  {/if}
</div>

When the selected currency changes, all dashboard values -- balance, transaction amounts, revenue charts, invoice totals -- are re-converted using the cached rates.

Browser Locale Detection

On first visit, we detect the user's locale and set a sensible default currency:

typescriptfunction detectDefaultCurrency(): string {
  const locale = navigator.language || navigator.languages?.[0] || 'en-US';

  const localeToCurrency: Record<string, string> = {
    'fr-CI': 'XOF',  // Ivory Coast
    'fr-SN': 'XOF',  // Senegal
    'fr-ML': 'XOF',  // Mali
    'fr-BF': 'XOF',  // Burkina Faso
    'fr-BJ': 'XOF',  // Benin
    'fr-TG': 'XOF',  // Togo
    'fr-NE': 'XOF',  // Niger
    'fr-GW': 'XOF',  // Guinea-Bissau
    'fr-CM': 'XAF',  // Cameroon
    'fr-GA': 'XAF',  // Gabon
    'fr-CG': 'XAF',  // Congo
    'fr-TD': 'XAF',  // Chad
    'fr-CF': 'XAF',  // Central African Republic
    'fr-GQ': 'XAF',  // Equatorial Guinea
    'en-NG': 'NGN',  // Nigeria
    'en-GH': 'GHS',  // Ghana
    'sw-KE': 'KES',  // Kenya
    'en-KE': 'KES',
    'en-ZA': 'ZAR',  // South Africa
    'en-GB': 'GBP',  // UK
    'fr-FR': 'EUR',  // France
    'de-DE': 'EUR',  // Germany
  };

  // Try exact match first
  if (localeToCurrency[locale]) {
    return localeToCurrency[locale];
  }

  // Try language-only match
  const lang = locale.split('-')[0];
  if (lang === 'fr') return 'XOF';  // Default French-speaking to XOF
  if (lang === 'en') return 'USD';

  return 'USD';  // Global default
}

The Big Currency Update Plan: 4 New Database Columns

As 0fee.dev grew beyond Africa, we realized the transaction model needed to store more currency context. The plan involved adding four new columns:

sqlALTER TABLE transactions ADD COLUMN display_currency VARCHAR(3);
ALTER TABLE transactions ADD COLUMN display_amount DECIMAL(15, 2);
ALTER TABLE transactions ADD COLUMN rate_at_creation DECIMAL(15, 8);
ALTER TABLE transactions ADD COLUMN rate_at_completion DECIMAL(15, 8);

The rationale:

ColumnPurpose
display_currencyThe currency the merchant was viewing when the transaction was created
display_amountThe converted amount shown to the merchant at creation time
rate_at_creationExchange rate when the payment was initiated
rate_at_completionExchange rate when the payment was confirmed

The delta between rate_at_creation and rate_at_completion reveals currency fluctuation impact. For volatile currencies like NGN, a payment initiated at 1,600 NGN/USD might complete at 1,620 NGN/USD hours later. Storing both rates lets us show merchants exactly what happened.

Conversion in Practice

Here is a complete example tracing a cross-currency payment:

Scenario: Nigerian customer pays Kenyan merchant

Customer pays:     50,000 NGN via Paystack
Provider settles:  50,000 NGN to merchant's Paystack balance

0fee records:
  source_amount:       50,000
  source_currency:     NGN
  exchange_rate:       0.00063 (NGN/USD)
  amount_usd:          31.50
  platform_fee_usd:    0.31 (31.50 x 0.0099)

Merchant dashboard (KES view):
  display_currency:    KES
  rate (USD/KES):      129.87
  display_amount:      4,090.90 KES

Merchant dashboard (USD view):
  display_amount:      $31.50

The customer, the provider, and the merchant each see amounts in their own currency. 0fee.dev handles the conversion layer transparently.

Lessons Learned

  1. Free does not mean unreliable. The fawazahmed0 currency API has been more stable than some paid alternatives we evaluated. CDN distribution is the key differentiator.
  1. Cache aggressively. Exchange rates for our use case don't need real-time updates. 24-hour caching eliminates 99% of API calls while keeping rates accurate enough for fee calculation.
  1. Always have a fallback. The three-layer cache (memory, localStorage, hardcoded) means currency conversion never fails. Stale rates are better than no rates.
  1. Store the rate with the transaction. Without the exchange rate recorded at transaction time, retroactive auditing is impossible. We learned this the hard way when a merchant disputed a fee calculation from two months prior.
  1. Africa's currency landscape is complex. XOF and XAF have the same value (both pegged to EUR) but are different currencies used in different economic zones. Treating them as interchangeable is a bug.

This article is part of the "How We Built 0fee.dev" series. 0fee.dev is a payment orchestrator covering 53+ providers across 200+ countries, built by Juste A. GNIMAVO and Claude from Abidjan with zero human engineers. Follow the series for the complete build story.

Share this article:

Responses

Write a response
0/2000
Loading responses...

Related Articles