Skip to main content

SmartComply iOS SDK

The SmartComply iOS SDK is a native Swift library that delivers a fully self-contained identity verification flow for iOS apps. Drop in one SwiftUI view and the SDK handles session management, country and ID-type selection, document capture, identity verification, and liveness detection automatically.

Features

  • Drop-in SwiftUI view — SmartComplyFlowView manages the entire verification flow with no UI code required
  • Two verification modes — document photo capture or ID number data entry, configured from your Dashboard
  • Guide-box document capture — crops exactly what falls inside the ID card frame so the image sent to the backend is always clean
  • ID photo checked on the phone: for passports and the older Kenyan national ID, with every other document checked by the backend as before, the code at the bottom of the document is read on the device before upload, so a cut-off or blurred photo is retaken before it costs a check. See Checking the photo on the phone
  • ID checked before the selfie: the backend reads the ID and checks it against the government registry first, so a cropped or misread ID is retaken on the spot. See Document check
  • Liveness detection — a passive face scan (blink plus natural head movement) runs automatically after identity verification, with no step-by-step prompts
  • Dynamic ID types — channels and fields are fetched live from your Dashboard configuration
  • Multi-country support — renders a country picker automatically when more than one country is configured
  • Dark and light mode — theme adapts to the system colour scheme; override with preferredColorScheme
  • Automatic upload retry — transient upload failures are retried internally
  • Your brand, not ours: name, description, colour and logo come from the Dashboard, and the font is passed by your app. See Branding
  • Any backend host: production, staging, or your own, without a rebuild
  • Resubmit: a failed verification can be reopened from the Dashboard, and the customer redoes only the step that failed. See Resuming a failed verification

Requirements

  • iOS 16.0 or later
  • Xcode 16 or later. The shipped XCFramework’s module interface names SwiftUICore in public signatures, a module that first ships in the iOS 18 SDK, so Xcode 15 cannot compile it
  • A physical device that supports ARKit face tracking. Liveness gates on ARFaceTrackingConfiguration.isSupported, which covers iPhone X and later as well as A12 and later devices without a TrueDepth camera. Liveness cannot run in the Simulator

Installation

The SDK is distributed via Swift Package Manager as a precompiled XCFramework. The package you add contains the Swift Package manifest; Xcode downloads the binary automatically and embeds it in your app.
  1. Open your project in Xcode
  2. Go to File → Add Package Dependencies
  3. Enter the repository URL: https://github.com/386konsult/smartcomply-ios-sdk
  4. Select Up to Next Major Version and enter 1.4.0
  5. Click Add Package and select the SmartComplySDK library

Package.swift

1.4.0 checks the ID photo on the phone. For a Kenyan national ID (back) and a passport, the review screen reads the code at the bottom of the document with Apple’s Vision framework before upload and asks for a retake if it is cut off or unclear. The SDK still has no third-party dependencies. See Checking the photo on the phone.
1.3.0 adds Resubmit. Pass the resume token from a Dashboard Resubmit to SmartComplyFlowView and the customer redoes only the step that failed. It changes nothing for a flow presented without one. A dead token ends in onDismiss with errorCode "RESUME_LINK_INVALID". See Resuming a failed verification.
1.2.0 checks the ID before the selfie, and holds the selfie until the whole face is in the circle. The ID now goes up on its own and the backend reads it and checks it against the government registry before the liveness camera opens, so a cropped or misread ID is turned back with a retake screen instead of failing after the whole flow. The selfie and the scan then wait until the user’s whole face is inside the circle, with no timeout. onDismiss has two new causes. Read Document check and Liveness before you upgrade.
1.1.0 reworks the liveness screen and changes when the selfie is taken. Verification now happens in place, on a frozen blurred preview, instead of cutting to a separate progress screen. The guide is a circle rather than an oval. And the autoshot waits for a properly framed face rather than firing on a timer, which adds up to 2.5 seconds for a user slow to get into frame and nothing for one already there. 1.2.0 removes that 2.5 second limit; see Liveness.
Take 1.0.4, not 1.0.3. Up to 1.0.3 the front check required a face, and rejected genuine IDs whose portrait is engraved rather than printed, Kenya’s Maisha Namba card among them, with “No face detected”. 1.0.4 judges the front on text instead. See Document images.
1.0.3 changes two behaviours. FlowResult.status now holds the real verdict, and the success screen no longer means a pass. Read FlowResult before upgrading from 1.0.2 or below. It also adds Branding and a custom backend host.iOS and Android are versioned separately. Up to 1.1.x the numbers never lined up; 1.2.0, 1.3.0 and 1.4.0 ship the same changes on both under the same number. Android 1.2.1 is an Android-only fix with no iOS counterpart.
The package URL is smartcomply-ios-sdk, which is not the same as the SDK’s source repository. It ships both device and simulator slices, so the same dependency builds for a physical device and the Simulator without extra configuration.

