返回博客

Exchange Rate API for Mobile Apps

V
Vlado Grigirov
April 05, 2026
Mobile Development React Native Flutter Swift API Integration Currency API

Exchange Rate API for Mobile Apps

Mobile apps face a unique set of challenges when working with exchange rate data. Intermittent connectivity, battery constraints, limited memory, and app store review requirements all shape how you architect your currency integration. This guide covers practical patterns for React Native, Flutter, and native Swift, drawing on real production scenarios. For web-based integration, see our JavaScript and Node.js guides instead.

Why Mobile Is Different

Desktop and server applications can assume reliable internet, generous memory, and unlimited battery. Mobile apps cannot. Here's what changes:

Network unreliability: Users open your app on airplanes, in subway tunnels, and in rural areas with spotty coverage. Your currency feature must work offline or degrade gracefully.

Battery impact: Polling an API every 30 seconds drains battery. Mobile apps need smarter refresh strategies that balance freshness against power consumption.

App store rules: Both Apple and Google require apps to handle network failures without crashing. Apps that show blank screens or infinite spinners on network errors get rejected.

Memory pressure: On low-end Android devices, keeping a full rate table for 170+ currencies in memory alongside your app's other data requires careful management.

Choosing Your Architecture

Before writing any code, decide between two approaches:

Direct API calls from the app work for simple use cases. The mobile app calls Finexly's API directly, caches results locally, and handles offline scenarios. This is simpler to build but exposes your API key in the app binary.

Backend proxy is better for production apps. Your server calls Finexly's API, caches rates centrally, and exposes a custom endpoint to your mobile apps. This hides your API key, lets you add business logic (markup, rounding rules), and reduces your API usage since all app installs share one cache. Our PHP integration guide and Python tutorial cover building the backend side.

For this guide, we'll show the direct approach first, then explain the proxy pattern.

React Native Implementation

Basic Rate Fetching

import { useState, useEffect, useCallback } from 'react';
import AsyncStorage from '@react-native-async-storage/async-storage';
import NetInfo from '@react-native-community/netinfo';

const API_KEY = 'YOUR_API_KEY';
const CACHE_KEY = '@exchange_rates';
const CACHE_TTL = 30 * 60 * 1000; // 30 minutes

export function useExchangeRates(baseCurrency = 'USD') {
  const [rates, setRates] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);
  const [isStale, setIsStale] = useState(false);

  const fetchRates = useCallback(async () => {
    try {
      // Check network status first
      const netState = await NetInfo.fetch();

      if (!netState.isConnected) {
        const cached = await loadCachedRates(baseCurrency);
        if (cached) {
          setRates(cached.data);
          setIsStale(true);
          setLoading(false);
          return;
        }
        throw new Error('No network and no cached data');
      }

      const response = await fetch(
        `https://api.finexly.com/v1/latest?api_key=${API_KEY}&base=${baseCurrency}`
      );

      if (!response.ok) throw new Error(`HTTP ${response.status}`);

      const data = await response.json();
      setRates(data);
      setIsStale(false);
      await cacheRates(baseCurrency, data);

    } catch (err) {
      setError(err.message);
      // Try cached data as fallback
      const cached = await loadCachedRates(baseCurrency);
      if (cached) {
        setRates(cached.data);
        setIsStale(true);
      }
    } finally {
      setLoading(false);
    }
  }, [baseCurrency]);

  useEffect(() => { fetchRates(); }, [fetchRates]);

  return { rates, loading, error, isStale, refresh: fetchRates };
}

Persistent Caching with AsyncStorage

async function cacheRates(base, data) {
  try {
    await AsyncStorage.setItem(
      `${CACHE_KEY}_${base}`,
      JSON.stringify({ data, timestamp: Date.now() })
    );
  } catch (e) {
    // AsyncStorage can fail on low storage - fail silently
  }
}

async function loadCachedRates(base) {
  try {
    const stored = await AsyncStorage.getItem(`${CACHE_KEY}_${base}`);
    if (!stored) return null;

    const parsed = JSON.parse(stored);
    const age = Date.now() - parsed.timestamp;

    return {
      data: parsed.data,
      isExpired: age > CACHE_TTL,
      age
    };
  } catch (e) {
    return null;
  }
}

Smart Refresh Strategy

Don't fetch rates every time the app opens. Instead, use a tiered refresh approach:

import { AppState } from 'react-native';

function useSmartRefresh(fetchRates, cacheAge) {
  useEffect(() => {
    const subscription = AppState.addEventListener('change', (state) => {
      if (state === 'active' && cacheAge > CACHE_TTL) {
        // Only refresh when app comes to foreground AND cache is stale
        fetchRates();
      }
    });

    return () => subscription.remove();
  }, [fetchRates, cacheAge]);
}

This saves battery and API calls by only refreshing when the user actually looks at the app and the data is stale.

Flutter Implementation

Rate Service with Dio and Hive

