Use URLSession for the network call and Codable for the response contract. The keyless request is safe for evaluation; keep a real key on your server and never embed it in an iOS, macOS, or watchOS client bundle.
Key points
Use URLComponents to build the endpoint and URLSession for async networking.
Decode snake_case fields with JSONDecoder.keyDecodingStrategy.
Keep authentication on a trusted server; the mobile app can call your server instead.
Check HTTP status before decoding and preserve source and update timestamps.
Define the response model
The model keeps the rate map and the fields that explain the observation. data_updated_at stays a provider timestamp string so fractional-second and whole-second ISO-8601 responses are both preserved, while currency values remain Decimal for later amount calculations.
swift · ExchangeRateClient.swiftcopy
import Foundation
final class NoRedirects: NSObject, URLSessionTaskDelegate {
func urlSession(_ session: URLSession, task: URLSessionTask, willPerformHTTPRedirection response: HTTPURLResponse, newRequest request: URLRequest, completionHandler: @escaping (URLRequest?) -> Void) {
completionHandler(nil)
}
}
struct Latest: Decodable {
let base: String
let source: String
let marketSession: String
let dataUpdatedAt: String
let rates: [String: Decimal]
}
struct ExchangeRateClient {
let session: URLSession
let apiKey: String?
func latest(base: String) async throws -> Latest {
let normalizedBase = base.uppercased()
guard normalizedBase.count == 3, normalizedBase.allSatisfy({ $0.isASCII && $0.isLetter }) else {
throw URLError(.badURL)
}
guard var components = URLComponents(string: "https://api.exchangerate.dev/v1/latest/\(normalizedBase)") else {
throw URLError(.badURL)
}
components.queryItems = [URLQueryItem(name: "symbols", value: "EUR,GBP,JPY")]
guard let url = components.url else { throw URLError(.badURL) }
var request = URLRequest(url: url)
request.timeoutInterval = 10
if let apiKey { request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") }
let (data, response) = try await session.data(for: request)
guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
throw URLError(.badServerResponse)
}
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
return try decoder.decode(Latest.self, from: data)
}
}
Keep the key on a server
For a mobile or desktop app, call your own backend and let that backend add Authorization: Bearer .... A key placed in an app bundle can be extracted. For a server-side Swift process, read the key from ProcessInfo.processInfo.environment and pass it into the client.
swift · server setupcopy
let key = ProcessInfo.processInfo.environment["EXCHANGERATE_API_KEY"]
let session = URLSession(configuration: .ephemeral, delegate: NoRedirects(), delegateQueue: nil)
let client = ExchangeRateClient(
session: session,
apiKey: key
)
let latest = try await client.latest(base: "USD")
print(latest.rates["EUR"] as Any, latest.source, latest.dataUpdatedAt)
Handle status and freshness
Treat non-2xx responses as errors before decoding. Fix a 401 by checking authentication, and handle 429 with bounded backoff and less polling. Keep source, market_session, and data_updated_at in the model so the UI can explain whether a value is intraday or a daily reference.
Indicative data needs context
A successful HTTP response does not make a rate executable. Show the observation timestamp and source context, and keep settlement or regulated pricing on the provider responsible for that transaction.
Configure URLSession timeouts for the app’s network policy.
Cache successful observations with their timestamp.
Do not put API keys in Info.plist, source code, or app logs.
Use Decimal and an explicit rounding rule for displayed amounts.