# Swift movie API tutorial with SwiftUI

> This tutorial builds an iOS 16 app in Swift 5.9 that shows this week's trending movies in a SwiftUI list with posters, years and scores. It uses URLSession with async/await and AsyncImage, handles 401 and 429, and explains why a production app should call amodb through your own server.

## What you build

You build `Trending`, an iOS app with one screen: a list of this week's trending movies with a poster, title, year and score. You need Xcode with Swift 5.9 or newer, an iOS 16 deployment target and an amodb key. The app uses `URLSession` with async/await, `Decodable` models and `AsyncImage`, with no packages. Plan on about 20 minutes.

## A key inside an app can be extracted

Treat any secret that ships in an app as public. Someone can read strings from the binary, inspect network traffic or read the app's storage. For this tutorial the key comes from an Xcode scheme variable, which is fine on your own device. For an app you release, run a small server that holds the key, forwards the call to amodb and returns the JSON. Set `baseURL` in the client below to that server and authenticate your users there. The rate limit applies per key, so a server also lets you cache one answer for all of your users.

## Step 1: Get an API key

Compare the plans on [pricing](https://amodb.dev/pricing/). Every plan returns all data, so the choice is about requests per minute. Create a key in your account dashboard. Check it from a terminal before you write any Swift:

```bash
export AMODB_KEY=YOUR_API_KEY
curl -s -o /dev/null -w "%{http_code}\n" \
  "https://api.amodb.dev/3/trending/movie/week?api_key=$AMODB_KEY"
```

A `200` means the key works. A `401` means it is wrong. The [authentication](https://amodb.dev/authentication/) page explains both ways to send the key.

## Step 2: Create the Xcode project

Create an iOS App project named `Trending` with the SwiftUI interface and set the minimum deployment target to iOS 16. Delete the generated `ContentView.swift`. In your scheme, open Edit Scheme, choose Run, then Arguments, and add an environment variable named `AMODB_KEY` with your key. Xcode passes it to the app only when you run from Xcode.

## Step 3: Send the first request

Read the key from the environment and send it as a Bearer header. Set a 10 second timeout so a stalled connection ends.

```swift
let key = ProcessInfo.processInfo.environment["AMODB_KEY"] ?? ""
var request = URLRequest(url: URL(string: "https://api.amodb.dev/3/trending/movie/week?language=en-US")!)
request.timeoutInterval = 10
request.setValue("Bearer \(key)", forHTTPHeaderField: "Authorization")

let (data, response) = try await URLSession.shared.data(for: request)
print((response as? HTTPURLResponse)?.statusCode ?? 0, data.count)
```

## Step 4: Decode the response and show posters

The reply is a page with a `results` array of 20 items. Each movie has `id`, `title`, `release_date`, `poster_path` and `vote_average`, plus `media_type` set to `movie`. Set `keyDecodingStrategy` to `.convertFromSnakeCase` and declare camel case properties. Fields you do not declare are ignored, which suits fields that appear later. `poster_path` can be `null` and `release_date` can be missing or an empty string, so the model declares both as optional.

```swift
struct Movie: Decodable, Identifiable {
    let id: Int
    let title: String
    let releaseDate: String?
    let posterPath: String?
    let voteAverage: Double

    var posterURL: URL? {
        guard let posterPath else { return nil }
        return URL(string: "https://img.amodb.dev/t/p/w185\(posterPath)")
    }
}
```

`poster_path` starts with a slash, so the string above joins the size and the file name correctly. Posters come from `img.amodb.dev`, which needs no key and caches images for 30 days. The [images](https://amodb.dev/images-api/) page lists the sizes, and [trending and lists](https://amodb.dev/trending-lists/) covers the endpoint.

## Step 5: Handle errors and rate limits

Map each status to an error the screen can show.

| Status     | Meaning                           | What the app does                                               |
| ---------- | --------------------------------- | --------------------------------------------------------------- |
| 401        | Key missing, wrong or suspended   | Show a message, no retry.                                       |
| 402        | The plan period has ended         | Show a message. Renew on [pricing](https://amodb.dev/pricing/). |
| 429        | Over your requests per minute     | Wait `Retry-After` seconds, capped at 30, retry at most twice.  |
| 500 or 504 | Server side failure               | Show the status and offer pull to refresh.                      |
| `URLError` | Offline, timeout, lost connection | Show `localizedDescription`.                                    |

```swift
switch status {
case 200..<300:
    return try Self.decoder.decode(T.self, from: data)
case 401:
    throw AmodbError.invalidKey
case 429 where attempt < 2:
    let header = (response as? HTTPURLResponse)?.value(forHTTPHeaderField: "Retry-After")
    try await Task.sleep(for: .seconds(min(Int(header ?? "") ?? 5, 30)))
    attempt += 1
case 429:
    throw AmodbError.rateLimited
default:
    throw AmodbError.server(status)
}
```

See [errors](https://amodb.dev/errors/) and [rate limits](https://amodb.dev/rate-limits/) for every code.

## Step 6: Cache the list

The edge caches trending lists for 30 minutes, so asking again sooner returns the same movies. The rate limit still counts the call. Keep the last result in the view model and reload only when it is older than 30 minutes or the user pulls to refresh.

## Step 7: The complete app

This single file holds the models, the client, the view model and the views. Paste it over the generated app file.

```swift
import SwiftUI

// MARK: Models

struct TrendingPage: Decodable {
    let results: [Movie]
}

struct Movie: Decodable, Identifiable {
    let id: Int
    let title: String
    let releaseDate: String?
    let posterPath: String?
    let voteAverage: Double

    var year: String { String((releaseDate ?? "").prefix(4)) }

    var posterURL: URL? {
        guard let posterPath else { return nil }
        return URL(string: "https://img.amodb.dev/t/p/w185\(posterPath)")
    }
}

// MARK: Client

enum AmodbError: LocalizedError {
    case missingKey
    case invalidKey
    case rateLimited
    case server(Int)

    var errorDescription: String? {
        switch self {
        case .missingKey: return "Set AMODB_KEY in the Xcode scheme."
        case .invalidKey: return "The API key was rejected (401)."
        case .rateLimited: return "Too many requests. Try again in a minute."
        case .server(let status): return "The service answered with status \(status)."
        }
    }
}

struct AmodbClient {
    var baseURL = URL(string: "https://api.amodb.dev/3")!
    let apiKey: String

    private static let decoder: JSONDecoder = {
        let decoder = JSONDecoder()
        decoder.keyDecodingStrategy = .convertFromSnakeCase
        return decoder
    }()

    func get<T: Decodable>(_ path: String, query: [URLQueryItem] = []) async throws -> T {
        guard !apiKey.isEmpty else { throw AmodbError.missingKey }
        var components = URLComponents(url: baseURL.appendingPathComponent(path), resolvingAgainstBaseURL: false)!
        components.queryItems = query
        var request = URLRequest(url: components.url!, timeoutInterval: 10)
        request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")

        var attempt = 0
        while true {
            let (data, response) = try await URLSession.shared.data(for: request)
            let status = (response as? HTTPURLResponse)?.statusCode ?? 0
            switch status {
            case 200..<300:
                return try Self.decoder.decode(T.self, from: data)
            case 401:
                throw AmodbError.invalidKey
            case 429 where attempt < 2:
                let header = (response as? HTTPURLResponse)?.value(forHTTPHeaderField: "Retry-After")
                try await Task.sleep(for: .seconds(min(Int(header ?? "") ?? 5, 30)))
                attempt += 1
            case 429:
                throw AmodbError.rateLimited
            default:
                throw AmodbError.server(status)
            }
        }
    }
}

// MARK: View model

@MainActor
final class TrendingViewModel: ObservableObject {
    @Published private(set) var movies: [Movie] = []
    @Published private(set) var errorMessage: String?
    @Published private(set) var isLoading = false

    private let client: AmodbClient
    private var loadedAt: Date?

    init(client: AmodbClient) {
        self.client = client
    }

    func load(force: Bool = false) async {
        if !force, let last = loadedAt, !movies.isEmpty, Date().timeIntervalSince(last) < 30 * 60 {
            return
        }
        isLoading = true
        defer { isLoading = false }
        do {
            let page: TrendingPage = try await client.get(
                "trending/movie/week",
                query: [URLQueryItem(name: "language", value: "en-US")]
            )
            movies = page.results
            loadedAt = Date()
            errorMessage = nil
        } catch {
            errorMessage = error.localizedDescription
        }
    }
}

// MARK: Views

struct MovieRow: View {
    let movie: Movie

    var body: some View {
        HStack(spacing: 12) {
            AsyncImage(url: movie.posterURL) { phase in
                switch phase {
                case .success(let image):
                    image.resizable().scaledToFill()
                case .failure:
                    Color.gray.opacity(0.2)
                default:
                    ProgressView()
                }
            }
            .frame(width: 60, height: 90)
            .clipShape(RoundedRectangle(cornerRadius: 6))

            VStack(alignment: .leading, spacing: 4) {
                Text(movie.title).font(.headline)
                Text(movie.year).font(.subheadline).foregroundStyle(.secondary)
                Label(String(format: "%.1f", movie.voteAverage), systemImage: "star.fill")
                    .font(.caption)
            }
        }
    }
}

struct ContentView: View {
    @StateObject private var model: TrendingViewModel

    init(client: AmodbClient) {
        _model = StateObject(wrappedValue: TrendingViewModel(client: client))
    }

    var body: some View {
        NavigationStack {
            List(model.movies) { movie in
                MovieRow(movie: movie)
            }
            .overlay {
                if model.isLoading && model.movies.isEmpty {
                    ProgressView()
                } else if let message = model.errorMessage, model.movies.isEmpty {
                    Text(message).multilineTextAlignment(.center).padding()
                }
            }
            .navigationTitle("Trending")
            .refreshable { await model.load(force: true) }
            .task { await model.load() }
        }
    }
}

@main
struct TrendingApp: App {
    private let client = AmodbClient(apiKey: ProcessInfo.processInfo.environment["AMODB_KEY"] ?? "")

    var body: some Scene {
        WindowGroup {
            ContentView(client: client)
        }
    }
}
```

Run the app on a simulator. The list fills after the first call, and pulling down forces a reload. Scores are the aggregate `vote_average` from the list, and a title with no votes yet shows 0.0. For IMDb or Kinopoisk scores, request a movie with `append_to_response=ratings`. The `ratings` object holds `imdb`, `kinopoisk`, `metacritic`, `rotten_tomatoes` and `tmdb`, each either `null` or an object with `value` and `votes`, as the [ratings](https://amodb.dev/ratings/) page shows.

## Next steps

- Add a detail screen that loads `/movie/{id}` with `append_to_response=credits,ratings`, and see [movies](https://amodb.dev/movies/) for the fields.
- Switch the `language` query item to the device language, using the 14 base languages on the [languages](https://amodb.dev/languages/) page.
- Move the key to your server and point `baseURL` at it before you publish the app.
- Build the same list for the web with [Next.js](https://amodb.dev/nextjs-movie-app/).

## Questions and answers

### Is it safe to put my API key in an iOS app?

No. Anything stored in an app binary or its settings can be extracted. Keep the key on a server you control, let the app call that server, and use a key inside the app only for local testing.

### Which iOS and Swift versions does this tutorial need?

Swift 5.9 or newer with Xcode, and a deployment target of iOS 16. That gives you NavigationStack, Task.sleep with a duration and AsyncImage. The view model uses ObservableObject, so it also runs on iOS 16.

### How do I show posters without extra libraries?

Build the URL as https://img.amodb.dev/t/p/w185 plus the poster_path value and pass it to AsyncImage. The image host needs no key. Use w342 or w500 for larger layouts, and handle the failure phase with a placeholder.

### What should the app do after a 429 response?

Read the Retry-After header, wait that many seconds with a cap, and retry at most twice. If it still fails, show a message and let the user pull to refresh. Do not loop without a limit.

### Can I use a different language for titles?

Yes. Add a language query item such as ru-RU or pt-BR. Fourteen base languages are served, and any other tag falls back to English. Titles then come back localized when a translation exists.

## Related pages

- [Next.js movie app tutorial](https://amodb.dev/nextjs-movie-app/): Build a Next.js 15 movie app in plain JavaScript: server components fetch trending, search and details from amodb with revalidate and keep the key private.
- [Trending and list endpoints](https://amodb.dev/trending-lists/): Trending movies API with TMDB v3 paths: day and week charts, plus now playing, popular, top rated and upcoming lists for movies and TV, cached for 30 minutes.
- [Images API and image host](https://amodb.dev/images-api/): Movie images API with TMDB v3 paths: list posters, backdrops, logos and stills per title, then load them from img.amodb.dev in 10 sizes with no key.
- [Authentication](https://amodb.dev/authentication/): How to send your amodb key: api_key in the query, an Authorization Bearer header, or apikey for OMDb calls. Key format, storage, rotation and the 401 errors.
- [Rate limits](https://amodb.dev/rate-limits/): amodb limits each key by requests per minute in a sliding 60 second window: 20, 40 or 120 by plan. Read the rate limit headers and handle 429 with Retry-After.
- [Quickstart](https://amodb.dev/quickstart/): Make your first amodb call: get a key, request movie details in curl, JavaScript, Python, PHP or Go, then load the poster from img.amodb.dev. Under 2 minutes.

Updated: 2026-10-18. Source: https://amodb.dev/swift-movie-api/
