Android SDK#

The Android SDK (com.didww.android.sdk.verification) is an on-device Kotlin client for the Verification API. It supports SMS and phone call through a coroutine-based API, represents each verification as a Flow of states, and provides typed errors that your app can handle.

  • Source: didww/didww-verification-android-sdk

  • Requires: Android minSdk 23.

  • Build from source: Android compileSdk 36, JDK 17, and Kotlin 2.4.10.

  • Dependencies: Kotlin coroutines and kotlinx.serialization.

  • Transport: Uses the Android platform HTTP stack. There is no third-party HTTP client to configure.

  • Permissions: Declares INTERNET directly and does not require a dangerous runtime permission.

  • Sensitive data: Does not persist or write OTP codes, phone numbers, or credentials to diagnostic logs.

Note

Mobile app credentials can be extracted from the app binary. For that reason, the on-device SDK supports only the public and basic authentication schemes. HMAC-signed server-to-server authentication is not available because it requires a signing secret. See Authentication.

The SDK is written for Kotlin. Coroutines, Flow, and default arguments are part of its public API, so Java is not a supported integration path.

Before you begin#

  • Create an OTP application and obtain its credentials in the environment where the SDK will send verification requests. See Getting Started.

  • Set the OTP application’s Callback URL to an endpoint on your backend. Configure that endpoint to verify callback signatures using the application secret and return allow or deny. See Callbacks.

  • Keep the OTP application’s minimum authentication mode set to public so requests made with Auth.Public are accepted.

  • Copy the application key into the Android app and use it with Auth.Public. Keep the application secret on your backend; do not include it in the APK.

  • Choose the verification methods your app will support and add the matching SDK artifact. Use verification-all to select methods at runtime, verification-sms for SMS only, or verification-core for phone call only. See Installation.

  • Collect each handle’s states flow exactly once from a lifecycle-aware scope such as viewModelScope. The first collection sends the verification request. See Collect once.

  • Provide manual code entry for every supported method. Automatic SMS capture requires Google Play Services and should supplement, rather than replace, manual entry. See Automatic SMS capture.


How a verification flows#

The SDK models a verification as a state machine rather than a sequence of separate requests. Your app starts a handle, collects its states, and submits the user’s code. Each outcome, including success, failure, denial, and expiry, is returned as a state.

        %%{init: {
  "theme": "base",
  "themeVariables": {
    "primaryColor": "#e0f2fe",
    "primaryBorderColor": "#38bdf8",
    "primaryTextColor": "#1f2d3d",
    "lineColor": "#38bdf8",
    "secondaryColor": "#ccfbf1",
    "tertiaryColor": "#fef3c7",
    "fontSize": "14px"
  }
}}%%

stateDiagram-v2
    [*] --> Starting: First collection starts create or lookup

    Starting --> AwaitingInput
    Starting --> Denied
    Starting --> SetupError
    Starting --> Failed

    AwaitingInput --> Submitting: Submit a value
    AwaitingInput --> Captured: SMS code captured</br>automatically
    Captured --> Submitting

    Submitting --> Verified
    Submitting --> AwaitingInput: Rejected, lastError set
    Submitting --> Failed
    Submitting --> Expired

    Verified --> [*]
    Failed --> [*]
    Denied --> [*]
    SetupError --> [*]
    Expired --> [*]
    

Your app does not verify the code itself. It collects the code from the user and passes it to the SDK. The SDK handles the network requests and state transitions, while your app owns the user interface.

If the user submits an incorrect code, the verification can remain open for another attempt. The flow returns to AwaitingInput with lastError set, so your app can show the error while still accepting a new value. If the API returns too_many_attempts, the verification becomes terminal.

States#

State

Meaning

Terminal

Starting

The create request from start(...) or the by-number lookup from resume(...) is in flight.

AwaitingInput

The verification is waiting for a code. Carries verificationId and may also provide deliveryMethod, destination, fee, expiresAtEpochMillis, sms, and lastError.

