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 / 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.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

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.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

.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.

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

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.

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.

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.