react-native-kyc-insight
v0.1.3
Published
KYC Insight React Native SDK — native iOS + Android verification widget bridge (liveness, document capture, BVN / NIN / DL / Passport / CAC).
Maintainers
Readme
react-native-kyc-insight
React Native bridge for the KYC Insight identity verification widget. A thin wrapper over the native iOS + Android SDKs — same config, same lifecycle callbacks, one TypeScript surface.
- Active-vision liveness with on-device face detection
- Document capture + file upload
- BVN / NIN / Driver's License / Passport / CAC consent flows
- Verdict-aware result screen
Install
yarn add react-native-kyc-insight
# or
npm install react-native-kyc-insightiOS
cd ios && pod installThe CocoaPods spec pulls in the native KYCWidget pod automatically.
iOS 15.0+ required.
Android
Nothing extra to do — autolinking picks up the module. The native
ng.netapps:kyc-insight artifact is pulled from Maven Central
transitively. minSdk 24+ required.
If you want to pin a specific version of the native SDK, override the
dependency in your app's android/app/build.gradle:
dependencies {
implementation 'ng.netapps:kyc-insight:0.1.0'
}Permissions
iOS — required Info.plist keys
iOS will crash your app the moment the widget touches the camera /
microphone / photo library if your host app's Info.plist doesn't
declare a usage description. This is enforced by the OS (TCC), not by
the SDK — the strings must live in your app's plist, not in the pod.
Add these three keys to ios/<YourApp>/Info.plist:
<key>NSCameraUsageDescription</key>
<string>This app uses the camera to verify your identity (liveness check) and capture document photos.</string>
<key>NSMicrophoneUsageDescription</key>
<string>This app records short audio with the liveness video when required.</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>This app lets you upload documents from your photo library.</string>The exact wording is up to you — App Review reads it. Empty strings will fail submission.
Android — runtime permission
The SDK declares android.permission.CAMERA via manifest-merger. Your
host app must still request the runtime permission on API 23+
before calling present(). With the AndroidX activity-result API:
import { PermissionsAndroid } from 'react-native';
const granted = await PermissionsAndroid.request(
PermissionsAndroid.PERMISSIONS.CAMERA,
);
if (granted === PermissionsAndroid.RESULTS.GRANTED) {
await widget.present();
}Quick start
The easiest way to use the SDK in a React Native component is the
useKYCWidget hook — it owns the lifecycle for you (auto-cleanup on
unmount, listener management, state machine).
import { useKYCWidget } from 'react-native-kyc-insight';
function VerifyScreen() {
const { present, close, state, error, verdict, currentLevel } =
useKYCWidget(
{
publicKey: 'NA_PUB_PROD-xxxxxxxxxxxxxxxxxxxxxxxx',
userRef: 'user-1234',
slug: 'supplier_registration',
name: 'Lawrence Olu',
levelSlug: 'tier_1',
},
{
onSuccess: () => navigation.replace('Home'),
onLivenessSubmitted: (v) => analytics.track('liveness', v),
onError: (e) => Alert.alert(e.message),
},
);
return (
<>
<Button
title="Verify"
onPress={present}
disabled={state !== 'idle' && state !== 'closed'}
/>
{state === 'active' && <Button title="Cancel" onPress={close} />}
{error && <Text style={{ color: 'red' }}>{error.message}</Text>}
</>
);
}If you'd rather manage the widget imperatively, use the KYCWidget
class directly:
import { KYCWidget } from 'react-native-kyc-insight';
const widget = new KYCWidget({ /* same config */ });
widget.addListener('success', () => console.log('All tiers approved'));
widget.addListener('close', () => widget.destroy());
await widget.present();Hook API — useKYCWidget(config, handlers?)
function useKYCWidget(
config: KYCWidgetConfig,
handlers?: UseKYCWidgetHandlers,
): {
present: () => Promise<void>;
close: () => void;
state: 'idle' | 'presenting' | 'active' | 'closed';
error: KYCWidgetError | null;
verdict: KYCLivenessVerdict | null;
currentLevel: KYCWidgetLevel | null;
};What the hook does for you
- Lifecycle ownership — creates the native widget on mount, destroys
it on unmount. You never call
new KYCWidget(...)ordestroy()yourself. - Listener cleanup — wires all 8 native events under the hood and removes them when the component unmounts.
- Handler stability — handlers passed in
handlersare pinned in a ref, so passing inline arrow functions doesn't rewire native listeners on every render. - State surfacing — returns
state,error,verdict,currentLevelas React state so your UI re-renders when the native side fires events. - Config re-creation — recreates the underlying widget only when
a config field actually changes (deep equality via
JSON.stringify), not on every render where the caller spreads a new object.
Returned fields
| Field | Description |
|---|---|
| present() | Launch the widget. Resolves once the native modal mounts; rejects with E_CONFIG / E_NO_HOST. Clears the previous error. |
| close() | Dismiss the widget. Same effect as the user tapping the close button. Idempotent. |
| state | One of 'idle' (initial / after close), 'presenting' (after present(), before ready), 'active' (after ready), 'closed' (after close). |
| error | The most recent KYCWidgetError surfaced by the SDK. Cleared on each present() call. |
| verdict | The most recent KYCLivenessVerdict (after livenessSubmitted). null until the user completes liveness. |
| currentLevel | The most recent KYCWidgetLevel (after levelChange or levelApproved). null until the user enters their first tier. |
Handler bag
All handlers are optional — pass only what you need:
interface UseKYCWidgetHandlers {
onReady?: () => void;
onLevelChange?: (level: KYCWidgetLevel) => void;
onLevelApproved?: (level: KYCWidgetLevel) => void;
onSubmit?: (payload: unknown) => void;
onSuccess?: (result: unknown) => void;
onError?: (error: KYCWidgetError) => void;
onClose?: () => void;
onLivenessSubmitted?: (verdict: KYCLivenessVerdict) => void;
}Each handler maps 1:1 to a native event — see Events below for full semantics.
Class API
new KYCWidget(config)
| Field | Required | Description |
|---|---|---|
| publicKey | yes | Merchant's NA_PUB_* key. |
| userRef | yes | Stable identifier for the end user. Reusing the same value returns the same customer record. |
| slug | yes | KYC group slug. |
| name | yes | End-user display name. |
| levelSlug | yes | Starting tier slug. |
| vName | no | Billing-line alias for verification-link integrations. |
| apiEnvironment | no | 'TEST' or 'LIVE' (default). |
Methods
| Method | Returns | Description |
|---|---|---|
| present() | Promise<void> | Validate the config and launch the native widget modally over the current RN host. Resolves once the widget is mounted; rejects with E_CONFIG on validation failure or E_NO_HOST if no Activity / view controller is available to present from. |
| destroy() | void | Programmatic close. Dismisses the active widget, fires the close event, and clears native state. Idempotent — safe to call when no widget is presented. Use this when you need to close the widget from JS (back-press handler, timeout, after your own success/cancel handling, navigating away in React Navigation, etc.). |
| close() | void | Alias for destroy(). Same behaviour — provided for the modal-style .close() idiom many RN libraries follow. |
| addListener(event, handler) | () => void | Subscribe to a lifecycle event. Returns an unsubscribe function — call it when your component unmounts to avoid leaks. The full event catalogue is below. |
Programmatic close
const widget = new KYCWidget(config);
await widget.present();
// Later, anywhere in your app — even from outside the component that
// created the widget — call destroy() (or close()) to dismiss it:
widget.destroy();
// or
widget.close();Both methods do the same thing. Calling either:
- Dismisses the native modal / finishes the host Activity.
- Tears down the native KYC session and frees its resources (camera, ML Kit detector, network client).
- Fires the
closeevent back to your JS listeners, so a single handler can react to both user-initiated and programmatic closes.
A common pattern is to wire destroy() to React Navigation's back
intent:
useEffect(() => {
const unsubscribe = navigation.addListener('beforeRemove', () => {
widget.destroy();
});
return unsubscribe;
}, [navigation, widget]);Events
Subscribe with widget.addListener(name, handler). The handler runs on
the JS thread. All eight native lifecycle callbacks are exposed:
ready
The widget has mounted, fetched its schema from the backend, and is ready for user input. This is your signal that the verification UI is now visible to the user.
Payload: none.
widget.addListener('ready', () => {
// Hide your own loading spinner, log "session started" analytics, etc.
});levelChange
The user moved to a new tier — either by submitting the last section of the previous tier, or by navigating manually through the journey outline. Fires on every tier transition, including the initial landing.
Payload: { slug: string, index: number }
slug— the tier's identifier (e.g.'tier_1').index— its zero-based position in theschema.stepsarray.
widget.addListener('levelChange', (level) => {
console.log(`Now on ${level.slug} (#${level.index})`);
});levelApproved
Every section in a tier has been fully approved server-side and is no longer flagged for update. Fires once per transition — never double-fires on a refresh. Useful for kicking off your own backend hooks when a customer crosses a verification milestone.
Payload: { slug: string, index: number } (same shape as
levelChange).
widget.addListener('levelApproved', (level) => {
analytics.track('kyc_tier_approved', { slug: level.slug });
});submit
A section was successfully submitted to the backend (the user tapped Continue / Submit and the backend accepted the payload). Fires before the cursor advances to the next section.
Payload: the section object — { id, name, providerType, status }.
Useful as a fine-grained progress signal; for tier-level callbacks
prefer levelApproved.
widget.addListener('submit', (section) => {
console.log('Submitted section:', section);
});success
Every tier in the user's flow is now approved or pending review. Terminal event — the widget is about to show the "Verification submitted" screen.
Payload: none (currently — may carry the schema in a future version).
widget.addListener('success', () => {
// The customer has finished. Navigate away, refresh your local
// user-state cache, etc.
});livenessSubmitted
The backend's risk scorer has evaluated a liveness session and returned
a verdict. Fires before levelApproved so your analytics see the
score with the approval decision.
Payload:
{
sessionToken: string;
status: 'passed' | 'failed' | 'requires_manual_review' | 'expired' | 'submitted';
riskScore: number | null; // analytics only — never shown to the user
failureReason: string | null;
}status === 'passed'— auto-approved; the user advances.status === 'requires_manual_review'— queued for a reviewer; the user advances but the section status becomespending.status === 'failed'or'expired'— the user is asked to retake;failureReasoncarries the user-friendly hint.riskScore— the backend's internal score (0-1 typically). The widget UI never displays this to the user — it's signal for your analytics, dashboards, and review queues only.
widget.addListener('livenessSubmitted', (v) => {
analytics.track('liveness_verdict', {
status: v.status,
risk_score: v.riskScore,
});
});error
A fatal load or submission failure. The widget will surface a recovery banner with a "Try again" button to the user; you receive a typed copy so you can log / report / alert.
Payload: { type, message, detail? } where type is one of:
| Type | Meaning |
|---|---|
| missingRequiredConfig | A required config field was empty. detail holds the field name. |
| loadFailed | createMerchantCustomer or initial session load failed. message carries the network/server reason. |
| submissionFailed | The backend rejected a section submission. |
| cameraUnavailable | No usable camera (rare — emulators without virtual cameras). |
| permissionDenied | Runtime permission denied. detail holds the permission kind (e.g. 'Camera'). |
| externalConsentFailed | An out-of-app consent step (NIN auth, BVN consent) failed or was cancelled. |
| unknown | Fallback for un-typed errors. |
widget.addListener('error', (err) => {
if (err.type === 'permissionDenied') {
Alert.alert(`${err.detail} permission required`);
} else {
crashlytics().recordError(new Error(err.message));
}
});close
The widget was dismissed — either by the user tapping the close button
or by your code calling widget.destroy() / widget.close(). Always
the last event fired in a session's lifetime; once it fires the
widget instance is unusable, construct a new one if you need another
session.
Payload: none.
widget.addListener('close', () => {
// Optional: refresh whatever screen sits behind the modal.
refreshCustomerState();
});Cleanup
Each addListener returns its own unsubscribe function. Inside a
component, call all of them on unmount:
useEffect(() => {
const widget = new KYCWidget(config);
const subs = [
widget.addListener('ready', () => {}),
widget.addListener('levelChange', () => {}),
widget.addListener('levelApproved', () => {}),
widget.addListener('submit', () => {}),
widget.addListener('success', () => {}),
widget.addListener('livenessSubmitted', () => {}),
widget.addListener('error', () => {}),
widget.addListener('close', () => {}),
];
widget.present();
return () => {
for (const u of subs) u();
widget.destroy();
};
}, []);License
MIT
