n4nAI

Calling the OpenAI API from Swift with URLSession

Hands-on Swift tutorial: call the OpenAI API from iOS with URLSession. Build a typed client, parse JSON, stream tokens, and handle errors. No third-party SDKs required.

n4n Team3 min read696 words

Audio narration

Coming soon — every post will get a voice note here.

Most iOS apps proxy LLM calls through a server, but talking to the swift urlsession openai api directly from an iPhone is practical for local tooling, quick prototypes, and cases where you want to avoid sending user text to your own backend. You control the full request lifecycle, can meter token usage, and keep dependencies minimal. This guide builds a complete client with native Swift concurrency and no external libraries.

Prerequisites

  • Xcode 15+ (Swift 5.9+ with async/await)
  • A valid OpenAI API key, or any OpenAI-compatible endpoint credentials
  • Swift Codable and URLSession basics
  • iOS 15+ deployment target (required for URLSession.data(for:) and bytes(for:))

Do not hardcode the API key in shipping code. For the examples below, we read it from an environment-injected property or a debug plist. In production, issue short-lived tokens from your own service.

Why URLSession instead of an SDK

Third-party Swift wrappers for OpenAI add abstraction you do not need for basic chat. URLSession is already async-safe, handles HTTP/2, and supports streaming via bytes(for:). You avoid version drift when the API adds a field—your Codable structs decode what you care about and ignore the rest. The swift urlsession openai api surface for chat is just one POST endpoint.

Request and response models

OpenAI’s chat completion format is stable. Define the minimal shapes you need:

struct ChatMessage: Codable {
    let role: String
    let content: String
}

struct ChatRequest: Codable {
    let model: String
    let messages: [ChatMessage]
    var temperature: Double = 0.7
    var stream: Bool = false
}

struct ChatResponse: Codable {
    struct Choice: Codable {
        let message: ChatMessage
    }
    let id: String
    let choices: [Choice]
    let usage: Usage?

    struct Usage: Codable {
        let prompt_tokens: Int
        let completion_tokens: Int
        let total_tokens: Int
    }
}

That covers the non-streaming happy path. The usage field lets you track cost locally. If the API adds system_fingerprint or similar, Codable ignores it unless you add a property.

A minimal chat client

Create a struct that holds the session and auth, and exposes one async method.

struct OpenAIClient {
    let baseURL: URL
    let apiKey: String
    let session: URLSession

    init(baseURL: URL = URL(string: "https://api.openai.com/v1")!,
         apiKey: String,
         session: URLSession = .shared) {
        self.baseURL = baseURL
        self.apiKey = apiKey
        self.session = session
    }

    func chat(_ request: ChatRequest) async throws -> ChatResponse {
        var urlRequest = URLRequest(url: baseURL.appendingPathComponent("chat/completions"))
        urlRequest.httpMethod = "POST"
        urlRequest.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
        urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type")
        urlRequest.httpBody = try JSONEncoder().encode(request)

        let (data, response) = try await session.data(for: urlRequest)
        guard let http = response as? HTTPURLResponse else {
            throw URLError(.badServerResponse)
        }
        guard 200...299 ~= http.statusCode else {
            throw NSError(domain: "OpenAI", code: http.statusCode,
                          userInfo: [NSLocalizedDescriptionKey: String(data: data, encoding: .utf8) ?? ""])
        }
        return try JSONDecoder().decode(ChatResponse.self, from: data)
    }
}

The baseURL defaults to OpenAI, but the same client works against any compatible gateway.

Send your first message

Wire it up in a SwiftUI view model or a plain script.

let client = OpenAIClient(apiKey: "sk-...") // replace securely
let req = ChatRequest(model: "gpt-4o-mini",
                      messages: [ChatMessage(role: "user", content: "Explain URLSession in one sentence.")])

do {
    let res = try await client.chat(req)
    if let text = res.choices.first?.message.content {
        print("Assistant: \(text)")
    }
    if let u = res.usage {
        print("Tokens — prompt: \(u.prompt_tokens), completion: \(u.completion_tokens)")
    }
} catch {
    print("Error: \(error.localizedDescription)")
}

Expected output (truncated):

Assistant: URLSession is Apple’s API for sending and receiving HTTP requests, handling tasks like data, download, and upload with built-in delegation and concurrency support.
Tokens — prompt: 14, completion: 27

The swift urlsession openai api call is now a typed round-trip. No Alamofire, no OpenAI package.

Handling errors and rate limits

OpenAI returns 4xx/5xx with a JSON error body. Capture it instead of dropping it:

struct APIError: Codable {
    struct ErrorDetail: Codable { let message: String; let type: String }
    let error: ErrorDetail
}

In chat, before decoding ChatResponse, check status and decode APIError if needed. For 429, implement exponential backoff with Task.sleep:

