Skip to content

About

OTP Verification Python SDK

Topics

Resources

Contributing

Security policy

Stars

1 star

Watchers

0 watching

Forks

Repository files navigation

DIDWW Verification SDK for Python

Python client for the DIDWW Verification API: start a phone verification, report the code the user entered, and read the outcome. Ships a synchronous and an asynchronous client, and a callback verifier that needs no HTTP client at all.

Installation

pip install didww-verification

Quick start

from didww_verification import BasicAuth, VerificationClient

with VerificationClient(BasicAuth(key, secret)) as client:
    verification = client.start_verification(
        destination="+37112345678",
        delivery_method="sms",
    )

    # The code arrives by SMS; ask the user for it, then report it.
    # A wrong code raises: see "Reporting a code".
    verification = client.report_verification(verification.id, delivery_method="sms", code="123456")

    print(verification.status)  # "verified", "failed", ...

Async is the same surface with await:

from didww_verification import AsyncVerificationClient, BasicAuth

async with AsyncVerificationClient(BasicAuth(key, secret)) as client:
    verification = await client.start_verification(
        destination="+37112345678", delivery_method="sms"
    )

Both clients build, sign and decode through the same code and put identical bytes on the wire.

Outcomes are data, not exceptions

A verification that ends failed, expired or denied is a successful API call. Read the result rather than catching something:

verification = client.get_verification(verification_id)

if verification.status == "verified":
    grant_access()
elif verification.is_finished:
    # error_code says why: too_many_attempts, expired, superseded, ...
    show(verification.error_detail)
else:
    keep_polling()

is_finished is the signal to stop polling. Statuses and error codes are an open set: one added after this release arrives as a plain string rather than raising, so compare against is_known_verification_status before switching exhaustively.

Only transport faults, non-2xx responses and unreadable bodies raise — see Errors. A wrong code is one of those non-2xx responses.

Reporting a code

Each report consumes one of three attempts. While attempts remain, a wrong code is rejected with 422 and code_invalid, and the verification stays pending, so the user can try again. Once all three are used, the next report is answered with a normal 200 whose status is failed and whose error_code is too_many_attempts.

from didww_verification import DidwwValidationError

try:
    verification = client.report_verification(verification.id, delivery_method="sms", code=entered)
except DidwwValidationError as exc:
    if exc.has_code("code_invalid"):
        ask_again()  # still pending
    elif exc.has_code("not_ready_to_report"):
        retry_shortly()  # the challenge is still being sent
    else:
        raise

Addressing a verification by phone number

When the id was never persisted, every read and report has a by_number twin:

client.get_verification_by_number("+37112345678")
client.report_verification_by_number("+37112345678", delivery_method="sms", code="123456")

"By number" resolves to the newest verification for that number, finished ones included. One caveat worth designing around: a start that is itself denied does not supersede an earlier live verification, so a by_number read can return the denied row while the live one is reachable only by its id. Hold the id from the start response when you can.

Per-channel options

Options travel in a block named after the channel. Only the block matching delivery_method is read.

from didww_verification import CalloutOptions, SmsOptions

client.start_verification(
    destination="+37112345678",
    delivery_method="sms",
    sms=SmsOptions(languages=["lv-LV", "en-US"]),
)

client.start_verification(
    destination="+37112345678",
    delivery_method="callout",
    callout=CalloutOptions(languages=["de-DE"]),
)

SMS autofill is set with autofill: Autofill.app_hash("<11-char hash>") frames the message for the Android SMS Retriever, Autofill.domain() adds a domain-bound line for the application's configured SMS domain, Autofill.none() sends no marker, and leaving it unset uses the application default. The verification echoes it back as verification.sms.autofill, only when a hash or domain marker was stored; for domain, value is the domain. Autofill.domain() on an application with no SMS domain is refused with sms_domain_not_configured, and a malformed autofill with autofill_invalid.

from didww_verification import Autofill

SmsOptions(autofill=Autofill.app_hash("abcdefghijk"))

SmsOptions(app_hash=...) and SmsInfo.app_hash still work but are deprecated in favour of autofill. The alias is sent as autofill, and passing it together with a different autofill raises ValueError.

Languages are BCP 47 tags, tried in order, falling back to en-US. Send the region subtag. A bare primary subtag like pl passes validation and then silently falls back, because the catalogue is matched on the exact canonical tag.

The response reports the tag actually used, so a fallback is detected rather than guessed at:

