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

Rebuild your app after updating the SDK. The SDK ships its own native views (the blurred surround of the full-screen liveness camera, and on iOS the guard that keeps a finger on the signature pad from scrolling the screen or dragging the sheet down). They are autolinked, but a JavaScript-only update cannot add them: run npx expo prebuild (or cd ios && pod install) and build the app again whenever you move to a new SDK version. An app that only reloads its bundle keeps working, with a plain tint in place of the blur.

Presence reporting (Address Intelligence): required if you use presence verification

Presence verification needs setup in your app. When the flow is submitted the SDK sends the first report and turns on background monitoring, which only works once your app has the background location setup below. Every other report has to come from your app calling the reporter when it opens. If people do not open your app daily, also turn the evening reminder webhook into a push notification. If your app has none of these, every presence check collects one day and ends inconclusive. The full checklist is on the Address Intelligence page.

When a workflow enables presence verification, the SDK stores the confirmed pin on the phone under the userId you pass (so pass one) and sends the first report when the flow is submitted. After that, call the reporter when your app starts and every time it comes back to the foreground, for the signed-in user:

tsx
// Call this from your root component.
import { useEffect } from 'react';
import { AppState } from 'react-native';
import { reportAddressPresence } from '@myazahq/kyc-sdk-react-native';

export function usePresenceReporting(userId: string | null) {
  useEffect(() => {
    if (!userId) return;
    const report = () =>
      reportAddressPresence({
        apiKey: 'pk_live_…',
        externalUserId: userId, // the same userId the KYC flow ran with
      });
    report(); // the app has just opened
    const sub = AppState.addEventListener('change', (state) => {
      if (state === 'active') report(); // back in the foreground
    });
    return () => sub.remove();
  }, [userId]);
}
// Each call resolves to { reported, inside, reason }, reason being
// 'reported' | 'no_pin' | 'services_off' | 'no_fix' | 'outside_fence' | 'no_watch' | 'stopped' | 'network_error'

It is safe to call on every open for every user: for a user this phone holds no address for it returns no_pin straight away without touching location, and several reports on one day count once. What each reason means, and what to do about it, is in the reason table. With the reporter alone a person needs to open your app at home on 5 different evenings, or on 9 different days, within the default 10-day window; background monitoring below needs 3 nights or 5 days and happens without them doing anything.

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)

People already being checked. The SDK switches background monitoring on when a flow is submitted. People who verified on an older version, and people on always-on monitoring, never pass that step again. From 3.5.0 the reporter does it for them: when their check is still running and background monitoring is not on, a report switches it on. The person sees the system's "allow all the time" prompt once, and the SDK never asks again from a report. If they already allowed it in Settings there is no prompt. Your app still needs the background location setup below. To show your own explanation first, pass autoBackground: false and call enableBackgroundPresence() when you are ready.

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 the phone and flush them, the same privacy floor as the foreground tier.

It is on by default. When a presence flow is submitted, the SDK asks the person to allow location all the time and arms the geofence itself; you do not call anything for that. What it cannot do is set up your app, so add these three once:

  1. Install the optional peer: npx expo install expo-task-manager.
  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:
tsx
// index.js
import { registerBackgroundPresence } from '@myazahq/kyc-sdk-react-native';
registerBackgroundPresence();

Without all three the SDK skips background monitoring, asks for nothing, and your app's reporter is the only source of evidence. To turn it off for a workflow, switch off Background monitoring on the Address step's Presence tab.

You can still arm or disarm it yourself, for example to offer it again later to someone who refused:

tsx
const result = await enableBackgroundPresence({ apiKey: 'pk_live_…', externalUserId: 'user_42' });
// result.reason: 'enabled' | 'module_missing' | 'not_registered' | 'no_pin' | 'foreground_denied' | 'background_denied' | 'start_failed'

When evidence is sent. A stay is reported when the person leaves the area, or at a "still here" check-in: the first 35 minutes after they arrive, then every three hours. Nothing is sent in between, so the day count does not move while someone simply stays at home. A check-in happens when the app is opened and, if you install the optional expo-background-task (npx expo install expo-background-task, plus its config plugin), about every 40 minutes in the background. Android and iOS decide when a background task really runs.

presenceStay('user_42') returns the stay the phone has open, { since, nextReportAt } in epoch milliseconds, or null. Use it to tell the person a report is on its way.

Rebuild after installing an optional Expo package. expo-task-manager and expo-background-task contain native code. If the JavaScript is installed but the native module is not in the app build (a package added without a rebuild, or one hoisted by a monorepo), SDK 3.4.0 and older closed the app in a release build when background monitoring was switched on. Later versions check for the native module first and skip the feature. Either way the feature only works after a rebuild.

not_registered means step 3 is missing. 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: consent → id-type → id-input → document-capture → liveness → submitted.