---
title: "Free Currency Converter API for iOS and Android: Swift and Kotlin"
description: "Use a free currency converter API in iOS and Android apps: Swift and Kotlin code, offline cache with a rates-as-of time, key kept out of the app."
canonical: https://allratestoday.com/blog/currency-api-swift-kotlin-mobile/
source: AllRatesToday (https://allratestoday.com/) — Markdown mirror of the page above; index of all pages at https://allratestoday.com/llms.txt
generated: 2026-11-11T15:57:45.225Z
---

A free currency converter API is easy to call from a mobile app. Doing it well takes three more things: the app must work offline with the last known rates, it must tell the user how old those rates are, and the API key must not be inside the app binary. This guide covers all three, with working code for iOS in Swift and Android in Kotlin.

The examples use the AllRatesToday rates endpoint, which returns mid-market rates for 160+ currencies, refreshed every 60 seconds.

## The architecture: app, proxy, API

An iOS or Android app is a file the user downloads. Anything compiled into it, including an API key in `Info.plist`, `BuildConfig` or an obfuscated string, can be extracted with free tools in minutes. Once extracted, the key works from anywhere until you rotate it.

So the app never talks to the rates API directly:

1. The app calls `https://rates.example.com/rates?source=USD&target=EUR,GBP,JPY` on your own backend.
2. The backend adds `Authorization: Bearer YOUR_API_KEY`, calls AllRatesToday, caches the answer and returns the same JSON array.
3. The app decodes the array, saves it with a timestamp and converts amounts locally.

The proxy is about 40 lines on Cloudflare Workers or as a Next.js route handler. The full version, with caching, a currency allowlist and rate limiting, is in [how to hide an exchange rate API key behind a proxy](https://allratestoday.com/blog/hide-exchange-rate-api-key-proxy/). The rest of this article assumes it exists and returns exactly what the API returns.

## The free currency converter API response you decode

```
curl -H "Authorization: Bearer YOUR_API_KEY" \
  "https://allratestoday.com/api/v1/rates?source=USD&target=EUR,GBP,JPY"
```

```
[
  {"rate":0.9215,"source":"USD","target":"EUR","time":"2026-04-03T12:00:00Z"},
  {"rate":0.7893,"source":"USD","target":"GBP","time":"2026-04-03T12:00:00Z"},
  {"rate":151.42,"source":"USD","target":"JPY","time":"2026-04-03T12:00:00Z"}
]
```

It is a JSON array even when you ask for one pair. Decode it as `[Rate]` in Swift and `List<Rate>` in Kotlin. Decoding into a single object is the most common error with this endpoint. All targets in one call cost one request.

## Converting on the device

Fetch one base, USD, with every currency your app supports. Then any conversion is arithmetic:

- USD to EUR: `amount * rate[EUR]`
- EUR to USD: `amount / rate[EUR]`
- EUR to JPY: `amount / rate[EUR] * rate[JPY]`

This keeps the app to one request per refresh and lets you cache one response for all users. Round only for display, never before the multiplication. The [base currency conversion guide](https://allratestoday.com/blog/how-to-handle-base-currency-conversion/) explains cross rates in more detail.

## iOS: Swift with URLSession, async/await and Codable

```
import Foundation

struct Rate: Codable {
    let rate: Double
    let source: String
    let target: String
    let time: String
}

struct RatesSnapshot: Codable {
    let rates: [Rate]
    let fetchedAt: Date

    func rate(for code: String) -> Double? {
        code == "USD" ? 1 : rates.first { $0.target == code }?.rate
    }

    func convert(_ amount: Double, from: String, to: String) -> Double? {
        guard let f = rate(for: from), let t = rate(for: to) else { return nil }
        return amount / f * t
    }
}

final class RatesStore {
    private let endpoint = URL(string: "https://rates.example.com/rates")!
    private let cacheURL = FileManager.default
        .urls(for: .cachesDirectory, in: .userDomainMask)[0]
        .appendingPathComponent("rates.json")

    func fetch(targets: [String]) async throws -> RatesSnapshot {
        var components = URLComponents(url: endpoint, resolvingAgainstBaseURL: false)!
        components.queryItems = [
            URLQueryItem(name: "source", value: "USD"),
            URLQueryItem(name: "target", value: targets.joined(separator: ","))
        ]
        var request = URLRequest(url: components.url!)
        request.timeoutInterval = 10

        let (data, response) = try await URLSession.shared.data(for: request)
        guard let http = response as? HTTPURLResponse, http.statusCode == 200 else {
            throw URLError(.badServerResponse)
        }
        let rates = try JSONDecoder().decode([Rate].self, from: data)   // an array
        let snapshot = RatesSnapshot(rates: rates, fetchedAt: Date())
        try? JSONEncoder().encode(snapshot).write(to: cacheURL, options: .atomic)
        return snapshot
    }

    func cached() -> RatesSnapshot? {
        guard let data = try? Data(contentsOf: cacheURL) else { return nil }
        return try? JSONDecoder().decode(RatesSnapshot.self, from: data)
    }
}
```

`time` is decoded as a string so a change in timestamp precision can never break decoding. `fetchedAt` is the device's own record of when it last got fresh data; that is the value you show as "rates as of".

A minimal SwiftUI view model that shows cached rates first and refreshes in the background:

```
@MainActor
final class ConverterModel: ObservableObject {
    @Published var snapshot: RatesSnapshot?
    @Published var isStale = false
    private let store = RatesStore()
    private let maxAge: TimeInterval = 15 * 60

    func load() async {
        snapshot = store.cached()
        if let s = snapshot, Date().timeIntervalSince(s.fetchedAt) < maxAge { return }
        do {
            snapshot = try await store.fetch(targets: ["EUR", "GBP", "JPY", "INR", "CAD"])
            isStale = false
        } catch {
            isStale = snapshot != nil   // keep old rates, flag them
        }
    }
}
```

In the view, show `Text("Rates as of \(snapshot.fetchedAt.formatted())")`, and a warning when `isStale` is true. Call `load()` from `.task` and again when the scene becomes active.

## Android: Kotlin with OkHttp and kotlinx.serialization

Add `com.squareup.okhttp3:okhttp` and `org.jetbrains.kotlinx:kotlinx-serialization-json`, and apply the `kotlinx-serialization` Gradle plugin.

```
import android.content.Context
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.OkHttpClient
import okhttp3.Request
import java.io.File
import java.io.IOException

@Serializable
data class Rate(val rate: Double, val source: String, val target: String, val time: String)

@Serializable
data class RatesSnapshot(val rates: List<Rate>, val fetchedAt: Long) {
    fun rateFor(code: String): Double? =
        if (code == "USD") 1.0 else rates.firstOrNull { it.target == code }?.rate

    fun convert(amount: Double, from: String, to: String): Double? {
        val f = rateFor(from) ?: return null
        val t = rateFor(to) ?: return null
        return amount / f * t
    }
}

class RatesRepository(context: Context, private val client: OkHttpClient = OkHttpClient()) {
    private val json = Json { ignoreUnknownKeys = true }
    private val cacheFile = File(context.cacheDir, "rates.json")

    suspend fun fetch(targets: List<String>): RatesSnapshot = withContext(Dispatchers.IO) {
        val url = "https://rates.example.com/rates".toHttpUrl().newBuilder()
            .addQueryParameter("source", "USD")
            .addQueryParameter("target", targets.joinToString(","))
            .build()
        client.newCall(Request.Builder().url(url).build()).execute().use { res ->
            if (!res.isSuccessful) throw IOException("HTTP ${res.code}")
            val rates = json.decodeFromString<List<Rate>>(res.body!!.string())  // an array
            RatesSnapshot(rates, System.currentTimeMillis()).also {
                cacheFile.writeText(json.encodeToString(it))
            }
        }
    }

    fun cached(): RatesSnapshot? =
        runCatching { json.decodeFromString<RatesSnapshot>(cacheFile.readText()) }.getOrNull()
}
```

If you prefer Ktor, the same code works with `HttpClient(OkHttp)` and the `ContentNegotiation` plugin: `client.get(url).body<List<Rate>>()`. The important part is the same: decode a list.

A ViewModel that mirrors the Swift version:

```
class ConverterViewModel(private val repo: RatesRepository) : ViewModel() {
    val snapshot = MutableStateFlow(repo.cached())
    val isStale = MutableStateFlow(false)
    private val maxAgeMs = 15 * 60 * 1000L

    fun refresh() = viewModelScope.launch {
        val current = snapshot.value
        if (current != null && System.currentTimeMillis() - current.fetchedAt < maxAgeMs) return@launch
        try {
            snapshot.value = repo.fetch(listOf("EUR", "GBP", "JPY", "INR", "CAD"))
            isStale.value = false
        } catch (e: IOException) {
            isStale.value = current != null
        }
    }
}
```

Call `refresh()` from `onResume`. Show "Rates as of" with `DateFormat.getDateTimeInstance().format(Date(snapshot.fetchedAt))`.

## Offline behaviour

The cache file is the offline mode. The rules that make it honest:

- **Show cached rates immediately** on launch, before any network call returns.
- **Always show the age.** "Rates as of 11 Nov, 14:02" next to the result.
- **Flag old data.** If the last fetch is more than a day old, show a visible warning, not just a timestamp.
- **Never invent a rate.** If there is no cache and no network, say so instead of showing 1.0 or zero.

## Refresh policy

The rates refresh every 60 seconds upstream, but a converter in someone's pocket does not need that. A sensible policy:

- Refresh on app launch and when the app returns to the foreground, if the cache is older than 15 minutes.
- Offer pull-to-refresh, limited to once a minute.
- Do not refresh in the background unless your app has a real need, such as a rate alert.

Because the proxy caches the response, upstream usage depends on the proxy's cache time, not on your number of users. With one base and a 10-minute proxy cache, the proxy makes at most 144 calls a day, about 4,320 in 30 days, which fits the Small plan's 5,000 requests a month. One caveat: the Workers Cache API is per data centre, so a global user base can produce a few more upstream calls than that. The guide on [caching exchange rates](https://allratestoday.com/blog/how-to-cache-exchange-rates-avoid-rate-limits/) covers shared caches.

A free API key is fine for building and testing the app. A published app is commercial use, which starts on the Small plan; see [pricing](https://allratestoday.com/pricing/). If you are still comparing providers, the [free currency API comparison](https://allratestoday.com/blog/best-free-currency-exchange-api-2026/) has the limits side by side.

## Rates are mid-market

The numbers are mid-market rates: the midpoint between buy and sell prices, with no bank margin. That is the right number for a converter. If your app shows what a bank or card will actually charge, label the result "mid-market rate" so users are not surprised. [Mid-market vs retail rates](https://allratestoday.com/blog/mid-market-vs-retail-exchange-rates/) explains the difference.

## FAQ

### Is there a free currency converter API for mobile apps?

Yes. You can get a free API key from AllRatesToday and call the rates endpoint from your own backend for development and testing. A published app counts as commercial use, which starts on the Small plan at 4.99 euros a month.

### Can I put the API key in my iOS or Android app?

No. Anything in the app binary can be extracted, including obfuscated strings. Keep the key on a small backend proxy and have the app call the proxy.

### Why does decoding fail with a type mismatch?

The rates endpoint always returns a JSON array, even for one pair. Decode [Rate] in Swift or List<Rate> in Kotlin, not a single object.

### How should the app work offline?

Save the last successful response together with the time you fetched it. On launch, show the cached rates and a "rates as of" time, then refresh when the network is available.

### How often should a mobile converter refresh rates?

On launch and on return to the foreground when the cache is older than about 15 minutes, plus pull-to-refresh. Upstream rates refresh every 60 seconds, but a converter rarely needs more.

## Build your converter

Mid-market rates for 160+ currencies, refreshed every 60 seconds. Start with a free API key, put it behind your proxy, and ship.

 [Get your free API key](https://allratestoday.com/register)

## Related Articles

- [Hide an exchange rate API key behind a proxy](https://allratestoday.com/blog/hide-exchange-rate-api-key-proxy/)
- [Free currency converter API in JavaScript](https://allratestoday.com/blog/free-currency-converter-api-javascript/)
- [How to handle base currency conversion](https://allratestoday.com/blog/how-to-handle-base-currency-conversion/)
- [How to cache exchange rates](https://allratestoday.com/blog/how-to-cache-exchange-rates-avoid-rate-limits/)
- [Mid-market vs retail exchange rates](https://allratestoday.com/blog/mid-market-vs-retail-exchange-rates/)
- [Currency API](https://allratestoday.com/currency-api/)
