Skip to content

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:

  1. 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.
  2. Your app runs the capture steps with the Prove ID iOS SDK and sends each one to Prove ID with that token.
  3. 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.

Add an endpoint of your own, for example POST /api/kyc/mobile-session, that only signed-in users of your app can call. It:

  1. Gets an access token with POST /oauth/access_token (form-encoded, client_credentials grant) and caches it until shortly before expires_in.
  2. Starts the transaction with POST /id/start, declaring the document type and the steps.
  3. Stores the transactionId against your user.
  4. Returns accessToken, transactionId and gestureType to 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), Express
const 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 });
});

To add the hologram or NFC step, add HOLOGRAM or NFC to controls; see Starting a transaction.

Point the SDK and the service client to Prove ID once, when the app starts:

AppDelegate.swift
import RKYC_iOS
import 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:

KYCSession.swift
import Foundation
import 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.

One view controller can handle both sides: it detects .NEW_ID for the front and .NEW_ID_BACK for the back.

DocumentCaptureViewController.swift
import UIKit
import ProveIDApiClient
import id_card_detection_ios_wrapper
import 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.

Map the gestureType from step 1 to the SDK and record the video:

LivenessViewController.swift
import UIKit
import RKYC_iOS
import 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.

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 });
});