Captured

An SMS code was captured automatically and is about to be submitted. See Automatic SMS capture.

Submitting

A value is in flight.

Verified

The server accepted the value.

Yes

Failed

Carries a FailureReason — either an API error or an SDK-side one.

Yes

Denied

The application’s callback rejected the request or did not return a usable response.

Yes

SetupError

The OTP application is misconfigured. Retrying or changing user input cannot resolve it.

Yes

Expired

The deadline passed with no accepted value.

Yes

Note

The fee value on AwaitingInput is the quoted verification fee, not an immediate charge. It is billed only when the verification reaches Verified. The quote does not include the SMS or call used to deliver the challenge, which is billed separately.

Installation#

The SDK is published as three artifacts. Choose the artifact that matches the delivery methods your app uses. All artifacts use the com.didww.android.sdk.verification group ID and are published to Maven Central.

Artifact

Contains

Depend on it when

verification-core

Transport, error model, state machine, and phone call.

You only use phone call.

verification-sms

verification-core plus the SMS channel and Google Play Services integration.

You only send SMS.

verification-all

The umbrella artifact, including DidwwVerification.

You choose the channel at runtime.

// settings.gradle.kts — repositories { mavenCentral() }

dependencies {
    implementation("com.didww.android.sdk.verification:verification-all:1.0.0")
}

Note

verification-core directly declares only INTERNET. verification-sms also brings in Google Play Services and related AndroidX manifest components through its dependencies, but it does not require a dangerous runtime permission. Apps that do not use SMS can depend on verification-core to avoid those SMS-related dependencies. See the measured manifest breakdown.

Quick start#

The following example keeps the SDK client and verification handle in a ViewModel. It starts an SMS verification, collects the handle exactly once, and exposes the current state to the UI through a StateFlow:

import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.didww.android.sdk.verification.Auth
import com.didww.android.sdk.verification.DeliveryMethod
import com.didww.android.sdk.verification.Environment
import com.didww.android.sdk.verification.VerificationHandle
import com.didww.android.sdk.verification.VerificationState
import com.didww.android.sdk.verification.all.DidwwVerification
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch

class VerifyViewModel(application: Application) : AndroidViewModel(application) {

    private val didww = DidwwVerification(
        context = application,
        auth = Auth.Public(BuildConfig.DIDWW_APPLICATION_KEY),
        environment = Environment.Sandbox,
    )

    private val _state = MutableStateFlow<VerificationState?>(null)
    val state: StateFlow<VerificationState?> = _state.asStateFlow()

    private var handle: VerificationHandle? = null

    fun start(destination: String) {
        val started = didww.start(destination, DeliveryMethod.SMS)
        handle = started
        _state.value = null

        // Collect each handle once, outside the view layer.
        viewModelScope.launch {
            started.states.collect { emission ->
                // An older handle must not overwrite the state of a newer start.
                if (started === handle) _state.value = emission
            }
        }
    }

    fun submit(value: String) {
        handle?.submit(value)
    }
}

Render the StateFlow with lifecycle awareness. The UI functions below are placeholders for your own components:

import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle

@Composable
fun VerifyScreen(viewModel: VerifyViewModel) {
    val state by viewModel.state.collectAsStateWithLifecycle()

    when (val current = state) {
        null -> PhoneNumberEntry(onStart = viewModel::start)
        VerificationState.Starting -> Spinner("Requesting a code")
        is VerificationState.AwaitingInput -> CodeEntry(
            hint = current.sms?.template,
            error = current.lastError?.detail,
            expiresAt = current.expiresAtEpochMillis,
            onSubmit = viewModel::submit,
        )
        is VerificationState.Captured -> Spinner("Code received")
        VerificationState.Submitting -> Spinner("Checking")
        is VerificationState.Verified -> Success()
        is VerificationState.Failed -> Failure(current.reason)
        is VerificationState.Denied -> Failure(current.error?.detail)
        is VerificationState.SetupError -> Misconfigured(current.code, current.detail)
        VerificationState.Expired -> Expired()
    }
}

