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 verificationsms_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
endTesting
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 globalsPer-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
endRoutes (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
endStatuses 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 ", "
endRetries
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-Aftercan 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.