import 'package:dio/dio.dart';
import 'package:hive/hive.dart';
import 'package:connectivity_plus/connectivity_plus.dart';

class ExchangeRateService {
  static const _apiKey = 'YOUR_API_KEY';
  static const _baseUrl = 'https://api.finexly.com/v1';
  static const _cacheDuration = Duration(minutes: 30);

  final Dio _dio = Dio(BaseOptions(
    connectTimeout: const Duration(seconds: 10),
    receiveTimeout: const Duration(seconds: 10),
  ));

  Future<Map<String, dynamic>> getRates(String base) async {
    // Check cache first
    final cached = await _getCachedRates(base);
    if (cached != null && !_isExpired(cached['timestamp'])) {
      return cached['data'];
    }

    // Check connectivity
    final connectivity = await Connectivity().checkConnectivity();
    if (connectivity == ConnectivityResult.none) {
      if (cached != null) return cached['data']; // Stale cache
      throw Exception('No internet and no cached rates');
    }

    try {
      final response = await _dio.get(
        '$_baseUrl/latest',
        queryParameters: {'api_key': _apiKey, 'base': base},
      );

      await _cacheRates(base, response.data);
      return response.data;

    } on DioException catch (e) {
      if (cached != null) return cached['data'];
      rethrow;
    }
  }

  bool _isExpired(int timestamp) {
    return DateTime.now().millisecondsSinceEpoch - timestamp
        > _cacheDuration.inMilliseconds;
  }

  Future<void> _cacheRates(String base, Map<String, dynamic> data) async {
    final box = await Hive.openBox('exchange_rates');
    await box.put(base, {
      'data': data,
      'timestamp': DateTime.now().millisecondsSinceEpoch,
    });
  }

  Future<Map<String, dynamic>?> _getCachedRates(String base) async {
    final box = await Hive.openBox('exchange_rates');
    return box.get(base);
  }
}

Flutter Widget with Loading States

class CurrencyConverter extends StatefulWidget {
  @override
  _CurrencyConverterState createState() => _CurrencyConverterState();
}

class _CurrencyConverterState extends State<CurrencyConverter> {
  final _service = ExchangeRateService();
  Map<String, dynamic>? _rates;
  bool _loading = true;
  String _from = 'USD';
  String _to = 'EUR';
  double _amount = 1.0;

  @override
  void initState() {
    super.initState();
    _loadRates();
  }

  Future<void> _loadRates() async {
    setState(() => _loading = true);
    try {
      final data = await _service.getRates(_from);
      setState(() {
        _rates = data;
        _loading = false;
      });
    } catch (e) {
      setState(() => _loading = false);
      // Show error snackbar
    }
  }

  double get _convertedAmount {
    if (_rates == null) return 0;
    final rate = _rates!['rates'][_to] ?? 0;
    return _amount * (rate as num).toDouble();
  }

  @override
  Widget build(BuildContext context) {
    // Build your converter UI here
    // Use _loading, _convertedAmount, _from, _to
  }
}

Swift Implementation (iOS)

URLSession with Cache Policy

import Foundation

class ExchangeRateManager: ObservableObject {
    private let apiKey = "YOUR_API_KEY"
    private let baseURL = "https://api.finexly.com/v1"
    private let cacheDuration: TimeInterval = 1800 // 30 minutes

    @Published var rates: [String: Double] = [:]
    @Published var isLoading = false
    @Published var isStale = false

    private let cache = NSCache<NSString, CachedRates>()

    func fetchRates(base: String = "USD") async {
        // Check memory cache
        if let cached = cache.object(forKey: base as NSString),
           Date().timeIntervalSince(cached.timestamp) < cacheDuration {
            await MainActor.run {
                self.rates = cached.rates
                self.isStale = false
            }
            return
        }

        await MainActor.run { self.isLoading = true }

        do {
            var components = URLComponents(string: "\(baseURL)/latest")!
            components.queryItems = [
                URLQueryItem(name: "api_key", value: apiKey),
                URLQueryItem(name: "base", value: base)
            ]

            let (data, response) = try await URLSession.shared.data(
                from: components.url!
            )

            guard let httpResponse = response as? HTTPURLResponse,
                  httpResponse.statusCode == 200 else {
                throw URLError(.badServerResponse)
            }

            let result = try JSONDecoder().decode(RateResponse.self, from: data)

            let cached = CachedRates(
                rates: result.rates,
                timestamp: Date()
            )
            cache.setObject(cached, forKey: base as NSString)

            // Persist to UserDefaults for offline use
            persistToDisk(base: base, rates: result.rates)

            await MainActor.run {
                self.rates = result.rates
                self.isLoading = false
                self.isStale = false
            }

        } catch {
            // Fall back to disk cache
            if let diskRates = loadFromDisk(base: base) {
                await MainActor.run {
                    self.rates = diskRates
                    self.isLoading = false
                    self.isStale = true
                }
            }
        }
    }