Calling start() does not send the request. It only creates the handle. The request starts when your app first collects handle.states. This example uses the sandbox; switch to Environment.Production and production credentials for live verifications.

The SDK retains context.applicationContext internally. Passing an Activity as the constructor context does not cause the SDK to retain that Activity.

Collect once#

Warning

handle.states is a cold Flow and can be collected only once. If it is collected a second time, the handle emits Failed(FailureReason.Sdk(SdkError.AlreadyRunning)). After that, the same handle cannot be used again.

Collect handle.states from a ViewModel-scoped coroutine and mirror the states into a StateFlow for the UI to render. Do not collect directly from a composable or an Activity that can be recreated.

This keeps lifecycle events, such as screen rotation, from attempting to collect the same handle again. Reuse the same DidwwVerification instance across starts so its in-process supersession tracking also remains active.

The first collection starts the request. Cancelling the collection cancels the in-flight request and releases resources registered by the channel. There is no separate stop() method. To retry, start a new handle.

Submitting a value#

The handle can receive submitted values as soon as it is created. If the user submits a value before AwaitingInput is emitted, the SDK buffers it instead of dropping it.

submit() never blocks and never throws, so your UI does not need to wait for a network round trip to call it. Disable repeated submission while the state is Submitting. Every submitted value is queued, so duplicate taps can produce additional report attempts.

A submitted value can be accepted only after the verification has been dispatched, such as after the SMS is sent or the call is placed. If a value is submitted too early, the API returns ApiErrorCode.NOT_READY_TO_REPORT (not_ready_to_report). This is retryable: the state machine returns to AwaitingInput with lastError set. The value can be submitted again after the verification is ready.

Expiry#

AwaitingInput.expiresAtEpochMillis is the server-provided expiry time for the verification. The SDK does not define its own TTL.

The local countdown uses elapsed time instead of the device wall clock, so time changes on the device, such as an NTP correction or a manual date change, do not shorten the verification window.

The SDK can emit Expired from this local countdown, but the server remains authoritative. A late submitted value is still sent to the API, and Expired is not emitted while a submission is in flight.

Delivery methods#

DidwwVerification selects the delivery method at runtime:

didww.start(number, DeliveryMethod.SMS)     // a code by text message
didww.start(number, DeliveryMethod.CALLOUT) // a spoken code

If your app uses only one delivery method, you can use its channel-specific class directly:

import com.didww.android.sdk.verification.sms.SmsVerification          // verification-sms
import com.didww.android.sdk.verification.callout.CalloutVerification  // verification-core

SmsVerification(context, auth).start(number)
CalloutVerification(context, auth).start(number)

For both SMS and CALLOUT, the user submits the code they received.

Delivery-method options#

Method-specific options use the same name as the delivery method. For example, SMS options are passed with sms in Kotlin and sent as an sms block in the API request.

didww.start(number, DeliveryMethod.SMS, sms = SmsOptions(languages = listOf("en-US")))

The SDK also computes the Android SMS Retriever app hash when it is available. The resulting request has this form:

{
  "data": {
    "destination": "+4915112345678",
    "delivery_method": "sms",
    "sms": {
      "languages": ["en-US"],
      "app_hash": "<computed-app-hash>"
    }
  }
}

CALLOUT takes the announcement language the same way:

didww.start(number, DeliveryMethod.CALLOUT, callout = CalloutOptions(languages = listOf("de-DE")))

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.

Passing options for a different channel throws IllegalArgumentException before the request is sent:

didww.start(number, DeliveryMethod.CALLOUT, sms = SmsOptions(...))   // throws

The API reads only the options block that matches delivery_method. The SDK rejects mismatched options early so the app does not start a verification with unintended defaults.

