React Native SDK#

The React Native SDK is an on-device client for the Verification API. 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.


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, and the Node.js SDK 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 and phone call 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.

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.

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, 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#