DLens SDK Documentation
DLens is a production-grade iOS SDK for scanning driver licences, passports, and national ID cards — entirely on-device, with no data stored or transmitted.
Overview
DLens uses the iOS Vision framework and AVFoundation to detect and parse identity documents directly on device. No network connection is required. No document data leaves the device at any point.
Every frame is first passed through document detection (Vision document segmentation with perspective correction), so the decoders below work on a flattened, tightly cropped, upright image of the card rather than the whole frame. MRZ reads are then voted character-by-character across frames and validated by the ICAO checksums before a result is returned.
Beneath that, the SDK runs four decoding pipelines:
- AVCaptureMetadataOutput — hardware ISP barcode decoder for PDF417 (primary, fastest)
- zxing-cpp — software PDF417 decoder, notably stronger on damaged, dense, or low-contrast barcodes
- Vision VNDetectBarcodesRequest — preprocessing fallback ladder (gamma, contrast normalisation, sharpening) for difficult lighting
- Vision VNRecognizeTextRequest — ICAO MRZ OCR for passports and national IDs
Requirements
- iOS 17.0 or later
- Xcode 15.0 or later
- Swift 5.9 or later
- A valid DLens licence key (request one here)
NSCameraUsageDescriptionkey in yourInfo.plist
The framework is fully self-contained — it links only Apple system frameworks plus a statically bundled copy of zxing-cpp (Apache License 2.0). There is nothing extra to embed, and no other third-party dependencies.
Installation
XCFramework (Recommended)
Download the framework
Request your trial key at /dlens/#contact — we'll send you DLensSDK.xcframework along with your key.
Add to your project
Drag DLensSDK.xcframework into your Xcode project navigator. In the dialog, check Copy items if needed.
Embed & Sign
In your target's General settings, open Frameworks, Libraries, and Embedded Content, then set DLensSDK.xcframework to Embed & Sign.
Add camera permission
Add NSCameraUsageDescription to your Info.plist with a description explaining why your app needs camera access.
Tip: The framework must be Embed & Sign, not just Do Not Embed. Without embedding, the framework won't be copied into your app bundle and the install will fail with a missing signature error.
Quick Start
Three steps to your first scan:
import SwiftUI import DLensSDK // Step 1: Initialise at app launch @main struct MyApp: App { init() { try? DLensKit.initialize( apiKey: "YOUR_API_KEY", secret: "YOUR_SECRET" ) } var body: some Scene { WindowGroup { ContentView() } } } // Step 2: Present the scanner struct ContentView: View { @State private var isScanning = false var body: some View { Button("Scan Licence") { isScanning = true } .fullScreenCover(isPresented: $isScanning) { // Step 3: Handle the result DLensScanner { result in print(result.fullName) print(result.licenseNumber) isScanning = false } } } }
Initialisation
Call DLensKit.initialize() once at app launch — typically in your App.init() or AppDelegate.application(_:didFinishLaunchingWithOptions:). Calling it more than once is safe and is a no-op after the first successful call.
import DLensSDK do { try DLensKit.initialize( apiKey: "YOUR_API_KEY", secret: "YOUR_SECRET", configuration: { var c = DLensConfiguration() c.showTorchButton = true c.showZoomControls = true c.scanMode = .all return c }() ) } catch { // DLensError.invalidAPIKey — malformed, or locked to another bundle ID // DLensError.licenseKeyMismatch — key and secret do not match // DLensError.licenseExpired — the licence has passed its expiry date print("DLens init failed: \(error.localizedDescription)") }
SwiftUI Integration
Use DLensScanner inside a .fullScreenCover or .sheet. It fills the presented area with the camera UI automatically.
DLensScanner( onResult: { result in // Called on main thread when scan succeeds self.scanResult = result self.isScanning = false }, onError: { error in // Camera permission denied, key expired, etc. print(error.localizedDescription) self.isScanning = false }, onCancel: { // User tapped the cancel/back button self.isScanning = false } )
UIKit Integration
let scanner = DLensScannerViewController( onResult: { [weak self] result in self?.handleResult(result) }, onError: { error in print(error) } ) present(scanner, animated: true)
Objective-C Integration
DLens ships a full Objective-C surface: DLNScanner, DLNScanResult, DLNScanMode, and DLNConfiguration. Swift apps should keep using DLensKit and DLensScanner directly.
// At launch NSError *error = nil; [DLNScanner initializeWithApiKey:apiKey secret:secret error:&error]; // Present the scanner UIViewController *scanner = [DLNScanner makeScannerViewControllerWithMode:DLNScanModeAll onResult:^(DLNScanResult *r) { NSLog(@"%@", r.fullName); } onError:^(NSError *e) { } onCancel:^{ }]; [self presentViewController:scanner animated:YES completion:nil];
DLNConfiguration mirrors every property of the Swift DLensConfiguration:
DLNConfiguration *config = [DLNConfiguration new]; config.scanMode = DLNScanModeAll; config.maxZoom = 3.0; config.showTipCards = NO; [DLNScanner initializeWithApiKey:apiKey secret:secret configuration:config error:&error];
ℹ️ DLNScanner.sdkVersion reports the SDK version — NSObject already owns the +version selector, so it is named differently in Objective-C than in Swift.
Headless Scanning
Get parsed results without DLens presenting any camera UI — when you run your own capture session, already hold a photo, or have decoded the barcode yourself and only want the parsing.
// From an image you already hold — no DLens UI is presented let result = try await DLensKit.scan(image: photo, mode: .all) print(result.fullName) // Straight from your own AVCaptureVideoDataOutput let result = try await DLensKit.scan(pixelBuffer: buffer) // From a payload you decoded yourself (AAMVA string or MRZ lines) let result = try DLensKit.parse(payload: rawString)
| Method | Returns | Notes |
|---|---|---|
| scan(image:mode:) | async throws | UIImage — normalised upright and size-capped before analysis |
| scan(cgImage:mode:) | async throws | CGImage variant |
| scan(pixelBuffer:mode:) | async throws | CVPixelBuffer — for your own capture pipeline |
| parse(payload:) | throws | No image processing; returns synchronously |
[DLNScanner scanImage:photo mode:DLNScanModeAll completion:^(DLNScanResult *r, NSError *e) { if (r) NSLog(@"%@", r.fullName); }];
ℹ️ The headless path shares one implementation with the live scanner, so a document analysed either way produces an identical DLensScanResult. Both throw DLensError.scanningFailed when no document is found — they never return a blank result.
Configuration
| Property | Type | Default | Description |
|---|---|---|---|
| scanMode | DLensScanMode | .barcodeOnly | Mode used when a scanner is created without an explicit mode |
| timeoutSeconds | TimeInterval | 60 | Seconds before the scan fails with DLensError.timeout. 0 disables the timeout |
| accentColor | Color | .blue | Tint for the progress and processing UI |
| orientation | DLensOrientationMode | .automatic | How the preview and analysed frames are oriented — see Orientation |
| autoZoom | Bool | true | Automatic zoom sweep while scanning. When off, the scanner holds the last successful zoom |
| minZoom | CGFloat | 1.0 | Lower bound for the sweep and the manual buttons |
| maxZoom | CGFloat | 2.5 | Upper bound, capped at 8.0 and by the device maximum |
| rescanZoomOffset | CGFloat | 0.5 | How far below the last successful zoom the scanner opens, so the sweep glides up through the known-good level |
UI visibility
Every built-in control can be hidden independently — useful when you want the camera and your own chrome.
| Property | Type | Default | Hides |
|---|---|---|---|
| showTorchButton | Bool | true | Torch toggle (top-right) |
| showZoomControls | Bool | true | Manual −/+ zoom pill |
| showCancelButton | Bool | true | Cancel button |
| showTipCards | Bool | true | The three hint cards along the bottom |
| showInstructionCard | Bool | true | Central live-feedback card |
| showModeBadge | Bool | true | "Auto-Detect" badge (top-left) |
⚠️ With showCancelButton = false and timeoutSeconds = 0 the scanner has no built-in exit. Dismiss it yourself, or keep a timeout.
Orientation
The scanner follows the host app's interface orientation by default, can track the physical device instead, or can be pinned. Both the preview and the frames handed to OCR rotate, so MRZ reading works sideways.
| Mode | Behaviour |
|---|---|
| .automatic | Matches the host interface orientation. A portrait-only app behaves exactly like .fixed(.portrait). Default. |
| .followDevice | Keeps the horizon level by following the physical device, even when the interface is locked — kiosks, portrait-locked apps |
| .fixed(_) | .portrait, .portraitUpsideDown, .landscapeLeft, .landscapeRight — names mirror UIInterfaceOrientation |
// At initialisation config.orientation = .followDevice // While a scanner is on screen — applies immediately DLensKit.setOrientation(.fixed(.landscapeLeft)) let current = DLensKit.currentOrientation // DLensOrientation?, nil when no scanner is up
Objective-C: config.orientation = DLNOrientationModeFollowDevice;, [DLNScanner setOrientation:DLNOrientationModeLandscapeLeft];, DLNScanner.currentOrientation.
ℹ️ .automatic can only rotate into orientations your app declares in UISupportedInterfaceOrientations. If your app is portrait-only and you want a level preview when users turn the phone, use .followDevice.
Changing configuration at runtime
Pass a configuration to initialize for your defaults, then call DLensKit.configure(_:) whenever they change — useful for a settings screen. It applies to the next scanner you present; a scanner already on screen keeps the configuration it opened with. DLensKit.currentConfiguration reads back what is in effect.
var c = DLensKit.currentConfiguration c.showTipCards = false c.maxZoom = 4.0 try DLensKit.configure(c) // affects the next scanner presented
Objective-C: [DLNScanner configure:config error:&error].
ℹ️ Both sample apps ship a Settings screen wired to every one of these options, so you can change them on-device and see the effect immediately.
DLensScanResult
All fields are optional String? or Date? unless marked otherwise.
| Field | Type | Description |
|---|---|---|
| firstName | String | Given name — always present |
| lastName | String | Surname — always present |
| middleName | String? | Middle name (AAMVA and MRZ) |
| suffix | String? | Name suffix — JR, SR, III… (AAMVA) |
| fullName | String | Computed: firstName + middleName + lastName |
| dateOfBirth | Date? | Parsed date of birth |
| formattedDateOfBirth | String? | DOB formatted as "MMM d, yyyy" |
| licenseNumber | String | Document/licence number |
| licenseClass | String? | Vehicle class codes (AAMVA) |
| restrictions | String? | Driving restrictions (AAMVA) |
| endorsements | String? | Driving endorsements (AAMVA) |
| expiryDate | Date? | Document expiry date |
| formattedExpiryDate | String? | Expiry formatted as "MMM d, yyyy" |
| issueDate | Date? | Document issue date |
| streetAddress | String? | Street address (AAMVA) |
| city | String? | City (AAMVA) |
| state | String? | State or province code, e.g. "FL", "ON" (AAMVA) |
| postalCode | String? | 5-digit US ZIP, or Canadian postal code as "A1A 1A1" (AAMVA) |
| country | String? | "USA" or "Canada" for licences; the nationality code for MRZ documents |
| gender | String? | "Male", "Female", or "Not specified" |
| eyeColor | String? | Eye colour code (AAMVA) |
| height | String? | Height as 5'8" or 170 cm (AAMVA) |
| rawPayload | String | Raw barcode/MRZ string |
Errors
| Error Case | Description |
|---|---|
| DLensError.notInitialized | DLensKit.initialize() was not called before scanning |
| DLensError.invalidAPIKey | The API key is missing, malformed, or was issued for a different bundle ID |
| DLensError.invalidSecret | The secret is missing or empty |
| DLensError.licenseKeyMismatch | The API key and secret do not match |
| DLensError.licenseExpired | The licence encoded in the key has passed its expiry date |
| DLensError.cameraPermissionDenied | The user denied camera access. Direct them to Settings. |
| DLensError.timeout | The scanner reached timeoutSeconds without a result |
| DLensError.scanningFailed(String) | No readable document was found — most often from the headless scan and parse APIs |
Licence Keys
DLens uses HMAC-SHA256 signed JSON tokens. Each key encodes your bundle ID, expiry date, and a signature that can be verified entirely on-device without a network call.
Keep your secret private. Never commit your API key or secret to a public repository. For production apps, load credentials from your app's secure configuration — not hardcoded in source.
For development, you can generate test credentials that work without a paid licence:
// Generate dev credentials (for testing only) let (apiKey, secret) = DLensKit.makeTestCredentials() try DLensKit.initialize(apiKey: apiKey, secret: secret)
Driver Licences
DLens parses AAMVA-standard PDF417 barcodes from all 50 US states, Washington DC, and all Canadian provinces and territories. Both legacy AAMVA 2000 and modern ANSI D20 formats are supported with automatic version detection.
Scan the back of the licence where the PDF417 barcode is located. The scanner automatically activates hardware barcode detection via AVCaptureMetadataOutput for maximum speed.
Passports
DLens reads the Machine-Readable Zone (MRZ) on the photo data page of ICAO TD3-format passports from 190+ countries. Full ICAO 9303 check digit validation is performed on all fields before returning a result.
Scan the bottom two lines of the photo page (the MRZ zone). The scanner uses Vision framework OCR with a two-strategy parser to handle real-world imperfections.
National ID Cards
TD1 (three-line, 30 chars) and TD2 (two-line, 36 chars) MRZ formats are supported, covering EU national ID cards, Middle Eastern national IDs, biometric residence permits, and many other document types.
Developer FAQ
The scanner is not finding my barcode. What should I try?
Ensure good lighting, and keep the barcode flat and unfolded. The scanner opens just below the zoom level that last succeeded and then glides smoothly between minZoom and maxZoom until it decodes — widen that range if your documents are read at an unusual distance. Worn or damaged barcodes fall through to software decoding (zxing-cpp, then a multi-pass Vision ladder), which takes slightly longer.
Can I customise the scanner UI appearance?
Yes. DLensConfiguration.accentColor sets the tint, and every built-in control can be hidden independently — showTorchButton, showZoomControls, showCancelButton, showTipCards, showInstructionCard, and showModeBadge. If you want a completely custom camera experience, run your own capture session and use headless scanning instead.
How do I handle camera permission denial?
Catch DLensError.cameraPermissionDenied in your onError handler and redirect the user to UIApplication.openSettingsURLString.
Can I use DLens from Objective-C?
Yes — see Objective-C Integration. The bridge exposes DLNScanner, DLNScanResult, DLNScanMode, and DLNConfiguration, including the headless entry points.
Can I get results without showing the DLens camera?
Yes — headless scanning parses an image or a payload you already hold and presents no UI. Both paths share one implementation with the live scanner, so results are identical.
Does the SDK work offline?
Yes, completely. All scanning and parsing happen on-device, and the SDK makes no network requests at all — licence keys are HMAC-SHA256 tokens verified locally, with no activation call and nothing to re-validate. No image or scan result ever leaves the device.