Every SDK request (except a handful of public endpoints) requires an OAuth2 bearer token:
Authorization: Bearer <40-char token>
- Log in to cryptohopper.com.
- Developer → Create App — gives you a
client_id+client_secret. - Complete the OAuth consent flow for your app, which returns a bearer token.
For iOS / macOS apps, the standard pattern is:
import AuthenticationServices
final class CryptohopperAuth: NSObject, ASWebAuthenticationPresentationContextProviding {
func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor {
UIApplication.shared.windows.first { $0.isKeyWindow } ?? UIWindow()
}
func signIn() async throws -> String {
let url = URL(string: "https://www.cryptohopper.com/oauth2/authorize?client_id=...&redirect_uri=myapp://callback&response_type=code")!
let session = ASWebAuthenticationSession(
url: url,
callbackURLScheme: "myapp"
) { callback, _ in /* completion */ }
let result: URL = try await withCheckedThrowingContinuation { cont in
let s = ASWebAuthenticationSession(url: url, callbackURLScheme: "myapp") { callbackURL, error in
if let url = callbackURL { cont.resume(returning: url) }
else { cont.resume(throwing: error!) }
}
s.presentationContextProvider = self
s.prefersEphemeralWebBrowserSession = false // share Safari cookies for SSO
s.start()
}
// Extract code from result.queryParameters and exchange for a token.
let code = URLComponents(url: result, resolvingAgainstBaseURL: false)!
.queryItems!
.first { $0.name == "code" }!
.value!
return try await exchangeCodeForToken(code) // your code
}
}Persist the resulting token in Keychain — never in UserDefaults or a file. The simplest wrapper is KeychainAccess:
import KeychainAccess
let keychain = Keychain(service: "com.example.cryptohopper")
keychain[string: "cryptohopper_token"] = token
let stored = keychain[string: "cryptohopper_token"]For server-side Swift, use the official CLI once interactively to obtain a token, then read it from your secret store at startup.
import Cryptohopper
import Foundation
let client = try Client(
apiKey: ProcessInfo.processInfo.environment["CRYPTOHOPPER_TOKEN"]!,
appKey: ProcessInfo.processInfo.environment["CRYPTOHOPPER_APP_KEY"],
baseURL: "https://api.cryptohopper.com/v1",
timeout: 30,
maxRetries: 3,
userAgent: "my-app/1.0"
)Only apiKey: is required.
Cryptohopper lets OAuth apps identify themselves on every request via the x-api-app-key header (value = your OAuth client_id). When set, the SDK adds the header automatically. Reasons to set it:
- Shows up in Cryptohopper's server-side telemetry — you can attribute your own traffic.
- Drives per-app rate limits — if two apps share a token, they get independent quotas.
- Harmless to omit; the server accepts unattributed requests.
Empty strings are treated as "not set," so passing Optional<String> from ProcessInfo.environment["..."] directly is safe.
Override for staging or a local dev server. The default is https://api.cryptohopper.com/v1. The trailing /v1 is part of the base; resource paths are relative to it.
let client = try Client(
apiKey: token,
baseURL: "https://api.staging.cryptohopper.com/v1"
)timeout: is a TimeInterval (seconds) — the per-request total deadline. Defaults to 30.
The default URLSessionHTTPClient applies your timeout: to both URLSessionConfiguration.timeoutIntervalForRequest (idle) and timeoutIntervalForResource (total deadline) so body reads can't exceed the budget either — URLSession.shared defaults timeoutIntervalForResource to 7 days, which is rarely what you want. If you bring your own HTTPClient you're responsible for setting both fields yourself.
maxRetries: is the number of automatic retries on HTTP 429. Default 3. Set to 0 to disable. See Rate Limits.
If you need custom transport — certificate pinning, OpenTelemetry tracing, on-device proxy support — implement the HTTPClient protocol:
public protocol HTTPClient: Sendable {
func send(_ request: URLRequest) async throws -> (Data, HTTPURLResponse)
}For trading apps where you really care about MITM resistance:
final class PinnedSessionDelegate: NSObject, URLSessionDelegate {
let pinnedKeyHashes: Set<String>
init(pinnedKeyHashes: Set<String>) { self.pinnedKeyHashes = pinnedKeyHashes }
func urlSession(
_ session: URLSession,
didReceive challenge: URLAuthenticationChallenge,
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
) {
guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust,
let trust = challenge.protectionSpace.serverTrust,
SecTrustEvaluateWithError(trust, nil) else {
completionHandler(.cancelAuthenticationChallenge, nil)
return
}
// ... compare SubjectPublicKeyInfo hashes against pinnedKeyHashes ...
// (see https://developer.apple.com/news/?id=g9ejcf8y for details)
}
}
struct PinnedHTTPClient: HTTPClient {
let session: URLSession
init(pinnedKeyHashes: Set<String>) {
let delegate = PinnedSessionDelegate(pinnedKeyHashes: pinnedKeyHashes)
let config = URLSessionConfiguration.default
config.timeoutIntervalForRequest = 30
config.timeoutIntervalForResource = 30
self.session = URLSession(configuration: config, delegate: delegate, delegateQueue: nil)
}
func send(_ request: URLRequest) async throws -> (Data, HTTPURLResponse) {
let (data, response) = try await session.data(for: request)
guard let http = response as? HTTPURLResponse else {
throw CryptohopperError(code: .networkError, message: "Non-HTTP", status: 0)
}
return (data, http)
}
}
let client = try Client(
apiKey: token,
httpClient: PinnedHTTPClient(pinnedKeyHashes: ["sha256/..."])
)This shifts trust from the system CA store onto the public-key hashes you've pinned. Trade-offs are real — if Cryptohopper rotates keys without warning, your app breaks until you ship an update. Pin SubjectPublicKeyInfo (not the whole cert) to survive cert renewals that keep the same key.
Appended after the SDK's own User-Agent (cryptohopper-sdk-swift/<version>). Set this to identify your app to Cryptohopper support if you ever need to debug something with them.
If your Cryptohopper app has IP allowlisting enabled, requests from unlisted IPs return 403 FORBIDDEN. The SDK surfaces this as CryptohopperError with code == .forbidden and ipAddress populated:
catch let error as CryptohopperError {
if error.code == .forbidden {
print("Blocked: caller IP was \(error.ipAddress ?? "?")")
}
}For mobile apps, this is rarely useful — users connect from constantly-changing carrier IPs. If you've allowlisted server-side, mobile clients won't fit.
Cryptohopper bearer tokens are long-lived but can be revoked:
- Manually from the dashboard.
- When the user revokes consent.
The SDK surfaces revocation as .unauthorized on the next call. There is no automatic refresh-token handling in the SDK today — handle the .unauthorized branch in your app:
actor CryptohopperGateway {
private var client: Client
private let tokens: TokenStore
init(tokens: TokenStore) throws {
self.tokens = tokens
self.client = try Client(apiKey: tokens.current)
}
func call<T>(_ fn: (Client) async throws -> T) async throws -> T {
do {
return try await fn(client)
} catch let error as CryptohopperError where error.code == .unauthorized {
// Refresh, swap atomically, retry once.
client = try Client(apiKey: try await tokens.refresh())
return try await fn(client)
}
}
}Wrapping in an actor keeps the swap atomic — concurrent callers see either the old client or the new one, never an in-progress half-swap.
Client is a value type; Transport is an actor. Sharing Client across tasks is safe — concurrent await client.user.get() from N tasks gets serialized through the actor's mailbox.
For maximum throughput, use withThrowingTaskGroup to fan out:
let results = try await withThrowingTaskGroup(of: (String, Any?).self) { group in
for hopperId in hopperIds {
group.addTask {
(hopperId, try await client.hoppers.get(hopperId))
}
}
var out: [String: Any?] = [:]
for try await (id, hopper) in group {
out[id] = hopper
}
return out
}See Rate Limits for guidance on capping concurrency at the API quota.
A handful of endpoints accept anonymous calls:
/market/*— marketplace browse/platform/*— i18n, country list, blog feed/exchange/ticker,/exchange/candle,/exchange/orderbook,/exchange/markets,/exchange/exchanges,/exchange/forex-rates— public market data
The SDK still requires a non-empty apiKey: at construction; pass any placeholder if you only intend to hit public endpoints. The server ignores the bearer header on whitelisted routes.
let client = try Client(apiKey: "anonymous")
let btc = try await client.exchange.ticker(exchange: "binance", market: "BTC/USDT")