Skip to main content

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.

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. You can clone it and run it immediately, or follow this tutorial to build it step by step.

Prerequisites

RequirementVersionNotes
Xcode15.0+Swift 5.9 toolchain
iOS target16.0+NavigationStack, onChange(of:)
Node.js18+For the companion server
GBG Go credentialsClient ID, secret, username, password. Contact your GBG account representative.
You also need the GBGBridge Swift package. See the 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

git clone https://github.com/gbgplc/gbg-go-ios-reference.git
cd gbg-go-ios-reference/server

2. Install dependencies

npm install

3. Configure credentials

Copy the example environment file and fill in your GBG Go credentials:
cp .env.example .env
Open .env in a text editor:
GO_CLIENT_ID=your-client-id
GO_CLIENT_SECRET=your-client-secret
GO_USERNAME=api-user@example.com
GO_PASSWORD=your-password

# 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

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:
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:
<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:
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. Create RootView.swift in the App group:
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:
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:
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:
FieldPurpose
journeyUrlThe URL to load in BridgeWebView
instanceIdUnique journey session identifier
connectTokenShort-lived device authentication token
expiresInToken validity in seconds (~120s)
The connect token expires quickly. If the journey URL fails to load, go back and tap “Start Journey” again to get a fresh token.

The Bridge Integration

This is the core of the integration. Create JourneyView.swift in the Journey group:
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.
iOS 16 uses onChange(of:) { newValue in ... }. iOS 17+ uses onChange(of:) { ... } without a parameter. The code above targets iOS 16.

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:
CallbackCalled when
onCapturedThe user captures a photo. Receives a DocumentCaptureResult or SelfieCaptureResult.
onCancelledThe 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.
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 for a step-by-step guide.

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:
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 — Replace stub camera views with production document scanning and face capture with liveness detection.
  • API Reference — Full documentation for BridgeHost, BridgeWebView, CaptureCapability, and all message types.
  • 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.