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: GitHub repository

  • Runtime requirement: iOS 15.0+

  • Swift Package Manager requirement: Swift 6.1 / Xcode 16.3+

  • Dependencies: none; the SDK uses URLSession and Codable

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 allow or deny. See Callbacks.

  • Keep the OTP application’s minimum authentication mode set to public so 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.2.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.2.0'

The CocoaPods specification supports Swift 5.9 and iOS 15.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.

SMS autofill#

When the OTP application has an SMS domain set, the API adds a domain-bound line (@<domain> #<code>) to every SMS by default, and iOS offers the code above the keyboard. SMSOptions.autofill overrides that default for one verification; leave it nil to keep it:

try await client.start(destination: number, method: .sms, sms: .init(autofill: .domain))

Value

Effect

nil (default)

The application’s default applies.

.domain

Adds the domain-bound line. An application with no SMS domain fails with APIErrorCode.smsDomainNotConfigured.

.disabled

Sends no autofill marker (none in the API request).

The Android SMS Retriever app hash is not offered on iOS. The added line counts toward the SMS length. The marker that was stored comes back as sms.autofill in details; see Reading delivery-method details.

Template groups#

templateGroup on SMSOptions and CalloutOptions uses one of the application’s template groups instead of its default. An unknown name fails with APIErrorCode.templateGroupNotFound:

try await client.start(destination: number, method: .sms, sms: .init(templateGroup: "checkout"))
try await client.start(destination: number, method: .callout, callout: .init(templateGroup: "checkout"))

Custom data for your callback#

custom: is an optional string of at most 4096 characters. It is forwarded to your request callback, so your backend can allow or deny the verification using context from the app. It is not returned in any response. A longer value fails with APIErrorCode.customTooLong.

try await client.start(destination: number, method: .sms, custom: "signup-flow-b")

Warning

custom is set on the device, so anyone can change it. Validate it on your backend and never trust it on its own.

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 start can also come back with 201 Created and no challenge sent for these reasons:

Status

reason

Cause

.denied

.deniedMissingCallbackURL

.public authentication is used and the application has no callback URL. See Callbacks.

.denied

.applicationPaused

The application is paused. Every start is denied until it is resumed.

.denied

.destinationCountryDisabled

The destination’s country is disabled for the account.

.failed

.destinationNotSupportedForChannel

The delivery method cannot reach the destination, or the number is in a blocked range. Earlier versions of the API rejected this with 422.

.deniedMissingCallbackURL and .applicationPaused are configuration problems. Retrying cannot fix them, so report them to your team rather than to the user.

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")
case .failed:
    show(verification.errorDetail ?? "verification failed")
default:
    show("unexpected start status: \(verification.status)")
}

Returned values#

The SDK uses two related types for verification data:

Type

Returned by

Purpose

Verification

start(...)

A handle containing the verification identifier, delivery method, expiry, quoted fee, and creation-time status.

VerificationResult

verify(...) and 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.Reason.numberUnreachable is no longer sent. The API reports those verifications as .failedToDeliver. The case is kept so existing switch statements compile.

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 targets the newest verification for the number. If it has already finished, the report does not change its outcome, so read status from the result. The API returns 404 only when the number has no verification.

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

.production (default)

https://verification.didww.com

.sandbox

https://verification-sandbox.didww.com

.custom(URL)

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

.public(appKey:)

Application <appKey>

Production, on-device.

.basic(appKey:secret:)

Basic base64(appKey:secret)

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. Both carry codeLength, the length of this verification’s code: 4 to 8 digits, set per application (6 by default). Size the code-entry field from it.

if case .sms(let sms) = result.details {
    print(sms.template ?? "no template")
    print(sms.language ?? "no language")
    print(sms.codeLength.map(String.init) ?? "no code length")
}

if case .callout(let callout) = result.details {
    print(callout.language ?? "no language")
    print(callout.codeLength.map(String.init) ?? "no code length")
}

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.

Both blocks also carry the template group fields:

  • templateGroup: the group applied, or nil.

  • templateGroupUsed: whether the group’s template was used.

  • templateFallbackReason: why the default system template was used instead: .languageMissing, .versionPending, .versionRejected, .audioNotReady, .groupMissing, or .other for a reason newer than the SDK. It is nil when the template was used, or when no template group applies (templateGroupUsed is then false).

sms.autofill is the stored autofill marker, or nil. Its type is .appHash, .domain, or .other, and value is the hash or the domain.

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

APIError.invalidParameters([APIErrorItem])

400

The request body or required parameters are malformed.

APIError.unauthorized

401

Authentication failed or the request uses a mode below the OTP application’s minimum. This case does not expose structured error items.

APIError.insufficientBalance

402

The account balance is insufficient to start a verification.

APIError.notFound

404

The requested verification could not be resolved.

APIError.validationFailed([APIErrorItem])

422

Validation or reporting failed, for example because of an incorrect code or delivery method.

APIError.unexpectedStatus(code:items:)

Other

The API returned another unsuccessful HTTP status. The case preserves its code and any error items. A 429 arrives here; see Limits (429).

APIError.unexpectedResponse(String)

The response body could not be decoded.

APIError.transport(URLError)

The request failed because of connectivity, DNS, TLS, or a timeout.

VerificationError.channelMismatch(expected:)

The supplied options do not match the selected delivery method. The error is thrown before a network request is sent.

VerificationError.invalidNumber

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

code

The raw error code, always present, for example destination_blank.

detail

Fixed human-readable text. Display it when needed, but do not use it for application logic.

known

The typed APIErrorCode when this SDK version recognizes the code; otherwise nil.

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.

APIErrorCode.codeValuePresent, .cliBlank, .cliValuePresent, and .cliInvalid are no longer sent by the API and are kept only for source compatibility.

Note

Version 1.2.0 adds cases to APIErrorCode and Verification.Reason. A switch over either enum without a default branch no longer compiles until you handle the new cases. The new Reason cases used to decode as .other("<code>"); match the typed case instead.

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")
}

Limits (429)#

Every 429 arrives as APIError.unexpectedStatus(code: 429, items:). The first item’s known value says which limit was hit:

APIErrorCode

Meaning

.destinationInCooldown

A verification for this number was started too recently.

.rateLimitPerIP

Too many requests from this IP address.

.rateLimitFailedAuth

Too many failed authentication attempts from this IP address.

.rateLimitPerAccount

Too many requests for this account.

.rateLimitPublicPerAccount

Too many public requests for this account.

.volumeLimitReached

The application reached its daily or monthly verification limit.

Each item’s retryAfter (TimeInterval?) holds the Retry-After header in whole seconds, or nil if the response has no usable header. Wait that long before trying again; the SDK never retries for you. .volumeLimitReached ends the flow for the user: its Retry-After can be days, so show an error instead of offering a retry. See Limits.

} catch APIError.unexpectedStatus(let code, let items) where code == 429 {
    if items.first?.known == .volumeLimitReached {
        showLimitReached()
    } else if let wait = items.first?.retryAfter {
        scheduleRetry(after: wait)
    }
}

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 any run of 4 to 15 digits, which covers OTP codes of every configured length and phone numbers. Verification IDs stay readable. 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.