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. You can clone it and run it immediately, or follow this tutorial to build it step by step.
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.
<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.
import SwiftUIstruct 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.
import SwiftUIstruct 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.
import Foundationenum 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)
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 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 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.
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.
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.
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.
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.
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.
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:
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.
The Simulator has no camera. The stub views automatically display programmatically drawn placeholder images with a shutter button. This is expected behaviour.
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.
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.
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.