KYC Docs Get credentials
Docs › Blink SDK › Flutter

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.

package blink_kyc ^2.1.0 API https://kyc-api.blink-pay.net Flutter 3.32+ · Dart 3.8+ iOS 15+ · Android API 24+

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.

1 · Your backend

Create a session

POST /api/blink/session/create with your client key and secret. Returns sessionId and sessionToken.

2 · Your Flutter app

Run the capture

Pass the sessionToken to BlinkKyc. The SDK shows the camera screens and uploads to Blink.

3 · Your backend

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:

pubspec.yamldependencies
dependencies:
  flutter:
    sdk: flutter
  blink_kyc:
    hosted: https://blink-pay.net/pub
    version: ^2.1.0
  http: ^1.2.0   # to call your own backend
terminalshell
flutter pub get

Import it wherever you start verification:

lib/kyc.dartdart
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/app/build.gradle.ktskotlin
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:

ios/Runner/Info.plistxml
<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

POST https://kyc-api.blink-pay.net/api/blink/session/createhttp
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"
}
FieldRequiredMeaning
clientKeyyesYour client key. Server-side only.
clientSecretyesYour client secret. Server-side only — never in the app.
purposenoFULL_CAPTURE (default: document + liveness + face match), DOCUMENT_VERIFICATION, LIVENESS or FACE_MATCH.
userRefnoYour 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)

server.jsjavascript
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)

bin/server.dartdart
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.

lib/kyc.dartdart
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

CallEffect
.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:

lib/kyc.dartdart
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.

GET https://kyc-api.blink-pay.net/api/blink/session/{sessionId}/resulthttp
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\":\"…\"}"
}
FieldValuesUse
statusCREATED · IN_PROGRESS · COMPLETED · EXPIRED · SUSPENDEDWhere the session is. Only COMPLETED carries a result.
resultVERIFIED · REJECTED · REVIEW · nullThe decision. null until the session completes. Route REVIEW to a person.
resultJsonJSON encoded as a string, or nullA 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)

server.jsjavascript
// 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

bin/server.dartdart
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

lib/kyc_own_camera.dartdart
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:

lib/kyc.dartdart
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.

CodeTypeWhat to do
DOCUMENT_UNREADABLEBlinkStepErrorAsk the user to retake in better light, then start a new session.
LIVENESS_FAILEDBlinkStepErrorOffer another attempt with a new session.
BLINK_SESSION_INVALIDBlinkError · 401Token missing, expired or already used. Create a new session.
BLINK_CHALLENGE_INVALIDBlinkErrorA step's one-time challenge expired. Retry with a new session.
BLINK_CAPTURE_CANCELLEDBlinkErrorThe user closed the camera. Not an error to report.
BLINK_CAMERA_DENIEDBlinkErrorExplain why the camera is needed and link to Settings.
BLINK_NETWORK · BLINK_TIMEOUTBlinkError · 0Connectivity. Let the user retry.
BLINK_QUOTA_EXCEEDEDBlinkError · 429Your 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

Credentials for sandbox and production are issued under a Blink KYC integration agreement. Talk to us to get set up.