Quickstart
This page builds a complete verification for the Turkish ID card (front + back) and liveness in Kotlin. It assumes you have finished Installation. Each step links to its detailed page, where the Java version and all options are.
An integration has three parts:
- Your server signs in to Prove ID, starts the transaction for the signed-in user and gives the app a token.
- Your app runs the camera steps and sends images and videos straight to Prove ID.
- Your server reads the result with the transaction ID it started and makes the decision.
1. Your server starts the session
Section titled “1. Your server starts the session”Your server needs two endpoints that only your signed-in users can call:
| Your endpoint | What it does |
|---|---|
POST /api/kyc/mobile-session |
Gets a Prove ID access token, starts the transaction (POST /id/start), remembers which user it belongs to and returns accessToken, transactionId and gestureType to the app. |
POST /api/kyc/mobile-session/complete |
Accepts the transactionId only if it was started for this user, reads the result (GET /id/id-info-with-tid/{transactionId}) and decides. |
// Node.js 18+ with Express. requireSignedInUser and db are your own.const API = process.env.PROVEID_API ?? 'https://test.api.proveid.io';let cached; // { token, expiresAt }
async function accessToken() { // Hand out tokens that stay valid for at least 10 more minutes. if (cached && cached.expiresAt > Date.now() + 10 * 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;}
app.post('/api/kyc/mobile-session', requireSignedInUser, 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({ userId: req.user.id, transactionId, status: 'started' }); res.json({ accessToken: token, transactionId, gestureType });});
app.post('/api/kyc/mobile-session/complete', requireSignedInUser, async (req, res) => { const session = await db.kycSessions.findOne({ userId: req.user.id, transactionId: String(req.body.transactionId), status: 'started', }); if (!session) return res.status(404).end(); // not this user's transaction
const info = await fetch( `${API}/id/id-info-with-tid/${encodeURIComponent(session.transactionId)}`, { headers: { Authorization: `Bearer ${await accessToken()}` } }, ).then((r) => { if (!r.ok) throw new Error(`Result failed: ${r.status}`); return r.json(); });
const controls = info.controlResults ?? []; const passed = controls.length > 0 && controls.every((c) => c.entryInterpretation === true); await db.kycSessions.update(session.id, { status: passed ? 'approved' : 'rejected' }); res.json({ passed });});# Flask. login_required, current_user and db are your own.import osimport timefrom urllib.parse import quote
import requestsfrom flask import abort, jsonify, request
API = os.environ.get("PROVEID_API", "https://test.api.proveid.io")_cached = {"token": None, "expires_at": 0.0}
def access_token(): # Hand out tokens that stay valid for at least 10 more minutes. if _cached["token"] and _cached["expires_at"] > time.time() + 600: return _cached["token"] res = requests.post( f"{API}/oauth/access_token", data={ "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"]
@app.post("/api/kyc/mobile-session")@login_requireddef start_mobile_session(): 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() db.kyc_sessions.insert(user_id=current_user.id, transaction_id=data["transactionId"], status="started") return jsonify(accessToken=token, transactionId=data["transactionId"], gestureType=data.get("gestureType"))
@app.post("/api/kyc/mobile-session/complete")@login_requireddef complete_mobile_session(): session = db.kyc_sessions.find_one( user_id=current_user.id, transaction_id=str(request.json.get("transactionId")), status="started" ) if session is None: abort(404) # not this user's transaction
res = requests.get( f"{API}/id/id-info-with-tid/{quote(session.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) db.kyc_sessions.update(session.id, status="approved" if passed else "rejected") return jsonify(passed=passed)The rule above accepts the verification only when every check passed; adapt it to your own risk policy (see Results).
2. Set up the app
Section titled “2. Set up the app”Point the SDK to the same environment when the app starts:
class App : Application() { override fun onCreate() { super.onCreate() EndpointManager.setServerUrl("https://test.api.proveid.io") Authentication.setSharedPreferences(applicationContext) }}ServerCall reports through callbacks. This helper turns them into suspend functions so the flow reads top to bottom:
import com.techsign.rkyc.services.TechsignServiceListenerimport kotlinx.coroutines.suspendCancellableCoroutineimport kotlin.coroutines.resumeimport kotlin.coroutines.resumeWithException
suspend fun <T> proveId(call: (TechsignServiceListener<T>) -> Unit): T = suspendCancellableCoroutine { cont -> call(object : TechsignServiceListener<T> { override fun onSuccess(result: T) { if (cont.isActive) cont.resume(result) } override fun onFailure(e: Exception) { if (cont.isActive) cont.resumeWithException(e) } }) }
fun Bitmap.toBase64(): String { val out = ByteArrayOutputStream() compress(Bitmap.CompressFormat.PNG, 100, out) return Base64.encodeToString(out.toByteArray(), Base64.NO_WRAP)}3. Run the flow
Section titled “3. Run the flow”One Activity hosts the steps in a FrameLayout with the ID container. backend is your own API client for the two endpoints in step 1. Ask for the camera permission before you open it (see Installation).
data class KycSession(val accessToken: String, val transactionId: String, val gestureType: String?)
class VerificationActivity : AppCompatActivity() {
private lateinit var session: KycSession
override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_verification)
lifecycleScope.launch { session = backend.startKycSession() // POST /api/kyc/mobile-session Authentication.setApiKey(session.accessToken) // memory only showDocumentCamera(CardType.NEW_ID) } }
// --- Document front and back -------------------------------------------------------
private fun showDocumentCamera(side: CardType) { val camera = IDCardDetectionBaseFragment.create(applicationContext) camera.setConfiguration(IDCardDetectionConfiguration().apply { cardTypeList = listOf(side) messageToEncrypt = session.transactionId isEncryptDeviceId = true detectBothOrientation = true }) camera.setListener(object : OcrListener { override fun cardDetected(captured: Bitmap, cardType: CardType) { camera.stopCapture() lifecycleScope.launch { onDocumentCaptured(captured, side) } }
override fun cardDetectionFailed(errors: CardDetectionErrorsModel) { runOnUiThread { showCaptureHint(errors) } // see Errors }
override fun cardOcrCompleted(result: OcrResultModel) = Unit }) supportFragmentManager.beginTransaction().replace(R.id.container, camera).commitNow() camera.startCapture() }
private suspend fun onDocumentCaptured(image: Bitmap, side: CardType) { val base64 = withContext(Dispatchers.Default) { image.toBase64() } try { if (side == CardType.NEW_ID) { proveId<CheckIdReturnModel> { ServerCall.verifyIDPhoto(CheckIDModel(base64, session.transactionId), it) } showDocumentCamera(CardType.NEW_ID_BACK) } else { proveId<AddSecondaryReturnModel> { ServerCall.addSecondary(AddSecondaryModel(base64, session.transactionId), it) } showLiveness() } } catch (e: Exception) { showError(e) // UnauthorizedException → get a new token from your server } }
// --- Liveness -----------------------------------------------------------------------
private fun showLiveness() { LivenessBaseFragment.setLayerShape(LivenessBaseFragment.Shape.OVAL) LivenessBaseFragment.setControlFaceCapture(true) LivenessBaseFragment.MESSAGE_TO_ENCRYPT = session.transactionId LivenessBaseFragment.LIVENESS_FROM_IMAGE_ENABLED = false LivenessBaseFragment.RANDOMIZED_GESTURES = emptyList() LivenessBaseFragment.GESTURE_TYPE = session.gestureType.toGestureType() LivenessBaseFragment.IS_GESTURE_CONTROL_ENABLED = true LivenessBaseFragment.VIDEO_PREPARATION_TIME_IN_MS = 3000 LivenessBaseFragment.VIDEO_RECORD_TIME_IN_MS = 5000
val liveness = LivenessBaseFragment.create(applicationContext) liveness.setGestureListener(object : GestureListener { override fun gestureControlled(model: GestureModel) { liveness.stopFaceCapture() lifecycleScope.launch { onLivenessVideo(model.videoPath, liveness.rotate) } }
override fun positionSucceed(model: PositionSucceedModel) = Unit override fun positionFailed(model: PositionFailedModel) = Unit override fun positionCorrect() = Unit override fun preparationPositionControlled(model: PreparationPositionModel) = Unit }) liveness.setFragmentListener(object : FragmentListener { override fun onVideoCaptured(videoPath: String) = Unit // passive liveness only override fun onVideoStarting() = Unit override fun onVideoStarted() = Unit override fun onVideoCanceled() = runOnUiThread { showMessage("Keep your face in the frame") } override fun onVideoProcessing() = Unit override fun onFaceDetectionFailure(isAway: Boolean) = Unit override fun onFaceDetectionSuccess() = Unit override fun onFaceDetectionIdle() = Unit override fun onMultiFaceDetected() = runOnUiThread { showMessage("Only one person, please") } }) supportFragmentManager.beginTransaction().replace(R.id.container, liveness).commitNow() liveness.startFaceCapture() }
private suspend fun onLivenessVideo(videoPath: String, rotate: Int) { val file = File(videoPath) try { val base64 = withContext(Dispatchers.IO) { Base64.encodeToString(file.readBytes(), Base64.NO_WRAP) } proveId<VerifyFaceReturnModel> { ServerCall.checkLiveness(FaceVerificationModel(base64, session.transactionId, rotate), it) } finishVerification() } catch (e: Exception) { showError(e) } finally { file.delete() // never leave the video on the device } }
// --- Result -------------------------------------------------------------------------
private suspend fun finishVerification() { Authentication.setApiKey(null) val passed = backend.completeKycSession(session.transactionId) // POST .../complete showOutcome(passed) }
override fun onDestroy() { super.onDestroy() if (isFinishing) Authentication.setApiKey(null) }}
private fun String?.toGestureType(): GestureType = when (this) { "MOUTH" -> GestureType.MOUTH "HEAD-VERTICAL" -> GestureType.HEAD_VERTICAL "HEAD-HORIZONTAL" -> GestureType.HEAD_HORIZONTAL else -> GestureType.EYE}4. Before you ship
Section titled “4. Before you ship”- Add hologram or NFC steps by adding
HOLOGRAMorNFCtocontrolson your server. - Handle every error with a clear message to the user: Errors.
- Go through the Go-live checklist.
- Common questions are answered in the FAQ.