Skip to main content

Captur iOS SDK: Implementation Guide

Alpha. The public API may change in a breaking way before general availability. Ensure you pin an exact version and check the changelog when you upgrade.

This guide covers a full integration: install the package, set up your API key, prepare a session, show the camera, handle events, and tear down.


Requirements

RequirementMinimum
iOS deployment targetiOS 15.0
Xcode16 or newer
Swift5 language mode (5.5+ toolchain)
DistributionSwift Package Manager (compiled XCFramework)

The SDK is a binary XCFramework with ABI stability enabled. Nothing is built from source on your side.


1. Install the package

Xcode: File → Add Package Dependencies… and enter:

https://gitlab.development.captur.ai/captur/mobile-sdks/captur-mobile-ios-sdk.git

Or in Package.swift:

dependencies: [
.package(url: "https://gitlab.development.captur.ai/captur/mobile-sdks/captur-mobile-ios-sdk.git", exact: "<version>")
],
targets: [
.target(
name: "YourApp",
dependencies: [
.product(name: "CapturSDK", package: "captur-mobile-ios-sdk")
]
)
]

2. Add camera permission

Add a camera usage description to your app target's Info.plist:

<key>NSCameraUsageDescription</key>
<string>This app uses the camera to capture and verify images.</string>

The SDK does not request camera permission for you. Request it in your app before preparing the camera. If permission hasn't been granted, prepareCamera throws an error (see Step 6).


3. Get an API key

Captur will issue an API key for your app. Pass it to the Captur initialiser (see step 4).


4. Initialise the SDK

Create one Captur instance in a stable owner above the view hierarchy and keep it for the lifetime of the app. In SwiftUI, the App type is a natural place to own it:

import CapturSDK
import SwiftUI

@main
struct ExampleApp: App {
private let captur = Captur(apiKey: "<YOUR_CAPTUR_API_KEY>")

var body: some Scene {
WindowGroup {
ContentView(captur: captur)
}
}
}

You can instead retain Captur in an app-owned view model, dependency container, or service layer. Pass that same instance down the hierarchy.


5. Prepare a capture session

prepareSession resolves the policy and model for your policyType at the given location, downloads the model if needed (an already-downloaded model is reused, including recompiling it after an OS update), and returns a CapturSession. It makes a network call, so call it as early as you know a capture is coming, so the camera opens without a cold start.

let session = try await captur.prepareSession(
policyType: "<YOUR_POLICY_TYPE>",
location: CapturLocation(latitude: 51.5074, longitude: -0.1278),
reference: "order-\(UUID().uuidString)"
)
ParameterRequiredDescription
policyTypeYesThe Captur policy for this capture.
locationYesCoordinate used to resolve the policy and model. A coarse, early location is fine here.
referenceYesYour own identifier for this capture, used for billing and correlation.

6. Prepare and show the camera

Ensure camera permission is granted, then call session.prepareCamera from the main actor with the device's current location.

guard await AVCaptureDevice.requestAccess(for: .video) else {
// Tell the user camera access is required.
return
}

let cameraController = try await session.prepareCamera(
location: CapturLocation(latitude: 51.5074, longitude: -0.1278),
onCapturEvent: { event in
handleCapturEvent(event)
}
)

prepareCamera loads the session's inference and policy models into Core ML and returns a CapturCameraController. It throws if either model fails to load (CapturCameraError.modelLoadFailed) or camera permission is missing. If the policy contract doesn't match, the SDK updates to the latest policy. A returned controller means both models are loaded and ready.

The camera hardware itself isn't touched yet: AVFoundation capture starts when you render the controller in a CapturCameraScreen.

Camera configuration

Pass a CapturCameraConfiguration to choose the initial camera position and lens, and to control consecutive-pass automatic capture:

let configuration = CapturCameraConfiguration(
position: .back,
lens: .wide,
disableAuto: false
)

let cameraController = try await session.prepareCamera(
location: CapturLocation(latitude: 51.5074, longitude: -0.1278),
configuration: configuration,
onCapturEvent: { event in
handleCapturEvent(event)
}
)
SettingOptionsDefaultDescription
position.back, .front.backCamera position to use when the preview starts.
lens.wide, .ultraWide.wideLens to use when the preview starts. Availability depends on the selected position and device.
disableAutotrue, falsefalseWhen true, consecutive PASS predictions do not automatically finish the capture. Manual capture and the SDK-managed timeout still apply.

You can omit configuration to use CapturCameraConfiguration.default. If the requested position and lens combination is unavailable, camera activation delivers a .failed event with CapturCameraError.lensUnavailable.

Torch and zoom are not initial configuration settings. Change them after the preview starts using the controller methods described in Step 7.

SwiftUI

Embed the preview and put your own controls on top:

import CapturSDK
import SwiftUI

struct CaptureScreen: View {
let cameraController: CapturCameraController

var body: some View {
ZStack {
CapturCameraScreen(capturCameraController: cameraController)
.ignoresSafeArea()

// Your capture controls overlay here.
}
}
}

7. Capture, inference & camera controls