func chatWithRetry(_ request: ChatRequest, retries: Int = 3) async throws -> ChatResponse {
    for attempt in 0...retries {
        do { return try await chat(request) }
        catch let e as NSError where e.code == 429 {
            let delay = UInt64(pow(2.0, Double(attempt)) * 500_000_000)
            try? await Task.sleep(nanoseconds: delay)
        }
    }
    throw NSError(domain: "OpenAI", code: 429, userInfo: [NSLocalizedDescriptionKey: "Rate limited"])
}

A 401 means the token is missing or malformed. A 400 usually means a bad model name or malformed messages array—log the decoded APIError to see the detail.

Streaming tokens with Server-Sent Events

Non-streaming blocks until the full completion finishes. For chat UX, stream. Set stream: true and read the response body as a byte stream.

func streamChat(_ request: ChatRequest) async throws -> AsyncThrowingStream<String, Error> {
    var mutable = request
    mutable.stream = true
    var urlRequest = URLRequest(url: baseURL.appendingPathComponent("chat/completions"))
    urlRequest.httpMethod = "POST"
    urlRequest.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
    urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type")
    urlRequest.httpBody = try JSONEncoder().encode(mutable)

    let (bytes, response) = try await session.bytes(for: urlRequest)
    guard let http = response as? HTTPURLResponse, 200...299 ~= http.statusCode else {
        throw URLError(.badServerResponse)
    }

    return AsyncThrowingStream { continuation in
        Task {
            for try await line in bytes.lines {
                if line.hasPrefix("data: ") {
                    let payload = String(line.dropFirst(6))
                    if payload == "[DONE]" { continuation.finish(); break }
                    if let data = payload.data(using: .utf8),
                       let chunk = try? JSONDecoder().decode(StreamChunk.self, from: data),
                       let token = chunk.choices.first?.delta.content {
                        continuation.yield(token)
                    }
                }
            }
            continuation.finish()
        }
    }
}

struct StreamChunk: Codable {
    struct Choice: Codable {
        struct Delta: Codable { let content: String? }
        let delta: Delta
    }
    let choices: [Choice]
}

Consume it:

let stream = try await client.streamChat(req)
for try await token in stream {
    print(token, terminator: "")
}
print()

Expected incremental console output:

URLSession
 is
 Apple
’s
 API

The swift urlsession openai api streaming path uses the same auth and URL; only the decoding loop changes. Note that SSE lines are prefixed with data: and terminated by a blank line. bytes.lines splits on newline, so we drop the prefix and ignore heartbeats.

Cancelling in-flight requests

URLSession respects Task cancellation if you use the async APIs. Wrap the call in a Task and call .cancel() from a SwiftUI .onDisappear or a button. The bytes.lines loop throws CancellationError when the parent task is cancelled, terminating the stream cleanly.

let task = Task {
    let stream = try await client.streamChat(req)
    for try await token in stream { print(token, terminator: "") }
}
// later
task.cancel()

Swap endpoints without rewriting

The request and response contracts are identical across OpenAI-compatible servers. If you target an OpenAI-compatible gateway such as n4n.ai—which fronts 240+ models and fails over automatically when a provider is rate-limited—the same Swift structs and URLSession call work unchanged; only the baseURL and bearer token differ. This keeps your iOS client dumb and lets backend policy control model routing.

Testing with a stubbed URLProtocol

To unit test without network, register a custom URLProtocol that returns a canned ChatResponse JSON. Your OpenAIClient takes a URLSession in init, so inject session with the stub configuration. This validates decoding and error mapping without burning tokens.

class StubProtocol: URLProtocol {
    override class func canInit(with request: URLRequest) -> Bool { true }
    override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
    override func startLoading() {
        let json = "{\"id\":\"x\",\"choices\":[{\"message\":{\"role\":\"assistant\",\"content\":\"hi\"}}]}".data(using: .utf8)!
        client?.urlProtocol(self, didReceive: HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!, cacheStoragePolicy: .notAllowed)
        client?.urlProtocol(self, didLoad: json)
        client?.urlProtocolDidFinishLoading(self)
    }
}

Security and next steps

  • Store the key in the Keychain, or better, issue short-lived client tokens from your own auth service.
  • Use URLSessionConfiguration.ephemeral if you want to avoid disk caching of prompts.
  • For background uploads of large batched prompts, use URLSessionDownloadTask with a delegate; the JSON shape stays the same.
  • Honor usage to show users token counts before they hit send.

You now have a typed, streaming, retry-aware client built entirely on URLSession. Extend it with function calling by adding tools to ChatRequest, or with vision by embedding base64 images in ChatMessage content arrays—both are plain Codable changes.

That is the full surface area most iOS apps need to integrate LLMs without taking a dependency on a moving SDK.

Tagsswiftiosurlsessionopenai-api

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →

All swift & ios llm integration posts →