Quickstart
This page builds the most common flow, a Turkish ID card (front and back) followed by liveness, from start to finish. Each step links to the page that covers it in detail.
An integration has three parts:
- Your server signs in to Prove ID, starts the transaction for your signed-in user and returns the access token and transaction ID to the app.
- Your app runs the capture steps with the Prove ID iOS SDK and sends each one to Prove ID with that token.
- Your server checks that the transaction belongs to the user, reads the result from Prove ID and makes the decision.
Before you start, add the pod, the ProveIDApiClient package and the camera permission as described in Installation.
1. Start the session on your server
Section titled “1. Start the session on your server”Add an endpoint of your own, for example POST /api/kyc/mobile-session, that only signed-in users of your app can call. It:
- Gets an access token with
POST /oauth/access_token(form-encoded,client_credentialsgrant) and caches it until shortly beforeexpires_in. - Starts the transaction with
POST /id/start, declaring the document type and the steps. - Stores the
transactionIdagainst your user. - Returns
accessToken,transactionIdandgestureTypeto the app.
The access token reaches the device, so use a dedicated Prove ID API user for the mobile flow, with only the permissions the verification steps need. Ask the Prove ID team to set one up.
// Node.js 18+ (built-in fetch), Expressconst API = process.env.PROVEID_API ?? 'https://test.api.proveid.io'; // production: https://api.proveid.io
let cached; // { token, expiresAt }
async function accessToken() { if (cached && cached.expiresAt > Date.now() + 60_000) return cached.token; const res = await fetch(`${API}/oauth/access_token`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'client_credentials', client_id: process.env.PROVEID_CLIENT_ID, client_secret: process.env.PROVEID_CLIENT_SECRET, }), }); if (!res.ok) throw new Error(`Prove ID sign-in failed: ${res.status}`); const data = await res.json(); cached = { token: data.access_token, expiresAt: Date.now() + data.expires_in * 1000 }; return cached.token;}
// requireUser is your own authentication middleware; db is your own storage.app.post('/api/kyc/mobile-session', requireUser, async (req, res) => { const token = await accessToken(); const start = await fetch(`${API}/id/start`, { method: 'POST', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ optionalData: { idType: 'IDCARD', controls: ['FRONT', 'BACK', 'LIVENESS'] }, }), }); if (!start.ok) return res.status(502).end(); const { transactionId, gestureType } = await start.json();
await db.kycSessions.insert({ transactionId, userId: req.user.id }); res.set('Cache-Control', 'no-store').json({ accessToken: token, transactionId, gestureType });});import osimport time
import requests
API = os.environ.get("PROVEID_API", "https://test.api.proveid.io") # production: https://api.proveid.io_cached = {"token": None, "expires_at": 0.0}
def access_token(): if _cached["token"] and _cached["expires_at"] > time.time() + 60: return _cached["token"] res = requests.post( f"{API}/oauth/access_token", data={ # form-encoded "grant_type": "client_credentials", "client_id": os.environ["PROVEID_CLIENT_ID"], "client_secret": os.environ["PROVEID_CLIENT_SECRET"], }, timeout=30, ) res.raise_for_status() data = res.json() _cached.update(token=data["access_token"], expires_at=time.time() + data["expires_in"]) return _cached["token"]
def start_mobile_session(user_id): """Call from a view that only signed-in users can reach; send the result with Cache-Control: no-store.""" token = access_token() res = requests.post( f"{API}/id/start", headers={"Authorization": f"Bearer {token}"}, json={"optionalData": {"idType": "IDCARD", "controls": ["FRONT", "BACK", "LIVENESS"]}}, timeout=30, ) res.raise_for_status() data = res.json() KycSession.objects.create(transaction_id=data["transactionId"], user_id=user_id) # your own model return {"accessToken": token, "transactionId": data["transactionId"], "gestureType": data.get("gestureType")}To add the hologram or NFC step, add HOLOGRAM or NFC to controls; see Starting a transaction.
2. Set up the SDK in the app
Section titled “2. Set up the SDK in the app”Point the SDK and the service client to Prove ID once, when the app starts:
import RKYC_iOSimport ProveIDApiClient
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { let serverUrl = "https://test.api.proveid.io" // production: https://api.proveid.io EndpointManager.SERVER_URL = serverUrl ProveIDApiClientAPI.basePath = serverUrl return true}When the user starts the verification, call your endpoint with your own session and keep the token in memory only:
import Foundationimport ProveIDApiClient
struct KYCSessionInfo { let transactionId: String let gestureType: String}
enum KYCSession { static let backend = URL(string: "https://your-backend.example.com")!
/// Starts a verification on your server and sets the Prove ID token for the SDK calls. static func begin(completion: @escaping (Result<KYCSessionInfo, Error>) -> Void) { var request = URLRequest(url: backend.appendingPathComponent("api/kyc/mobile-session")) request.httpMethod = "POST" request.setValue("Bearer \(YourAuth.sessionToken)", forHTTPHeaderField: "Authorization") // your app's own session
URLSession.shared.dataTask(with: request) { data, response, error in guard error == nil, (response as? HTTPURLResponse)?.statusCode == 200, let data, let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let token = json["accessToken"] as? String, let transactionId = json["transactionId"] as? String else { completion(.failure(error ?? URLError(.badServerResponse))) return } ProveIDApiClientAPI.customHeaders["Authorization"] = "Bearer \(token)" let gestureType = json["gestureType"] as? String ?? "EYE" completion(.success(KYCSessionInfo(transactionId: transactionId, gestureType: gestureType))) }.resume() }
/// Call when the flow ends (finished, cancelled or failed). static func end() { ProveIDApiClientAPI.customHeaders.removeValue(forKey: "Authorization") }}Don’t store the token in UserDefaults, files or the Keychain, and don’t print it. See Authentication for what to do on 401.
3. Capture the front and the back
Section titled “3. Capture the front and the back”One view controller can handle both sides: it detects .NEW_ID for the front and .NEW_ID_BACK for the back.
import UIKitimport ProveIDApiClientimport id_card_detection_ios_wrapperimport id_card_detection_ios_cnn
enum DocumentSide { case front, back }
final class DocumentCaptureViewController: UIViewController { @IBOutlet weak var containerView: UIView!
var side: DocumentSide = .front var transactionId = "" var onFinished: ((Result<Void, Error>) -> Void)?
private var reader: IDCardDetectionWrapper? private var isSending = false
override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated)
let reader = IDCardDetectionWrapper(frame: containerView.bounds) containerView.addSubview(reader.getView()!)
let conf = IDCardDetectionWrapperConfiguration() conf.cardTypeList = [side == .front ? .NEW_ID : .NEW_ID_BACK] conf.messageToEncrypt = transactionId conf.encryptDeviceId = true conf.detectBothOrientation = true reader.setConfiguration(conf: conf)
reader.setDelegate(delegate: self) reader.setViewDelegate(delegate: self) reader.setFrameDelegate(delegate: self) reader.startCapture() self.reader = reader }
override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) reader?.stopCapture() }
private func send(image: UIImage, frame: UIImage?) { guard !isSending, let png = image.pngData() else { return } isSending = true reader?.stopCapture()
let imageBase64 = png.base64EncodedString() let frameBase64 = frame?.pngData()?.base64EncodedString()
switch side { case .front: let body = CheckIDModel(transactionId: transactionId, img: imageBase64, frame: frameBase64) IDServiceAPI.checkNewIDCard(body: body) { [weak self] result in self?.finish(result.map { _ in () }) } case .back: let body = SecondaryIDInformationModel(image: imageBase64, frame: frameBase64, transactionID: transactionId) IDServiceAPI.addSecondary(body: body) { [weak self] result in // capturedData carries sno, bdat and edat if you add the NFC step later self?.finish(result.map { _ in () }) } } }
private func finish(_ result: Result<Void, ErrorResponse>) { DispatchQueue.main.async { self.isSending = false self.onFinished?(result.mapError { $0 as Error }) } }}
extension DocumentCaptureViewController: IDCardDetectionWrapperDelegate, IDCardDetectionWrapperViewDelegate, IDCardDetectionWrapperFrameDelegate { func cardDetected(cardImage: UIImage, cardType: WrapperCardType, frame: UIImage) { send(image: cardImage, frame: frame) }
func cardDetected(image: UIImage, cardType: WrapperCardType) { // Not used: with a frame delegate the method above is called instead. }
func cardDetectionFailed(wrapperErrorsModel: WrapperErrorsModel) { // Guide the user: move closer, avoid glare, etc. See Errors. }
func onViewDrawn(mainRect: CGRect, photoRect: CGRect?) { }}Present it with side = .front; when onFinished succeeds, present it again with side = .back. If a call fails, let the user try again (see Errors). More options: Document capture.
4. Liveness
Section titled “4. Liveness”Map the gestureType from step 1 to the SDK and record the video:
import UIKitimport RKYC_iOSimport ProveIDApiClient
final class LivenessViewController: UIViewController { @IBOutlet weak var containerView: UIView!
var transactionId = "" var gestureType = "EYE" var onFinished: ((Result<Void, Error>) -> Void)?
private let livenessController = LivenessController()
override func viewDidLoad() { super.viewDidLoad()
LivenessController.FACE_DETECTION_LAYER_SHAPE = .OVAL LivenessController.CONTROL_FACE_CAPTURE = true LivenessController.STOP_VIDEO_WHILE_RECORDING = true LivenessController.IS_GESTURE_CONTROL_ENABLED = true LivenessController.VIDEO_PREPARATION_TIME_IN_SECONDS = 3 LivenessController.VIDEO_TIME_IN_SECONDS = 5
switch gestureType { case "MOUTH": LivenessController.GESTURE_TYPE = .MOUTH case "HEAD-VERTICAL": LivenessController.GESTURE_TYPE = .HEAD_VERTICAL case "HEAD-HORIZONTAL": LivenessController.GESTURE_TYPE = .HEAD_HORIZONTAL case let value where value.hasPrefix("MULTIDIR_"): let directions = value.dropFirst("MULTIDIR_".count).split(separator: "-") LivenessController.GESTURE_TYPE = .RANDOMIZED_MULTI_GESTURE LivenessController.RANDOMIZED_GESTURES = directions.compactMap { HeadPosition(string: String($0)) } LivenessController.VIDEO_PREPARATION_TIME_IN_SECONDS = 0 default: LivenessController.GESTURE_TYPE = .EYE }
livenessController.setDelegate(self) livenessController.setGestureDelegate(self) containerView.addSubview(livenessController.view) }
override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) livenessController.startCapture() }
private func send(videoURL: URL, rotate: Int) { defer { try? FileManager.default.removeItem(at: videoURL) } // never leave the video on the device guard let video = try? Data(contentsOf: videoURL) else { return }
let body = FaceVerificationModel(transactionId: transactionId, video: video.base64EncodedString(), rotate: rotate) FaceServiceAPI.verifyFace(body: body) { [weak self] result in DispatchQueue.main.async { self?.onFinished?(result.map { _ in () }.mapError { $0 as Error }) } } }}
extension LivenessViewController: LivenessDelegate, GestureDelegate { func gestureControlled(gestureModel: GestureModel) { livenessController.stopCapture() send(videoURL: gestureModel.videoURL, rotate: gestureModel.rotate) }
func onVideoCaptured(videoURL: URL, rotate: Int) { } func onVideoStarting() { /* show a countdown */ } func onVideoStarted() { } func onVideoCanceled() { /* the face left the frame; ask the user to try again */ } func onFaceDetectionFailure(isAway: Bool) { } func onFaceDetectionSuccess() { } func onFaceDetectionIdle() { } func onMultiFaceDetected() { /* only one person in the frame */ }
func positionFailed(model: PositionFailedModel) { } func positionSucceed(model: PositionSuccedModel) { } func positionCorrect() { } func preparationPositionControlled(model: PreparationPositionModel) { }}Passive liveness, liveness from a photo and liveness with the ID card are covered in Liveness.
5. Finish on your server
Section titled “5. Finish on your server”When liveness succeeds, call KYCSession.end() and send transactionId to your own endpoint, for example POST /api/kyc/mobile-session/complete, with your app’s session. Don’t approve the user in the app: the app only knows that the steps are done.
Your server first checks that this transactionId was stored for this user in step 1, then reads the result from Prove ID with GET /id/id-info-with-tid/{transactionId} and applies your own rule. The example below accepts the verification only when every check passed; adapt it to your risk policy.
app.post('/api/kyc/mobile-session/complete', requireUser, async (req, res) => { const { transactionId } = req.body; const session = await db.kycSessions.findOne({ transactionId, userId: req.user.id }); if (!session) return res.status(404).end(); // not this user's transaction
const result = await fetch(`${API}/id/id-info-with-tid/${encodeURIComponent(transactionId)}`, { headers: { Authorization: `Bearer ${await accessToken()}` }, }); if (!result.ok) return res.status(502).end(); const info = await result.json(); const controls = info.controlResults ?? []; const passed = controls.length > 0 && controls.every((c) => c.entryInterpretation === true);
await db.kycSessions.update({ transactionId }, { passed }); res.json({ passed });});from urllib.parse import quote
def complete_mobile_session(user_id, transaction_id): session = KycSession.objects.filter(transaction_id=transaction_id, user_id=user_id).first() if session is None: raise PermissionError("Not this user's transaction")
res = requests.get( f"{API}/id/id-info-with-tid/{quote(transaction_id, safe='')}", headers={"Authorization": f"Bearer {access_token()}"}, timeout=30, ) res.raise_for_status() controls = res.json().get("controlResults") or [] passed = bool(controls) and all(c.get("entryInterpretation") is True for c in controls)
session.passed = passed session.save() return passedNext steps
Section titled “Next steps”- Add hologram or NFC chip reading for stronger checks.
- Show helpful messages for each case in Errors.
- Go through the Go-live checklist before you publish.
- Common questions are answered in the FAQ.