The project is in a healthy, maintained state
Client for the DIDWW OTP verification API with basic, public and HMAC-signed (application) auth, plus inbound callback signature verification.
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
 Dependencies

Runtime

~> 0.2
~> 2.0
 Project Readme

DIDWW OTP Verification Ruby SDK

Ruby client for the DIDWW OTP Verification API (/api/v1). Wraps all verification endpoints (address a verification by id or by phone number), all three auth modes, and inbound callback signature verification.

📖 API documentation

Installation

gem "didww-otp_verification"

Quick start

require "didww/otp_verification"

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

verification = client.start_verification(
  destination:     "+4915112345678",
  delivery_method: "sms",                 # "sms" | "callout"
  sms:             {languages: ["en-US"]} # optional, see below
)
verification.id       # => "0f9c8b7a-..."
verification.status   # => "pending"
verification.pending? # => true
verification.to_h     # => raw response data Hash with string keys

# Report the code the user entered (counts as an attempt; max 3, expires per
# the app's code lifetime)
result = client.report_verification(
  verification.id, delivery_method: "sms", code: "1234"
)
result.verified? # => true/false

# Poll status
client.get_verification(verification.id)

Both delivery methods are reported the same way: code: carries what the user entered, whether they read it off a message or heard it in a call.

Per-method options

Options that apply to one delivery method only go in a keyword named after that method and travel verbatim, so the call above goes on the wire as:

{ "data": { "destination": "+4915112345678", "delivery_method": "sms",
            "sms": { "languages": ["en-US"] } } }

sms: and callout: are the keywords today, one per delivery method. The API reads only the block matching delivery_method and ignores the others.

sms:

Field Description
languages Preferred template languages as BCP 47 tags, most preferred first. Matched exactly, so the region subtag is required — "pl" does not match pl-PL. Unmatched tags fall back to en-US.
autofill Auto-fill marker for the delivered message. {type: "app_hash", value: "..."} is the Android SMS Retriever hash: exactly 11 characters of [A-Za-z0-9+/]. The message is then prefixed with <#> and the hash appended as its last token, so the handset can auto-fill the code. {type: "domain"} adds a domain-bound line for the application's configured SMS domain. {type: "none"} explicitly asks for no marker. Omit it to use the application default.
template_group Name of one of your template groups, used instead of the application's default.

