Claude API Swift & iOS Integration Guide (2026)
There is no official Swift SDK for the Claude API, but integration is straightforward: build a URLSession-based client that posts JSON to https://api.anthropic.com/v1/messages with the required headers. However, you must never ship your Anthropic API key inside an iOS app — App Store binaries are easily reversed. The correct pattern is a lightweight backend proxy that your iOS app calls, which then forwards requests to Anthropic. This guide covers the full stack: a safe APIClient in Swift, a SwiftUI streaming chat UI, async/await patterns, and a proxy server you can deploy in minutes.
Security Warning: Never Bundle API Keys in iOS Apps
Do not put your ANTHROPIC_API_KEY directly in your Swift app. iOS .ipa files can be unpacked and strings extracted in seconds with tools like strings or Hopper. Anyone who downloads your app from the App Store can steal your key, rack up API charges, and access any data you pass to the model.
The correct architecture:
iOS App → Your Backend Proxy → Anthropic API
(holds the key)
Your iOS app authenticates to your server (via Firebase Auth, Supabase, or a signed JWT). Your server validates the user, enforces rate limits, and relays requests to Anthropic. This also lets you log usage, cap spend per user, and rotate the Anthropic key without a new App Store release.
See the proxy server pattern section below for a ready-to-deploy Node.js implementation.
Direct Call vs. Proxy: Quick Comparison
| Approach | Security | Key Rotation | Per-User Rate Limits | Cost Visibility |
|---|---|---|---|---|
| API key in iOS app | Unsafe | Requires App Store update | No | No |
| Backend proxy | Safe | Server-side only | Yes | Yes |
The direct approach is only acceptable during local development on a physical device where you control the build and never distribute the binary.
Building the Swift API Client (URLSession)
Create APIClient.swift in your Xcode project:
// APIClient.swift
import Foundation
struct Message: Codable {
let role: String
let content: String
}
struct ClaudeRequest: Codable {
let model: String
let maxTokens: Int
let messages: [Message]
enum CodingKeys: String, CodingKey {
case model
case maxTokens = "max_tokens"
case messages
}
}
struct ClaudeResponse: Codable {
struct Content: Codable {
let type: String
let text: String
}
let content: [Content]
}
enum ClaudeError: Error, LocalizedError {
case invalidResponse(Int)
case decodingFailed
case networkError(Error)
var errorDescription: String? {
switch self {
case .invalidResponse(let code): return "API error: HTTP \(code)"
case .decodingFailed: return "Failed to decode API response"
case .networkError(let err): return err.localizedDescription
}
}
}
actor ClaudeAPIClient {
// Point this at YOUR proxy, not directly at Anthropic in production
private let baseURL: URL
private let session: URLSession
init(proxyURL: URL = URL(string: "https://your-proxy.example.com")!) {
self.baseURL = proxyURL
self.session = URLSession(configuration: .default)
}
func sendMessage(
_ userMessage: String,
model: String = "claude-sonnet-4-5",
maxTokens: Int = 1024
) async throws -> String {
let payload = ClaudeRequest(
model: model,
maxTokens: maxTokens,
messages: [Message(role: "user", content: userMessage)]
)
var request = URLRequest(url: baseURL.appendingPathComponent("/v1/chat"))
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
// Your proxy handles auth — pass a user token here instead of an Anthropic key
// request.setValue("Bearer \(userToken)", forHTTPHeaderField: "Authorization")
request.httpBody = try JSONEncoder().encode(payload)
request.timeoutInterval = 60
do {
let (data, response) = try await session.data(for: request)
guard let http = response as? HTTPURLResponse else {
throw ClaudeError.decodingFailed
}
guard (200...299).contains(http.statusCode) else {
throw ClaudeError.invalidResponse(http.statusCode)
}
let decoded = try JSONDecoder().decode(ClaudeResponse.self, from: data)
return decoded.content.first?.text ?? ""
} catch let error as ClaudeError {
throw error
} catch {
throw ClaudeError.networkError(error)
}
}
}
For the error codes your proxy may return, see the Claude API error codes reference.
SwiftUI Streaming Chat UI
Create ChatView.swift — a full streaming chat interface using URLSession.bytes and async/await:
// ChatView.swift
import SwiftUI
@MainActor
class ChatViewModel: ObservableObject {
@Published var messages: [ChatMessage] = []
@Published var inputText: String = ""
@Published var isStreaming: Bool = false
private let proxyURL = URL(string: "https://your-proxy.example.com/v1/stream")!
func sendMessage() async {
guard !inputText.trimmingCharacters(in: .whitespaces).isEmpty else { return }
let userMessage = inputText
inputText = ""
messages.append(ChatMessage(role: .user, text: userMessage))
var assistantMessage = ChatMessage(role: .assistant, text: "")
messages.append(assistantMessage)
let lastIndex = messages.count - 1
isStreaming = true
do {
var request = URLRequest(url: proxyURL)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = try JSONEncoder().encode([
"message": userMessage,
"stream": true
])
let (asyncBytes, response) = try await URLSession.shared.bytes(for: request)
guard let http = response as? HTTPURLResponse,
(200...299).contains(http.statusCode) else {
messages[lastIndex].text = "Error: server returned an error response."
isStreaming = false
return
}
for try await line in asyncBytes.lines {
guard line.hasPrefix("data: ") else { continue }
let jsonString = String(line.dropFirst(6))
guard jsonString != "[DONE]",
let data = jsonString.data(using: .utf8),
let chunk = try? JSONDecoder().decode(StreamChunk.self, from: data),
let delta = chunk.delta?.text else { continue }
messages[lastIndex].text += delta
}
} catch {
messages[lastIndex].text = "Error: \(error.localizedDescription)"
}
isStreaming = false
}
}
struct ChatMessage: Identifiable {
enum Role { case user, assistant }
let id = UUID()
let role: Role
var text: String
}
struct StreamChunk: Codable {
struct Delta: Codable { let text: String? }
let delta: Delta?
}
struct ChatView: View {
@StateObject private var viewModel = ChatViewModel()
var body: some View {
VStack(spacing: 0) {
ScrollViewReader { proxy in
ScrollView {
LazyVStack(alignment: .leading, spacing: 12) {
ForEach(viewModel.messages) { message in
MessageBubble(message: message)
.id(message.id)
}
}
.padding()
}
.onChange(of: viewModel.messages.count) { _ in
if let last = viewModel.messages.last {
withAnimation { proxy.scrollTo(last.id, anchor: .bottom) }
}
}
}
Divider()
HStack(spacing: 8) {
TextField("Ask anything…", text: $viewModel.inputText, axis: .vertical)
.textFieldStyle(.roundedBorder)
.lineLimit(1...5)
.disabled(viewModel.isStreaming)
Button {
Task { await viewModel.sendMessage() }
} label: {
Image(systemName: viewModel.isStreaming ? "stop.circle.fill" : "arrow.up.circle.fill")
.font(.title2)
.foregroundColor(viewModel.isStreaming ? .red : .blue)
}
.disabled(viewModel.inputText.trimmingCharacters(in: .whitespaces).isEmpty
&& !viewModel.isStreaming)
}
.padding()
}
.navigationTitle("Claude Chat")
}
}
struct MessageBubble: View {
let message: ChatMessage
var body: some View {
HStack {
if message.role == .user { Spacer() }
Text(message.text.isEmpty ? "…" : message.text)
.padding(12)
.background(message.role == .user ? Color.blue : Color(.secondarySystemBackground))
.foregroundColor(message.role == .user ? .white : .primary)
.clipShape(RoundedRectangle(cornerRadius: 16))
.frame(maxWidth: UIScreen.main.bounds.width * 0.75, alignment:
message.role == .user ? .trailing : .leading)
if message.role == .assistant { Spacer() }
}
}
}
Backend Proxy Server Pattern
Deploy this Node.js proxy to Railway, Fly.io, or any VPS. Your iOS app never sees the Anthropic key:
// proxy-server.js (Node.js + Express)
import express from "express";
import fetch from "node-fetch";
const app = express();
app.use(express.json());
const ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY; // Set in your hosting env
const ANTHROPIC_URL = "https://api.anthropic.com/v1/messages";
// Optional: verify a Firebase/Supabase JWT before forwarding
app.post("/v1/chat", async (req, res) => {
const { message, model = "claude-sonnet-4-5", maxTokens = 1024 } = req.body;
const upstream = await fetch(ANTHROPIC_URL, {
method: "POST",
headers: {
"x-api-key": ANTHROPIC_API_KEY,
"anthropic-version": "2023-06-01",
"content-type": "application/json",
},
body: JSON.stringify({
model,
max_tokens: maxTokens,
messages: [{ role: "user", content: message }],
}),
});
const data = await upstream.json();
res.json(data);
});
// Streaming endpoint — relays SSE directly to iOS URLSession.bytes
app.post("/v1/stream", async (req, res) => {
const { message, model = "claude-sonnet-4-5" } = req.body;
res.setHeader("Content-Type", "text/event-stream");
res.setHeader("Cache-Control", "no-cache");
const upstream = await fetch(ANTHROPIC_URL, {
method: "POST",
headers: {
"x-api-key": ANTHROPIC_API_KEY,
"anthropic-version": "2023-06-01",
"content-type": "application/json",
},
body: JSON.stringify({
model,
max_tokens: 1024,
stream: true,
messages: [{ role: "user", content: message }],
}),
});
upstream.body.pipe(res);
});
app.listen(3000);
This proxy pattern also lets you add per-user rate limiting, usage logging, and cost attribution without any App Store update. For production multi-agent patterns, see the Claude Code React Native Mobile guide which covers similar backend relay patterns.
Mid-Article Offer
Building iOS apps powered by Claude or other AI agents? The Agent SDK Cookbook ($49) includes 15 production-ready agent implementations — customer support, data pipeline, code review, and more — with full error handling, retry logic, and cost tracking patterns you can adapt to any mobile backend.
→ Get the Agent SDK Cookbook — $49
Async/Await Patterns and Error Handling
Swift's async/await maps naturally onto URLSession. Key patterns for Claude API calls:
// Retry with exponential backoff
func sendWithRetry(message: String, maxRetries: Int = 3) async throws -> String {
var lastError: Error?
for attempt in 0..<maxRetries {
do {
return try await sendMessage(message)
} catch ClaudeError.invalidResponse(429) {
// Rate limited — back off
try await Task.sleep(nanoseconds: UInt64(pow(2.0, Double(attempt))) * 1_000_000_000)
lastError = ClaudeError.invalidResponse(429)
} catch ClaudeError.invalidResponse(500),
ClaudeError.invalidResponse(529) {
// Server overload — retry
try await Task.sleep(nanoseconds: 2_000_000_000)
lastError = ClaudeError.invalidResponse(500)
} catch {
throw error // Non-retriable: bad request, auth failure, etc.
}
}
throw lastError ?? ClaudeError.networkError(URLError(.unknown))
}
HTTP 429 means rate-limited; 529 means Anthropic is overloaded. Both are retriable. 400 and 401 are not — fix the request or key. See the full list in the Claude API error codes reference.
For choosing the right model tier to balance speed and cost in your iOS app, see the Haiku vs Sonnet vs Opus guide.
Frequently Asked Questions
Is there an official Swift or iOS SDK for the Claude API?
No — as of 2026, Anthropic does not publish an official Swift SDK. iOS and macOS integrations use the REST API directly via URLSession. All Claude features (streaming, tool use, vision, prompt caching) are accessible through the REST API, so the lack of a native SDK is not a limitation in practice.
How do I secure my API key in an iOS app?
You must not store the API key in the iOS app at all. Use a backend proxy server (Node.js, Python, Go, etc.) that holds the key in a server-side environment variable. Your iOS app authenticates to your proxy using a user-specific token (e.g., Firebase Auth ID token or a signed JWT). This prevents key extraction, enables per-user rate limiting, and allows key rotation without a new App Store release.
Can I call the Claude API directly from the iOS Simulator for testing?
Yes — during local development you can call Anthropic directly from the Simulator if you set the key in a Debug.xcconfig file that is excluded from version control (add it to .gitignore). Never use this approach in a TestFlight or App Store build. Switch to your proxy URL via a build scheme environment variable before distributing.
How do I handle streaming responses in SwiftUI?
Use URLSession.bytes(for:) which returns an AsyncBytes sequence. Iterate with for try await line in asyncBytes.lines, parse the data: {...} Server-Sent Event lines, and update @Published properties on the main actor. Because ChatViewModel is marked @MainActor, all UI updates are automatically dispatched to the main thread.
What model should I use for a real-time iOS chat app?
Claude Haiku is the best choice for low-latency chat — it responds in under 1 second for most conversational turns and costs roughly 25x less than Sonnet. Use Sonnet when you need more nuanced reasoning or longer outputs. See the model comparison guide for a detailed cost-latency breakdown.
Go Deeper
Agent SDK Cookbook — $49 — 15 complete agent implementations ready for production: customer support, content pipeline, code review, DevOps monitoring, and more. Backend-agnostic patterns that pair perfectly with an iOS front end.
→ Get the Agent SDK Cookbook — $49
30-day money-back guarantee. Instant download.