In the response, AwaitingInput.sms contains SMS-specific details returned by the API: template (the message with {{CODE}} still in it), language (the template language the API selected), and interceptionTimeoutSeconds. AwaitingInput.callout contains language, the language the announcement was played in. Both report what the API selected, which is not necessarily the first language requested.

Resume after process recreation#

A VerificationHandle exists only in memory. If Android terminates your app process after a verification starts, persist the destination and use resume(...) to reattach to the latest verification for that number. Do not call start(...) to recover an existing verification; starting another one supersedes the previous verification and sends a new challenge.

Like start(...), resume(...) performs no I/O until the returned handle’s states flow is collected:

fun resume(destination: String) {
    val resumed = didww.resume(destination, DeliveryMethod.SMS)
    handle = resumed
    _state.value = null

    viewModelScope.launch {
        resumed.states.collect { emission ->
            if (resumed === handle) _state.value = emission
        }
    }
}

The SDK removes non-digit characters from the destination when it builds the by-number path, so formatted and unformatted versions of the same number reach the same endpoint. A value with no digits throws IllegalArgumentException before a request is sent.

The resumed handle behaves according to the verification found by the API:

API result

State emitted by the handle

The latest verification is active

AwaitingInput. The app can submit the code through the resumed handle.

The latest verification is finished

Its terminal state, such as Verified, Failed, Denied, or Expired.

No verification exists for the number

Failed with an API error whose known value is ApiErrorCode.NOT_FOUND.

The method argument selects the channel-specific client behavior. For SMS, it enables the automatic capture checks. The delivery method returned by the API remains authoritative and is used when the SDK reports a submitted value.

See Get verification status by number and Report a verification by number for the underlying API behavior.

Environments#

Choose the environment when creating the SDK client. If no environment is provided, the SDK uses Environment.Production. The SDK removes a trailing slash from the selected base URL and appends /api/v1.

Environment

Host

Environment.Production (default)

https://verification.didww.com

Environment.Sandbox

https://verification-sandbox.didww.com

Environment.Custom(url)

A custom scheme and host, optionally with a base path, for a local backend, proxy, or test server.

Note

Use Environment.Sandbox while building and testing your integration.

Use credentials from an OTP application created in the same environment as the SDK client. See Choose an environment.

DidwwVerification(context, auth, Environment.Sandbox)
DidwwVerification(context, auth, Environment.Custom("http://10.0.2.2:3000"))

Timeouts are configured through Config. See Cancellation and timeouts.

Authentication#

Scheme

Header

Use

Auth.Public(applicationKey)

Application <key>

Production, on-device.

Auth.Basic(key, secret)

Basic base64(key:secret)

Local development only.

Use Auth.Public for production mobile apps. The application key identifies your application, but it does not by itself authorize a verification. DIDWW asks your application’s callback URL to approve each request before sending an SMS or phone call. This corresponds to the API’s public auth mode.

Warning

Auth.Basic uses a server-to-server secret. Do not include this secret in a production mobile app, because it can be recovered from the APK. The SDK logs a warning at runtime if Auth.Basic is used in a build that is not marked debuggable. If this secret has already been shipped in an app, treat it as disclosed and rotate it.

Keep the OTP application’s minimum auth mode set to public when it is used by the Android SDK. Raising the minimum auth mode rejects SDK requests: basic requires a secret in the APK, and signed authentication also requires a signing secret on the device. Use a stricter minimum auth mode only for applications driven by your own server.

A start can come back denied#

With Auth.Public, DIDWW asks your application’s request callback to authorize the start request before sending an SMS or phone call. If the callback denies the request, the HTTP request can still succeed with 201 Created and the flow reaches Denied rather than AwaitingInput.

        %%{init: {
  "theme": "base",
  "themeVariables": {
    "actorBkg": "#e0f2fe",
    "actorBorder": "#38bdf8",
    "actorTextColor": "#1f2d3d",
    "actorLineColor": "#38bdf8",

    "signalColor": "#1f2d3d",
    "signalTextColor": "#1f2d3d",

    "noteBkgColor": "#fef3c7",
    "noteBorderColor": "#facc15",
    "noteTextColor": "#1f2d3d",

    "labelBoxBkgColor": "#ccfbf1",
    "labelBoxBorderColor": "#2dd4bf",
    "labelTextColor": "#1f2d3d",
    "loopTextColor": "#1f2d3d"
  }
}}%%