Platform Setup

Add the following key to your app’s Info.plist:
This is the only usage-description key the SDK needs. It records no audio, and the Upload from library control uses PHPickerViewController, which runs out of process and requires no photo-library key.

Credentials

Both values come from your Adhere Dashboard and both are permanent. Reuse the same pair for every verification.
clientId is not a per-attempt value. Generating your own UUID returns 404 SDK_CONFIG_NOT_FOUND on the very first call, because the server resolves it against an existing SDK Config record.

Quick Start

1. Create the SDK instance

Create a SmartComply instance once, for example in your view model or app entry point.
Always pass environment explicitly. SDKConfig defaults to .sandbox, which points at http://localhost:8000 — see Environments.

2. Present the flow view

Embed SmartComplyFlowView anywhere in your SwiftUI hierarchy. The SDK loads automatically when the view appears.
The SDK manages the entire flow automatically. The exact steps depend on the verification mode configured in your Dashboard: Document mode (photo capture):
  1. Creates a secure session
  2. Displays a welcome screen with your logo and ID type cards
  3. Shows a country picker if multiple countries are configured. From 1.2.0 Continue stays disabled until a country is picked
  4. User photographs the front of their ID inside the guide box
  5. Photographs the back, when the Dashboard’s requires_back_side for that ID type is required or optional. On optional the capture screen also shows a This ID has no back side control; tapping it goes straight to the document check with no back image. When the backend sends no value at all, the SDK falls back to matching the ID type’s name against national, driver, resident and voter
  6. From 1.2.0, uploads the ID on its own and waits for the backend to read it and check it against the registry. A rejected ID gets a retake screen; an accepted one shows Are these your details? See Document check
  7. Runs the passive liveness scan on the same entry
  8. Returns a FlowResult to your completion handler
Data mode (ID number entry):
  1. Creates a secure session
  2. Displays a welcome screen with ID type selection
  3. Shows a country picker if multiple countries are configured. From 1.2.0 Continue stays disabled until a country is picked
  4. User enters their ID number and any required fields
  5. Identity is verified against the national database
  6. Runs the passive liveness scan
  7. Returns a FlowResult to your completion handler

Document check

From 1.2.0 the ID is checked before the liveness camera opens. Up to 1.1.0 it went up with the selfie once the scan had started, so a cropped or misread ID was only rejected after the whole flow, and Try Again threw away both photos. After the last photo the SDK uploads the ID on its own, polls for the result for up to 90 seconds, and shows the user one of these: The message on the rejection screens is the backend’s own. A retake reuses the same entry, and liveness then submits to that entry rather than creating a new one. A rejected ID is not billed. End session and Close call onDismiss with errorCode "DOCUMENT_REJECTED" or "MANUAL_REVIEW_REQUIRED"; without an onDismiss, End session returns the user to choosing a document. Data mode is unchanged: it has no document to check.

Checking the photo on the phone

From 1.4.0, before a photo is uploaded, the SDK reads the machine readable zone (the two or three lines of letters, numbers and < at the bottom of the document) on the phone with Vision and checks every line is there and every check digit adds up. A cut-off, blurred or covered photo is caught in under a second, instead of after the backend has read it. This is an extra check in front of the backend, not a limit on which documents are supported. Every document type your Dashboard offers still works on both SDKs. More documents will get the check on the phone as their code formats are confirmed. What the user sees on the review screen: The message says the document is cut off or unclear, or, when no code is found at all, that the user may have photographed the wrong side. VoiceOver announces it. The check never blocks a photo it cannot judge. If Vision cannot run, the check takes longer than 5 seconds, or the card is a different Kenyan ID (the Maisha Namba), the photo goes through exactly as in 1.3.0 and the backend checks it. A photo picked from the library is checked too, including one taken sideways. Nothing read from the code is sent anywhere or logged.

Document images

