iOS SDK#
The iOS SDK (DIDWWVerification) is an on-device Swift client for the
Verification API. It supports SMS and phone call
verification through async/await methods, Swift data types, and structured errors that
your app can catch and handle.
Source: didww/didww-verification-ios-sdk
Runtime requirement: iOS
13.0+Swift Package Manager requirement: Swift
6.1/ Xcode16.3+Dependencies: none; the SDK uses
URLSessionandCodable
Note
A mobile app cannot keep a credential secret private. For this reason, use public
authentication for production iOS apps. The HMAC-signed server-to-server mode is not
available in the SDK because it requires a signing secret. See
Authentication.
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 with.public(appKey:)are accepted.Copy the application key into the iOS app and use it with
.public(appKey:). Keep the application secret on your backend; do not include it in the app binary.Choose the verification methods your app will support and provide an input screen for the code required by each method.
How a verification flows#
Your app starts a verification, checks the initial status, collects the code from the user, and submits it through the SDK. You can request the current status while the verification is pending.
%%{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
actor User as End user
participant App as Your iOS app
participant SDK as Verification SDK
participant API as Verification API
User->>App: Enters phone number
App->>SDK: client.start(destination:method:sms:)
SDK->>API: POST /api/v1/verifications<br/>Authorization: Application {appKey}
Note over SDK,API: With .public authentication,<br/>your backend approves the request<br/>before delivery.
API-->>SDK: 201 Created - initial status
SDK-->>App: Verification
API->>User: SMS / phone call
Note over API,User: Delivery is asynchronous.<br/>201 means accepted, not delivered.<br/>Delivery can still fail later.
opt Check status while waiting
App->>SDK: client.status(verification)
SDK->>API: GET /api/v1/verifications/{id}
API-->>SDK: 200 OK - current status
end
User->>App: Enters the code
App->>SDK: client.verify(verification, code:)
SDK->>API: PUT /api/v1/verifications/{id}
API-->>SDK: 200 OK - status "verified"
SDK-->>App: VerificationResult
App-->>User: Verification completed
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, while your app controls the user interface and the action taken after verification.
Installation#
Swift Package Manager#
In Xcode, choose File > Add Package Dependencies and enter the repository URL, or declare
the package in Package.swift:
dependencies: [
.package(url: "https://github.com/didww/didww-verification-ios-sdk.git", from: "1.0.0")
],
targets: [
.target(name: "YourApp", dependencies: [
.product(name: "DIDWWVerification", package: "didww-verification-ios-sdk")
])
]
Swift Package Manager requires Swift 6.1 or Xcode 16.3+ to build the package.
CocoaPods#
Add the SDK directly from its Git repository and pin it to a release tag:
pod 'DIDWWVerification', :git => 'https://github.com/didww/didww-verification-ios-sdk.git', :tag => '1.0.0'
The CocoaPods specification supports Swift 5.9 and iOS 13.0+.
Quick start#
The following example creates a sandbox client with public authentication, starts an SMS
verification, checks the initial status, and submits the code entered by the user:
import DIDWWVerification
let client = VerificationClient(
environment: .sandbox,
auth: .public(appKey: "your-app-key"),
configuration: .init(timeout: 30)
)
let verification = try await client.start(
destination: "+4915112345678",
method: .sms,
sms: .init(languages: ["en-US"])
)
if verification.status == .pending {
let result = try await client.verify(verification, code: "123456")
switch result.status {
case .verified:
print("verified")
case .failed, .denied:
print(result.errorDetail ?? "not verified")
case .expired:
print("expired")
case .pending:
print("still pending")
case .other(let raw):
print("unrecognised status: \(raw)")
}
} else {
print(verification.errorDetail ?? "verification did not start")
}
The SDK sends the start request when start(...) is called. A successful HTTP request can
still return a verification with status .denied, so check verification.status before
showing the input screen.
Use client.status(verification) when you need the latest state. The SDK does not poll
automatically.
Delivery-method options#
Pass method-specific options through the parameter named after the delivery method:
sms: takes an SMSOptions, callout: takes a CalloutOptions. The SDK sends them
in the matching object of the API request.
The following example requests a German SMS template:
try await client.start(
destination: "+4915112345678",
method: .sms,
sms: .init(languages: ["de-DE"])
)
This sends:
{
"data": {
"destination": "+4915112345678",
"delivery_method": "sms",
"sms": {
"languages": ["de-DE"]
}
}
}
A phone call verification takes the announcement language the same way:
try await client.start(
destination: "+5511987654321",
method: .callout,
callout: .init(languages: ["pt-BR", "pt-PT"])
)
This sends:
{
"data": {
"destination": "+5511987654321",
"delivery_method": "callout",
"callout": {
"languages": ["pt-BR", "pt-PT"]
}
}
}
Language preferences use BCP 47 tags. SMSOptions and CalloutOptions 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 for matching and fallback
behavior.
If sms: or callout: is passed with a different method:, the SDK throws
VerificationError.channelMismatch before sending a network request. This prevents the API
from starting a verification with unintended default options.
A start can come back denied#
With .public authentication, the Verification API sends a synchronous
request callback to your backend before delivering the
challenge. If your backend denies the request or the callback response cannot be used,
start() still returns normally with HTTP 201 Created. The returned Verification has
status .denied, and errorCode explains why no challenge was sent.
%%{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 - status "pending"
else Your backend denies
CB-->>API: 200 {"action":"deny"}
API-->>SDK: 201 Created - status "denied"<br/>errorCode "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 - status "denied"<br/>errorCode "denied_invalid_callback_response"
end
Note over SDK,CB: No challenge is delivered in either denied branch.
A denied start can also happen when no callback URL is configured. If .public
authentication is used without a callback URL on the application, start() returns a
Verification with status .denied and errorCode
denied_missing_callback_url. See Callbacks.
Handle .denied as part of the normal result from start():
let verification = try await client.start(destination: number, method: .sms)
switch verification.status {
case .pending:
presentCodeEntry(for: verification)
case .denied:
show(verification.errorDetail ?? "verification denied")
default:
show("unexpected start status: \(verification.status)")
}
Returned values#
The SDK uses two related types for verification data:
Type |
Returned by |
Purpose |
|---|---|---|
|
|
A handle containing the verification identifier, delivery method, expiry, quoted fee, and creation-time status. |
|
|
The state returned after reporting a value or requesting the latest verification status. |
The status on Verification is the state returned when the verification was created. It
is not updated automatically. Pass the handle to status(_:) to retrieve a
VerificationResult with the current state:
let current = try await client.status(verification)
if current.status.isTerminal {
stopPolling()
}
Verification.Status and Verification.Reason include an .other(String) fallback. If
the API adds a new value, the SDK preserves the raw value instead of failing to decode the
response. An unknown status is treated as non-terminal.
verification.isExpired compares expiresAt with the device clock. It is a local
convenience; the Verification API remains authoritative when a value is submitted.
Note
The fee value is the quoted verification fee, not an immediate charge. It is billed only
when the verification reaches .verified. SMS or call delivery costs are billed
separately as ordinary DIDWW traffic.
Report or check status by phone number#
The SDK can address a verification by destination number when your app no longer holds the
original Verification handle.
Use status(number:) to get the newest verification for a number:
let current = try await client.status(number: "+4915112345678")
If an active verification exists, the status request returns it. Otherwise, it returns the
most recent finished verification. A 404 means that no verification history exists for the
number.
Use the by-number verify methods to report a value to the active verification:
let result = try await client.verify(
number: "+4915112345678",
code: "123456",
method: .sms
)
Reporting by number requires an active verification that can receive the value. If no active
verification exists, the API returns 404 even when a finished verification exists for the
same number.
Common phone-number formatting is accepted. Before building the request path, the SDK removes
all non-digit characters. For example, "+49 151 1234 5678" and
"4915112345678" reach the same endpoint. If the value contains no digits, the SDK throws
VerificationError.invalidNumber before sending a network request.
One active verification per number#
Only one unfinished verification can exist for the same OTP application and phone number. If
your app calls start() again for a number with an unfinished verification, the new
verification supersedes the previous one. The previous verification changes to .failed
with reason .superseded.
By-number operations resolve to the new verification. To observe .superseded, request the
status of the previous verification through its original handle with status(_:).
Warning
When reporting a code by number, method: must match the delivery method used to start the
active verification. An incorrect method and an incorrect code use the same
APIError.validationFailed case, but the contained APIErrorItem identifies the cause.
Check item.known or item.code for delivery_method_invalid or code_invalid.
Environments#
Choose the environment when creating the client. The SDK uses .production by default and
adds /api/v1 to the selected URL.
Environment |
Host |
|---|---|
|
|
|
|
|
A custom scheme and host with an optional base path, such as a local backend, proxy, or test server. |
Note
Use .sandbox while building and testing your integration. Use credentials from an OTP
application created in the same environment as the client. See
Choose an environment.
let client = VerificationClient(
environment: .sandbox,
auth: .public(appKey: "your-sandbox-app-key")
)
For .custom(URL), provide the URL before /api/v1. For example, a custom URL ending in
/verification produces endpoint paths under /verification/api/v1.
Authentication#
Mode |
Header |
Use |
|---|---|---|
|
|
Production, on-device. |
|
|
Local development in a trusted environment. |
Use .public for production mobile apps. It sends only the application key, and the
application’s callback URL authorizes each start request. Without a callback URL,
start() returns HTTP 201 with status .denied and errorCode
denied_missing_callback_url.
The SDK case names match the API’s authentication modes. Both public and signed application authentication use the
Application header scheme. The signed mode is not implemented in the iOS SDK because it
requires the secret on the device.
Warning
.basic embeds the application secret in your app, where it can be extracted from the
binary. Use it only for local development in a trusted environment. If the secret has been
distributed in an app, treat it as disclosed and rotate it.
Leave the OTP application’s minimum authentication mode at public for an on-device iOS
integration. Raising it to basic or application rejects requests made with
.public. Use a higher minimum only for an application called exclusively from your own
server.
Reading delivery-method details#
VerificationResult.details contains delivery-method-specific data returned by the API,
keyed by the delivery method. SMS responses carry the message template and the template
language; phone call responses carry the announcement language.
if case .sms(let sms) = result.details {
print(sms.template ?? "no template")
print(sms.language ?? "no language")
}
if case .callout(let callout) = result.details {
print(callout.language ?? "no language")
}
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.
Error handling#
Verification outcomes and thrown errors are different. A request can succeed and return a
Verification or VerificationResult with status .failed, .expired, or
.denied. Handle those values through status and reason.
Thrown errors describe an HTTP, transport, decoding, or client-side validation failure. The
SDK uses APIError for failures returned by the API or transport layer and
VerificationError for validation performed before a request is sent.
Case |
HTTP status |
Meaning |
|---|---|---|
|
|
The request body or required parameters are malformed. |
|
|
Authentication failed or the request uses a mode below the OTP application’s minimum. This case does not expose structured error items. |
|
|
The account balance is insufficient to start a verification. |
|
|
The requested verification could not be resolved. |
|
|
Validation or reporting failed, for example because of an incorrect code or delivery method. |
|
Other |
The API returned another unsuccessful HTTP status. The case preserves its code and any error items. |
|
The response body could not be decoded. |
|
|
The request failed because of connectivity, DNS, TLS, or a timeout. |
|
|
The supplied options do not match the selected delivery method. The error is thrown before a network request is sent. |
|
|
A by-number operation received a value containing no digits. The error is thrown before a network request is sent. |
The error cases that contain APIErrorItem values mirror the API’s
coded error envelope:
Property |
Meaning |
|---|---|
|
The raw error code, always present, for example |
|
Fixed human-readable text. Display it when needed, but do not use it for application logic. |
|
The typed |
APIErrorCode has no .other case. An unrecognized API error leaves known as nil
while code preserves the raw value, so decoding does not fail and the error code is not
lost.
do {
_ = try await client.verify(verification, code: code)
} catch APIError.validationFailed(let items) {
for item in items {
switch item.known {
case .codeInvalid:
print("wrong code")
case .deliveryMethodInvalid:
print("wrong delivery method")
case nil:
print("unmodelled code: \(item.code) - \(item.detail)")
default:
print("\(item.code): \(item.detail)")
}
}
} catch APIError.notFound {
print("verification not found")
} catch APIError.unauthorized {
print("authentication failed")
} catch APIError.insufficientBalance {
print("insufficient balance")
} catch APIError.unexpectedResponse(let message) {
print("response could not be decoded: \(message)")
} catch APIError.transport(let urlError) {
print("network request failed: \(urlError)")
} catch VerificationError.channelMismatch(let expected) {
print("value does not match \(expected)")
} catch VerificationError.invalidNumber {
print("phone number contains no digits")
} catch is CancellationError {
print("request cancelled")
}
Cancellation and timeout#
Run an SDK call in a Task when your app needs to cancel it. Cancelling the task cancels the
underlying URLSession request and throws CancellationError:
let task = Task {
try await client.status(verification)
}
task.cancel()
Configuration(timeout:) sets the timeout for each network request. The default is 30
seconds. This timeout is independent of the verification lifetime represented by
expiresAt.
Warning
Do not automatically resubmit a code after a timeout or another ambiguous network failure. The API may already have processed the report, and another report can consume an additional attempt. Request the current status before asking the user to submit the code again. See Report a verification.
Debug logging#
Logging is disabled by default. To enable it, provide a VerificationLogger when creating
the client. The SDK logs the request method and URL and the HTTP response status. It never logs
request or response bodies.
Before a message reaches your logger, the SDK masks standard six-digit OTP codes and digit sequences in the usual phone-number length range. Avoid adding credentials or unredacted user input in your own logging implementation.
struct ConsoleLogger: VerificationLogger {
func log(_ message: String) { print(message) }
}
let client = VerificationClient(
environment: .sandbox,
auth: auth,
configuration: .init(logger: ConsoleLogger())
)
Sample CLI#
The SDK repository includes a macOS command-line sample that demonstrates the complete SMS flow. It reads the environment, credentials, and destination from environment variables:
ENVIRONMENT=sandbox \
APP_KEY=your-app-key \
SECRET=your-app-secret \
DESTINATION=+4915112345678 \
swift run SampleCLI
Important
The sample uses basic authentication because it runs as a trusted command-line process.
Do not copy its secret-based authentication configuration into an iOS app. For an on-device
integration, use .public(appKey:) with a request callback.