# React Native SDK

The React Native SDK is an on-device client for the [Verification
API](../api-reference/index.html#otp-verification-api-reference). A single hook, `useVerification()`, drives a whole
verification: it starts the verification, holds the state your screen renders from, submits
what the user typed, and on Android reads the code out of the incoming SMS without requesting
an SMS permission.

It ships as two packages:

| Package | Contents |
| --- | --- |
| `@didww/verification-react-native` | The `useVerification()` hook, the state machine, the code-input props, and the Android SMS auto-capture native module. |
| `@didww/verification-core` | The client, the wire types, and the error tree. Installed with it. |

Note

On-device SDKs omit the signed `application` authentication mode because a signing secret
must never be included in an app. Use the `public` mode, where your backend authorizes
each start request through a [callback](../callbacks.html#otp-verification-callbacks).

---

## Requirements

|  |  |
| --- | --- |
| React Native | A **development build**. The package ships an Android native module, so Expo Go cannot link it. Use `expo run:android` or an EAS build. |
| Android | Auto-capture uses the platform SMS Retriever API. The package autolinks on install. |
| iOS | No native module runs. Auto-fill is handled by the system keyboard through `otpInputProps`. |
| Dependencies | No third-party dependencies. `@didww/verification-react-native` depends only on `@didww/verification-core`, which declares none at all. `react` and `react-native` are peer dependencies; `expo-modules-core` is an optional one. |

## Installation

```
npm install @didww/verification-react-native
```

There is no config plugin and nothing to add to `app.json`. The package's own Android
manifest contributes no SMS or call-log permission - not `RECEIVE_SMS`, not `READ_SMS`,
not `READ_CALL_LOG`.

## Quick start

Build the client once, at module scope. A client rebuilt on every render rebuilds its
transport too:

```
import { useEffect, useState } from 'react';
import { Button, Text, TextInput, View } from 'react-native';
import { VerificationClient, publicAuth } from '@didww/verification-core';
import { otpInputProps, useVerification } from '@didww/verification-react-native';

const client = new VerificationClient({
  auth: publicAuth('your-app-key'),
  environment: 'sandbox',
});

export function VerifyScreen({ destination }: { destination: string }) {
  const controller = useVerification({ client });
  const { state } = controller;
  const [typed, setTyped] = useState('');

  // A captured code is handed to you, not submitted for you.
  useEffect(() => {
    if (state.kind === 'captured') controller.submit(state.value);
  }, [state, controller]);

  switch (state.kind) {
    case 'idle':
      return (
        <Button
          title="Send code"
          onPress={() => {
            controller.start({
              destination,
              deliveryMethod: 'sms',
              sms: { languages: ['en-US'] },
            });
          }}
        />
      );

    case 'starting':
    case 'submitting':
      return <Text>Please wait...</Text>;

    case 'awaitingInput':
      return (
        <View>
          {state.lastError === null ? null : (
            <Text>{state.lastError.detail ?? state.lastError.code}</Text>
          )}
          <TextInput {...otpInputProps} value={typed} onChangeText={setTyped} />
          <Button title="Verify" onPress={() => controller.submit(typed)} />
        </View>
      );

    case 'captured':
      return <Text>Code received automatically...</Text>;

    case 'verified':
      return <Text>Verified.</Text>;

    case 'denied':
      return <Text>{state.error?.detail ?? 'This number was not allowed.'}</Text>;

    case 'expired':
      return <Button title="Start again" onPress={() => controller.reset()} />;

    case 'setupError':
      return <Text>{`This app is misconfigured: ${state.code}`}</Text>;

    case 'failed':
      return <Text>Verification failed.</Text>;
  }
}
```

The controller's methods - `start`, `resume`, `resumeById`, `submit` and `reset` -
never throw and never return a promise. Every outcome arrives through `state`.

## Authentication

| Mode | Constructor | Use it |
| --- | --- | --- |
| `public` | `publicAuth(key)` | The mode to use in an app. The key is an identifier, not a secret. |
| `basic` | `basicAuth(key, secret)` | Server-side only. |

Warning

`basicAuth` puts a recoverable secret in your app. The secret is sent verbatim on every
request, and anything in a shipped bundle can be read out of it - `__DEV__` is not a
protection and neither is minification. The SDK logs a console warning when it detects a
release build, but the warning is a reminder, not a control.

A `public` start is authorized by a callback to **your** server before the verification is
created. If your application has no callback URL registered, every `public` start comes back
already denied with `denied_missing_callback_url`. See
[Callbacks](../callbacks.html#otp-verification-callbacks), and the
[Node.js SDK](node.html#otp-verification-sdk-node) for a ready-made handler.

Signed `application` authentication is not reachable from React Native at all: it needs
`node:crypto`, which Metro cannot resolve. The alternative is to give the app no credentials
and proxy every call through your own backend.

## Every state

| `state.kind` | Terminal | What to render |
| --- | --- | --- |
| `idle` | no | The "send me a code" affordance. Nothing has been started. |
| `starting` | no | A spinner. No verification exists yet. |
| `awaitingInput` | no | The code field. Carries `destination`, `fee`, `sms`, `callout`, `expiresAt` and `lastError`. |
| `captured` | no | A code arrived from SMS auto-capture and has **not** been submitted. |
| `submitting` | no | A spinner over the code field. The verification is still alive. |
| `verified` | **yes** | Success. Carries `verificationId`. |
| `failed` | **yes** | The failure. Carries `reason`, which says whether the API or the SDK decided it. |
| `denied` | **yes** | Your callback, or an answer the API could not read, refused this verification. |
| `expired` | **yes** | The verification ran out of time. Offer `reset()` and a fresh start. |
| `setupError` | **yes** | **Your app is misconfigured** and the user can do nothing. Do not show a retry button. |

Terminal means only `reset()` or a fresh `start()` leaves it. Reaching a terminal state
also disarms the SMS listener.

`captured` is deliberately not submitted for you. You may want to show the code you filled
in, or run a check of your own, before it is spent: a report is not idempotent and only three
attempts exist.

### Single-flighting

`start()` called while one is in flight yields `already_running` rather than a second,
billable verification. `submit()` is single-flighted too, so a double tap sends one report;
calling it before the verification is live buffers the value and sends it once the start
lands.

## Delivery methods

The value the user submits depends on the delivery method. `submit()` routes it to the right
field for you:

| Delivery method | What the user submits |
| --- | --- |
| `sms` | The code from the text message. Auto-capture can fill it in on Android. |
| `callout` | The code read aloud during the call. |

## Delivery-method options

Method-specific options travel in a property named after the delivery method:

```
controller.start({
  destination,
  deliveryMethod: 'sms',
  sms: { languages: ['de-DE'] },
});

controller.start({
  destination,
  deliveryMethod: 'callout',
  callout: { languages: ['pt-BR', 'pt-PT'] },
});
```

Both option types accept the same language tags with the same semantics, so one language list
works for both delivery methods. The catalogues behind them differ, however: the announcement
recordings are a different set from the message templates, so a tag that is honored for SMS
can still fall back for a phone call. See
[SMS languages](../verification-methods/sms.html#otp-verification-sms-languages) and
[phone call languages](../verification-methods/phone-call.html#otp-verification-callout-languages).

Read the language back from `state.sms.language` or `state.callout.language` to see which
language the API **selected**, which is not necessarily the first one requested.
`state.sms.codeLength` and `state.callout.codeLength` give the length of this
verification's code: 4 to 8 digits, set per application (6 by default). Size the code field
from it rather than hard-coding a length.

Note

`app_hash` is not part of `SmsOptions`. It identifies the running build rather than a
value your code chooses, so the hook computes it on the device and sends it with the start.

## Which errors are recoverable

A submit that fails with one of these returns to `awaitingInput` with the error in
`lastError`, and the verification stays alive for another attempt: `code_invalid`,
`code_blank`, `delivery_method_invalid`, `validation_failed`, `not_ready_to_report`.

Everything else is terminal. Two absences are deliberate:

- **``too\_many\_attempts`` is not recoverable**, and there is no local attempt counter. Whether
  another attempt is allowed is the API's decision, and it answers `200` with `status`
  `failed` once the limit is passed rather than a `4xx`.
- **``already\_verified`` is a failure, not a success.** The API returns it when the
  verification was verified earlier *and this submission was wrong*. Treating it as success
  would admit whoever just typed the wrong code.

**Every failure during the start phase is terminal**, including ones that would be recoverable
during submit. The recoverable set presupposes a verification to return to, and while
`starting` there is none.

A start for the same number too soon after the previous one is refused with `429` and ends
in `failed` with `destination_in_cooldown`. The state does not carry the `Retry-After`
value, so wait out the API's 30-second cooldown before calling `start()` again for that
number; a start is never retried automatically.

`failed` carries a `reason` that is either `{source: 'api', error}` - an error the API
produced, with `code` and `detail` - or `{source: 'sdk', error}`, which the SDK decided
itself: `already_running`, `transport` (no response) or `decoding` (a response arrived
and was not what this release expects). Match on `source` before reading `code`: the API
has a `superseded` slug of its own, and `source` is what tells the two apart.

## The code input

```
<TextInput {...otpInputProps} value={typed} onChangeText={setTyped} />
```

`otpInputProps` sets `textContentType`, `autoComplete` and `keyboardType` so both
platforms' keyboards offer the incoming code. On iOS this is the whole of the auto-fill story:
the system surfaces the code above the keyboard and the user taps it.

## SMS auto-capture on Android

When the delivery method is `sms` and the native module is present, the hook computes the
build's app hash, sends it with the start, arms the platform SMS Retriever, and moves to
`captured` when a matching message arrives. The user taps nothing.

The listener disarms itself on a terminal state, when `expiresAt` passes, and when the API's
`interceptionTimeoutSeconds` budget runs out. That last one is a **budget for the listener,
not a deadline for the user**: manual entry keeps working until `expiresAt`.

Pass `autoCapture: false` to `useVerification` to turn it off.

The platform hands the SDK exactly one message - the one addressed to this build - and never
the user's inbox.

### The app hash

The Retriever delivers a message only when its last token is an 11-character hash of **your
package name plus the certificate the APK was signed with**. The API appends it to the message
body, so it must be sent with the start, which the hook does for you.

```
import { getAppHash, isSmsAutoCaptureAvailable } from '@didww/verification-react-native';

const hash = await getAppHash();          // null where the native module is absent
isSmsAutoCaptureAvailable();              // false where it is absent
```

`getAppHash()` never rejects.

Warning

**The signing certificate is the part that catches people.** The hash changes with the
package name *and* with the signing certificate, so a debug build, a locally signed
release, and the build your users install can each have a different one. Under Play App
Signing the certificate that signs the delivered artifact is held by the store, not by the
upload key you hold, so the hash on a user's device is not the hash of anything you can
build locally. Read it off an installed build.

**A wrong hash is completely silent.** The SMS arrives, the Retriever does not fire, nothing
throws, nothing is logged in a release build, and the user types the code by hand as if the
feature were never there. There is one guard: the API echoes the hash it stored, and if that
echo does not equal what was sent the listener declines to arm and warns - but only in a
development build. Manual entry always works, so the worst case is a missing convenience.

### Where the module is absent

`getAppHash()` returns `null`, `isSmsAutoCaptureAvailable()` returns `false`, no hash
is sent, no listener is armed, and the verification runs normally with manual entry. This is
the case on iOS, in Expo Go - where a native module can never be linked - and in a bare React
Native app without Expo Modules. It is not an error and nothing needs handling.

Note that the check is module presence, not `Platform.OS === 'android'`: on Android in Expo
Go the platform reports `android` while capture is impossible.

## Resuming

`start()` bills the account and supersedes whatever the destination already had. Reattach
instead of starting again when a screen is remounted:

```
controller.resume({ destination, deliveryMethod: 'sms' });
controller.resumeById({ verificationId, deliveryMethod: 'sms' });
```

`resume` reattaches to the newest verification for a number; `resumeById` reattaches to
one your app persisted across a restart. Both land in whatever state that verification is
actually in, including a terminal one, and neither bills anything. Both answer `404` once
the verification they name passes the
[retention period](../api-reference/get-verification-status.html#otp-verification-retention), so persist the outcome rather than the
id if you need it later.

`resume` answers with the **newest** verification for the number whatever its status, which
is not the same as the live one: a start that was itself denied supersedes nothing, so it is
newest while an earlier verification is still live.

## Next steps

- [Start a verification](../api-reference/start-verification.html#otp-verification-api-start): Review all start-request and
  response fields.
- [Node.js SDK](node.html#otp-verification-sdk-node): Implement the callback endpoint that
  authorizes each `public` start.
- [Errors and status codes](../api-reference/errors.html#otp-verification-api-errors): Look up machine-readable error
  codes.

On this page
