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.
Requires: Android
minSdk 23.Build from source: Android
compileSdk 36, JDK17, and Kotlin2.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
INTERNETdirectly 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
allowordeny. See Callbacks.Keep the OTP application’s minimum authentication mode set to
publicso requests made withAuth.Publicare 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-allto select methods at runtime,verification-smsfor SMS only, orverification-corefor phone call only. See Installation.Collect each handle’s
statesflow exactly once from a lifecycle-aware scope such asviewModelScope. 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 |
|---|---|---|
|
The create request from |
|
|
The verification is waiting for a code. Carries |
|
|
An SMS code was captured automatically and is about to be submitted. See Automatic SMS capture. |
|
|
A value is in flight. |
|
|
The server accepted the value. |
Yes |
|
Carries a |
Yes |
|
The application’s callback rejected the request or did not return a usable response. |
Yes |
|
The OTP application is misconfigured. Retrying or changing user input cannot resolve it. |
Yes |
|
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 |
|---|---|---|
|
Transport, error model, state machine, and phone call. |
You only use phone call. |
|
|
You only send SMS. |
|
The umbrella artifact, including |
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 |
|
The latest verification is finished |
Its terminal state, such as |
No verification exists for the number |
|
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 |
|---|---|
|
|
|
|
|
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 |
|---|---|---|
|
|
Production, on-device. |
|
|
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
smsoptions 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 |
|---|---|
|
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. |
|
A successful HTTP response could not be decoded as a verification. |
|
A newer handle from the same SDK client replaced this handle for the same destination. |
|
The handle’s |
Server-side errors are returned as ApiErrorItem values, matching the API’s
coded error envelope:
Property |
Meaning |
|---|---|
|
The raw slug, always present — for example |
|
Fixed human-readable text. Display it; never branch on it. |
|
The typed |
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 |
Resume by phone number |
|
Report requests |
A handle created by |
Language |
Kotlin. Coroutines and |
Sample application#
The Android SDK sample application demonstrates state rendering, lifecycle-aware collection, automatic SMS capture, repeated starts, and SDK error handling.