Document photos are capped at 5 MB server-side. Both routes, the in-SDK camera and Upload from library, are downscaled to 1280px on the long edge and re-encoded as JPEG at quality 0.72 before upload, typically 150 to 350 KB, so a full-resolution phone photo does not fail the size limit. Before the review screen the SDK checks the front photo looks like a document: from 1.0.4 that means at least three regions of text. A failure is a warning, not a block. The user can still confirm, because a false negative would otherwise trap someone holding a genuine but unusual ID. The backend is the check that actually rejects, and from 1.2.0 it does so before the selfie: see Document check. From 1.4.0 the back of a Kenyan ID and a passport photo page are also checked on the device: see Checking the photo on the phone.
Up to 1.0.3 this check also required a face, and got it wrong on some genuine IDs. Cards that engrave the portrait into polycarbonate rather than printing it, Kenya’s Maisha Namba card among them, are monochrome and low contrast under a security pattern, and Vision does not find a face on them at any detector revision. Holders of those cards were told “No face detected” on a perfectly good capture. From 1.0.4 the face is not required.This is one of the few places the two SDKs deliberately differ. Android still requires a face, because ML Kit’s accurate model does find these portraits. iOS requires text, because Vision does not. Neither is a bug to be reconciled: each platform uses the check its own detector can actually support, and both remain advisory with the backend OCR as the real gate.

Resuming a failed verification

From 1.3.0. When a verification fails, your team can click Resubmit on it in the Adhere Dashboard. That issues a single-use resume token, valid for 24 hours. Present the flow with it and the customer redoes only the step that failed, on the same verification, instead of starting again.
1

Receive the token on your backend

It arrives in the liveness.resubmit_requested webhook. Without a webhook URL on your SDK Config, it is shown in the Resubmit dialog for your team to copy.
2

Get it to the customer's app

Getting it onto the phone is up to you; see the warning below.
3

Present the flow with it

The result comes back exactly as for any other run: onComplete with result.status, and a liveness.completed webhook with the same verification_id as the original. A token that is expired, already used, or replaced by a newer Resubmit shows a “Link No Longer Valid” screen. Its Close calls onDismiss with errorCode "RESUME_LINK_INVALID"; without an onDismiss the screen has no Close button. Issue a new token rather than presenting the flow without one, which would start an unrelated verification. The attempt limit carries over. Three failed attempts per verification, counted across the original session and the resume. A verification that has used them cannot be resubmitted; it goes to manual review instead. A customer who reaches the limit during the resume sees the same We couldn’t finish your verification screen as in Document check.
The token is a credential. Whoever holds it can continue that customer’s verification until it is used or expires. The best route is an in-app fetch: after the customer signs in, your app asks your own backend whether a resubmit is pending, then presents the flow. A push notification can prompt this without carrying the token. Universal Links are fine too, since only your verified app receives them. Avoid custom URL schemes such as myapp://resume?token=...: any app can register the same scheme and receive the link.
Driving the modules yourself, call try sdk.resumeSession(token) in place of createSession(), then try await sdk.resumeInfo() to learn which step to open. Do not call createSession() after it: a new session replaces the token and the resume is silently lost.

Environments

.sandbox targets a server running on the device itself, for local backend development. It is not a hosted test environment. Use .production for all integration work.
.sandbox is a plain http:// target, which App Transport Security blocks by default. Without an opt-in the flow dies at the loading screen with an ATS error that reads like a backend outage. If you genuinely need it, scope the exception to localhost rather than reaching for NSAllowsArbitraryLoads, which weakens every connection your app makes.
.production is HTTPS and needs none of this.

A different host

SDKEnvironment covers the two hosts you ship against, and one of them is loopback on the handset. For anything else, a staging deployment or a QA build that has to reach more than one backend without being rebuilt, override it. Added in 1.0.3.
A trailing slash is trimmed, and a blank value falls back to environment rather than being treated as a host, so a build reading this off an empty field does not post verifications to a relative URL.

Branding

The flow carries your brand, not Adhere’s. Colour and logo come from the SDK Config on the dashboard, so they change without an app release, and your name is shown while the session is being set up. The font is the one exception and is passed by the host. The welcome heading and subheading are fixed SDK copy: “Verify Your Identity”, then one of two lines keyed off the verification mode. Upload it under Settings → Integrations → SDK → Brand Details. PNG, JPEG or WebP, up to 512 KB and 1024x1024 pixels. Requires 1.0.3. It is drawn on its own, with no box or border behind it, and bounded by height rather than fitted into a square so a wordmark stays legible. Only the SDK’s fallback shield sits in a tinted frame. The logo belongs to the SDK Config, not the company. A branch running several configs for different products gives each its own mark, and a config with no logo shows the SDK’s own shield rather than falling back to the company logo on your profile page. If you want the same mark everywhere, upload it to each config.
A logo never delays or blocks a verification. The SDK fetches it after the welcome screen is already on screen, on a three second budget, and keeps its own shield if the fetch fails. It is also decoded through a pixel cap, so an oversized image cannot exhaust memory on the handset.
The logo is rendered by the iOS and Android SDKs. The Web SDK does not show it yet, so a config used by both will carry your mark on mobile and the default on web.