sequenceDiagram
    participant SDK as Verification SDK
    participant API as Verification API
    participant CB as Your backend

    SDK->>API: POST /api/v1/verifications
    API->>CB: POST {callback_url}<br/>Authorization: Application {appKey}:{signature}<br/>x-timestamp: {unix seconds}
    Note over CB: verify the signature,<br/>then decide

    alt your backend allows
        CB-->>API: 200 {"action":"allow"}
        API-->>SDK: 201 Created — AwaitingInput
    else your backend denies
        CB-->>API: 200 {"action":"deny"}
        API-->>SDK: 201 Created — Denied<br/>denied_by_callback
    else no usable answer
        CB--xAPI: non-2xx, timeout, invalid JSON,<br/>unknown action, or a body over 8 KB
        API-->>SDK: 201 Created — Denied<br/>denied_invalid_callback_response
    end

    Note over SDK,CB: No challenge is dispatched in either denied branch.
    

SetupError means a configuration problem#

SetupError indicates a configuration problem that the user cannot fix. With Auth.Public, this happens when the application has no callback URL configured. In that case, each start request is denied with denied_missing_callback_url until a callback URL is added.

is VerificationState.SetupError ->
    // Retrying will not help; no user input can rescue it.
    Log.e("didww", "verification misconfigured: ${state.code} ${state.detail}")

Treat SetupError as an application configuration issue, not as a phone number problem. Log it or report it to your team, and avoid showing it to the end user as a retryable verification failure. See Callbacks.

Automatic SMS capture#

SMS codes can be captured automatically. The SDK computes the app’s SMS Retriever hash, sends it with each SMS verification, and starts listening only when the API response echoes the same hash. When a matching message arrives, the flow moves to Captured and submits the code without the user typing it. Handle VerificationState.Captured as a normal part of your flow.

The SMS Retriever app hash does not need manual configuration. The SDK computes it at runtime from the certificate used to sign the installed APK. This is the certificate that Google Play services checks when matching incoming SMS messages.

No extra configuration is needed for Play App Signing. If Google re-signs the app before installation, the SDK reads the certificate used on the installed APK and computes the matching hash from it.

Automatic capture begins after the create or resume response is decoded. An SMS that arrives before that point cannot be captured by the SDK, so manual entry must remain available.

Note

Always provide manual entry as a fallback. Manual entry works for SMS and phone call and remains available while automatic capture is listening. Automatic SMS capture requires Google Play services and is not available when the app depends on verification-core alone, because that artifact does not include the SMS channel.

AwaitingInput.sms.interceptionTimeoutSeconds is the automatic capture window, not the verification deadline. It tells the SDK how long to keep listening for a matching SMS. When the window ends, the SDK stops listening, but the verification remains active and manual entry still works. expiresAtEpochMillis is the value that defines when the verification expires.

The SDK writes limited diagnostics to Android Logcat under the DidwwVerification tag, including app-hash availability, the computed app hash, and SMS Retriever re-arming. These diagnostics do not include OTP codes, phone numbers, or credentials.

Error handling#

A failed verification is reported through the state flow rather than thrown as an exception. Each verification outcome is represented as a VerificationState.

Two invalid calls throw IllegalArgumentException synchronously, before a request is sent:

  • Passing sms options to a non-SMS delivery method.

  • Calling resume(...) with a destination that contains no digits.

Failed carries a FailureReason with either an API error or an SDK-side error:

