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

didww_verification

VerificationClient (the five endpoints) and VerificationSession (the state machine a screen needs). Pure Dart.

didww_verification_sms

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 didww_verification_sms.

Dependencies

None for didww_verification.

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

VerificationIdle

Nothing started, or reset() was called.

VerificationStarting

The start or resume request is in flight.

VerificationAwaitingInput

Live. Carries the id, the channel, expiresAt, the sms or callout block and lastError.

VerificationCaptured

A code was recovered from a message and is about to be submitted.

VerificationSubmitting

A report is in flight.

VerificationVerified

The reported value was correct.

VerificationExpired

The API says the deadline passed.

VerificationDenied

Refused before dispatch.

VerificationSetupError

The application is misconfigured; no user input can fix it.

VerificationFailed

Anything else, with an ApiFailure or an SdkFailure.

Calls that cannot go wrong#

  • start() and the resumes never throw. Every outcome, failure included, arrives through states.

  • A second start() while one is in flight sends nothing and reports SdkFailure(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 from states, 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

PublicAuthorization(key)

Application <key>

On a device. Carries no secret.

BasicAuthorization(key: …, secret: …)

Basic <base64(key: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

startVerification(...)

POST /verifications

reportVerification(id, ...)

PUT /verifications/{id}

getVerification(id)

GET /verifications/{id}

reportVerificationByNumber(number, ...)

PUT /verifications/by_number/{number}

getVerificationByNumber(number)

GET /verifications/by_number/{number}

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

sms

const ReportValue.code('123456')

callout

const ReportValue.code('123456')

reportVerification throws ChannelMismatchException before sending anything when the value does not suit the channel.

Verification responses#

Field

Dart type

Description

id

String

Verification identifier.

destination

String

Destination number normalized without a leading +.

deliveryMethod

String

Raw delivery method. knownDeliveryMethod is the typed value, or null when this release does not model it.

status

String

Raw status. knownStatus is the typed value; isFinished is false for a status this release does not model, never a guess.

fee

String?

Quoted verification fee, as a decimal string. Never parse it as a double.

errorCode

String?

Raw outcome code. knownErrorCode is the typed value; outcome is the same as an ApiErrorItem.

errorDetail

String?

Human-readable text for errorCode.

expiresAt

DateTime

The deadline, in UTC.

sms

SmsInfo?

SMS response fields. It is null for a phone call.

callout

CalloutInfo?

Phone call response fields. It is null for SMS.

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``. pending is 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

ConfigurationException

The client or a value it was given is unusable. Thrown before any request.

ChannelMismatchException

The reported value does not suit the delivery method. Thrown before any request.

TransportException

No response: a network failure, a timeout, or a socket error.

DecodingException

A response arrived and was not the shape this release expects.

UnauthorizedException

401

Authentication failed, or the mode is below the application’s minimum.

BalanceInsufficientException

402

The account balance is insufficient.

NotFoundException

404

No verification matches the identifier or number.

ValidationException

400, 422

Request validation failed.

ServerException

5xx

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#