
# React Native SDK

The React Native SDK mirrors the [web SDK](https://trust.myaza.co/documentation/sdk-react/markdown)'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](https://react-native-vision-camera.com) 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](https://trust.myaza.co/documentation/sdks/markdown).

> **Feature availability.** This SDK is at **full parity** with the web and Flutter SDKs: [workflow](https://trust.myaza.co/documentation/workflows/markdown) embedding (`workflowId`), the capture add-ons (contact OTP, proof of address, questionnaire), business (KYB) verification, any [Global Documents](https://trust.myaza.co/documentation/id-types/markdown) country, plus **[NFC chip reading](https://trust.myaza.co/documentation/nfc-chip/markdown)**, which the web SDK can't do at all (browsers can't talk to a passport chip). See [Feature availability](https://trust.myaza.co/documentation/sdks/markdown#feature-availability).

## Requirements

| Requirement | Minimum |
|---|---|
| **iOS** deployment target | **15.1** |
| **Android** `minSdkVersion` | **24** (Android 7.0) · `compileSdk` 34 · NDK 27.1 |
| **Expo SDK** | **56** (React 19, React Native 0.85) |
| **React Native** | **0.83+**, with the **New Architecture enabled** (VisionCamera v5 / Nitro requires it; Expo SDK 56 enables it by default) |
| **Build toolchain** | Xcode + CocoaPods (iOS) · **JDK 17** for Android Gradle builds |
| **Runtime** | A **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.

### Expo app (managed / prebuild, recommended)

```bash
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:

```jsonc
// 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.

```bash
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](https://trust.myaza.co/documentation/address-intelligence/markdown) 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:

| Module | What installing it buys | Without it |
|--------|------------------------|-----------|
| `expo-device` | Make, model, manufacturer and physical-vs-simulator in the device metadata | Those fields are omitted and the device class is guessed from the platform, so [Device Intelligence](https://trust.myaza.co/documentation/workflows/markdown#capture-add-ons) has a weaker fingerprint and shared-device detection suffers |
| `expo-application` | Your app's id, version and build number on the submission | The `app` block is omitted entirely, so a result cannot be traced to the build that produced it |
| `expo-localization` | The device's region, explicitly | Country 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 steps | Those steps accept a camera capture only, so a PDF bank statement cannot be submitted at all |

```bash
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:

```bash
# 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](https://trust.myaza.co/documentation/address-intelligence/markdown) 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](https://react-native-vision-camera.com/docs/guides) (it relies on `react-native-worklets` / `react-native-vision-camera-worklets`, installed above).

### Presence reporting (Address Intelligence)

When a workflow enables [presence verification](https://trust.myaza.co/documentation/address-intelligence/markdown#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](https://trust.myaza.co/documentation/address-intelligence/markdown#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](https://trust.myaza.co/documentation/background-location-declarations/markdown) 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](https://trust.myaza.co/documentation/address-intelligence/markdown#the-android-foreground-service) 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](https://trust.myaza.co/documentation/address-intelligence/markdown#showing-the-person-where-the-check-stands).

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

### Recommended: mount a workflow

Build the flow once in the dashboard as a [workflow](https://trust.myaza.co/documentation/workflows/markdown), 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](https://trust.myaza.co/documentation/sdk-react/markdown#props), 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](#programmatic-control) 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`.