Font

Passed by your app, because the SDK neither ships nor downloads font files: your loading, caching and licensing stay yours. Requires 1.0.3. A face listed under UIAppFonts, or registered at runtime, is available process-wide, so the SDK needs only its PostScript name.
Names are per weight because Font.custom(_:size:) selects a face by name and takes no weight argument, so a single name would render every screen at one weight. Only regular is required; anything omitted falls back to the nearest name you did supply.
A name that is not registered logs a warning under the com.smartcomply.sdk subsystem and falls back to the system font rather than rendering nothing. Dynamic Type still applies either way.

Liveness

Two actions are drawn from .blink and .turnHead in random order and detected passively. Both are watched on every frame, neither is named to the user, and the HUD only reports how many are done: there is no prompted sequence. Head yaw is measured against the phone, not against the world, and frames during which the device itself is rotating are not credited: turning the handset does not satisfy the challenge. The scan has a 20 second cap.

The screen, from 1.1.0

The guide is a circle rather than an oval, and verification happens in place. Earlier versions cut to a separate progress screen the moment the capture was submitted, so the result poll began by throwing away the thing the user was looking at. The preview now freezes on its last frame, blurs, and the guide is replaced by a dotted ring while the poll runs underneath; the checkmark lands in the same circle. ARKit is paused as soon as the preview freezes. Nothing past .uploading reads another frame, so the camera indicator no longer stays lit for the whole poll. LivenessOrchestrator.stopCamera() is public if you drive the orchestrator yourself.

When the autoshot is taken

Two stills leave the device. The autoshot is taken before the scan and the snapshot once both actions are done. From 1.2.0, in document mode the autoshot goes up with the video, because the entry already exists from the document check; in data mode it goes up with the entry as before. From 1.2.0 nothing starts until the whole face is inside the circle. After a 300ms settle the SDK holds the autoshot until three consecutive frames put the face inside the circle the user is looking at, and there is no timeout. Recording and the 20 second cap start only then. Up to 1.1.0 the check looked at the centre of the face alone, so a face with its chin well below the circle passed, and after 2.5 seconds it took whatever ARKit saw.
  • “Whole face” is ARKit’s face mesh. The ellipse around the mesh, forehead to chin and cheek to cheek, is projected into the preview with ARFrame.camera.projectPoint and must sit entirely inside the circle. On an iPhone 13 Pro the projected outline traced the face.
  • The prompt says what is wrong. “Position your face in the frame” when there is no face, “Move back a little” when the face is centred but too big to fit, and “Fit your whole face inside the circle” when it is off to one side or too low. It stays up until the face is in.
  • During the scan an action only counts while the face is in the circle. If the face is out for more than half a second the prompt comes back and nothing is credited until it returns. The grace keeps a head turn, which swings the face across the edge, from flashing the prompt.
  • Framing is not cosmetic. Measured on device, a selfie with the face in the corner of the frame scored 25.6% against the ID photo and was reported as a mismatch; the same person framed in the guide scored 67%.
Reaching the cap fails the check. Nothing is uploaded and nothing is billed, and the user is offered Try Again. Earlier versions submitted anyway and reported success with no actions completed: if you are on 1.0.2 or below, expect that behaviour.
Liveness cannot run on the Simulator. Test on a physical device that meets the requirement above.

Accessibility

From 1.0.3 the flow honours the user’s text size setting (Settings → Accessibility → Display & Text Size → Larger Text). Earlier versions used fixed point sizes throughout, so the setting had no effect on any screen. Scaling is capped at .accessibility1. The liveness guide, the document frame and the buttons are sized to fit their content, and the accessibility sizes above that cap overflow them: the bound keeps everything below it usable rather than shipping layouts that break at the top of the range.

What “Verification Complete” means