autofill is here because a Ruby backend often starts the verification on behalf of an Android app — the hash identifies that app, so only the app can compute it. {type: "domain"} takes no value; on an application with no SMS domain it is refused with sms_domain_not_configured. A malformed autofill is refused with autofill_invalid. The former app_hash: key still works as a deprecated alias for autofill: {type: "app_hash", value: ...} (it prints a deprecation warning when Ruby's :deprecated warnings are enabled). Passing both with non-nil values raises ArgumentError; autofill: nil counts as absent.

client.start_verification(
  destination: "+4915112345678", delivery_method: "sms",
  sms: {languages: ["en-US"], autofill: {type: "app_hash", value: "A1b2C3d4E5f"}}
)

callout:

Field Description
languages Preferred announcement languages — the same tags, order and matching rules as the sms: ones, so the same list is valid for either method. It is not shared between them, though: each method reads only its own block, so the list goes under callout: here and under sms: there. The catalogues are separate too, so a tag that has a message template but no recording is accepted and falls back to en-US.
template_group Name of one of your template groups, as for sms:.

An unknown template_group on either method is refused with template_group_not_found.

client.start_verification(
  destination: "+4915112345678", delivery_method: "callout",
  callout: {languages: ["de-DE", "en-US"]}
)

The SDK names no field inside a block individually — the block travels verbatim, so a field the API adds to either one needs no release here and works the day the API ships it. The cost of that is no typo protection: an unrecognized option is ignored rather than rejected, and a misspelled key returns 201 with the defaults applied, not an error.

custom:

Optional free text, up to 4096 characters, sent as top-level data.custom (an empty string counts as absent). It is not returned by the API; it reaches your callback server as data.custom in the verification_request body; the key is absent when unset. A longer value is refused with custom_too_long.

client.start_verification(
  destination: "+4915112345678", delivery_method: "sms",
  custom: "order-42"
)

Reading a method block back

The response carries the block for the method that was used, readable field by field or as a whole:

v.sms_template             # => "Your code is {{CODE}}"
v.sms_language             # => "en-US"
v.sms_interception_timeout # => 300
v.sms_code_length          # => 6
v.sms_autofill             # => {"type" => "app_hash", "value" => "A1b2C3d4E5f"}, or nil
v.sms_template_group       # => "checkout", or nil if none applied
v.sms_template_group_used  # => true
v.sms_template_fallback_reason # => nil, or e.g. "language_missing"
v.sms                      # => the raw block, or nil on a callout verification

v.callout_language         # => "de-DE"
v.callout_code_length      # => 6
v.callout_template_group   # => "checkout", or nil if none applied
v.callout_template_group_used # => true
v.callout_template_fallback_reason # => nil, or e.g. "audio_not_ready"
v.callout                  # => the raw block, or nil on an sms verification

sms_language and callout_language are the tag the server chose — the first requested language it had a template (or a recording) for, otherwise the en-US fallback — never an echo of what was asked for. Comparing one against the list that was sent is how a fallback is detected rather than guessed at, and because the two catalogues are separate, a tag honoured on sms can still fall back on callout.

sms_interception_timeout is how many seconds a client should keep an on-device SMS listener armed. It equals the app's configured code lifetime (60–600 s, default 300) and is not a deadline for the verification itself — manual entry keeps working until expires_at. sms_code_length/callout_code_length is the length of this verification's code, 4–8 digits, set per application (default 6). sms_autofill is echoed back only when a hash or domain marker was stored, so it reflects what was persisted rather than what was requested; for domain, value is the domain. sms_app_hash is a deprecated reader derived from it: the value when the type is app_hash, otherwise nil. *_template_group_used is true when the group's template was used. When it is false, *_template_fallback_reason says why: language_missing, version_pending, version_rejected, audio_not_ready or group_missing (the group no longer exists; *_template_group is then nil). Both are false/nil when no template group applies at all. Like error codes, the reasons are an open set of plain strings.

Address by phone number

Every report/fetch call has a _by_number variant that targets the latest verification for a phone number (E.164, leading + optional) instead of an id:

client.get_verification_by_number("+4915112345678")
client.report_verification_by_number(
  "+4915112345678", delivery_method: "sms", code: "1234"
)

Numbers are matched on their digits, so any formatting works. "Latest" is the active verification when there is one, otherwise the most recent finished attempt — so you can still read an outcome you missed. A 404 means the number has no verification at all.

Sandbox environment

The SDK targets production by default. To hit the sandbox, pass env: :sandbox:

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

Or set it as a global default:

DIDWW::OTPVerification.configure do |c|
  c.env = :sandbox
end

Testing

The sandbox sends no real SMS and places no real calls. Only the reserved test numbers give a predefined result, such as delivered, failed or expired; a delivered one has a fixed code. Any other number is never delivered to, and its verification ends as failed. The test numbers, their outcomes and the code to submit are listed in Testing on sandbox.

Configuration

Set global defaults; every field is overridable per Client.

DIDWW::OTPVerification.configure do |c|
  c.key       = ENV["DIDWW_OTP_KEY"]
  c.secret    = ENV["DIDWW_OTP_SECRET"]
  c.auth_mode = :basic                      # :basic (default) | :public | :application

  # Register extra environments (a local dev server, a private mock, ...)
  c.register_env(:local, "http://localhost:3000")

  # Customize every Faraday connection (proxy, logging, timeouts, adapter)
  c.faraday do |conn|
    conn.options.timeout = 10
    conn.response :logger
  end
end

DIDWW::OTPVerification::Client.new  # picks up the globals

Per-request credentials / config

Nothing is global-only. Pass anything straight to Client.new — ideal for multi-tenant apps:

DIDWW::OTPVerification::Client.new(
  key: tenant.key, secret: tenant.secret,
  base_url: "https://custom.host",          # wins over env
  adapter: :typhoeus                         # any Faraday adapter
) { |conn| conn.proxy = "http://proxy:3128" }

Every request carries User-Agent: didww-verification-ruby/<version>.

Auth modes

Mode Header Secret Notes
:basic (default) Basic base64(key:secret) required Documented, simplest
:public Application <key> not used Requires a callback URL on the app
:application Application <key>:<signature> + x-timestamp required HMAC-SHA256 signed

Set the mode per Client via auth_mode:, or globally via c.auth_mode in configure.

:basic (default)

Sends Basic base64(key:secret). Requires both key and secret.

client = DIDWW::OTPVerification::Client.new(
  key:    "your-app-key",
  secret: "your-app-secret",
  auth_mode: :basic          # optional — this is the default
)

:public

Sends Application <key>. No secret is used; the app must have a callback URL configured so DIDWW can deliver results.

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

:application

Sends Application <key>:<signature> plus an x-timestamp header. The secret is the URL-safe base64 signing key and is required. Signing is applied automatically; the signature is computed over the exact request bytes.

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

Verifying inbound callbacks

DIDWW sends signed verification_request callbacks to your server using the same HMAC scheme. The verifier has no HTTP-client dependency. The callback request payload is documented at Request from DIDWW.

verifier = DIDWW::OTPVerification::CallbackVerifier.new(secret: app_secret)

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

ok = verifier.valid?(
  method:       request.request_method,   # "POST"
  path:         request.path,             # your callback URL's path
  content_type: request.content_type,
  body:         request.raw_post,         # RAW body bytes — do not re-serialize
  timestamp:    request.headers["x-timestamp"],
  signature:    signature,
  previous_signature: request.headers["x-signature-previous"]  # optional
)

render json: { action: ok ? "allow" : "deny" }

The verification_request body carries the custom value you passed to start_verification as data.custom (absent when unset). When the verification is started from a mobile app, that value is client-controlled: validate it against your own records rather than trusting it.

The verifier also enforces a 5-minute timestamp window (configurable via tolerance:) to reject replays.

Rotating the application secret

While a rotated-out secret is still valid, DIDWW also sends an x-signature-previous header: the same signature computed with the previous secret. The verifier accepts a callback when either the Authorization signature or previous_signature matches the secret you configured. A receiver can therefore switch from the old secret to the new one at any point during the grace period, with no failed callbacks. Without the header the verifier behaves as before.

Upgrade the SDK before you rotate a secret, because an older version ignores x-signature-previous and rejects callbacks signed with the new secret while the old one is configured.

Rails

RailsCallbackVerifier pulls every signed field off an ActionDispatch request for you. It lives in a separate file that is not auto-required — Rails is an optional runtime dependency — so require it explicitly:

require "didww/otp_verification/rails"

Controller:

class DidwwCallbacksController < ActionController::API
  before_action :verify_didww_signature

  # Signature is already verified here — decide allow/deny from your own
  # logic and the callback params.
  def create
    # some custom logic to decide whether to allow or deny the verification request
    allow = expected_destination? params.dig(:data, :destination)

    render json: { action: allow ? "allow" : "deny" }
  end

  private

  def verify_didww_signature
    verifier = DIDWW::OTPVerification::RailsCallbackVerifier.new(
      secret: ENV["DIDWW_OTP_SECRET"]
    )
    head(:unauthorized) unless verifier.valid?(request)
  end
end

Routes (config/routes.rb) — the path must match the callback URL registered on your app, since it is part of the signed payload:

post "/callbacks/didww", to: "didww_callbacks#create"

Verification outcomes

Every 2xx response carries the verification's status. One that ended badly also carries error_code (switch on it) and error_detail (display it). These are not exceptions — a failed or denied verification is a successful HTTP call.

status Terminal error_code
pending no nil — on its way, or awaiting a report
verified yes nil
failed yes too_many_attempts, dispatch_failed, superseded, application_deleted, destination_not_supported_for_channel, stale_dispatch (deprecated)
expired yes expired — past the application's code lifetime (60–600 s, default 300)
denied yes denied_by_callback, denied_missing_callback_url, denied_invalid_callback_response, application_paused, destination_country_disabled

superseded means a newer start_verification for the same number retired this one — only the newest verification per number stays active.

Some starts are refused as an outcome rather than an error: a 201 whose status is denied (application_paused, destination_country_disabled) or failed (destination_not_supported_for_channel: the number has no supported country, can't be reached by this delivery method, or is in a blocked range). Earlier versions returned the last one as a 422; that code stays in the error list for compatibility only.

stale_dispatch is deprecated: the service no longer sends it and reports these verifications as dispatch_failed.

v = client.get_verification(id)

if v.finished?  # verified / failed / expired / denied — stop polling
  case v.error_code
  when nil                 then grant_access
  when "too_many_attempts" then lock_out
  when "expired"           then offer_resend
  else logger.warn("verification #{v.id}: #{v.error_detail}")
  end
end

Statuses and codes are plain strings and an open set — new ones ship without an SDK release, so always keep a default branch and treat the API documentation as the authoritative list.

Errors

Non-2xx responses raise a typed error under DIDWW::OTPVerification::Error:

Class Status Typical code
UnauthorizedError 401 unauthorized
BalanceInsufficientError 402 balance_insufficient
NotFoundError 404 not_found
ValidationError 400 / 422 parameter_missing / per-field codes
RateLimitedError 429 destination_in_cooldown, rate_limit_*, volume_limit_reached
ServerError 5xx internal_error

Every error carries the API's coded envelope: #errors is an array of ErrorItems (#code, #detail), and #code/#codes are shortcuts to the stable, machine-readable slugs. Switch on code; the human detail is fixed per code and only for display. One response can carry several errors — check #codes rather than #code, which only returns the first.

Every status the API produces carries this envelope. #errors is empty only when a response has no JSON body at all — e.g. a 502 from a proxy in front of it.

begin
  client.start_verification(destination: "", delivery_method: "sms")
rescue DIDWW::OTPVerification::ValidationError => e
  e.status            # => 422
  e.code              # => "destination_blank"
  e.codes             # => ["destination_blank"]
  e.errors.first.code # => "destination_blank"
  e.message           # => every error's detail, joined by ", "
end

Retries

The SDK does not auto-retry. If you add faraday-retry, exclude POST (double-charges) and PATCH (each report counts against the 3-attempt limit).

A 429 raises RateLimitedError, and its code says which limit was hit:

  • destination_in_cooldown: a verification for this destination was requested too recently.
  • rate_limit_per_ip: too many requests from this IP address.
  • rate_limit_failed_auth: too many failed authentication attempts from this IP address.
  • rate_limit_per_account: too many requests for this account.
  • rate_limit_public_per_account: too many public requests for this account.
  • volume_limit_reached: the application reached its daily or monthly verification limit. Retry-After can be long (days) — surface it to the user instead of retrying. Contact support to raise the limit.

#retry_after gives the wait in seconds (nil if the Retry-After header is missing or unparseable) — wait that long before trying again yourself; never auto-retry it.

Development

Development is pinned to the Ruby in .ruby-version; the gem itself supports 3.3+.

bundle install
bundle exec rake spec         # core gem specs (no Rails)
bundle exec rake spec:rails   # Rails integration suite (spec-rails/)

The signing implementation is pinned to the server's own test vectors in spec/didww/otp_verification/signer_spec.rb.

Rails is a dev-only dependency: it is exercised solely by the spec-rails/ suite and is never a runtime dependency of the gem. The core specs under spec/ never load Rails.