# iOS SDK

The iOS SDK (`DIDWWVerification`) is an on-device Swift client for the
[Verification API](../api-reference/index.html#otp-verification-api-reference). 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](https://github.com/didww/didww-verification-ios-sdk)
- **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](../authentication.html#otp-verification-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](../getting-started.html#otp-verification-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](../callbacks.html#otp-verification-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](../verification-methods/index.html#otp-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.1.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.1.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](../verification-methods/sms.html#otp-verification-sms-languages) and
[phone call languages](../verification-methods/phone-call.html#otp-verification-callout-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](../callbacks.html#otp-verification-callbacks) 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](../callbacks.html#otp-verification-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 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](../getting-started.html#otp-verification-environments).

```
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](../authentication.html#otp-verification-authentication). 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`.

## 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 with `APIErrorCode.destinationInCooldown`: a start for the same number came too soon after the previous one. Each item's `retryAfter` (`TimeInterval?`) holds the `Retry-After` header in whole seconds; wait that long before starting again for that number. It is `nil` if the response has no usable header. A start is never retried automatically. |
| `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](../api-reference/errors.html#otp-verification-api-errors):

| 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](../api-reference/report-verification.html#otp-verification-api-report).

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

On this page
