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.present. 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: ^1.0.13
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
blink_kyc is a thin bridge over the native Blink Android and iOS SDKs: the native capture
screens own the camera, the on-device checks and the upload.
Android
The plugin depends on the native Blink Android SDK, served from the Blink Maven repository. Add the repository to your app's Gradle build, and keep the minimum SDK at 24 or higher:
allprojects {
repositories {
google()
mavenCentral()
maven { url = uri("https://blink-pay.net/maven/releases") } // Blink SDK repository
}
}android {
defaultConfig {
minSdk = 24 // required by the Blink Android SDK
}
}The camera and internet permissions and the capture screens are merged in from the SDK; a plain
FlutterActivity works, and the screens ask for the camera themselves. Build with JDK 17 or 21.
iOS
At pod install the plugin downloads the prebuilt Blink iOS SDK from the Blink artifact site.
It needs iOS 15.5, and pods linked statically, because the SDK and TensorFlow Lite are static binaries:
platform :ios, '15.5'
target 'Runner' do
use_frameworks! :linkage => :static
# … the rest of Flutter's generated Podfile
endAdd the camera usage description to ios/Runner/Info.plist:
<key>NSCameraUsageDescription</key>
<string>We use the camera to photograph your document and confirm your identity.</string>Passport chip reading is off in 1.0.13. The native screens capture the document photo and the selfie; no NFC capability or NFC keys are needed.
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.present, and the native capture
screens take over: document detection, auto-capture, on-device MRZ reading and liveness.
import 'dart:convert';
import 'package:blink_kyc/blink_kyc.dart';
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
Future<void> startVerification(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>;
try {
// 2. The native SDK runs document + liveness capture and uploads to Blink.
final outcome = await BlinkKyc.present(BlinkConfig(
sessionToken: session['sessionToken'] as String,
issuingCountry: 'SDN', // SDN | ARE; omit to let the session decide
documentType: BlinkDocumentType.nationalId, // passport | nationalId; omit to let the session decide
locale: 'ar', // Blink's built-in Arabic; omit for the device locale
));
// 3. Show progress to the user, but decide nothing here —
// ask your backend, which reads the authoritative result (section 05).
debugPrint('device copy: ${outcome.verdict.name}'); // verified | rejected | review
await http.post(
Uri.parse('https://api.yourbank.com/kyc/sessions/${session['sessionId']}/complete'),
headers: {'authorization': 'Bearer $userJwt'},
);
} on BlinkKycException catch (e) {
// A cancelled journey, a failed step or a session problem. Switch on e.code, not the message.
if (e.code == 'BLINK_CAPTURE_CANCELLED') return; // user closed the camera
debugPrint('${e.code}: ${e.message}');
}
}The configuration
| Field | Meaning |
|---|---|
sessionToken | Required. The short-lived token your backend got from Blink. |
baseUrl | The Blink API. Defaults to https://kyc-api.blink-pay.net. |
issuingCountry | 'SDN' or 'ARE'. Omit to let the session decide. |
documentType | BlinkDocumentType.passport or nationalId. Two-sided cards capture front then back. |
locale | 'ar' for Blink's built-in Arabic, right to left; 'en' for English. Omit for the device locale. |
rejectExpiredDocuments | On by default: an expired document ends the journey with BLINK_DOCUMENT_EXPIRED. false leaves the decision to Blink. |
theme | A BlinkTheme: colours, sizes, positions and images for the native screens. Omit for the built-in look. See Customise. |
BlinkKyc.present resolves with a BlinkSessionResult: verdict
(BlinkVerdict.verified, rejected or review) and an optional
detail. It throws BlinkKycException with the native code otherwise.
The session's policy wins. The native SDK reads the session's required steps and allowed documents from Blink when it starts. The configuration only fills gaps: the steps come from the session's purpose.
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, take the session token from its t parameter. Only accept links for the
API host you expect:
final link = Uri.parse(scannedLink);
if (link.scheme != 'https' || link.host != 'kyc-api.blink-pay.net') throw const FormatException('Not a Blink link');
final token = link.queryParameters['t'];
if (token == null) throw const FormatException('No session token in the link');
final outcome = await BlinkKyc.present(BlinkConfig(
sessionToken: token,
locale: link.queryParameters['lang'] == 'en' ? 'en' : 'ar', // hosted links default to Arabic
));05Backend: read the result
This is the only verdict to act on. The value BlinkKyc.present 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 BlinkKyc.present 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.
The Flutter plugin always runs the built-in native screens, so it cannot hand the camera to your own code. If you need your own capture screens, for example to keep a copy of the image in the app, integrate the native SDKs' headless mode instead (Android, iOS). Blink still issues the challenges, receives the upload and decides.
07Customise
Pass a BlinkTheme to style the native screens. Every field is optional and keeps the built-in
look when left out:
final result = await BlinkKyc.present(BlinkConfig(
sessionToken: sessionToken,
theme: const BlinkTheme(
accent: Color(0xFF15803D),
brandName: 'Your Bank',
logo: BlinkImage.asset('assets/bank_logo.png'),
scrimColor: Color(0xFF0E1E4D), // the dimmed area outside the document frame
scrimOpacity: 0.35, // 0 clear … 1 opaque; 0.6 unless set
guideSuccessColor: Color(0xFF22C55E),
),
));The fields are the native BlinkTheme's, listed with what each one changes in
Customisation. Images are BlinkImage.asset (an asset in
your pubspec.yaml), BlinkImage.resource (an Android drawable or iOS asset-catalogue
name) or BlinkImage.file. An invalid value throws BlinkKycException with
BLINK_CONFIG.
For the language, set locale: 'ar' for Blink's built-in Arabic copy with right-to-left
screens. Individual sentences cannot be replaced from Flutter yet.
08Errors
BlinkKyc.present throws BlinkKycException, carrying the native SDK's error
code. Switch on code, never on the message text.
| Code | Raised by | What to do |
|---|---|---|
DOCUMENT_UNREADABLE | A failed step | Ask the user to retake in better light, then start a new session. |
LIVENESS_FAILED | A failed step | Offer another attempt with a new session. |
BLINK_SESSION_INVALID | Blink · HTTP 401 | Token missing, expired or already used. Create a new session. |
BLINK_CHALLENGE_INVALID | The native SDK | A step's one-time challenge expired. Retry with a new session. |
BLINK_CAPTURE_CANCELLED | The native SDK | The user closed the camera. Not an error to report. |
BLINK_CAMERA_DENIED | The native SDK | Explain why the camera is needed and link to Settings. |
BLINK_DOCUMENT_EXPIRED | The native SDK | The document's expiry date has passed. The SDK told the user, sent the photograph so the session records it, and closed when they pressed the button; offer a new attempt with a new session. |
BLINK_NETWORK · BLINK_TIMEOUT | The device | Connectivity. Let the user retry. |
BLINK_CONFIG | The plugin | A bad configuration value (an unsupported country, a missing token). Fix the call. |
BLINK_NO_ACTIVITY · BLINK_NO_UI | The plugin | No Android activity or iOS view controller to show the screens from. Call it from a visible screen. |
BLINK_QUOTA_EXCEEDED | Blink · HTTP 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.