> ## Documentation Index
> Fetch the complete documentation index at: https://docs.go.gbgplc.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Tutorial: Build an iOS Identity Verification App

> Step-by-step guide to building a complete iOS app that runs a GBG Go identity verification journey with document and selfie capture.

This tutorial walks you through building a complete iOS app that runs a GBG Go identity verification journey. By the end, you will have a working SwiftUI app that launches a web-based journey, handles document and selfie capture requests from the journey, and returns results back to the web via the native bridge.

The app you build has three screens: a configuration form, a WebView running the journey, and full-screen camera views that appear when the journey requests a capture. The entire integration takes about 100 lines of Swift.

> **Reference app:** The complete source code for this tutorial is available at [gbg-go-ios-reference](https://github.com/gbgplc/gbg-go-ios-reference). You can clone it and run it immediately, or follow this tutorial to build it step by step.

## Prerequisites

| Requirement       | Version | Notes                                                                                                                                                                     |
| ----------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Xcode             | 15.0+   | Swift 5.9 toolchain                                                                                                                                                       |
| iOS target        | 16.0+   | NavigationStack, onChange(of:)                                                                                                                                            |
| Node.js           | 18+     | For the companion server                                                                                                                                                  |
| GBG Go API client | —       | Client ID and client secret. Create an API client in the Account management portal. See [Manage API clients](/docs/go-v2/platform/account-management/manage-api-clients). |

You also need the GBGBridge Swift package. See the [Getting Started](/docs/go-v2/developer-integration/sdks/ios/getting-started) guide for installation options.

## Set Up the Backend

The iOS app does not call the GBG Go API directly. Instead, it calls a lightweight companion server that creates journey sessions using the GBG Go Core SDK. This keeps your API credentials on the server, never on the device.

### 1. Clone the reference repository

```bash theme={null}
git clone https://github.com/gbgplc/gbg-go-ios-reference.git
cd gbg-go-ios-reference/server
```

### 2. Install dependencies

```bash theme={null}
npm install
```

### 3. Configure credentials

Copy the example environment file and fill in your GBG Go credentials:

```bash theme={null}
cp .env.example .env
```

Open `.env` in a text editor:

```dotenv theme={null}
GO_CLIENT_ID=your-client-id
GO_CLIENT_SECRET=your-client-secret

# Regional server index: 0 = EU, 1 = US, 2 = AU
GO_SERVER_IDX=0

# Default resource ID for journeys
GO_RESOURCE_ID=a4c68509c24789888eb466@latest

PORT=3000
```

### 4. Start the server

```bash theme={null}
node index.mjs
```

You should see:

```
Authenticated with GBG Go (EU region)
Server listening on http://localhost:3000
```

### 5. Test the server

Open a new terminal and verify it responds:

```bash theme={null}
curl http://localhost:3000/health
```

Expected output: `{"ok":true}`

Leave the server running. You will need it for the rest of the tutorial.

## Create Your iOS Project

Open Xcode and create a new project:

1. **File → New → Project**
2. Choose **App** under the iOS tab.
3. Set the product name to **GBGGoReference**.
4. Set the interface to **SwiftUI** and the language to **Swift**.
5. Click **Create**.

### Add GBGBridge via Swift Package Manager

1. **File → Add Package Dependencies...**
2. Enter the GBGBridge package URL.
3. Select **Up to Next Major Version** from `1.0.0`.
4. Click **Add Package** and ensure **GBGBridge** is added to your app target.

### Configure Info.plist

Add these keys to your `Info.plist`:

```xml theme={null}
<key>NSAppTransportSecurity</key>
<dict>
    <key>NSAllowsLocalNetworking</key>
    <true/>
</dict>
<key>NSCameraUsageDescription</key>
<string>Camera access is needed to capture identity documents and selfie photos for verification.</string>
<key>UISupportedInterfaceOrientations</key>
<array>
    <string>UIInterfaceOrientationPortrait</string>
</array>
```

`NSAllowsLocalNetworking` permits HTTP requests to `localhost` during development. Without it, requests to the companion server silently fail.

### File structure

Create the following group structure in Xcode's Project Navigator:

```
GBGGoReference/
├── Sources/
│   ├── App/
│   │   ├── GBGGoReferenceApp.swift
│   │   └── RootView.swift
│   ├── Journey/
│   │   ├── JourneyService.swift
│   │   └── JourneyView.swift
│   └── Settings/
│       └── SetupView.swift
└── Resources/
    └── Info.plist
```

## App Entry Point

Replace the contents of `GBGGoReferenceApp.swift` with:

```swift theme={null}
import SwiftUI

@main
struct GBGGoReferenceApp: App {
    var body: some Scene {
        WindowGroup {
            RootView()
        }
    }
}
```

This is a standard SwiftUI entry point. All bridge setup happens later in `JourneyView`.

## Navigation Setup

Create `RootView.swift` in the `App` group:

```swift theme={null}
import SwiftUI

struct RootView: View {
    @State private var navigationPath = NavigationPath()

    var body: some View {
        NavigationStack(path: $navigationPath) {
            SetupView(onJourneyStarted: { url in
                navigationPath.append(url)
            })
            .navigationDestination(for: URL.self) { url in
                JourneyView(journeyURL: url)
            }
        }
    }
}
```

`RootView` manages a `NavigationStack` with two destinations. When the companion server returns a journey URL, the closure pushes it onto the navigation path, which triggers the `JourneyView`.

## Configuration Screen

Create `SetupView.swift` in the `Settings` group:

```swift expandable theme={null}
import SwiftUI

struct SetupView: View {
    let onJourneyStarted: (URL) -> Void

    @AppStorage("serverURL") private var serverURL = "http://localhost:3000"
    @AppStorage("resourceId") private var resourceId = ""

    @State private var isLoading = false
    @State private var errorMessage: String?

    var body: some View {
        Form {
            Section {
                Text("Configure the companion server connection and journey template, then tap Start Journey to begin.")
                    .font(.subheadline)
                    .foregroundStyle(.secondary)
            }

            Section("Companion Server") {
                TextField("Server URL", text: $serverURL)
                    .textContentType(.URL)
                    .autocorrectionDisabled()
                    .textInputAutocapitalization(.never)
                    .keyboardType(.URL)

                Text("The companion server creates journey sessions using the GBG Go Core SDK. See server/README.md for setup.")
                    .font(.caption)
                    .foregroundStyle(.secondary)
            }

            Section("Journey Template") {
                TextField("Resource ID (optional)", text: $resourceId)
                    .autocorrectionDisabled()
                    .textInputAutocapitalization(.never)

                Text("Leave blank to use the server's default. Format: <id>@<version> (e.g. abc123@latest).")
                    .font(.caption)
                    .foregroundStyle(.secondary)
            }

            if let errorMessage {
                Section {
                    Label(errorMessage, systemImage: "exclamationmark.triangle")
                        .foregroundStyle(.red)
                        .font(.callout)
                }
            }

            Section {
                Button(action: startJourney) {
                    if isLoading {
                        ProgressView()
                            .frame(maxWidth: .infinity)
                    } else {
                        Text("Start Journey")
                            .frame(maxWidth: .infinity)
                            .fontWeight(.semibold)
                    }
                }
                .disabled(isLoading || serverURL.isEmpty)
            }
        }
        .navigationTitle("GBG Go Reference")
    }

    private func startJourney() {
        isLoading = true
        errorMessage = nil

        Task {
            do {
                let response = try await JourneyService.startJourney(
                    serverURL: serverURL,
                    resourceId: resourceId.isEmpty ? nil : resourceId
                )
                onJourneyStarted(response.journeyUrl)
            } catch {
                errorMessage = error.localizedDescription
            }
            isLoading = false
        }
    }
}
```

The form collects two values:

* **Server URL** — the companion server address. Defaults to `http://localhost:3000`, which works in the Simulator. On a physical device, use your Mac's local IP address (e.g. `http://192.168.1.42:3000`).
* **Resource ID** — identifies which journey template to run. Leave blank to use the server's default.

Both values persist across launches via `@AppStorage`.

## Calling Your Backend

Create `JourneyService.swift` in the `Journey` group:

```swift expandable theme={null}
import Foundation

enum JourneyService {

    struct StartResponse: Decodable {
        let journeyUrl: URL
        let instanceId: String
        let connectToken: String
        let expiresIn: Int
    }

    enum ServiceError: LocalizedError {
        case invalidServerURL(String)
        case serverError(statusCode: Int, message: String)
        case networkError(Error)
        case decodingError(Error)
        case missingJourneyURL

        var errorDescription: String? {
            switch self {
            case .invalidServerURL(let url):
                return "Invalid server URL: \(url). Check the URL format."
            case .serverError(let code, let message):
                return "Server returned \(code): \(message)"
            case .networkError(let error):
                return "Cannot reach the server: \(error.localizedDescription). Is it running?"
            case .decodingError(let error):
                return "Unexpected server response: \(error.localizedDescription)"
            case .missingJourneyURL:
                return "Server did not return a journey URL. Check the server logs."
            }
        }
    }

    static func startJourney(
        serverURL: String,
        resourceId: String?
    ) async throws -> StartResponse {
        guard let baseURL = URL(string: serverURL),
              let endpoint = URL(string: "/api/journey/start", relativeTo: baseURL) else {
            throw ServiceError.invalidServerURL(serverURL)
        }

        var body: [String: Any] = [:]
        if let resourceId {
            body["resourceId"] = resourceId
        }

        var request = URLRequest(url: endpoint)
        request.httpMethod = "POST"
        request.setValue("application/json", forHTTPHeaderField: "Content-Type")
        request.timeoutInterval = 30

        if !body.isEmpty {
            request.httpBody = try JSONSerialization.data(withJSONObject: body)
        }

        let data: Data
        let response: URLResponse
        do {
            (data, response) = try await URLSession.shared.data(for: request)
        } catch {
            throw ServiceError.networkError(error)
        }

        if let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode != 200 {
            let message: String
            if let errorBody = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
               let errorMessage = errorBody["message"] as? String ?? errorBody["error"] as? String {
                message = errorMessage
            } else {
                message = String(data: data, encoding: .utf8) ?? "Unknown error"
            }
            throw ServiceError.serverError(statusCode: httpResponse.statusCode, message: message)
        }

        do {
            let startResponse = try JSONDecoder().decode(StartResponse.self, from: data)
            return startResponse
        } catch {
            throw ServiceError.decodingError(error)
        }
    }
}
```

`JourneyService` makes a single POST request to the companion server's `/api/journey/start` endpoint. The server authenticates with GBG Go, creates a journey session, registers a mobile device, and returns a journey URL. The iOS app never touches API credentials directly.

The `StartResponse` includes:

| Field          | Purpose                                 |
| -------------- | --------------------------------------- |
| `journeyUrl`   | The URL to load in `BridgeWebView`      |
| `instanceId`   | Unique journey session identifier       |
| `connectToken` | Short-lived device authentication token |
| `expiresIn`    | Token validity in seconds (\~120s)      |

<Note>The connect token expires quickly. If the journey URL fails to load, go back and tap "Start Journey" again to get a fresh token.</Note>

## The Bridge Integration

This is the core of the integration. Create `JourneyView.swift` in the `Journey` group:

```swift expandable theme={null}
import GBGBridge
import SwiftUI

struct JourneyView: View {
    let journeyURL: URL

    @StateObject private var host = BridgeHost(hostVersion: "1.0.0")

    @State private var showDocumentCamera = false
    @State private var showSelfieCamera = false

    var body: some View {
        ZStack(alignment: .bottom) {
            BridgeWebView(url: journeyURL, host: host)
                .ignoresSafeArea()

            if let error = host.lastError {
                Text(error)
                    .font(.caption)
                    .foregroundColor(.white)
                    .padding(8)
                    .background(Color.red.cornerRadius(8))
                    .padding()
            }
        }
            .navigationTitle("Journey")
            .navigationBarTitleDisplayMode(.inline)
            .onAppear(perform: configureHandlers)
            .fullScreenCover(isPresented: $showDocumentCamera) {
                documentCameraView
            }
            .fullScreenCover(isPresented: $showSelfieCamera) {
                selfieCameraView
            }
            .onChange(of: host.documentCapture.activeRequest?.correlationId) { _ in
                showDocumentCamera = host.documentCapture.activeRequest != nil
            }
            .onChange(of: host.selfieCapture.activeRequest?.correlationId) { _ in
                showSelfieCamera = host.selfieCapture.activeRequest != nil
            }
    }

    // MARK: - Handler Configuration

    private func configureHandlers() {
        host.documentCapture.handler = { [weak host] _ in
            guard let host else { return .cancelled(reason: "Host deallocated") }
            return await host.documentCapture.awaitCompletion()
        }

        host.selfieCapture.handler = { [weak host] _ in
            guard let host else { return .cancelled(reason: "Host deallocated") }
            return await host.selfieCapture.awaitCompletion()
        }
    }

    // MARK: - Camera Views

    private var documentCameraView: some View {
        StubDocumentCameraView(
            onCaptured: { result in
                host.documentCapture.complete(.document(result))
            },
            onCancelled: {
                host.documentCapture.cancelIfBusy(reason: "User dismissed camera")
            }
        )
    }

    private var selfieCameraView: some View {
        StubSelfieCameraView(
            onCaptured: { result in
                host.selfieCapture.complete(.selfie(result))
            },
            onCancelled: {
                host.selfieCapture.cancelIfBusy(reason: "User dismissed camera")
            }
        )
    }
}
```

There is a lot happening here. Let's break it down in the sections below.

### How the Bridge Works

The `BridgeHost` is the native side of a bidirectional communication channel between your Swift code and the JavaScript running inside the WebView. When the web journey needs a native capability — like capturing a document photo — it sends a request message through the bridge. Your Swift code handles the request and sends a response back.

`BridgeWebView` wraps `WKWebView` with the bridge pre-configured. It injects a bootstrap script at document start, registers a JavaScript message handler, and attaches the host. You provide the URL and the host — everything else is automatic.

### The Typed Slot Pattern

`BridgeHost` exposes two built-in properties called **typed slots**:

* `host.documentCapture` — handles document photo capture requests
* `host.selfieCapture` — handles selfie/liveness capture requests

Each slot is a `CaptureCapability` object with a `handler` property. Setting a handler does two things:

1. It defines what happens when the journey requests that capture type.
2. It automatically marks the capability as "supported" in `capability.query` responses, so the journey knows the app can handle it.

The handler follows a suspension pattern:

1. The web journey sends a capture request through the bridge.
2. The slot's handler closure is called.
3. The handler calls `awaitCompletion()`, which **suspends** — the closure pauses and waits.
4. The `activeRequest` property becomes non-nil, which triggers `onChange` to set `showDocumentCamera = true`.
5. SwiftUI presents the camera view via `fullScreenCover`.
6. The user captures a photo (or cancels).
7. The camera view calls `slot.complete(.document(result))` or `slot.cancelIfBusy()`.
8. `awaitCompletion()` **resumes**, returning the result.
9. The handler returns the result, which the bridge sends back to the journey.
10. `activeRequest` becomes nil, dismissing the camera.

This pattern cleanly bridges SwiftUI's declarative presentation model with the bridge's async request/response protocol.

### Why `[weak host]`?

The handler closures capture `[weak host]` to prevent a retain cycle. Without it: the closure retains the host, the host retains the WebView's configuration, and the configuration retains the closure — a cycle that leaks memory.

### Why `onAppear` and not the initializer?

Handlers are assigned in `onAppear`, not in `init`. The `@StateObject` host persists across SwiftUI re-renders, but `onAppear` is the idiomatic place for setup that should happen once when the view appears.

### Why `onChange` with `correlationId`?

SwiftUI's `onChange` modifier needs an `Equatable` value to observe. `BridgeMessage` itself is not `Equatable`, but `correlationId` (a `String`) is. When a new capture request arrives, the correlation ID changes, triggering the closure. When the request completes, `activeRequest` becomes nil, and the closure sets the presentation boolean to `false`.

<Note>iOS 16 uses `onChange(of:) { newValue in ... }`. iOS 17+ uses `onChange(of:) { ... }` without a parameter. The code above targets iOS 16.</Note>

## Handling Capture Requests

The `StubDocumentCameraView` and `StubSelfieCameraView` are included in the GBGBridge SDK. They provide a working capture UI out of the box:

* **On a real device:** They present `UIImagePickerController` with the rear camera (document) or front camera (selfie).
* **On the Simulator:** They display programmatically drawn placeholder images — a passport-style document card or a face silhouette — with a shutter button. This is expected behaviour, not a bug. The Simulator has no camera hardware.

Both views accept two callbacks:

| Callback      | Called when                                                                             |
| ------------- | --------------------------------------------------------------------------------------- |
| `onCaptured`  | The user captures a photo. Receives a `DocumentCaptureResult` or `SelfieCaptureResult`. |
| `onCancelled` | The user dismisses the camera without capturing.                                        |

In the `onCaptured` callback, call `slot.complete()` with the result. In `onCancelled`, call `slot.cancelIfBusy()`. The bridge sends the appropriate response back to the journey.

<Tip>**Swap point:** For production, replace `StubDocumentCameraView` with the SmartCapture document SDK view, and `StubSelfieCameraView` with the face liveness SDK view. The completion callbacks remain the same — only the view implementation changes. See [Tutorial Part 2: Integrate Smart Capture SDKs](/docs/go-v2/developer-integration/sdks/ios/tutorial-smart-capture) for a step-by-step guide.</Tip>

## Run the App

1. Make sure the companion server is running. Run `node index.mjs` in the `server/` directory.
2. Select an iPhone Simulator in Xcode (e.g. iPhone 15 Pro).
3. Press **Cmd+R** to build and run.
4. On the Setup screen, leave the server URL as `http://localhost:3000`.
5. Tap **Start Journey**.
6. The journey loads in the WebView. When it requests a document capture, the stub camera appears as a full-screen overlay.
7. Tap the shutter button to capture. The result is sent back to the journey.

## Common Pitfalls

A few rough edges trip up most teams running the tutorial app for the first time — Simulator-vs-device URLs, ATS rejecting `http://` in production, and SwiftUI state-ownership patterns. The notes below explain how to avoid them.

### Simulator vs device

`http://localhost:3000` only works in the Simulator. On a physical device, the companion server is not on `localhost` — use your Mac's IP address instead (e.g. `http://192.168.1.42:3000`). Find your IP with:

```bash theme={null}
ipconfig getifaddr en0
```

### App Transport Security

If you forget `NSAllowsLocalNetworking` in `Info.plist`, HTTP requests to the companion server fail silently. There is no error in the console — the request simply never reaches the server. Always check this first if the Setup screen shows a network error.

### Camera on Simulator

The Simulator has no camera. The stub views automatically display programmatically drawn placeholder images with a shutter button. This is expected behaviour.

### Connect token expiry

The connect token from the server is short-lived (\~120 seconds). If the journey URL does not load in time, go back and tap "Start Journey" again for a fresh token.

### Handler assignment timing

Assign handlers in `onAppear`, not in the initializer. The `@StateObject` host persists across re-renders, but `onAppear` is the SwiftUI-idiomatic place for one-time setup.

### Memory leak prevention

Use `[weak host]` in handler closures. Without it, the closure captures a strong reference to the host, which captures the WebView, creating a retain cycle that leaks memory.

## What's Next

* **[Tutorial Part 2: Integrate Smart Capture SDKs](/docs/go-v2/developer-integration/sdks/ios/tutorial-smart-capture)** — Replace stub camera views with production document scanning and face capture with liveness detection.
* **[API Reference](/docs/go-v2/developer-integration/sdks/ios/api-reference)** — Full documentation for `BridgeHost`, `BridgeWebView`, `CaptureCapability`, and all message types.
* **[Concepts](/docs/go-v2/developer-integration/sdks/ios/concepts)** — Deeper dive into the bridge architecture, message protocol, and capability system.
* **NFC Reading** — Add passport chip reading as a custom capability.
* **UIKit Integration** — Use `BridgeWebViewConfigurator` directly with `UIViewController` instead of SwiftUI.