Once CapturCameraScreen is on screen and frames are flowing, real-time predictions arrive through the onCapturEvent callback you passed to prepareCamera. Call captureImage() to take the final shot; the result arrives through the same callback as .finalDecision. Calling captureImage() before the preview is running throws CapturCameraError.cameraPreviewNotRunning.

let cameraController = try await session.prepareCamera(
location: CapturLocation(latitude: 51.5074, longitude: -0.1278),
onCapturEvent: { event in
switch event {
case .prediction(let prediction):
// Live, per-frame result. `decision` carries `value`, `title` and `reasonCode`.
print(prediction.decision?.value ?? "pending")
case .finalDecision(let finalDecision):
// The captured outcome: `imageData`, plus `decision`, `modelVersionUID`, `prediction` and `trigger`.
print(finalDecision.imageData.count)
case .failed(let error):
print(error.localizedDescription)
@unknown default:
break
}
}
)

// Trigger a manual final decision.
try await cameraController.captureImage()

// Adjust the camera
try await cameraController.togglePosition() // front / back
try await cameraController.toggleLens() // wide / ultra-wide, etc.
try await cameraController.toggleTorch() // flash on / off
try await cameraController.toggleZoom() // cycle zoom levels

Event payloads

.prediction delivers a CapturPrediction; .finalDecision delivers a CapturFinalDecision:

public struct CapturPrediction {
public let decision: CapturPredictionDecision?
}

public struct CapturPredictionDecision {
public let value: String // the policy decision value
public let title: String? // display title, when the policy provides one
public let reasonCode: String? // why the policy decided this, when provided
}

public struct CapturFinalDecision {
public let decision: CapturPredictionDecision?
public let modelVersionUID: String // the on-device model version that produced this decision
public let imageData: Data
public let trigger: CapturFinalDecisionTrigger
}

8. Retake or dismiss the camera

Three calls matter once a capture attempt ends:

  • try cameraController.retake() starts a fresh attempt after a final decision, clearing it and restarting the auto-capture timer. It only needs the preview to be running; no need to remove or pause the camera first.
  • Removing CapturCameraScreen from the view hierarchy pauses the camera and inference but keeps the controller alive, so you can show a review UI and remount the same controller. Remounting before a final decision restarts the capture count and timeout; remounting after one does not start a new attempt, that takes retake().
  • await cameraController.close() is the terminal, idempotent shutdown: it releases the camera backend, stops callbacks, and marks the session closed, returning once shutdown completes. It does not dismiss your UI.

Only one camera preparation runs at a time per Captur instance; a new preparation closes the previous controller before returning its replacement. If a preparation is in flight and no longer needed, cancel its task. If it has already returned but you'll never present the screen, close() the controller before discarding it.


End-to-end example

import AVFoundation
import CapturSDK
import SwiftUI

@main
struct ExampleApp: App {
private let captur = Captur(apiKey: "<YOUR_CAPTUR_API_KEY>")

var body: some Scene {
WindowGroup {
ContentView(captur: captur)
}
}
}

struct ContentView: View {
let captur: Captur
@State private var session: CapturSession?
@State private var cameraController: CapturCameraController?
@State private var errorMessage: String?

var body: some View {
VStack {
Button("Prepare Session") {
Task { await prepareSession() }
}

Button("Prepare Camera") {
Task { await prepareCamera() }
}
.disabled(session == nil)
}
.fullScreenCover(isPresented: .constant(cameraController != nil)) {
if let cameraController {
ZStack(alignment: .topTrailing) {
CapturCameraScreen(capturCameraController: cameraController)
.ignoresSafeArea()

Button("Close") {
Task { await closeCapture() }
}
.padding()
}
}
}
}

@MainActor
private func prepareSession() async {
do {
let location = CapturLocation(latitude: 51.5074, longitude: -0.1278)

// Call prepareSession earlier in the flow so the model is prepared before capture.
let session = try await captur.prepareSession(
policyType: "<YOUR_POLICY_TYPE>",
location: location,
reference: "example-\(UUID().uuidString)"
)

self.session = session
} catch {
errorMessage = error.localizedDescription
}
}

@MainActor
private func prepareCamera() async {
guard let session else { return }

guard await AVCaptureDevice.requestAccess(for: .video) else {
errorMessage = "Camera permission is required."
return
}

do {
let controller = try await session.prepareCamera(
location: CapturLocation(latitude: 51.5074, longitude: -0.1278),
onCapturEvent: { event in
if case .finalDecision(let finalDecision) = event {
// Captured: `finalDecision.imageData` is the image, `finalDecision.decision`
// the outcome. Display or upload them here. See §7 for all event cases.
}
}
)
cameraController = controller
} catch {
errorMessage = error.localizedDescription
}
}

@MainActor
private func closeCapture() async {
await cameraController?.close()
cameraController = nil
session = nil
}
}

SDK version

For support requests and bug reports, read the bundled SDK version at runtime:

CapturSDKMetadata.version // the bundled marketing version string
CapturSDKMetadata.fullVersion // version with any prefix/suffix