Python SDK#
The Python SDK (didww-verification) is a server-side client for the
Verification API. It provides methods for starting,
reporting, and retrieving verifications by ID or phone number. It ships a synchronous and an
asynchronous client, supports all three authentication modes, and verifies inbound callback signatures.
Source: GitHub repository
Requires: Python
>= 3.10
The package is fully typed and ships py.typed, so type checkers resolve its annotations
without a stub package.
Before you begin#
Complete Getting Started in the environment where the Python client will send verification requests. Create the OTP application and copy its credentials from that environment.
Choose the authentication mode for the integration.
BasicAuthrequires the application key and secret.ApplicationAuthalso requires both and signs each request.PublicAuthuses 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
allowordeny. See Callbacks.
Installation#
pip install didww-verification
The distribution is named didww-verification and imports as didww_verification. It
installs two runtime dependencies, httpx2 and anyio.
The optional callback adapters do not add dependencies. Install the web framework you already use; the SDK imports it only from the adapter module for that framework.
Quick start#
Create a client, start an SMS verification, submit the code entered by the user, and read the result. This example uses the sandbox environment and HTTP Basic authentication:
import os
from didww_verification import (
BasicAuth,
DidwwValidationError,
Environment,
SmsOptions,
VerificationClient,
)
auth = BasicAuth(os.environ["DIDWW_KEY"], os.environ["DIDWW_SECRET"])
with VerificationClient(auth, environment=Environment.SANDBOX) as client:
verification = client.start_verification(
destination="+4915112345678",
delivery_method="sms",
sms=SmsOptions(languages=["en-US"]),
)
verification.id # "0f9c8b7a-1e2d-4c3b-9a8f-7e6d5c4b3a21"
verification.status # "pending"
verification.is_finished # False
code = input("code from the SMS: ").strip()
# One attempt is consumed whatever the outcome, so never loop on this.
try:
verification = client.report_verification(
verification.id, delivery_method="sms", code=code
)
except DidwwValidationError as exc:
if not exc.has_code("code_invalid"):
raise
ask_again() # still pending; the user may try again
if verification.status == "verified":
grant_access()
The client holds a connection pool. Use it as a context manager, or call close() when the
application shuts down.
Each method returns a new Verification describing 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 raise. While attempts remain, the report is rejected with 422
and code_invalid — a DidwwValidationError — and the verification stays pending, so
the user can try again. Once three attempts are used, the next report returns 200 with
status failed and error_code too_many_attempts. See Reporting a code.
Asynchronous client#
AsyncVerificationClient is the same surface with await:
import asyncio
from didww_verification import AsyncVerificationClient, BasicAuth, Environment
async def main(destination: str) -> None:
auth = BasicAuth(os.environ["DIDWW_KEY"], os.environ["DIDWW_SECRET"])
async with AsyncVerificationClient(auth, environment=Environment.SANDBOX) as client:
verification = await client.start_verification(
destination=destination, delivery_method="sms"
)
print(verification.id)
asyncio.run(main("+4915112345678"))
Both clients build, sign, and decode through the same code and put identical bytes on the wire. Only where the request is sent and where the retry backoff waits differ between them.
Available methods#
SDK method |
HTTP request |
Purpose |
|---|---|---|
|
|
Start a verification for one phone number. |
|
|
Submit a code for a specific verification. |
|
|
Retrieve a specific verification. |
|
|
Submit a code without providing a verification ID. |
|
|
Retrieve the latest verification associated with a phone number. |
The SDK adds the /api/v1 base path to these requests.
start_verification(...) takes keyword arguments only. report_verification(...) and
get_verification(...) take the identifier positionally and the rest by keyword.
Two further methods, report_verification_raw(...) and
report_verification_by_number_raw(...), take delivery_method as an open string and
perform no client-side check. Use them only when a verification you can read reports a
delivery method that this release does not model.
Delivery methods#
The delivery method determines which value your application must submit when reporting a verification:
Delivery method |
Report parameter |
Example |
|---|---|---|
|
|
|
|
|
|
The delivery_method supplied when reporting must match the method used to start the
verification. DELIVERY_METHODS holds the pair the SDK knows, and
is_known_delivery_method(value) reports whether a value read back from the API is in it.
Delivery-method options#
Pass method-specific options in the dataclass named after the delivery method: sms= when
starting an SMS verification, callout= when starting a phone call verification. The SDK
sends only the block matching delivery_method, so passing both is safe.
SMS options#
Field |
Description |
|---|---|
|
Preferred message-template languages as BCP 47 tags, ordered from most to least preferred. See Supported languages. |
|
Android SMS Retriever application hash. It must contain exactly 11 characters from
|
from didww_verification import SmsOptions
verification = client.start_verification(
destination="+4915112345678",
delivery_method="sms",
sms=SmsOptions(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 Python backend can pass the value supplied by the Android application to the Verification API.
SmsOptions is a frozen dataclass, so an unsupported or misspelled field name is a
TypeError at the call site rather than a silently dropped key.
Phone call options#
Field |
Description |
|---|---|
|
Preferred announcement languages as BCP 47 tags, ordered from most to least preferred. See Supported languages. |
from didww_verification import CalloutOptions
verification = client.start_verification(
destination="+5511987654321",
delivery_method="callout",
callout=CalloutOptions(languages=["pt-BR", "pt-PT"]),
)
Warning
Send the region subtag. Template lookup is an exact match on the canonical tag, so a bare
primary subtag such as pl passes validation and then silently falls back to
en-US. Send pl-PL.
The two catalogues are separate. A tag that has an SMS template may still have no announcement recording, so one language list can resolve differently per delivery method.
Verification responses#
Every successful SDK request returns a Verification. It is a frozen dataclass, so its
fields are read-only.
Field |
Python type |
Description |
|---|---|---|
|
|
Verification identifier. |
|
|
Destination number normalized without a leading |
|
|
Delivery method used for the verification. |
|
|
Quoted verification fee. |
|
|
Current verification status. |
|
|
Machine-readable reason for a failed, expired, or denied verification. |
|
|
Human-readable text associated with |
|
|
Verification expiration time. |
|
|
SMS-specific response fields. It is |
|
|
Phone call response fields. It is |
|
|
Property. |
The decoded response envelope is not retained by default, because it keeps the destination
number and the delivery metadata in memory for as long as the object lives. Build the client
with keep_raw_payload=True to read it from verification.raw. That field is excluded
from equality and is not covered by semantic versioning.
Reading SMS response details#
An SMS verification carries an sms block:
verification.sms.template # "Your code is {{CODE}}"
verification.sms.language # "en-US"
verification.sms.interception_timeout # 300
verification.sms.app_hash # "A1b2C3d4E5f", or None
verification.sms.code_length # 6
verification.sms is None for a phone call verification, so guard the access.
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.
code_length is the length of this verification’s code: 4 to 8 digits, set per application
(6 by default).
app_hash is echoed back only when a hash was stored for the verification. Comparing it
with the value you sent is the only confirmation that it was accepted.
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 carries a callout block:
verification.callout.language # "de-DE"
verification.callout.code_length # 6
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. code_length is the length of this verification’s code, as for SMS.
Outcomes are results, not exceptions#
A verification that ends failed, expired, or denied is a successful API call.
It is returned, not raised. Read the result instead of catching something:
verification = client.get_verification(verification_id)
if verification.status == "verified":
grant_access()
elif verification.is_finished:
show_verification_error(verification.error_code, verification.error_detail)
else:
keep_polling()
is_finished is the signal to stop polling. It is derived from status rather than from
a list of terminal states, so a status added after this release is treated as finished rather
than polled forever.
Statuses and error codes are open sets. A value added after this release arrives as a plain string rather than raising, so test membership before switching exhaustively:
from didww_verification import VERIFICATION_STATUSES, is_known_verification_status
VERIFICATION_STATUSES # ("pending", "verified", "failed", "expired", "denied")
if not is_known_verification_status(verification.status):
report_unknown_status(verification.status)
error_code and error_detail describe an unsuccessful verification result. They are
None while the verification is pending and when it is verified. These fields are different
from the errors carried by a raised DidwwApiError.
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.
Reporting a code#
Each report consumes one of three attempts. While attempts remain, an incorrect code is
rejected with 422 and code_invalid, and the verification stays pending. Once all
three are used, the next report returns 200 with status failed and error_code
too_many_attempts. A report sent while the challenge is still being delivered is rejected
with 422 and not_ready_to_report; submit the value again shortly.
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
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 newest verification associated with the
number, finished ones included. report_verification_by_number(...) reports against that
same newest 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
DidwwNotFoundError.
Warning
A start that is itself denied does not supersede an earlier live verification. A by-number read can therefore return the denied record while the live verification remains reachable only by its ID. Keep the ID from the start response whenever you can, and use the ID-based methods when the report or status check must apply to the exact verification shown to the user.
Environments#
Choose the environment when creating the client. The SDK uses Environment.PRODUCTION by
default.
Environment |
Base URL |
|---|---|
|
|
|
|
|
Any origin you supply. |
Use the sandbox while building and testing your integration:
from didww_verification import Environment, VerificationClient
client = VerificationClient(auth, environment=Environment.SANDBOX)
Use credentials from an OTP application created in the same environment as the client. See Choose an environment for the corresponding User Panel and API URLs.
base_url= overrides environment= and is how you point the client at a local test
server:
client = VerificationClient(auth, base_url="http://localhost:3000")
Warning
base_url must be an origin with no path. The SDK adds its own /api/v1 prefix,
so a base URL that already contains it produces a doubled path, and the API answers 404
as though the verification did not exist. A base URL carrying a path, a query, or a
fragment raises DidwwConfigurationError when the client is created.
Configuration#
All client options are keyword arguments to the constructor:
Option |
Default |
Description |
|---|---|---|
|
|
Which published environment to address. |
|
|
An origin that overrides |
|
|
Request timeout in seconds. |
|
|
Read-retry policy. See Retries. |
|
|
Value sent in the |
|
|
Retain the decoded response envelope on each |
|
|
Supply your own |
A client you supply through http_client is not closed by the SDK, because you own its
lifetime:
import httpx2
from didww_verification import VerificationClient
http = httpx2.Client(proxy="http://proxy.example.com:3128", timeout=10.0)
client = VerificationClient(auth, http_client=http)
Warning
The SDK itself does not log, but its HTTP client does: httpx2 logs every request’s
method and URL at INFO on the httpx2 logger, and a by_number URL contains the
phone number. If your application logs at INFO, raise that logger:
import logging
logging.getLogger("httpx2").setLevel(logging.WARNING)
If you enable more detailed HTTP client logging, configure it to redact the
Authorization header, application credentials, phone numbers, and submitted codes.
Authentication#
Pass an authentication object as the first argument to the client. The OTP application’s minimum authentication mode must allow the mode you select. See Authentication.
Class |
Header |
Secret |
Use |
|---|---|---|---|
|
|
Required |
Server-to-server integrations. |
|
|
Not used |
Untrusted clients controlled through a request callback. |
|
|
Required |
Signed server-to-server integrations. |
HTTP Basic authentication#
BasicAuth sends the application key as the username and the secret as the password. It is
server-to-server only: the secret is recoverable from anything that ships it.
from didww_verification import BasicAuth, VerificationClient
client = VerificationClient(BasicAuth(key, secret))
Public authentication#
PublicAuth sends only the application key, which identifies rather than authenticates. A
callback URL must be configured on the OTP application so your backend can approve each start
request. With no callback URL registered, a start under this mode is denied outright.
from didww_verification import PublicAuth, VerificationClient
client = VerificationClient(PublicAuth(key))
Signed application authentication#
ApplicationAuth signs each request with HMAC-SHA256 and sends an x-timestamp header.
It is the only mode whose start requests skip the outbound callback, because a signed caller
is already trusted.
from didww_verification import ApplicationAuth, VerificationClient
client = VerificationClient(ApplicationAuth(key, secret))
The application secret must be valid URL-safe Base64. ApplicationAuth decodes it when it
is constructed and raises DidwwConfigurationError there, rather than on the first request.
Warning
Treat the application secret as a password. Store it in a secrets manager or a protected environment variable, never in source control or client-side code.
Every authentication failure — unknown key, wrong secret, bad signature, stale timestamp, a
mode below the application’s minimum — answers 401 with no further detail, by design.
Handling a denied start#
With PublicAuth, 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 that case
start_verification(...) returns a Verification with status denied instead of
raising.
Handle the initial status before asking the user to enter a code:
verification = client.start_verification(
destination="+4915112345678", delivery_method="sms"
)
if verification.status == "pending":
show_code_entry(verification)
elif verification.status == "denied":
show_unavailable(verification.error_code, verification.error_detail)
else:
handle_unexpected_start_status(verification.status)
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 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 300-second timestamp window by default.
from didww_verification.callback import CallbackVerifier
verifier = CallbackVerifier(
secret=application_secret,
callback_url="https://example.com/callbacks/didww", # as registered, verbatim
tolerance=300,
)
There is one request and no retry. Whatever you answer decides the verification.
Warning
callback_url must be the URL registered with DIDWW, not the path the request
arrives on. An ingress that rewrites paths, or an application mounted under a prefix,
makes the two differ. Two consequences follow:
A registered URL with no path —
https://example.com— signs the empty string, not/. A verifier that defaults to the received path computes a valid signature over/and then denies every verification, with correct code on both sides.https://example.comandhttps://example.com/produce different signatures. Do not normalize the trailing slash.
Importing didww_verification.callback pulls in no HTTP client, so a service that only
receives callbacks does not pay for one.
FastAPI and Starlette#
from fastapi import FastAPI, Request, Response
from didww_verification.callback import allow, deny
from didww_verification.callback.fastapi import verify_request
app = FastAPI()
@app.post("/callbacks/didww")
async def didww_callback(request: Request) -> Response:
if not await verify_request(verifier, request):
# No reason in the body: it would be an oracle for which keys exist.
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#
import json
from django.http import HttpRequest, 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: HttpRequest) -> HttpResponse:
# Verify before touching request.POST: that consumes the stream.
if not verify_request(verifier, request):
return HttpResponse(status=401)
payload = json.loads(request.body)
body = allow() if payload.get("event") == "verification_request" else deny()
return HttpResponse(body, 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:
from didww_verification.callback import parse_authorization
key, signature = parse_authorization(request.headers.get("authorization"))
valid = verifier.is_valid(
method=request.method,
content_type=request.content_type,
body=raw_body, # the received bytes
timestamp=request.headers.get("x-timestamp"),
signature=signature,
)
body must be the bytes exactly as received. Parsing the JSON and serializing it again
changes key order, whitespace, and escaping, and an otherwise correct signature will not
match.
check(...) is the same call returning a RejectionReason instead of a boolean. Use it
for your logs only. Answer with allow() or deny() and nothing else: reporting why a
request failed distinguishes an unknown key from a bad signature, which turns the endpoint
into an oracle for which application keys exist.
If your endpoint receives callbacks for several OTP applications, use the key returned by
parse_authorization(...) to select the matching secret, and build a verifier per
application.
Retries#
Only reads are retried, on transport faults and 5xx — once by default:
from didww_verification import RetryPolicy, VerificationClient
VerificationClient(auth, retry=RetryPolicy(attempts=3, base_delay=0.5))
VerificationClient(auth, retry=RetryPolicy(attempts=1)) # off
attempts counts total tries, so the default of 2 means one retry.
Warning
Starts and reports are never retried, and you should not add retries around them. The
API has no idempotency key: a repeated start supersedes the live verification and sends
another code, and a repeated report consumes one of three attempts. Exceeding the attempt limit is
answered with a normal 200 whose status is failed, so read the result rather than
counting attempts yourself.
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.
Error handling#
Only transport faults, non-2xx responses, and unreadable bodies raise. Every exception
descends from DidwwVerificationError.
Exception |
HTTP status |
When it is raised |
|---|---|---|
|
Not applicable |
An invalid signing secret or an unusable base URL, raised when the client is built. |
|
|
Authentication failed or the authentication mode is not allowed. |
|
|
The account balance is insufficient. |
|
|
The verification could not be found, either because no such verification exists for the OTP application or because it passed the retention period. |
|
|
The request is invalid. |
|
|
|
|
|
The API, or infrastructure in front of it, returned a server error. |
|
Other |
Any other unsuccessful status. Base class of the six above. |
|
Not applicable |
No response at all: connection failure, timeout, or TLS error. |
|
|
A successful response this SDK could not read. |
Every DidwwApiError carries the following:
Attribute |
Description |
|---|---|
|
HTTP response status. |
|
Tuple of |
|
Machine-readable codes from all returned errors, in order. |
|
|
|
The raw response body, or |
One response can carry several errors — a validation failure returns one per field — so use
codes and has_code(...) rather than errors[0]. Each ErrorItem has code and
detail: branch on code, and use detail only as display text, never parse it.
A non-2xx whose body is not JSON still raises the status-mapped exception with an empty
errors tuple, so an error page produced by a proxy surfaces as the server error it is. See
Errors and status codes.
from didww_verification import (
DidwwApiError,
DidwwTransportError,
DidwwUnauthorizedError,
DidwwValidationError,
)
try:
verification = client.start_verification(
destination=number, delivery_method="sms"
)
except DidwwValidationError as exc:
if exc.has_code("destination_invalid"):
show_number_error()
else:
show_request_error(exc.codes)
except DidwwUnauthorizedError:
report_configuration_error()
except DidwwApiError as exc:
report_api_error(exc.status, exc.codes)
except DidwwTransportError as exc:
show_network_error(exc)
DidwwTransportError wraps every underlying HTTP library failure, so the HTTP library never
appears in your except clauses and can be replaced without breaking them.