verification.sms.language  # the tag the message was rendered in
verification.callout.language  # the tag the announcement is played in

The two catalogues are separate: a tag with an SMS template may still have no recording.

Each response also reports the generated code's length, 4–8:

verification.sms.code_length
verification.callout.code_length

Template groups

template_group picks one of your template groups instead of the application's default. An unknown name is refused with template_group_not_found.

SmsOptions(template_group="checkout")
CalloutOptions(template_group="checkout")

Both sms and callout in the response report what happened:

verification.sms.template_group  # the group applied, or None
verification.sms.template_group_used  # True when the group's template was used
verification.sms.template_fallback_reason  # why not: language_missing, version_pending, ...

When no template group applies at all, template_group_used is False and template_fallback_reason is None. group_missing means the requested or default group no longer exists, and comes with template_group set to None.

template_fallback_reason is an open vocabulary (TEMPLATE_FALLBACK_REASONS); a reason added later arrives as a plain string.

Passing data to your callback

custom is a free-text string of up to 4096 characters, sent at the top level of the request rather than inside a channel block:

client.start_verification(
    destination="+37112345678",
    delivery_method="sms",
    custom="order-42",
)

It reaches your callback server unchanged as data.custom (see below) and is not returned by any response. Longer values are refused with custom_too_long; an empty string is treated as unset.

Environments

from didww_verification import Environment, VerificationClient

VerificationClient(auth, environment=Environment.SANDBOX)
VerificationClient(auth, base_url="http://localhost:3000")  # an origin, no path

base_url must be an origin. The SDK adds its own /api/v1 prefix, and a base URL that already contains it produces a doubled path.

Testing

The sandbox environment sends no real SMS and places no real calls. Only the reserved test numbers give a predefined result, such as delivered, failed or expired, and a delivered one has a fixed code. Any other number is never delivered to, and its verification ends as failed.

VerificationClient(auth, environment=Environment.SANDBOX)

The test numbers, their outcomes and the code to submit are listed in Testing on sandbox.

Authentication

Three schemes, ranked public < basic < application. Each application has a minimum; anything below it is rejected with 401.

from didww_verification import ApplicationAuth, BasicAuth, PublicAuth

PublicAuth(key)  # Authorization: Application <key>
BasicAuth(key, secret)  # Authorization: Basic base64(key:secret)
ApplicationAuth(key, secret)  # HMAC-signed, plus an x-timestamp header
  • PublicAuth carries no secret. The key identifies rather than authenticates, so it is safe in a client users can read. What authorises a start is your registered callback URL — with none registered, a start under this scheme is denied outright.
  • BasicAuth is server-to-server only; the secret is recoverable from anything that ships it.
  • ApplicationAuth signs every request. It is the only scheme whose starts skip the outbound callback, since a signed caller is already trusted. A malformed secret fails at construction rather than on the first request.

Every authentication failure — unknown key, wrong secret, bad signature, stale timestamp, too weak a scheme — answers 401 with no further detail, by design.

Verifying inbound callbacks

Before creating a verification, the API can call your registered callback URL and wait for you to allow or deny it. There is one request and no retry: whatever you answer decides the verification.

from didww_verification.callback import CallbackVerifier, allow, deny

verifier = CallbackVerifier(
    secret=application_secret,
    callback_url="https://example.com/callbacks/didww",  # as registered, verbatim
)

callback_url must be the URL registered with DIDWW, not the path the request arrives on — an ingress that rewrites, or an app mounted under a prefix, makes these differ. Two consequences:

  • A registered URL with no path — https://example.com — signs the empty string, not /. A verifier that defaults to the received pathname computes a valid signature over / and then denies every verification, with correct code on both sides.
  • https://example.com and https://example.com/ are different signatures. Do not normalise the trailing slash.

The request body is the verification_request event. data.custom holds the value passed to start_verification; the key is absent when none was given.

custom = payload["data"].get("custom")

Treat custom as untrusted. When the start request comes from a mobile app, the app controls it, so validate it against your own records instead of acting on it as given.

Importing didww_verification.callback pulls in no HTTP client, so a service that only receives callbacks pays nothing for one.

Rotating the application secret

During a rotation the service also sends an x-signature-previous header: the same signature, made with the previous secret. The verifier accepts a callback when either signature matches the configured secret, so you can switch your receiver from the old secret to the new one at any time in the grace period, without downtime.

