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

Flutter

>=3.27.0 for didww_verification_sms.

Android

minSdk 24 for didww_verification_sms.

Dependencies

None for didww_verification. The optional didww_verification_sms package depends on Flutter and didww_verification.

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

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: by your callback, or because the destination’s country is disabled for the account (destination_country_disabled).

VerificationSetupError

✔

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

VerificationFailed

✔

Anything else, with an ApiFailure or an SdkFailure. Includes a destination the delivery method cannot reach, or one in a blocked range (destination_not_supported_for_channel): the API creates it as failed and sends nothing.

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

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.

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

null (default)

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 (@<domain> #<code>) for iOS and web one-time-code autofill.

SmsAutofill.appHash(value)

The Android SMS Retriever framing for an eleven-character app hash.

SmsAutofill.domain()

The domain-bound line. An application with no SMS domain fails with sms_domain_not_configured.

SmsAutofill.none()

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

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; // 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``. 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. 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

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.

TooManyRequestsException

429

A limit was hit; see the table below. retryAfter is the Retry-After header as a Duration?. A start is never retried automatically.

ServerException

5xx

The API failed to process the request.

The error code of a TooManyRequestsException says which limit was hit:

Code

Meaning

destination_in_cooldown

A verification for this number was started too recently.

rate_limit_per_ip

Too many requests from this IP address.

rate_limit_failed_auth

Too many failed authentication attempts from this IP address.

rate_limit_per_account

Too many requests for this account.

rate_limit_public_per_account

Too many public requests for this account.

volume_limit_reached

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#