Dart and Flutter SDK#
The Dart SDK is a client for the Verification API. It
is pure Dart on dart:io with no dependencies, so it runs on the Dart VM and in
Flutter and adds nothing to your dependency tree.
Source: GitHub repository
It ships as two packages:
Package |
Contents |
|---|---|
|
|
|
A Flutter plugin implementing automatic SMS code capture on Android over the SMS Retriever API. Optional, and a no-op off Android. |
Most Flutter applications want the session. A server-side Dart process can use the client alone.
Note
On-device use omits 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#
Dart SDK |
|
Flutter |
|
Android |
|
Dependencies |
None for |
Installation#
dependencies:
didww_verification: ^1.2.0
didww_verification_sms: ^1.2.0 # optional: Android SMS auto-capture
Important
Android release builds need ``INTERNET`` declared. Flutter’s application template
declares android.permission.INTERNET in
android/app/src/debug/AndroidManifest.xml and .../profile/AndroidManifest.xml, but
not in main/. A debug build works and the release build has no network, and the
first request fails with a socket error that reads like an SDK fault. Declare it yourself:
<!-- android/app/src/main/AndroidManifest.xml -->
<uses-permission android:name="android.permission.INTERNET" />
Quick start#
VerificationClient is the five endpoints:
import 'package:didww_verification/didww_verification.dart';
final client = VerificationClient(
auth: const PublicAuthorization('your-application-key'),
environment: VerificationEnvironment.sandbox,
);
final started = await client.startVerification(
destination: '+49 151 1234567',
deliveryMethod: DeliveryMethod.sms,
sms: const SmsOptions(languages: ['en-US']),
);
started.id; // '0f9c8b7a-1e2d-4c3b-9a8f-7e6d5c4b3a21'
started.knownStatus; // VerificationStatus.pending
started.sms?.language; // 'en-US'
final finished = await client.reportVerification(
started.id,
deliveryMethod: started.deliveryMethod,
value: const ReportValue.code('123456'),
);
finished.knownStatus; // VerificationStatus.verified
client.close();
Every method returns a new Verification describing the state at that moment. Objects are
snapshots and are never updated in place. Call close() when the client is no longer
needed.
VerificationSession: the state machine#
VerificationSession wraps the client in the state machine a screen actually needs: one
stream of states, single-flighted calls, and automatic code capture when you supply it.
final session = VerificationSession(
client: VerificationClient(auth: const PublicAuthorization('your-application-key')),
);
// In a widget:
StreamBuilder<VerificationState>(
stream: session.states,
initialData: session.state,
builder: (context, snapshot) => switch (snapshot.data!) {
VerificationIdle() || VerificationStarting() => const CircularProgressIndicator(),
VerificationAwaitingInput(:final lastError) => CodeField(
error: lastError?.detail,
onSubmitted: session.submit,
),
VerificationCaptured(:final value) => CodeField(value: value, enabled: false),
VerificationSubmitting() => const CircularProgressIndicator(),
VerificationVerified() => const Text('Verified'),
VerificationExpired() => const Text('That code expired'),
VerificationDenied(:final error) => Text(error?.detail ?? 'Refused'),
VerificationSetupError(:final code) => Text('Application misconfigured: $code'),
VerificationFailed(:final reason) => Text('$reason'),
},
);
await session.start(
destination: '+49 151 1234567',
deliveryMethod: DeliveryMethod.sms,
);
Call session.dispose() from State.dispose. It is awaitable, safe to call twice, and
cancels every subscription and timer.
states returns the same object on every call, so StreamBuilder does not
resubscribe on each rebuild. Every new listener receives the current state immediately.
The states#
VerificationState is sealed, so the switch above needs no default arm and a state
added in a later release is a compile error rather than a silent gap.
State |
Terminal |
Means |
|---|---|---|
|
Nothing started, or |
|
|
The start or resume request is in flight. |
|
|
Live. Carries the id, the channel, |
|
|
A code was recovered from a message and is about to be submitted. |
|
|
A report is in flight. |
|
|
✔ |
The reported value was correct. |
|
✔ |
The API says the deadline passed. |
|
✔ |
Refused before dispatch: by your callback, or because the destination’s country is
disabled for the account ( |
|
✔ |
The application is misconfigured or paused; no user input can fix it. |
|
✔ |
Anything else, with an |
Calls that cannot go wrong#
start()and the resumes never throw. Every outcome, failure included, arrives throughstates.A second
start()while one is in flight sends nothing and reportsSdkFailure(SdkAlreadyRunning()).A second
submit()while one is in flight is dropped, so a double tap cannot burn two attempts.submit()before the verification is live is buffered; after a terminal state it is ignored.submit()returns nothing on purpose. Drive your spinner fromstates, never from the call — every case where the value is dropped is one where the current state already says why.
Authentication#
Scheme |
Header |
Use it |
|---|---|---|
|
|
On a device. Carries no secret. |
|
|
Server-side only. |
Warning
A secret compiled into an application binary is recoverable — a release APK or IPA is a
file someone can unzip. PublicAuthorization exists so an app never has to carry one.
Reach for BasicAuthorization only where the process is yours.
Request signing is a third scheme the API accepts and this package deliberately does not implement: it needs a signing secret, which is the thing an app must not hold.
Note
Never append anything to the application key. The API routes on the first colon anywhere
after the Application `` prefix, so ``Application key:anything selects the signed scheme
and fails authentication rather than falling back.
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. A paused application denies every start with
application_paused. The session reports both as VerificationSetupError.
Environments#
VerificationClient(auth: auth); // production
VerificationClient(auth: auth, environment: VerificationEnvironment.sandbox);
VerificationClient(
auth: auth,
environment: VerificationEnvironment.custom(Uri.parse('https://proxy.example.com/verify')),
);
A custom base carries a scheme, a host and optionally a base path. The SDK appends the API version itself, so do not include it.
Methods#
Method |
Endpoint |
|---|---|
|
|
|
|
|
|
|
|
|
|
The SDK adds the /api/v1 base path to these requests.
getVerificationByNumber returns the newest verification for a number whatever its status.
That is usually the live one, because a start supersedes what came before it — but a start
that was itself denied supersedes nothing, so it is newest while an earlier verification is
still live. It bills nothing, which makes it the cheap way to reattach after a screen was
rebuilt or the app was restarted.
Delivery-method options#
Method-specific options travel in a parameter named after the delivery method, and the SDK
sends only the block matching deliveryMethod:
await client.startVerification(
destination: destination,
deliveryMethod: DeliveryMethod.sms,
sms: const SmsOptions(languages: ['pl-PL', 'en-US']),
);
await client.startVerification(
destination: destination,
deliveryMethod: DeliveryMethod.callout,
callout: const CalloutOptions(languages: ['pt-BR', 'pt-PT']),
);
SmsOptions.languages and CalloutOptions.languages accept the same 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.
SMS autofill#
SmsOptions.autofill chooses the autofill marker for one verification instead of the
application’s default:
sms: const SmsOptions(autofill: SmsAutofill.domain()), // the application's SMS domain
sms: const SmsOptions(autofill: SmsAutofill.none()), // no marker, whatever the default
Value |
Effect |
|---|---|
|
The app hash from automatic capture when there is one, otherwise the application’s
default. With an SMS domain set, the default adds a domain-bound line
( |
|
The Android SMS Retriever framing for an eleven-character app hash. |
|
The domain-bound line. An application with no SMS domain fails with
|
|
No marker. |
The marker counts toward the SMS length. SmsOptions.autofill takes precedence over the app
hash computed by automatic capture, so capture arms only when it is null or the same hash.
You normally do not pass the app hash. The capture implementation supplies it through the
appHash parameter of startVerification, and the SDK sends it as sms.autofill. A hash that is not eleven characters of [A-Za-z0-9+/] is dropped,
and the request goes out with no marker. Losing autofill beats failing a paid verification.
Template groups#
templateGroup on SmsOptions and CalloutOptions uses one of the application’s
template groups instead of its default. An unknown name fails with
template_group_not_found:
sms: const SmsOptions(templateGroup: 'checkout'),
callout: const CalloutOptions(templateGroup: 'checkout'),
Custom data for your callback#
custom on startVerification and VerificationSession.start 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.
await session.start(
destination: destination,
deliveryMethod: 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.
Reporting a value#
ReportValue is sealed and single-field, so supplying no value — or a value that is not the
one the channel expects — cannot be expressed:
Delivery method |
Value |
|---|---|
|
|
|
|
reportVerification throws ChannelMismatchException before sending anything when the
value does not suit the channel.
Verification responses#
Field |
Dart type |
Description |
|---|---|---|
|
|
Verification identifier. |
|
|
Destination number normalized without a leading |
|
|
Raw delivery method. |
|
|
Raw status. |
|
|
Quoted verification fee, as a decimal string. Never parse it as a |
|
|
Raw outcome code. |
|
|
Human-readable text for |
|
|
The deadline, in UTC. |
|
|
SMS response fields. It is |
|
|
Phone call response fields. It is |
Reading delivery-method details#
verification.sms?.template; // 'Your code is {{CODE}}'
verification.sms?.language; // 'en-US'
verification.sms?.interceptionTimeoutSeconds; // 300
verification.sms?.autofill; // SmsAutofill(app_hash, A1b2C3d4E5f), or null
verification.sms?.codeLength; // 6
verification.sms?.templateGroup; // 'checkout', or null
verification.sms?.templateGroupUsed; // true
verification.sms?.templateFallbackReason; // null, or 'language_missing'
verification.callout?.language; // 'de-DE'
verification.callout?.codeLength; // 6
verification.callout?.templateGroup; // same three fields as sms
Both language values report the language the API selected, which is not necessarily
the first one requested. Compare with the list you sent to detect a fallback to en-US.
interceptionTimeoutSeconds is a budget, not a deadline and not a countdown. It says
how long to keep an on-device listener armed. It does not shorten the verification: manual
entry keeps working until expiresAt. Do not render it as a timer to the user. It equals the
application’s Code timeout (60–600 seconds, 300 by default).
codeLength is the length of this verification’s code: 4 to 8 digits, set per application
(6 by default). Read it rather than compiling a length into the client.
autofill is the marker the API stored: type app_hash with the hash, or domain with
the domain. SmsInfo.appHash is deprecated; it returns the value of an app_hash marker.
templateGroup is the group applied, or null. templateGroupUsed says whether its
template was used. When it was not, the default system template was used and
templateFallbackReason gives the reason: language_missing, version_pending,
version_rejected, audio_not_ready, or group_missing.
knownTemplateFallbackReason is the typed TemplateFallbackReason, or null for a
reason newer than the SDK. With no template group, templateGroupUsed is false and the
reason is null.
Automatic SMS capture#
start() and submit() work identically with and without it; without it the user types
the code. Supply an SmsAutoCapture to have it filled in:
import 'package:didww_verification_sms/didww_verification_sms.dart';
final session = VerificationSession(
client: client,
autoCapture: const SmsRetrieverAutoCapture(), // a no-op off Android
);
On Android this is implemented over the SMS Retriever API, so the app needs no SMS permission and sees no message but its own. Everywhere else, capture reports that it has nothing rather than throwing, so an app can depend on the package unconditionally.
hasAutoCapture is true from construction, so a screen can decide up front whether to
promise the user anything. isAutoCaptureArmed is true only once capture is actually
running for the current verification.
Capture arms only when the API echoes back the same app hash the device computed. The hash
is computed before the start request and sent with it as sms.autofill; if the response’s
sms.autofill is not an app_hash marker with that hash, the platform listener is never
touched. On a resume the hash is computed and compared but never sent, which is what lets a resumed SMS verification keep capturing.
The subscription is cancelled on any terminal state, when the API’s interception_timeout
budget elapses, when expiresAt passes, and on reset() and dispose() — whichever
comes first.
Warning
Play App Signing re-signs your upload artifact, so a hash computed from a locally signed
build never matches in production, and the only symptom is that capture silently never
fires. Display getAppHash() in your app during development and register the value you
see there.
Re-entering the screen: resume first, start second#
start() bills the account and supersedes whatever the destination already had. The
session’s guards are per instance, so a route remount — Navigator.pushReplacement, a
tab switch that disposes the route, a deep link back into the same page — builds a new
session whose guards cannot see the old one, and a second start() bills again.
resumeByNumber bills nothing, so the recipe costs nothing:
await session.resumeByNumber(destination);
if (session.state is! VerificationAwaitingInput) {
await session.start(destination: destination, deliveryMethod: DeliveryMethod.sms);
}
The check is is! VerificationAwaitingInput rather than “did it 404”, because the by-number
read answers with the newest verification for the number whatever its status.
resumeById does the same for a verification you persisted across an app restart — and
answers 404 once a finished verification passes the
retention period, so persist the outcome rather than the
id if you need it later. Neither resume takes SmsOptions or CalloutOptions: every
option there is a create-time choice.
Which rejections keep the verification alive#
These codes send the session back to VerificationAwaitingInput with lastError set, so
the user can try again: 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.
The set is exported as recoverableErrorCodes. Everything else is terminal.
When a submitted code is refused with one of the four rate limits, the session retries it once
after Retry-After if that is at most 5 seconds. If the wait is longer, or the retry is
limited too, the session returns to VerificationAwaitingInput with the code as
lastError.
Three of those boundaries look wrong and are not:
``not_ready_to_report`` arrives while the status reads ``pending``.
pendingis public before the message has finished dispatching, so the report is refused for a moment on a verification that looks ready. Retry it; it is not terminal.``too_many_attempts`` is terminal, and there is no local attempt counter anywhere. Whether another attempt is allowed is the API’s decision, and that code is how it says no.
``already_verified`` is a failure, never ``VerificationVerified``. The verification succeeded earlier, but this submission was wrong. Reporting success would admit someone who typed the wrong code.
A rejection during VerificationStarting is always terminal, whatever the code: nothing is
retryable before a verification exists. That includes every 429, such as
destination_in_cooldown. The session does not carry the Retry-After value; read it from TooManyRequestsException
when you call the client directly. volume_limit_reached is terminal on submit as well.
Error handling#
Verification outcomes and thrown exceptions are different. A request can succeed and return a
Verification whose status is failed, expired or denied. Handle those through
knownStatus and knownErrorCode.
Thrown exceptions describe a transport, decoding, HTTP or client-side validation failure.
VerificationException is sealed, so a switch over it needs no default arm:
Exception |
HTTP status |
Meaning |
|---|---|---|
|
— |
The client or a value it was given is unusable. Thrown before any request. |
|
— |
The reported value does not suit the delivery method. Thrown before any request. |
|
— |
No response: a network failure, a timeout, or a socket error. |
|
— |
A response arrived and was not the shape this release expects. |
|
|
Authentication failed, or the mode is below the application’s minimum. |
|
|
The account balance is insufficient. |
|
|
No verification matches the identifier or number. |
|
|
Request validation failed. |
|
|
A limit was hit; see the table below. |
|
|
The API failed to process the request. |
The error code of a TooManyRequestsException 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. |
volume_limit_reached ends the flow for the user: its Retry-After can be days, so show an
error instead of offering a retry. See Limits.
An unmodelled error code resolves to a null ApiErrorItem.known and an element that is
not an object is skipped, so neither poisons the rest of the envelope.
Note
Version 1.2.0 adds ApiErrorCode values, so an exhaustive switch over the enum needs
the new cases or a wildcard arm.
See Errors and status codes for the full list of machine-readable error codes.
Testing#
package:didww_verification/testing.dart exports FakeTransport, so a test can script
responses and inspect the exact bytes sent without a network.
Next steps#
Start a verification: Review all start-request and response fields.
Callbacks: Review the request callback that authorizes each
publicstart.Errors and status codes: Look up machine-readable error codes.