# Ruby SDK

The Ruby SDK (`didww-otp_verification`) is a server-side client for the
[Verification API](../api-reference/index.html#otp-verification-api-reference). It provides methods for starting,
reporting, and retrieving verifications by ID or phone number. It also supports all three
[authentication modes](../authentication.html#otp-verification-authentication) and verifies inbound
[callback](../callbacks.html#otp-verification-callbacks) signatures.

- **Source:** [GitHub repository](https://github.com/didww/didww-verification-ruby-sdk)
- **Requires:** Ruby `>= 3.1`

---

## Before you begin

Complete [Getting Started](../getting-started.html#otp-verification-getting-started) in the environment where
the Ruby client will send verification requests. Create the OTP application and copy its
credentials from that environment.

- Choose the [authentication mode](../authentication.html#otp-verification-authentication) for the integration.
  `:basic` is the SDK default and requires the application key and secret.
  `:application` also requires both credentials and signs each request. `:public` uses
  only the application key but requires a callback URL so your server can approve each start.
- Store the application secret in a server-side environment variable or secret manager. Never
  include it in browser or mobile application code.
- If the OTP application has a callback URL, expose a server endpoint that can receive the
  request and verify its signature before returning `allow` or `deny`. See
  [Callbacks](../callbacks.html#otp-verification-callbacks).

## Installation

Add the gem to your `Gemfile`:

```
gem "didww-otp_verification"
```

Then install the dependency:

```
bundle install
```

## Quick start

Create a client, start an SMS verification, submit the code entered by the user, and retrieve
the current status. This example uses the sandbox environment and HTTP Basic authentication:

```
require "didww/otp_verification"

client = DIDWW::OTPVerification::Client.new(
  key:    ENV.fetch("DIDWW_OTP_KEY"),
  secret: ENV.fetch("DIDWW_OTP_SECRET"),
  env:    :sandbox
)

verification = client.start_verification(
  destination:     "+4915112345678",
  delivery_method: "sms",
  sms:             {languages: ["en-US"]}
)

verification.id           # => "0f9c8b7a-1e2d-4c3b-9a8f-7e6d5c4b3a21"
verification.status       # => "pending"
verification.pending?     # => true
verification.sms_language # => "en-US"

result = client.report_verification(
  verification.id,
  delivery_method: "sms",
  code:            "123456"
)

grant_access if result.verified?

current = client.get_verification(verification.id)
current.status
```

The start, report, and status methods each return a new `Verification` object containing the
state returned by that request. The object is a snapshot and is not updated automatically.
Call `get_verification(...)` when you need the latest state.

An incorrect code does not return a `Verification` object. It raises
`DIDWW::OTPVerification::ValidationError`. See [Error handling](#error-handling).

## Available methods

| SDK method | HTTP request | Purpose |
| --- | --- | --- |
| `start_verification(...)` | `POST /verifications` | Start a verification for one phone number. |
| `report_verification(id, ...)` | `PATCH /verifications/{id}` | Submit a code for a specific verification. |
| `get_verification(id)` | `GET /verifications/{id}` | Retrieve a specific verification. |
| `report_verification_by_number(number, ...)` | `PATCH /verifications/by_number/{number}` | Submit a code without providing a verification ID. |
| `get_verification_by_number(number)` | `GET /verifications/by_number/{number}` | Retrieve the latest verification associated with a phone number. |

The SDK adds the `/api/v1` base path to these requests.

## Delivery methods

The delivery method determines which value your application must submit when reporting a
verification:

| Delivery method | Report parameter | Example |
| --- | --- | --- |
| `sms` | `code:` | `code: "123456"` |
| `callout` | `code:` | `code: "123456"` |

The `delivery_method` supplied when reporting must match the method used to start the
verification.

## Delivery-method options

Pass method-specific options in a hash named after the delivery method: `sms:` when starting
an SMS verification, `callout:` when starting a phone call verification. The SDK sends only
the hash matching `delivery_method`, so passing both is safe.

### SMS options

| Field | Description |
| --- | --- |
| `languages` | Preferred message-template languages as BCP 47 tags, ordered from most to least preferred. See [Supported languages](../verification-methods/sms.html#otp-verification-sms-languages). |
| `app_hash` | Android SMS Retriever application hash. It must contain exactly 11 characters from `A-Z`, `a-z`, `0-9`, `+`, and `/`. Omit this field when automatic Android SMS capture is not used. |

The following example starts an SMS verification with a preferred language and an app hash
provided by the Android application:

```
verification = client.start_verification(
  destination:     "+4915112345678",
  delivery_method: "sms",
  sms: {
    languages: ["en-US"],
    app_hash:  "A1b2C3d4E5f"
  }
)
```

The app hash is derived from the Android package name and signing certificate. It is not a
secret and does not authenticate the application. A Ruby backend can pass the value supplied
by the Android application to the Verification API.

The SDK passes keys inside `sms:` to the API without validating their names. Check the key
names carefully because an unsupported or misspelled key may be ignored and the API defaults
may be used.

### Phone call options

| Field | Description |
| --- | --- |
| `languages` | Preferred announcement languages as BCP 47 tags, ordered from most to least preferred. See [Supported languages](../verification-methods/phone-call.html#otp-verification-callout-languages). |

```
verification = client.start_verification(
  destination:     "+5511987654321",
  delivery_method: "callout",
  callout:         {languages: ["pt-BR", "pt-PT"]}
)
```

### The language shorthand

`sms.languages` and `callout.languages` accept the same tags with the same semantics, so
one language list works for both delivery methods. Pass it as `languages:` and the SDK puts
it in the hash for the delivery method you named:

```
preferred = ["pt-BR", "pt-PT"]

client.start_verification(destination: number, delivery_method: "sms", languages: preferred)
client.start_verification(destination: number, delivery_method: "callout", languages: preferred)
```

An explicit `sms:` or `callout:` hash takes precedence over `languages:`. Use the
explicit hash when you also need `app_hash`.

## Verification responses

Every successful SDK request returns a `DIDWW::OTPVerification::Verification` object.

| Reader | Ruby type | Description |
| --- | --- | --- |
| `id` | `String` | Verification identifier. |
| `destination` | `String` | Destination number normalized without a leading `+`. |
| `delivery_method` | `String` | Delivery method used for the verification. |
| `fee` | `BigDecimal` | Quoted verification fee. |
| `status` | `String` | Current verification status. |
| `error_code` | `String` or `nil` | Machine-readable reason for a failed, expired, or denied verification. |
| `error_detail` | `String` or `nil` | Human-readable text associated with `error_code`. |
| `expires_at` | `Time` or `nil` | Verification expiration time. |
| `sms` | `Hash` or `nil` | SMS-specific response fields. It is `nil` for phone call. |
| `callout` | `Hash` or `nil` | Phone call response fields. It is `nil` for SMS. |
| `raw` | `Hash` | Unmodified `data` object returned by the API. |

Use `to_h` to retrieve the same raw response data as `raw`.

### Reading SMS response details

An SMS verification can include an `sms` object in the response. Read its fields through
the convenience methods or from the raw hash:

```
verification.sms_template             # => "Your code is {{CODE}}"
verification.sms_language             # => "en-US"
verification.sms_interception_timeout # => 300
verification.sms_code_length          # => 6
verification.sms_app_hash             # => "A1b2C3d4E5f", or nil
verification.sms                      # => the raw SMS Hash
```

`sms_interception_timeout` is the number of seconds an on-device client should continue
listening for automatic SMS capture. It equals the application's **Code timeout** (60-600 seconds,
300 by default). It is not the verification expiration time. Manual code entry remains
available until `expires_at`.

`sms_code_length` is the length of this verification's code: 4 to 8 digits, set per
application (6 by default).

`sms_app_hash` is returned only when an app hash was stored for the verification.

`sms_language` is the language the API **selected**, which is not necessarily the first one
requested. Compare it with the list you sent to detect a fallback to `en-US`.

### Reading phone call response details

A phone call verification includes a `callout` object in the response:

```
verification.callout_language    # => "de-DE"
verification.callout_code_length # => 6
verification.callout             # => the raw callout Hash
```

`callout_language` is the language the announcement was played in. The announcement
recordings are a different set from the SMS templates, so a tag that is honored for SMS can
still fall back for a phone call. `callout_code_length` is the length of this verification's
code, as for SMS.

### Handling verification status

Use the status predicates instead of comparing status strings directly:

```
verification.pending?
verification.verified?
verification.failed?
verification.expired?
verification.denied?
verification.finished?
```

`finished?` returns `true` for `verified`, `failed`, `expired`, and `denied`. It
indicates that polling can stop, but it does not mean that the phone number was verified.
Grant access only when `verified?` returns `true`:

```
current = client.get_verification(verification.id)

if current.verified?
  grant_access
elsif current.pending?
  keep_waiting
elsif current.finished?
  show_verification_error(current.error_code, current.error_detail)
else
  report_unknown_status(current.status)
end
```

`error_code` and `error_detail` describe an unsuccessful verification result. They are
`nil` while the verification is pending and when it is verified. These fields are different
from the errors attached to a raised `APIError`.

Note

The `fee` value is the quoted verification fee, not an immediate charge. It is billed
only when the verification becomes `verified`. SMS or call delivery costs are billed
separately.

## Using phone numbers instead of IDs

Use the by-number methods when your application retained the destination number but not the
verification ID:

```
current = client.get_verification_by_number("+4915112345678")

result = client.report_verification_by_number(
  "+4915112345678",
  delivery_method: "sms",
  code:            "123456"
)
```

Supply the phone number in E.164 format. The leading `+` is optional. The SDK percent-encodes
the number for the URL path, but it does not normalize or validate the number.

`get_verification_by_number(...)` returns the latest verification associated with the
number. `report_verification_by_number(...)` reports against that same latest
verification. If it has already finished, the report does not change its outcome, so read
`status` from the result. If the number has no verification, the SDK raises
`NotFoundError`.

If another verification is started for the same application and phone number, it supersedes
the previous unfinished verification. Use the ID-based methods when the report or status check
must apply to the exact verification originally shown to the user.

## Environments

Choose the environment when creating the client. The SDK uses `:production` by default.

| Environment | Base URL |
| --- | --- |
| `:production` (default) | `https://verification.didww.com` |
| `:sandbox` | `https://verification-sandbox.didww.com` |
| Registered environment | A custom base URL registered with `register_env`. |

Use `:sandbox` while building and testing your integration:

```
client = DIDWW::OTPVerification::Client.new(
  key:    ENV.fetch("DIDWW_OTP_KEY"),
  secret: ENV.fetch("DIDWW_OTP_SECRET"),
  env:    :sandbox
)
```

Use credentials from an OTP application created in the same environment as the client. See
[Choose an environment](../getting-started.html#otp-verification-environments) for the corresponding User Panel
and API URLs.

Register a custom environment, such as a local test server, and select it by name:

```
DIDWW::OTPVerification.configure do |config|
  config.register_env(:local, "http://localhost:3000")
end

client = DIDWW::OTPVerification::Client.new(
  key:    "your-app-key",
  secret: "your-app-secret",
  env:    :local
)
```

The SDK adds `/api/v1` to each request path. Do not include that path when registering a
custom base URL.

## Configuration

Set shared defaults with `DIDWW::OTPVerification.configure`. Values passed directly to
`Client.new` override the global defaults.

```
DIDWW::OTPVerification.configure do |config|
  config.key       = ENV.fetch("DIDWW_OTP_KEY")
  config.secret    = ENV.fetch("DIDWW_OTP_SECRET")
  config.env       = :sandbox
  config.auth_mode = :basic

  config.faraday do |connection|
    connection.options.open_timeout = 5
    connection.options.timeout = 10
  end
end

client = DIDWW::OTPVerification::Client.new
```

For a multi-tenant application, pass credentials and connection options to each client:

```
client = DIDWW::OTPVerification::Client.new(
  key:      tenant.otp_key,
  secret:   tenant.otp_secret,
  env:      :production,
  base_url: "https://verification-proxy.example.com"
) do |connection|
  connection.proxy = "http://proxy.example.com:3128"
  connection.options.timeout = 10
end
```

`base_url:` overrides `env:`. The URL must contain the scheme and host but should not
include `/api/v1`.

The SDK uses Faraday for HTTP transport. A custom Faraday adapter can be supplied with
`adapter:`, but the adapter gem must also be added to your application.

Warning

The SDK does not log requests by default. If you add Faraday logging middleware, configure
it to redact the `Authorization` header, application credentials, phone numbers, and
submitted codes.

## Authentication

Select an authentication mode with `auth_mode:` on `Client.new` or with the global
`config.auth_mode` setting. The OTP application's minimum authentication mode must allow the
selected mode. See [Authentication](../authentication.html#otp-verification-authentication).

| Mode | Header | Secret | Use |
| --- | --- | --- | --- |
| `:basic` (default) | `Basic base64(key:secret)` | Required | Server-to-server integrations. |
| `:public` | `Application <key>` | Not used | Untrusted clients controlled through a request callback. |
| `:application` | `Application <key>:<signature>` and `x-timestamp` | Required | Signed server-to-server integrations. |

### HTTP Basic authentication

`:basic` is the default mode. It sends the application key as the username and the secret
as the password:

```
client = DIDWW::OTPVerification::Client.new(
  key:       ENV.fetch("DIDWW_OTP_KEY"),
  secret:    ENV.fetch("DIDWW_OTP_SECRET"),
  auth_mode: :basic
)
```

### Public authentication

`:public` sends only the application key. A callback URL must be configured on the OTP
application so your backend can approve each start request:

```
client = DIDWW::OTPVerification::Client.new(
  key:       "your-app-key",
  auth_mode: :public
)
```

Public authentication is primarily intended for untrusted clients. A Ruby server can use it
when the verification must still pass through the callback approval flow.

### Signed application authentication

`:application` signs each request with HMAC-SHA256 and sends an `x-timestamp` header. The
SDK builds the signature from the final request bytes, so your application does not need to
implement signing:

```
client = DIDWW::OTPVerification::Client.new(
  key:       ENV.fetch("DIDWW_OTP_KEY"),
  secret:    ENV.fetch("DIDWW_OTP_SECRET"),
  auth_mode: :application
)
```

The application secret must be valid URL-safe Base64. The client validates it during
initialization and raises `ConfigurationError` when it cannot be decoded.

Warning

Treat the application secret as a password. Store it in a secrets manager or protected
environment variable, never in source control or client-side code.

## Handling a denied start

With `:public` authentication, the Verification API sends a synchronous request to the OTP
application's callback URL before attempting delivery. A denied start can still return
`201 Created` because the verification record was created successfully. In this case,
`start_verification(...)` returns a `Verification` with status `denied` instead of
raising an exception.

Handle the initial status before asking the user to enter a code:

```
verification = client.start_verification(
  destination:     "+4915112345678",
  delivery_method: "sms"
)

if verification.pending?
  show_code_entry(verification)
elsif verification.denied?
  show_unavailable(verification.error_code, verification.error_detail)
else
  handle_unexpected_start_status(verification.status)
end
```

An explicit callback denial returns `error_code` `denied_by_callback`. An unusable
callback response returns `denied_invalid_callback_response`. If no callback URL is
configured, the result is `denied_missing_callback_url`. See
[Callbacks](../callbacks.html#otp-verification-callbacks) for the complete flow.

## Verifying inbound callbacks

Use `CallbackVerifier` to verify a signed `verification_request` callback before parsing
its body or deciding whether to allow the verification. The verifier checks the signature and
enforces a five-minute timestamp window by default.

Select the application secret using the key from the `Authorization` header, then verify the
exact request bytes:

```
key, signature = DIDWW::OTPVerification::CallbackVerifier
                 .parse_authorization(request.headers["Authorization"])

secret = find_application_secret(key)

valid = secret && DIDWW::OTPVerification::CallbackVerifier.new(secret: secret).valid?(
  method:       request.request_method,
  path:         request.path,
  content_type: request.content_type,
  body:         request.raw_post,
  timestamp:    request.headers["x-timestamp"],
  signature:    signature
)

unless valid
  # Return 401 Unauthorized and stop processing the callback.
end

# After verification, apply your rules and return {"action":"allow"} or
# {"action":"deny"} with a 2xx status.
```

Use the raw body exactly as received. Parsing and serializing the JSON again can change the
bytes and invalidate an otherwise correct signature. The `path` value must exactly match the
path of the configured callback URL because it is part of the signature.

The callback verifier can be loaded without the HTTP client in a callback-only service:

```
require "didww/otp_verification/callback_verifier"
```

Set a different timestamp tolerance only when required by your deployment:

```
verifier = DIDWW::OTPVerification::CallbackVerifier.new(
  secret:    application_secret,
  tolerance: 300
)
```

### Rails

`RailsCallbackVerifier` reads the signed values from an `ActionDispatch::Request`. Rails
support is optional and must be required explicitly:

```
require "didww/otp_verification/rails"
```

Verify the request in a `before_action`. After verification succeeds, return the callback
decision from the controller action:

```
class DidwwCallbacksController < ActionController::API
  before_action :verify_didww_signature

  def create
    allowed = expected_destination?(params.dig(:data, :destination))
    render json: {action: allowed ? "allow" : "deny"}
  end

  private

  def verify_didww_signature
    verifier = DIDWW::OTPVerification::RailsCallbackVerifier.new(
      secret: ENV.fetch("DIDWW_OTP_SECRET")
    )

    return if verifier.valid?(request)

    head :unauthorized
  end
end
```

Route the same path that is configured as the callback URL on the OTP application:

```
# config/routes.rb
post "/callbacks/didww", to: "didww_callbacks#create"
```

The example above uses one application secret. If the endpoint receives callbacks for multiple
OTP applications, parse the application key first and select the matching secret before
verifying the request.

## Error handling

Client configuration problems and API responses raise errors under
`DIDWW::OTPVerification::Error`. Network failures raised by Faraday are not wrapped by the
SDK.

| Error class | HTTP status | When it is raised |
| --- | --- | --- |
| `ConfigurationError` | Not applicable | The client has a missing key or secret, an unknown environment or auth mode, or an invalid signing secret. |
| `UnauthorizedError` | `401` | Authentication failed or the authentication mode is not allowed. |
| `BalanceInsufficientError` | `402` | The account balance is insufficient. |
| `NotFoundError` | `404` | The requested verification could not be found, either because no such verification exists for the OTP application or because it passed the [retention period](../api-reference/get-verification-status.html#otp-verification-retention). |
| `ValidationError` | `400` or `422` | The request is invalid, including an incorrect code. |
| `RateLimitedError` | `429` | `destination_in_cooldown`: a start for the same number came too soon after the previous one. `retry_after` holds the `Retry-After` header in seconds, or `nil`. Wait that long before starting again for that number; a start is never retried automatically. |
| `ServerError` | `5xx` | The API encountered a server error. |
| `APIError` | Other HTTP status | The API returned another unsuccessful status or an unexpected successful response body. |

Every `APIError` provides the following readers:

| Reader | Description |
| --- | --- |
| `status` | HTTP response status. |
| `errors` | Array of `ErrorItem` objects from the API response. |
| `code` | Machine-readable code from the first error, or `nil`. |
| `codes` | Machine-readable codes from all returned errors. |
| `response` | Original Faraday response. |

Each `ErrorItem` contains `code` and `detail`. Branch on `code` in application logic
and use `detail` only as display text. Unknown error codes remain available as raw strings.
An error produced by a proxy or another non-JSON response can have an empty `errors` array.
See [Errors and status codes](../api-reference/errors.html#otp-verification-api-errors).

The following example handles an incorrect code separately from authentication, API, and
network failures:

```
begin
  result = client.report_verification(
    verification.id,
    delivery_method: "sms",
    code:            submitted_code
  )

  grant_access if result.verified?
rescue DIDWW::OTPVerification::ValidationError => error
  case error.code
  when "code_invalid"
    show_code_error(error.errors.first&.detail)
  when "too_many_attempts"
    require_new_verification
  else
    show_request_error(error.code, error.message)
  end
rescue DIDWW::OTPVerification::UnauthorizedError
  report_configuration_error
rescue DIDWW::OTPVerification::APIError => error
  report_api_error(error.status, error.codes)
rescue Faraday::TimeoutError, Faraday::ConnectionFailed => error
  show_network_error(error)
end
```

## Timeouts and retries

Set connection and response timeouts through the Faraday configuration block:

```
client = DIDWW::OTPVerification::Client.new(
  key:    ENV.fetch("DIDWW_OTP_KEY"),
  secret: ENV.fetch("DIDWW_OTP_SECRET")
) do |connection|
  connection.options.open_timeout = 5
  connection.options.timeout = 10
end
```

The SDK does not retry requests automatically. If you add retry middleware, choose retry
behavior by operation:

- A status `GET` request can be retried after a temporary network failure.
- Do not automatically retry `start_verification(...)`. The first request may already have
  created a verification, superseded an earlier one, or started delivery.
- Do not automatically retry a report request. Each report can consume one of the allowed
  attempts.

If a report request times out after it may have reached the API, retrieve the verification
status before deciding whether to submit the value again.

On this page