Nothing about the verdict. Every submitted entry reaches that screen, whether the backend passed or failed the check. The submit call returns processing, because the backend resolves face matching on a worker moments later. The SDK polls for up to about 15 seconds and puts whatever it learns in FlowResult.status, then shows the success screen either way. A user who failed is not told so and is not offered a retry. This is a product decision, not an oversight: tenants asked for the end user to see a submitted verification as accepted, and to act on the real verdict themselves from the dashboard and the webhook. It means you have to close the loop with a user who failed. Pre-submission failures are unaffected and still show their own screens: a denied camera, a configuration or network error, reaching the 20 second liveness cap, and from 1.2.0 a rejected ID. Nothing has been billed at that point and the user can recover on the spot.

Knowing the user gave up

onComplete only fires on a verification. Pass the optional onDismiss to learn that the flow ended without one: the user closed the failure screen, an error left nothing to retry, or from 1.2.0 the user ended the session on a rejected ID. Passing it also adds a Close control to the failure screen, which otherwise offers only Try Again.

SDK Configuration

Raise requestTimeout if your flow uses a data-mode ID channel.onboarding/verify blocks on a government identity provider and runs on this same 30 second budget. When it times out client-side the server carries on, completes the check and bills for it, so you are charged for a verification your app reported as failed. The user is left on the ID input screen with an error and will usually resubmit, which bills again. Pass requestTimeout: 90 when a data-mode channel is enabled.Unlike Android’s verifyTimeoutMs, this is not verify-specific. It applies to every non-upload call, including the result poll the SDK runs after submission, so a raised value also lengthens the worst case on the processing screen. It is the only lever 1.0.3 exposes.

FlowResult

Delivered to your onComplete closure when the user completes the flow in-app.
Branch on status. Reaching onComplete is not a pass.From 1.0.3 the SDK polls the backend for the terminal outcome and puts it here, so "passed" means passed and "failed" means failed. But every submitted entry shows the end user the success screen, including a failed one. That is deliberate and was asked for: tenants wanted their user to see a submitted verification as accepted and to read the real verdict themselves. The screen is neutral-positive; this value is not.So onComplete firing with status of "failed" is a normal outcome you must handle. Code that treats onComplete as a pass will admit users who failed verification."processing" means the backend had not resolved within about 15 seconds. The entry exists and is billed, and the outcome arrives on your webhook.On 1.0.2 and earlier status was always "processing" and carried no verdict at all, so the result was a submission receipt.
verifiedName and idNumber are populated from the poll as well as from submit, from 1.0.3. In document mode they previously appeared only when OCR happened to finish before the user completed liveness, so whether a user saw their own name came down to timing.
verifiedName and idNumber are always nil on 1.0.1 and earlier, in both verification modes. If you are pinned to one of those versions, read the verified identity from the webhook payload instead and do not build UI that depends on these two properties. Upgrade to 1.0.3.
From 1.0.2 both are populated. In document verification they come from the OCR of the captured document; in data verification they come from the identity provider’s response. verifiedName is the full name where the provider sends one, otherwise it is composed from the first, middle and last name fields.They are still only as good as the submission that produced them, so treat them as a receipt of what was read, not as a verdict. The webhook remains the source of truth for the final outcome.
The webhook is still the system of record. An entry’s status moves through pending, processing, and then passed, failed or expired, and the final status, the extracted fields and any failure reason are delivered to the webhook URL configured on your SDK Config. See Webhooks for the full payload.What changed in 1.0.3 is that onComplete no longer fires ahead of that verdict. It waits for the backend to reach a terminal status, so status agrees with what the webhook will say. Reconcile from the webhook for anything you persist: a run that has not resolved in about 15 seconds still returns "processing".

Error Handling

SmartComplyFlowView handles and displays error states automatically. For the headless API, all SDK methods throw on failure:
SDKError exposes message: String, statusCode: Int and errorCode: String?. AuthError, NetworkError and AlreadySubmittedError all subclass it, so a single catch let error as SDKError covers every case.
From 1.0.3 the error carries the backend’s own code. errorCode was previously hardcoded to AUTH_ERROR for every 401 and 403, so SDK_CONFIG_NOT_FOUND, an expired session and an invalid key were indistinguishable and host logic keyed on the code could not work. Validation failures likewise reported only the generic "One or more fields failed validation."; the per-field reason is now surfaced.AlreadySubmittedError is raised when an upload retry is rejected as a duplicate, which means the original attempt reached the server and the entry exists and was billed: the response was simply lost coming back. The bundled flow treats it as such and reads the outcome rather than reporting a failure. Handle it the same way if you drive the modules yourself.