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.
Source: GitHub repository
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#
Node.js (development) |
|
React and React Native |
React |
Development build |
Required for Android SMS auto-capture. 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. A paused application denies every start
with application_paused. Both end in setupError. 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, or the
destination’s country is disabled for the account ( |
|
yes |
The verification ran out of time. Offer |
|
yes |
Your app is misconfigured or paused 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.
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.
templateGroup on the sms and callout blocks uses one of the application’s
template groups instead of its default. An unknown name fails with
template_group_not_found:
controller.start({ destination, deliveryMethod: 'sms', sms: { templateGroup: 'checkout' } });
state.sms and state.callout report what happened: templateGroup (the group
applied, or null), templateGroupUsed, and templateFallbackReason, which says why the
default system template was used instead: language_missing, version_pending,
version_rejected, audio_not_ready, or group_missing. With no template group,
templateGroupUsed is false and the reason is null.
SMS autofill#
By default the hook computes the build’s app hash on Android and sends it as
sms.autofill. Set sms.autofill yourself to replace it:
|
Effect |
|---|---|
unset (default) |
The device-computed app hash where the native module is present, otherwise the
application’s default. With an SMS domain set, the default adds a domain-bound line
( |
|
Your hash is sent, and the listener arms against it when |
|
The domain-bound line. An application with no SMS domain fails with
|
|
No marker. No hash is computed and the listener stays off. |
controller.start({ destination, deliveryMethod: 'sms', sms: { autofill: { type: 'domain' } } });
The marker counts toward the SMS length. The stored marker comes back as
state.sms.autofill: type app_hash with the hash, or domain with the domain.
state.sms.appHash is deprecated.
Custom data for your callback#
custom is an optional string of at most 4096 characters. It is forwarded to your
request callback, so your backend can allow or deny the
verification using context from the app. It is not returned in any response. A longer value
fails with custom_too_long.
controller.start({ destination, deliveryMethod: 'sms', custom: 'screen=checkout' });
Warning
custom is set on the device, so anyone can change it. Validate it on your backend and
never trust it on its own.
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,
and the four rate limits rate_limit_per_ip, rate_limit_failed_auth,
rate_limit_per_account, and rate_limit_public_per_account.
A submit refused with one of the four rate limits is retried once after Retry-After when
that is at most 5 seconds. If the wait is longer, or the retry is limited too, the state
returns to awaitingInput with the error in lastError.
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.
A 429 on start ends in failed, and its code says which limit was hit:
Code |
Meaning |
|---|---|
|
A verification for this number was started too recently. |
|
Too many requests from this IP address. |
|
Too many failed authentication attempts from this IP address. |
|
Too many requests for this account. |
|
Too many public requests for this account. |
|
The application reached its daily or monthly verification limit. |
The state does not carry the Retry-After value, so wait before calling start() again
(30 seconds for destination_in_cooldown); a start is never retried automatically.
volume_limit_reached is terminal on submit as well and ends the flow for the user: its
Retry-After can be days, so show an error instead of offering a retry. See
Limits.
A destination the delivery method cannot reach, or one in a blocked range, ends in failed
with destination_not_supported_for_channel. The API creates the verification as failed
instead of rejecting it with 422, and sends nothing.
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, sms.autofill is unset, autoCapture is not
false, and the native module is present, the hook computes the build’s app hash and sends
it with the start as sms.autofill. Once the response echoes the same hash, it arms the
platform SMS Retriever, and it 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.