when (val reason = state.reason) {
    is FailureReason.Api -> reason.error       // the server said no — an ApiErrorItem
    is FailureReason.Sdk -> when (reason.error) {
        is SdkError.Transport   -> "offline, timed out, or TLS failed"
        is SdkError.Decoding    -> "the response could not be read"
        SdkError.Superseded     -> "another verification replaced this one"
        SdkError.AlreadyRunning -> "states was collected twice — see Collect once"
    }
}

SDK-side failures have the following meanings:

Error

Meaning

SdkError.Transport

The request did not complete because of DNS, connection, TLS, timeout, socket, or URL failure. A non-successful HTTP response without a usable API error envelope is also reported here.

SdkError.Decoding

A successful HTTP response could not be decoded as a verification.

SdkError.Superseded

A newer handle from the same SDK client replaced this handle for the same destination.

SdkError.AlreadyRunning

The handle’s states flow was collected more than once.

Server-side errors are returned as ApiErrorItem values, matching the API’s coded error envelope:

Property

Meaning

code

The raw slug, always present — for example code_invalid.

detail

Fixed human-readable text. Display it; never branch on it.

known

The typed ApiErrorCode when this SDK version recognises the slug, otherwise null.

when (error.known) {
    ApiErrorCode.CODE_INVALID      -> showError("That code is not right.")
    ApiErrorCode.TOO_MANY_ATTEMPTS -> showError("Too many attempts. Start over.")
    ApiErrorCode.BALANCE_INSUFFICIENT,
    ApiErrorCode.UNAUTHORIZED      -> reportToYourBackend(error.code)
    null                           -> showError(error.detail)   // a slug newer than this SDK
    else                           -> showError(error.detail)
}

ApiErrorCode does not have an .other case. If the SDK receives an error code it does not recognise, known is null and code still contains the raw value. This lets the SDK decode newer API error codes without losing the original code. Branch on code or known, never on detail.

Cancellation and timeouts#

Cancellation uses standard coroutine cancellation. Because the request starts when states is collected, cancelling the collecting coroutine cancels the in-flight request and releases resources registered by the channel, including an active SMS listener. There is no separate stop() method. When a ViewModel is cleared, its coroutine scope is cancelled automatically. Cancellation ends collection without emitting a Failed state.

val job = viewModelScope.launch {
    handle.states.collect { _state.value = it }
}

job.cancel()   // cancels the request and releases everything the channel registered

Per-request timeouts are configured through Config. They are transport timeouts, not the verification expiry policy. A verification expires according to the server-provided expiresAtEpochMillis value:

DidwwVerification(
    context, auth, Environment.Production,
    Config(connectTimeoutMillis = 15_000, readTimeoutMillis = 30_000),   // the defaults
)

One active verification per number#

Only one unfinished verification can exist for the same application and phone number. If your app collects a new start handle for a number that already has a verification in progress, the new verification supersedes the previous one on the server.

The SDK marks the older handle as SdkError.Superseded as soon as another handle is created by the same DidwwVerification instance with the same destination string. This local signal can therefore arrive before the new handle is collected. Reuse one client instance across starts so this in-process tracking remains available. Different formatting, such as +49 151... and 49151..., is not normalized for this local comparison.

If another client, process, device, or backend supersedes the verification, the current handle learns about it only after its next request is rejected. The API does not push this update to the SDK.

Android SDK behavior#

Capability

On Android

Polling for status

The SDK does not provide status(). The states flow reports transitions caused by the handle’s requests and local expiry countdown.

Resume by phone number

resume(destination, method) looks up the latest verification for the number and returns a new state-driven handle. Use it after process recreation when the original handle is no longer available.

Report requests

A handle created by start(...) reports by verification ID. A handle created by resume(...) reports through the by-number endpoint. The Android SDK uses PUT for both report forms.

Language

Kotlin. Coroutines and Flow are part of the public surface, so Java is not a supported integration path.

Sample application#

The Android SDK sample application demonstrates state rendering, lifecycle-aware collection, automatic SMS capture, repeated starts, and SDK error handling.