The FastAPI and Django adapters read the header for you. Upgrade the SDK before you rotate: an older verifier reads only Authorization, which is signed with the new secret, so a receiver still configured with the old secret would reject every callback.

FastAPI / Starlette

from fastapi import Request, Response
from didww_verification.callback import allow, deny
from didww_verification.callback.fastapi import verify_request


@app.post("/callbacks/didww")
async def didww_callback(request: Request) -> Response:
    if not await verify_request(verifier, request):
        return Response(status_code=401)

    payload = await request.json()
    body = allow() if is_expected(payload) else deny()
    return Response(body, media_type="application/json")

Django

from django.http import HttpResponse
from django.views.decorators.csrf import csrf_exempt
from didww_verification.callback import allow, deny
from didww_verification.callback.django import verify_request


@csrf_exempt
def didww_callback(request):
    if not verify_request(verifier, request):
        return HttpResponse(status=401)
    return HttpResponse(allow(), content_type="application/json")

The view must be CSRF-exempt: the request comes from DIDWW, not from a form, and carries its own signature.

Any other framework

Pass the pieces yourself. body must be the received bytes — re-serializing parsed parameters changes them and the signature will not match:

verifier.is_valid(
    method=request.method,
    content_type=request.content_type,
    body=raw_body,
    timestamp=request.headers.get("x-timestamp"),
    signature=signature,  # from parse_authorization(...)
    previous_signature=request.headers.get("x-signature-previous"),  # optional
)

Answer with allow() or deny() and nothing else. Never echo why a request failed: that distinguishes an unknown key from a bad signature, which turns your endpoint into an oracle for which application keys exist.

Errors

from didww_verification import DidwwApiError, DidwwValidationError

try:
    client.start_verification(destination=number, delivery_method="sms")
except DidwwValidationError as exc:
    if exc.has_code("destination_invalid"):
        ...
except DidwwApiError as exc:
    log.warning("didww: %s %s", exc.status, exc.codes)
Exception When
DidwwUnauthorizedError 401
DidwwBalanceInsufficientError 402
DidwwNotFoundError 404
DidwwValidationError 400, 422
DidwwRateLimitedError 429
DidwwServerError 5xx
DidwwApiError any other non-2xx; base class of the above
DidwwTransportError no response: connect, timeout, TLS
DidwwDecodingError a 2xx body this SDK could not read
DidwwConfigurationError a bad secret or an unusable base URL

All descend from DidwwVerificationError. One response can carry several errors — a validation failure returns one per field — so use codes and has_code, not errors[0]. code is a stable slug to switch on; detail is fixed prose to display, never to parse.

Some starts are refused as an outcome rather than an error: a 201 whose status is denied or failed, with error_code set to application_paused, destination_country_disabled or destination_not_supported_for_channel.

A non-2xx whose body is not JSON still raises the status-mapped error with empty errors, so an error page from a proxy surfaces as the server error it is.

Retries

Only reads are retried, on transport faults and 5xx — once by default, since attempts counts total tries:

from didww_verification import RetryPolicy

VerificationClient(auth, retry=RetryPolicy(attempts=3, base_delay=0.5))
VerificationClient(auth, retry=RetryPolicy(attempts=1))  # off

Starts and reports are never retried, and you should not add it. The API has no idempotency key: a repeated start supersedes the live verification and charges again, and a repeated report consumes one of three attempts. Exceeding that limit is answered with a normal 200 whose status is failed — read the result rather than counting attempts yourself.

A 429 raises DidwwRateLimitedError, 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, or contact support to raise the limit.

The SDK never retries a 429 automatically: wait retry_after seconds — None when the response carried no Retry-After header — then try again yourself.

Logging

The SDK itself logs nothing. Its HTTP client, httpx2, logs every request's method and URL at INFO on the httpx2 logger, and for the by_number calls that URL contains the phone number. If your application logs at INFO, raise that logger:

import logging

logging.getLogger("httpx2").setLevel(logging.WARNING)

A channel this release does not model

delivery_method is an open vocabulary on read. If a verification you can read reports a channel outside DELIVERY_METHODS, report it with the raw variant, which performs no client-side check:

client.report_verification_raw(
    verification.id, delivery_method=verification.delivery_method, code=value
)

Development

pip install -e ".[dev]"
pytest
ruff check . && mypy && pyright

About

OTP Verification Python SDK

Topics

Resources

Contributing

Security policy

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages