Documentation menu
Flutter
Install blink_kyc, create a session token on your backend, let the SDK run
the document and liveness capture, then read the verified result from your backend. This guide covers
both sides: the Flutter app and the server it talks to.
00How it works
Three parties, three calls. Your client secret lives only on your backend; the phone only ever holds a short-lived session token; and the result you act on is the one your backend reads from Blink.
Create a session
POST /api/blink/session/create with your client key and secret. Returns
sessionId and sessionToken.
Run the capture
Pass the sessionToken to BlinkKyc. The SDK shows the camera screens and
uploads to Blink.
Read the result
GET /api/blink/session/{sessionId}/result. Act on
VERIFIED, REJECTED or REVIEW.
Never put the client secret in the app. Anything shipped in a Flutter build can be extracted. The app asks your backend for a session token, and your backend asks Blink.
01Install the package
blink_kyc is served from the Blink package repository, not pub.dev. Add it with a
hosted source:
dependencies:
flutter:
sdk: flutter
blink_kyc:
hosted: https://blink-pay.net/pub
version: ^2.1.0
http: ^1.2.0 # to call your own backendflutter pub getImport it wherever you start verification:
import 'package:blink_kyc/blink_kyc.dart';02Android & iOS setup
Android
The plugin downloads the native Blink Android SDK from https://blink-pay.net/maven/releases
by itself. Your app needs a minimum SDK of 24 and two packaging rules for the passport-chip libraries:
android {
defaultConfig {
minSdk = 24 // required by the Blink Android SDK
}
// The chip reader's Bouncy Castle jars ship signature files that collide when merged.
packaging.resources.excludes += setOf(
"META-INF/*.SF", "META-INF/*.DSA", "META-INF/*.RSA",
"META-INF/versions/9/OSGI-INF/MANIFEST.MF",
)
// Keep the on-device MRZ models uncompressed.
androidResources { noCompress += listOf("tflite", "rtfm") }
}Camera, internet and NFC permissions and the capture screens are merged in from the SDK; a plain
FlutterActivity works. Build with JDK 17 or 21.
iOS
iOS 15 or later. Add the usage descriptions to ios/Runner/Info.plist:
<key>NSCameraUsageDescription</key>
<string>We use the camera to photograph your document and confirm your identity.</string>
<!-- Only if you enable passport chip reading with .nfc() -->
<key>NFCReaderUsageDescription</key>
<string>We read your passport chip to confirm your document is genuine.</string>
<key>com.apple.developer.nfc.readersession.iso7816.select-identifiers</key>
<array>
<string>A0000002471001</string>
<string>A0000002472001</string>
<string>00000000000000</string>
</array>Chip reading needs a paid Apple Developer team. Add the
Near Field Communication Tag Reading capability in Xcode (Runner → Signing & Capabilities).
Free personal teams cannot sign it — leave .nfc() off and skip the NFC keys, and document
photo plus liveness still work.
03Backend: create a session
Expose an endpoint on your backend that your app calls when the user starts verification. It authenticates your user, calls Blink with your client credentials, and returns only the session token and id.
The Blink call
curl -sX POST https://kyc-api.blink-pay.net/api/blink/session/create \
-H 'content-type: application/json' \
-d '{
"clientKey": "bkyc_live_…",
"clientSecret": "bksec_…",
"purpose": "FULL_CAPTURE",
"userRef": "customer-8841"
}'
→ 200
{
"sessionId": "[SESSION_ID]",
"sessionToken": "bkyc_sess_…",
"expiresAt": "2026-09-15T18:42:10Z",
"purpose": "FULL_CAPTURE"
}| Field | Required | Meaning |
|---|---|---|
clientKey | yes | Your client key. Server-side only. |
clientSecret | yes | Your client secret. Server-side only — never in the app. |
purpose | no | FULL_CAPTURE (default: document + liveness + face match), DOCUMENT_VERIFICATION, LIVENESS or FACE_MATCH. |
userRef | no | Your own opaque id for the end user. Don't send personal data here. |
Store sessionId against your user — you need it to read the result. Sessions are
short-lived: create one when the user taps Verify, not ahead of time.
Example: Node.js (Express)
import express from 'express';
const app = express();
const BLINK = 'https://kyc-api.blink-pay.net';
// Called by your Flutter app. requireUser = your own auth middleware.
app.post('/kyc/sessions', requireUser, async (req, res) => {
const r = await fetch(`${BLINK}/api/blink/session/create`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
clientKey: process.env.BLINK_CLIENT_KEY,
clientSecret: process.env.BLINK_CLIENT_SECRET,
purpose: 'FULL_CAPTURE',
userRef: req.user.id,
}),
});
if (!r.ok) return res.status(502).json({ error: 'kyc_unavailable' });
const { sessionId, sessionToken, expiresAt } = await r.json();
await db.kycSessions.insert({ userId: req.user.id, sessionId, status: 'CREATED' });
// Only the token and id go to the phone. Never the key or secret.
res.json({ sessionId, sessionToken, expiresAt });
});Example: Dart (shelf)
import 'dart:convert';
import 'dart:io';
import 'package:http/http.dart' as http;
import 'package:shelf/shelf.dart';
const blink = 'https://kyc-api.blink-pay.net';
Future<Response> createKycSession(Request request, String userId) async {
final r = await http.post(
Uri.parse('$blink/api/blink/session/create'),
headers: {'content-type': 'application/json'},
body: jsonEncode({
'clientKey': Platform.environment['BLINK_CLIENT_KEY'],
'clientSecret': Platform.environment['BLINK_CLIENT_SECRET'],
'purpose': 'FULL_CAPTURE',
'userRef': userId,
}),
);
if (r.statusCode != 200) return Response(502);
final body = jsonDecode(r.body) as Map<String, dynamic>;
// Save body['sessionId'] against userId before replying.
return Response.ok(
jsonEncode({'sessionId': body['sessionId'], 'sessionToken': body['sessionToken']}),
headers: {'content-type': 'application/json'},
);
}04App: run the capture
Ask your backend for a session, hand the token to BlinkKyc, and present the built-in
capture screens. On Android and iOS these are native screens with document detection,
auto-capture, on-device MRZ reading and liveness.
import 'dart:convert';
import 'package:blink_kyc/blink_kyc.dart';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
const blinkApi = 'https://kyc-api.blink-pay.net';
Future<void> startVerification(BuildContext context, String userJwt) async {
// 1. Your backend creates the Blink session (section 03).
final res = await http.post(
Uri.parse('https://api.yourbank.com/kyc/sessions'),
headers: {'authorization': 'Bearer $userJwt'},
);
final session = jsonDecode(res.body) as Map<String, dynamic>;
if (!context.mounted) return;
try {
// 2. The SDK runs document + liveness capture and uploads to Blink.
final outcome = await BlinkKyc(blinkApi, session['sessionToken'] as String)
.document(type: DocumentType.nationalId) // or omit type: the customer chooses
.face() // liveness + face match
.present(context) // the SDK owns the camera UI
.onProgress((p) => debugPrint('kyc ${p.step}'))
.run();
// 3. Show progress to the user, but decide nothing here —
// ask your backend, which reads the authoritative result (section 05).
debugPrint('device copy: ${outcome.result.wireValue}');
await http.post(
Uri.parse('https://api.yourbank.com/kyc/sessions/${session['sessionId']}/complete'),
headers: {'authorization': 'Bearer $userJwt'},
);
} on BlinkStepError catch (e) {
// A step ran and failed, e.g. DOCUMENT_UNREADABLE or LIVENESS_FAILED.
debugPrint('${e.step.wireValue}: ${e.code}');
} on BlinkError catch (e) {
// Network, session or capture problem. Switch on e.code, not the message.
if (e.code == 'BLINK_CAPTURE_CANCELLED') return; // user closed the camera
debugPrint('${e.code} (HTTP ${e.httpStatus})');
}
}Choosing the steps
| Call | Effect |
|---|---|
.document(type:, side:, country:) | Enable the document step. DocumentType.passport, nationalId, idCard or drivingLicence. Two-sided cards capture front then back. Omit type to show Blink's country and document chooser. |
.face() | Liveness and face match against the document photo. |
.disableFacialRecognition() | Document only. |
.nfc() | Also read the passport chip over NFC. Off by default. |
.setLocale('ar') | Blink's built-in Arabic copy. English is the default. |
.onProgress(cb) | Progress events such as document:capture, document:submit, liveness:capture, finalize, done. |
The session's policy wins. When run() starts, the SDK
reads the session's required steps and allowed documents from Blink. Local flags only fill gaps —
a step the session requires cannot be switched off in the app.
Hosted session links
If your dashboard or backend produces a hosted link (https://kyc-api.blink-pay.net/h/?t=bkyc_sess_…),
for example as a QR code, start from it directly. Parsing makes no network call:
final outcome = await BlinkKyc.fromHostedLink(scannedLink)
.present(context)
.run();Only accept links for the API host you expect — check the origin before passing a scanned link in.
Hosted links default to Arabic unless the link carries lang=en.
05Backend: read the result
This is the only verdict to act on. The value run() returns on the phone is a convenience
copy that a modified app could fake; your backend reads the real one, server to server.
curl -s https://kyc-api.blink-pay.net/api/blink/session/[SESSION_ID]/result \
-H 'X-Blink-Client-Key: bkyc_live_…' \
-H 'X-Blink-Client-Secret: bksec_…'
→ 200
{
"sessionId": "[SESSION_ID]",
"status": "COMPLETED",
"result": "VERIFIED",
"resultJson": "{\"sessionId\":\"[SESSION_ID]\",\"purpose\":\"FULL_CAPTURE\",\"result\":\"VERIFIED\",\"faceScore\":0.91,\"detail\":\"…\"}"
}| Field | Values | Use |
|---|---|---|
status | CREATED · IN_PROGRESS · COMPLETED · EXPIRED · SUSPENDED | Where the session is. Only COMPLETED carries a result. |
result | VERIFIED · REJECTED · REVIEW · null | The decision. null until the session completes. Route REVIEW to a person. |
resultJson | JSON encoded as a string, or null | A record for your logs and support screens: sessionId, purpose, result, a neutral detail, and faceScore when a face match ran. Decode it as a second step. Never branch on it — use result. |
The endpoint returns 404 with BLINK_RESULT_NOT_FOUND for an unknown session,
a session belonging to another client, or wrong credentials — deliberately indistinguishable. It is
idempotent, so it is safe to call again.
Example: finish the verification (Node.js)
// Called by the app after run() returns. Also poll it from a job for users who close the app.
app.post('/kyc/sessions/:sessionId/complete', requireUser, async (req, res) => {
const row = await db.kycSessions.find({ sessionId: req.params.sessionId, userId: req.user.id });
if (!row) return res.sendStatus(404);
const r = await fetch(`${BLINK}/api/blink/session/${row.sessionId}/result`, {
headers: {
'X-Blink-Client-Key': process.env.BLINK_CLIENT_KEY,
'X-Blink-Client-Secret': process.env.BLINK_CLIENT_SECRET,
},
});
if (!r.ok) return res.status(502).json({ error: 'kyc_unavailable' });
const { status, result, resultJson } = await r.json();
const record = resultJson ? JSON.parse(resultJson) : null; // for logs / support
await db.kycSessions.update(row.id, { status, result, detail: record?.detail });
if (status !== 'COMPLETED') return res.json({ state: 'pending' });
if (result === 'VERIFIED') await db.users.markVerified(req.user.id);
res.json({ state: result.toLowerCase() }); // verified | rejected | review
});Example: the same in Dart
Future<Map<String, dynamic>> fetchBlinkResult(String sessionId) async {
final r = await http.get(
Uri.parse('$blink/api/blink/session/$sessionId/result'),
headers: {
'X-Blink-Client-Key': Platform.environment['BLINK_CLIENT_KEY']!,
'X-Blink-Client-Secret': Platform.environment['BLINK_CLIENT_SECRET']!,
},
);
if (r.statusCode == 404) throw StateError('Unknown session or wrong credentials');
if (r.statusCode != 200) throw StateError('Blink returned ${r.statusCode}');
final body = jsonDecode(r.body) as Map<String, dynamic>;
final raw = body['resultJson'] as String?;
return {
'status': body['status'], // COMPLETED when done
'result': body['result'], // VERIFIED | REJECTED | REVIEW | null
'record': raw == null ? null : jsonDecode(raw), // detail, faceScore, purpose
};
}06KYC details & document images
The SDK uploads what it captures directly to Blink and does not hand the images back to your app. Your backend downloads the stored document scans and selfies with the Documents API: one call lists them, one returns them as a zip.
Extracted identity fields — name, date of birth, document number — are not returned by the client API. If your compliance process needs them from Blink, talk to us.
If you would rather keep a copy captured in the app itself — for example to store alongside your customer record — take over the camera step and supply the bytes yourself. Blink still issues the challenges, receives the upload and decides.
Your own screens, via a capture controller
final controller = BlinkCaptureController();
final run = BlinkKyc(blinkApi, sessionToken)
.document(type: DocumentType.nationalId)
.face()
.capture(controller) // instead of present(context)
.run();
// Build a screen for whatever the SDK asks for next.
ValueListenableBuilder<BlinkCaptureRequest?>(
valueListenable: controller.request,
builder: (context, request, _) => switch (request) {
BlinkDocumentCaptureRequest() => MyDocumentCamera(
onPhoto: (Uint8List jpeg) async {
await myBackend.storeDocumentCopy(jpeg); // your copy, your retention rules
controller.submitDocument(jpeg); // Blink's copy
},
onCancel: controller.cancel,
),
BlinkLivenessCaptureRequest(:final actions) =>
MyLivenessCamera(actions: actions, onFrames: controller.submitLiveness),
null => const SizedBox.shrink(),
},
);
final outcome = await run;For a fully headless flow, pass CallbackCaptureHooks(document: …, liveness: …) to
.capture() and return image bytes from your own camera code. Images are sent as JPEG;
PNG or WebP are converted.
Document images are personal data. Keeping a copy makes you
responsible for encrypting it, limiting who can see it, and deleting it on schedule. Replacing the
built-in screens also gives up its document detection, auto-capture and on-device MRZ checks, so
expect more DOCUMENT_UNREADABLE outcomes.
07Customise
Match the capture screens to your brand with BlinkTheme and BlinkStrings:
BlinkKyc(
blinkApi,
sessionToken,
theme: const BlinkTheme(
accent: Color(0xFF15803D),
background: Color(0xFF0E1E4D),
text: Color(0xFFFFFFFF),
buttonCornerRadius: 12,
instructionPosition: BlinkInstructionPosition.bottom,
brandName: 'Your Bank',
privacyPolicyUrl: 'https://yourbank.com/privacy',
),
strings: const BlinkStrings(
documentTitle: 'Verify your identity',
captureButton: 'Take photo',
),
)
.setLocale('ar')
.document()
.face()
.present(context);On Android the capture screens block screenshots by default (FLAG_SECURE); iOS has no
equivalent.
08Errors
The SDK throws two types. Switch on code, never on the message text.
| Code | Type | What to do |
|---|---|---|
DOCUMENT_UNREADABLE | BlinkStepError | Ask the user to retake in better light, then start a new session. |
LIVENESS_FAILED | BlinkStepError | Offer another attempt with a new session. |
BLINK_SESSION_INVALID | BlinkError · 401 | Token missing, expired or already used. Create a new session. |
BLINK_CHALLENGE_INVALID | BlinkError | A step's one-time challenge expired. Retry with a new session. |
BLINK_CAPTURE_CANCELLED | BlinkError | The user closed the camera. Not an error to report. |
BLINK_CAMERA_DENIED | BlinkError | Explain why the camera is needed and link to Settings. |
BLINK_NETWORK · BLINK_TIMEOUT | BlinkError · 0 | Connectivity. Let the user retry. |
BLINK_QUOTA_EXCEEDED | BlinkError · 429 | Your plan's quota is used up. Contact Blink. |
Blink's HTTP errors all share one shape: {"code": "…", "message": "…", "timestamp": "…"}.
The full code list is in the Errors reference.
09Go-live checklist
- The client key and secret exist only in your backend's secret store — not in the app, not in git.
- A new session is created for every attempt, when the user starts it.
- Your backend stores
sessionIdper user and reads/resultbefore changing any account state. REVIEWhas a manual path, andREJECTEDtells the user what to do next.- A background job re-reads results for sessions left
IN_PROGRESSwhen users close the app. - Tested on a real Android phone and a real iPhone with a real document — simulators have no camera.
- If you keep document copies: encryption at rest, access control and a deletion schedule are in place.
Credentials for sandbox and production are issued under a Blink KYC integration agreement. Talk to us to get set up.