    private func persistToDisk(base: String, rates: [String: Double]) {
        let data = try? JSONEncoder().encode(rates)
        UserDefaults.standard.set(data, forKey: "rates_\(base)")
        UserDefaults.standard.set(Date(), forKey: "rates_\(base)_timestamp")
    }

    private func loadFromDisk(base: String) -> [String: Double]? {
        guard let data = UserDefaults.standard.data(forKey: "rates_\(base)"),
              let rates = try? JSONDecoder().decode(
                  [String: Double].self, from: data
              ) else { return nil }
        return rates
    }
}

Offline-First Architecture

The best mobile currency apps work offline by default and sync when connectivity returns. Here's the pattern:

  1. On first launch: Fetch rates from the API, cache locally
  2. On subsequent opens: Show cached rates immediately, refresh in the background
  3. When offline: Use the last cached rates, show a "last updated X ago" indicator
  4. When back online: Silently refresh and update the UI

This approach means users never see a loading spinner for the currency feature after the first use. The Finexly API documentation covers rate update frequencies so you can set appropriate cache durations.

Handling Background Refresh (iOS)

On iOS, you can use Background App Refresh to keep rates current even when the app isn't open:

import BackgroundTasks

func registerBackgroundTask() {
    BGTaskScheduler.shared.register(
        forTaskWithIdentifier: "com.yourapp.refreshRates",
        using: nil
    ) { task in
        handleRateRefresh(task: task as! BGAppRefreshTask)
    }
}

func scheduleRateRefresh() {
    let request = BGAppRefreshTaskRequest(
        identifier: "com.yourapp.refreshRates"
    )
    request.earliestBeginDate = Date(timeIntervalSinceNow: 3600) // 1 hour
    try? BGTaskScheduler.shared.submit(request)
}

Use this sparingly — Apple throttles background tasks for apps that use them excessively.

API Key Security in Mobile Apps

Never ship a production API key in your mobile app binary. Decompiling an APK or IPA to extract hardcoded strings is trivial. Instead:

Option 1: Backend proxy (recommended). Your mobile app calls your own backend, which calls Finexly. The API key lives only on your server.

Option 2: Obfuscation + certificate pinning. If you must call Finexly directly, obfuscate the key at build time and implement certificate pinning to prevent MITM attacks. This raises the bar but isn't bulletproof.

Option 3: On-demand key delivery. After user authentication, your server issues a short-lived, rate-limited token that the app uses for Finexly API calls.

Rate Limiting and Batching

Mobile apps with many users can quickly hit API rate limits. Strategies to stay within your quota:

Shared cache server: One server fetches rates once per minute and serves all your app's users. This turns 100,000 app installs into 1 API consumer.

Batch currency requests: Instead of fetching USD→EUR, USD→GBP, and USD→JPY separately, fetch all rates for USD in one call. Finexly returns all 170+ target currencies in a single response.

Time-based throttling: Don't let users trigger rate refreshes faster than once per minute, even with pull-to-refresh. Debounce user-initiated refreshes.

See our free currency exchange rate API guide to understand the rate limits on Finexly's free tier and when you'd need to upgrade.

Testing Currency Features

Testing currency conversion in mobile apps requires mocking the API response. Never rely on live API calls in tests — they're slow, flaky, and consume your quota.

// React Native test example with Jest
jest.mock('./exchangeRateService', () => ({
  fetchRates: jest.fn().mockResolvedValue({
    base: 'USD',
    rates: { EUR: 0.92, GBP: 0.79, JPY: 149.50 }
  })
}));

test('converts USD to EUR correctly', async () => {
  const { result } = renderHook(() => useExchangeRates('USD'));
  await waitFor(() => expect(result.current.loading).toBe(false));
  expect(result.current.rates.rates.EUR).toBe(0.92);
});

Common Mobile-Specific Pitfalls

Timezone handling: Exchange rates are timestamped in UTC. When displaying "last updated" to users, always convert to the device's local timezone.

Locale-aware formatting: 1,234.56 in the US is 1.234,56 in Germany. Use Intl.NumberFormat (React Native), NumberFormat (Flutter), or NumberFormatter (Swift) to format currency amounts correctly for the user's locale.

Large number display: Some currencies like Vietnamese Dong (VND) or Indonesian Rupiah (IDR) have values in the millions. Make sure your UI handles 1 USD = 25,463 VND without layout overflow.

App store compliance: If your app facilitates actual money transfers (not just conversion display), you may need financial licensing. Consult legal counsel for your jurisdiction.

What's Next

Once your mobile currency integration is solid, consider adding historical rate charts so users can visualize trends over time. For apps in the trading space, our forex data API guide covers the additional requirements for real-time financial data.

Ready to integrate? Get your free Finexly API key — 1,000 requests per month, no credit card required. See pricing for production plans with higher limits.

Vlado Grigirov

Senior Currency Markets Analyst & Financial Strategist

Vlado Grigirov is a senior currency markets analyst and financial strategist with over 14 years of experience in foreign exchange markets, cross-border finance, and currency risk management. He has wo...

View full profile →