React Native SDK

The React Native SDK mirrors the web SDK's core API (same props, same callbacks) but runs on-device, native liveness: Apple Vision on iOS and Google ML Kit on Android, via a react-native-vision-camera v5 (Nitro) frame processor. Because it ships native code, it needs a custom native build and does not run in Expo Go. For shared concepts (supported countries, branding, results, and errors), see Client SDKs.

Feature availability. This SDK is at full parity with the web and Flutter SDKs: workflow embedding (workflowId), the capture add-ons (contact OTP, proof of address, questionnaire), business (KYB) verification, any Global Documents country, plus NFC chip reading, which the web SDK can't do at all (browsers can't talk to a passport chip). See Feature availability.

Requirements

RequirementMinimum
iOS deployment target15.1
Android minSdkVersion24 (Android 7.0) · compileSdk 34 · NDK 27.1
Expo SDK56 (React 19, React Native 0.85)
React Native0.83+, with the New Architecture enabled (VisionCamera v5 / Nitro requires it; Expo SDK 56 enables it by default)
Build toolchainXcode + CocoaPods (iOS) · JDK 17 for Android Gradle builds
RuntimeA dev client or bare build, not Expo Go

Peer dependencies to install in your app: expo (≥56), react (≥19), react-native (≥0.83), react-native-vision-camera (v5), react-native-vision-camera-worklets (≥5), react-native-worklets (≥0.8), react-native-nitro-modules (≥0.35), react-native-nitro-image (≥0.15), react-native-safe-area-context (≥4), react-native-svg (≥15).

Install

Pick the path that matches your project.

shell
npx expo install @myazahq/kyc-sdk-react-native \
  react-native-vision-camera react-native-vision-camera-worklets \
  react-native-worklets react-native-nitro-modules react-native-nitro-image \
  react-native-safe-area-context react-native-svg

Add the config plugin to app.json (it adds the iOS camera-usage string and Android CAMERA / INTERNET permissions), then build a dev client:

json
// app.json
{
  "expo": {
    "plugins": [
      "@myazahq/kyc-sdk-react-native"
    ]
  }
}

VisionCamera v5 ships no config plugin (v4 did), so it takes no plugins entry. Listing it makes expo prebuild fail with Cannot find module '.../lib/VisionCamera', because Expo loads the package's main entry as a plugin. The camera permission and usage strings come from the Myaza plugin; pass cameraPermission to change the iOS wording.

shell
npx expo prebuild
npx expo run:ios       # or: npx expo run:android

