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.
- 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.
import Foundationfinal 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.
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.
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.