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 |
|---|---|
|
The |
|
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 |
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
|
Dependencies |
No third-party dependencies. |
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 |
|---|---|---|
|
|
The mode to use in an app. The key is an identifier, not a 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#
|
Terminal |
What to render |
|---|---|---|
|
no |
The “send me a code” affordance. Nothing has been started. |
|
no |
A spinner. No verification exists yet. |
|
no |
The code field. Carries |
|
no |
A code arrived from SMS auto-capture and has not been submitted. |
|
no |
A spinner over the code field. The verification is still alive. |
|
yes |
Success. Carries |
|
yes |
The failure. Carries |
|
yes |
Your callback, or an answer the API could not read, refused this verification. |
|
yes |
The verification ran out of time. Offer |
|
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 |
|---|---|
|
The code from the text message. Auto-capture can fill it in on Android. |
|
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
200withstatusfailedonce the limit is passed rather than a4xx.``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#
Start a verification: Review all start-request and response fields.
Node.js SDK: Implement the callback endpoint that authorizes each
publicstart.Errors and status codes: Look up machine-readable error codes.