The SDK plugin accepts an optional custom camera prompt: ["@myazahq/kyc-sdk-react-native", { "cameraPermission": "Your message…" }]. It also adds the location permission strings by default (the Address Intelligence step's "Use my current location" shortcut and attest fix; foreground only, and never required to finish the flow) — pass { "location": false } to opt out if none of your workflows collect an address, or { "locationPermission": "Your message…" } to customise the iOS prompt.

Optional modules

Four expo-* modules are optional peers. The SDK loads each one lazily and carries on without it, so nothing crashes if you skip them, but each one is a capability rather than a detail:

ModuleWhat installing it buysWithout it
expo-deviceMake, model, manufacturer and physical-vs-simulator in the device metadataThose fields are omitted and the device class is guessed from the platform, so Device Intelligence has a weaker fingerprint and shared-device detection suffers
expo-applicationYour app's id, version and build number on the submissionThe app block is omitted entirely, so a result cannot be traced to the build that produced it
expo-localizationThe device's region, explicitlyCountry defaults and the reported locale fall back to the JS runtime's locale, which often carries no region at all (en rather than en-NG)
expo-document-picker"Choose a file" on the proof-of-address and KYB document stepsThose steps accept a camera capture only, so a PDF bank statement cannot be submitted at all
shell
npx expo install expo-device expo-application expo-localization expo-document-picker

react-native-webview is an optional peer. With it installed, the address step renders the Google map and the Street View entrance framer inside your app; without it the built-in map and the entrance photo remain, and nothing else changes. Address fields the workflow marks required are enforced in the app (Continue opens the details sheet naming what is still needed), so a submission is never refused for a missing field at the end of the flow.

Bare React Native app (no Expo prebuild)

The SDK uses a few expo-* modules, so add the Expo module runtime (you don't need the managed workflow), then install the SDK and its peers:

shell
# 1. One-time: add Expo modules to a bare RN app
npx install-expo-modules@latest

# 2. Install the SDK + peer dependencies
npm install @myazahq/kyc-sdk-react-native \
  react-native-vision-camera react-native-vision-camera-worklets \
  react-native-worklets react-native-nitro-modules react-native-nitro-image \
  react-native-safe-area-context react-native-svg \
  expo expo-image-manipulator expo-image-picker expo-speech expo-font \
  expo-glass-effect expo-crypto expo-location

# 3. iOS pods
cd ios && pod install && cd ..

Then add the native permissions manually (the config plugin only runs under prebuild):

xml
<!-- iOS  ios/<App>/Info.plist -->
<key>NSCameraUsageDescription</key>
<string>We use the camera to photograph your ID and capture a live selfie.</string>
xml
<!-- Android  android/app/src/main/AndroidManifest.xml -->
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.INTERNET" />

If your workflows use the Address Intelligence step, also add the location strings (iOS crashes on the permission request without the usage string):

xml
<!-- iOS  ios/<App>/Info.plist -->
<key>NSLocationWhenInUseUsageDescription</key>
<string>Your location helps place the map pin on your address.</string>
xml
<!-- Android  android/app/src/main/AndroidManifest.xml -->
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />

The native face-detector module (a VisionCamera v5 Nitro HybridObject plus its Android lib loader) is autolinked, so no manual linking is needed. Ensure the New Architecture is enabled and the worklets/frame-processor build is set up per VisionCamera's setup docs (it relies on react-native-worklets / react-native-vision-camera-worklets, installed above).

Presence reporting (Address Intelligence)

When a workflow enables presence verification, the SDK stores the confirmed pin on-device at capture. Call the reporter from your app on a natural moment (app open works well):

tsx
import { reportAddressPresence } from '@myazahq/kyc-sdk-react-native';

const result = await reportAddressPresence({
  apiKey: 'pk_live_…',
  externalUserId: 'user_42', // the same userId the KYC flow ran with
});
// result.reason: 'reported' | 'no_pin' | 'services_off' | 'no_fix' | 'outside_fence' | 'no_watch' | 'network_error'

You can also call it from onSubmit, the moment the flow finishes: the watch is created a few seconds after a submission is accepted, so when the pin was captured within the last 15 minutes the reporter waits for it (up to 90 seconds) before posting. no_watch means nothing is monitoring this user right now, so there was nothing to report to.

It never throws and never blocks startup. The geofence is evaluated on-device: only the derived day + night flag is transmitted, never a coordinate. A fix outside the fence sends nothing; a mock-location fix is reported flagged. clearPresencePin(externalUserId) drops the stored pin (sign-out, or after the watch resolves). Stored pins self-expire after 45 days for bounded checks; a pin captured under always-on monitoring never expires until revoked.

Background monitoring (OS geofencing)

The stronger tier: the OS wakes the SDK on fence crossings around the stored pin, app closed or not. Entries stamp a timestamp; exits fold the dwell into per-day aggregates on-device and flush them — the same privacy floor as the foreground tier. Three opt-ins, each deliberate:

  1. Install the optional peer: npx expo install expo-task-manager (without it the background tier simply does not exist).
  2. Declare background location via the config plugin — ["@myazahq/kyc-sdk-react-native", { "location": "always" }]. This changes your app's store review posture; the Background Location Declarations page carries the ready-to-paste Play Console and App Review texts.
  3. Register the task at your app's root module (before the component tree), then enable after capture:
tsx
// index.js
import { registerBackgroundPresence } from '@myazahq/kyc-sdk-react-native';
registerBackgroundPresence();

// later, once the KYC flow has stored a pin:
const result = await enableBackgroundPresence({ apiKey: 'pk_live_…', externalUserId: 'user_42' });
// result.reason: 'enabled' | 'module_missing' | 'no_pin' | 'foreground_denied' | 'background_denied' | 'start_failed'

disableBackgroundPresence() disarms the fence. A refusal at any step leaves the foreground tier working exactly as before: the tiers degrade, never break.

The Android foreground service

On phones whose battery managers drop geofence transitions (Tecno, Infinix, Xiaomi and friends), a persistent notification is what keeps the check alive. Opt-in, Android only, on the same location: "always" plugin setting, which also declares the FOREGROUND_SERVICE and FOREGROUND_SERVICE_LOCATION permissions. registerBackgroundPresence() at the root already defines its task:

tsx
const result = await enableForegroundService({
  apiKey: 'pk_live_…',
  externalUserId: 'user_42',
  notification: { title: 'Address verification in progress', body: 'Open the app to see your progress' },
});
// result.reason: 'enabled' | 'unsupported_platform' | 'module_missing' | 'no_pin' | 'foreground_denied' | 'background_denied' | 'start_failed'

A low-power fix every ten minutes becomes the same enter/exit spans the geofence folds, on the same stored state; the queue flushes while the process is alive; a dropped fence is re-armed. disableForegroundService() stops it. See Address Intelligence for the reasoning.

Which tier is running?

Permissions get revoked in Settings and nothing tells the app. Ask, and offer the only honest road back:

tsx
import { presenceStatus, openLocationSettings } from '@myazahq/kyc-sdk-react-native';

const status = await presenceStatus('user_42');
// status.tier: 'background' | 'foreground' | 'none', plus pinStored, alwaysOn,
// locationServicesEnabled, both permission states, geofenceArmed, foregroundServiceRunning
if (!status.locationServicesEnabled) {
  await openLocationSettings('services'); // the phone's location toggle is off
} else if (status.tier === 'none' && status.pinStored) {
  await openLocationSettings(); // no OS allows re-prompting in-app after a denial
}

To show the person where the check stands, poll GET /api/kyc/address/presence/:externalUserId with the publishable key; see Address Intelligence.

Voice guidance is text-to-speech output. The SDK never records audio, so no microphone permission is requested or required (enableMicrophonePermission: false).

Usage

<MyazaKYC /> renders a "Verify Identity" trigger plus the full-screen flow. The API is identical to the web SDK, with the same props and callbacks. Unlike the web SDK there is no stylesheet to import: styling is built in.

Build the flow once in the dashboard as a workflow, then mount it by id. The country, ID types, capture steps, branding and copy all come from the workflow, so changing the flow is a re-publish rather than an app release, which matters even more on mobile, where a redeploy means an app-store round trip. Requires ≥ 2.1.0 (the 2.0.x line silently ignores the id).

tsx
import { MyazaKYC } from "@myazahq/kyc-sdk-react-native";

export default function VerifyScreen() {
  return (
    <MyazaKYC
      apiKey="pk_test_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
      workflowId="wf_AbC123dEf456"
      // Runtime data — a workflow is a shared template and cannot carry any of it.
      userId="user_42"
      userData={{ firstName: "Jane", lastName: "Doe" }}
      metadata={{ requestId: "order_1001" }}
      onSubmit={(submission) => console.log("submitted", submission.verificationId)}
      onError={(err) => console.error(err.code, err.message)}
      onClose={() => console.log("closed")}
    >
      Verify Identity
    </MyazaKYC>
  );
}

userData is worth passing. It is the name you believe the user has, and it is compared against the name read off their document, and that comparison is what produces dataMatch on the verification. Leave it out and the check simply never runs: there is nothing to compare the document against, and dataMatch comes back null.

It cannot live on the workflow. userId, userData and metadata are per-user runtime values, and a workflow is a template shared by every visitor, so these stay in code even when everything else moves to the dashboard.

Or configure everything in code

Skip the workflow and pass the flow's shape as props. Useful for a quick start or a single fixed flow; anything you'd change later means an app release.

tsx
import { MyazaKYC } from "@myazahq/kyc-sdk-react-native";

export default function VerifyScreen() {
  return (
    <MyazaKYC
      apiKey="pk_test_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
      country="NG"
      idTypes={["bvn", "drivers-license", "passport"]}
      userData={{ firstName: "Jane", lastName: "Doe" }}
      enableSelfie
      enableDocumentCapture
      enableLiveness
      showThemeToggle
      appearance={{ primaryColor: "#5645F5", companyName: "Myaza", logo: "default", theme: "light" }}
      consent={{ title: "Welcome, {firstName}", description: "A quick check to confirm it's really you." }}
      success={{ title: "You're all set, {firstName}!", description: "We'll email you once your verification is reviewed." }}
      metadata={{ requestId: "order_1001", userId: "user_42" }}
      onSubmit={(submission) => {
        // The verification was created; status is always 'pending'.
        // Reconcile the final result on your backend via webhook or a secret-key
        // GET /verifications/:id call (never from the client).
        console.log("submitted", submission.verificationId);
      }}
      onError={(err) => console.error(err.code, err.message)}
      onClose={() => console.log("closed")}
    >
      Verify Identity
    </MyazaKYC>
  );
}

The SDK accepts the same props as the web SDK's props table, including workflowId, livenessMode, deviceIntelligence, consentStep (skip the welcome screen when your app has already asked for consent), and the capture add-ons (contact OTP, proof of address, questionnaire, NFC). The one exception is className (React Native has no class names); style the trigger by passing style, or render your own trigger with the hook.

disableClose blocks user dismissal on native too: the iOS swipe-down and the Android back button. Because the built-in <MyazaKYC /> trigger has no external close handle, pair disableClose with the useMyazaKYC() hook and call its close() to dismiss the flow yourself.

Biometric re-authentication

A workflow on the biometric-authentication scope confirms it is really your user, matching a live selfie against the face you enrolled. Three options on the workflow, plus your own words for its screens, shape how the React Native flow behaves (the web and Flutter SDKs honour the same three). All three live on the workflow's Presence Intelligence panel in the builder, under Flow, so they need no code change; a prop-configured mount can pass the same biometric block.

  • Where the verdict lands: In the app and on your webhook (the default) keeps the person on one loading screen from the moment the selfie is taken until the check settles, polling the status endpoint for up to a minute, then shows whether it was them; your app hears the verdict in onResult and your webhook receives it as on every other flow. In the app only is the same wait, but the server sends no webhook for that check at all (verification.started and the terminal events included), so the verdict is your app's alone. Webhook only shows the ordinary submitted screen and leaves the verdict to your webhook. Enrolment workflows record the reference and have no verdict to deliver, so they never wait.
  • Selfie review: whether the captured selfie is shown back with Retake and Continue before it is sent. Off by default on the biometric scopes: a face check is a few seconds long, and a review screen is a stop in the middle of it. Turn it on when you want the person to approve the photo first.
  • Done button: whether the final screen carries a Done button. On by default. Turn it off when your app closes the flow itself from onResult (or from onSubmit on a webhook delivery); the screen then stays until your app dismisses the SDK.
  • Screen copy: your own title and description for the screens the person sees, on the workflow's Presence Intelligence panel under Face check screens: the loading screen while the check runs ("Checking it's you") and, on a verdict delivered in the app, the verified screen ("You're verified") and the not-verified screen ("We couldn't confirm it's you"). Leave a field empty to keep the default; {firstName} and {lastName} fill from the userData you pass, as on the welcome and success screens. A not-verified description replaces the reason the server sends. Enrolment offers the loading screen only. In code the same block is biometric.copy (waiting, verified, declined, each with title and description).

When the flow waits, the SDK fires a callback with the verdict:

tsx
<MyazaKYC
  apiKey="pk_test_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
  workflowId="wf_AbC123dEf456" // a biometric-authentication workflow
  userId="user_42"              // required: the enrolled user being re-authenticated
  onSubmit={(s) => console.log("submitted", s.verificationId)}
  onResult={(r) => {
    // r.status is the same vocabulary GET /api/kyc/status/:id serves:
    // 'approved' | 'declined' | 'in_review' | 'error' | …
    if (r.status === "approved") unlockSensitiveAction();
  }}
>
  Confirm it is you
</MyazaKYC>

onSubmit still fires the moment the check is submitted. onResult fires once when the wait settles, never when it times out, and carries the state and reason pair only; scores and other result data stay behind your secret key. Treat it as a courtesy to your UI: the webhook (where the flow sends one) and GET /api/kyc/verifications/:id remain the record, and a decision your app enforces should rest on those, not on a value a client reported.

App size

The SDK adds native machine learning to your app, and that is where the weight sits. The defaults are already the small ones, but two of the four levers below are yours to pull and they are worth more than everything the SDK does on its own.

On-device models are fetched, not bundled. Face detection and text recognition both run on Google ML Kit on Android, and the SDK depends on the Play Services variants, which download their models on first use. Measured on a real integrator's release APK, the bundled pair cost 18.5 MB per device (arm64: text 10.55 MB, face 7.95 MB) plus .tflite files in assets/, which ship to every device because assets are not split by architecture. Fetched, that is about 0.4 MB of shims.

The SDK starts both downloads the moment the flow opens, so they overlap the consent and ID-type screens. If a model has not arrived by the time it is needed, the step says so rather than failing quietly: liveness waits and explains, and the MRZ scanner tells the person the printed code cannot be read and lets them continue without the chip.

The trade is real. The Play Services variants need Google Play Services, so they do not work on Huawei or bare AOSP devices. If you ship to those, put this in your root build.gradle and you get fully-offline models back, at 18.5 MB per device:

gradle
ext { myazaKycBundledMlKit = true }

Ship an App Bundle, or filter your architectures. Native libraries dominate the rest of the download, and a universal APK carries every architecture at once. An .aab lets Play deliver only the one a device needs. If you must ship an APK, name the architectures your users actually have:

gradle
android {
  defaultConfig {
    ndk { abiFilters 'arm64-v8a', 'armeabi-v7a' }
  }
}

Turn on R8 and resource shrinking. The SDK ships its own consumer rules, so you do not need to work out which of its classes are constructed by name:

gradle
android {
  buildTypes {
    release {
      minifyEnabled true
      shrinkResources true
    }
  }
}

Use Expo SDK 54 or newer. Its default template builds smaller than earlier ones, and the SDK's peer range assumes it.

Programmatic control

For a custom trigger, drive the flow with the useMyazaKYC() hook:

tsx
import { useMyazaKYC } from "@myazahq/kyc-sdk-react-native";
import { Pressable, Text } from "react-native";

function Trigger() {
  const { open, close, isOpen, currentStep } = useMyazaKYC({
    apiKey: "pk_test_…",
    country: "NG",
    onSubmit: (s) => console.log(s.verificationId),
  });

  return (
    <Pressable onPress={open} disabled={isOpen}>
      <Text>Verify ({currentStep ?? "idle"})</Text>
    </Pressable>
  );
}

The flow advances through the same KYCStep values as the web SDK: consentid-typeid-inputdocument-capturelivenesssubmitted.