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.
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 |
3.6 or later. |
Flutter |
3.27 or later, for |
Dependencies |
None for |
Installation#
dependencies:
didww_verification: ^0.1.0
didww_verification_sms: ^0.1.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. |
|
✔ |
The application is misconfigured; 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.
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.
Note
app_hash is not part of SmsOptions. It is a property of the installed Android build
rather than a choice a caller makes, and a malformed one fails the whole verification, so
it is supplied by the capture implementation and validated before it reaches the wire.
startVerification takes an appHash parameter for that path; a value that is not
eleven characters of [A-Za-z0-9+/] is dropped, and the request goes out identical
to one that never carried a hash. Losing autofill beats failing a paid verification.
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; // 120
verification.sms?.appHash; // 'A1b2C3d4E5f', or null
verification.callout?.language; // 'de-DE'
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.
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; if the response’s sms.appHash is
absent or different, 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#
Five 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. Everything else is terminal.
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.
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. |
|
|
The API failed to process the request. |
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.
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.