# Dart and Flutter SDK

The Dart SDK is a client for the [Verification API](../api-reference/index.html#otp-verification-api-reference). 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](../callbacks.html#otp-verification-callbacks).

---

## 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: ^1.1.0
  didww_verification_sms: ^1.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](../callbacks.html#otp-verification-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](../verification-methods/sms.html#otp-verification-sms-languages) and
[phone call languages](../verification-methods/phone-call.html#otp-verification-callout-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; // 300
verification.sms?.appHash;                    // 'A1b2C3d4E5f', or null
verification.sms?.codeLength;                 // 6

verification.callout?.language;               // 'de-DE'
verification.callout?.codeLength;             // 6
```

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.

## 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](../api-reference/get-verification-status.html#otp-verification-retention), 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. That includes `destination_in_cooldown` (`429`), a
start for the same number too soon after the previous one. The session does not carry the
`Retry-After` value, so wait out the API's 30-second cooldown before starting again for that
number.

## 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` | `destination_in_cooldown`: a start for the same number came too soon after the previous one. `retryAfter` is the `Retry-After` header as a `Duration?`. Wait that long before starting again for that number; a start is never retried automatically. |
| `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](../api-reference/errors.html#otp-verification-api-errors) 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](../api-reference/start-verification.html#otp-verification-api-start): Review all start-request and
  response fields.
- [Callbacks](../callbacks.html#otp-verification-callbacks): Review the request callback that authorizes
  each `public` start.
- [Errors and status codes](../api-reference/errors.html#otp-verification-api-errors): Look up machine-readable error
  codes.

On this page
