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.
- Python 3.10+
- Fully typed,
py.typedincluded - One runtime dependency (
httpx2) plusanyio - Verification API documentation
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"]),
)
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.
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.
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
PublicAuthcarries 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.BasicAuthis server-to-server only; the secret is recoverable from anything that ships it.ApplicationAuthsigns 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.comandhttps://example.com/are different signatures. Do not normalise the trailing slash.
Importing didww_verification.callback pulls in no HTTP client, so a service that
only receives callbacks pays nothing for one.
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(...)
)
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 |
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.
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.
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
Release files for didww-verification 1.0.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| didww_verification-1.0.0.tar.gz | 46.2 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| didww_verification-1.0.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 81.3 kB
Release files / didww_verification-1.0.0.tar.gz
| Download URL | didww_verification-1.0.0.tar.gz |
|---|---|
| Size | 46.2 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
b565069c798b7aad6ec7ee3303ca51da6fa8ae097502ec96680e319b05bbbf9b
|
|
BLAKE2b-256 checksum How to use checksums |
fdc799e4f851cff64ef860ea9a0787aae7f929141a92464ca5812fc2668cb23d
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.13.5
|
Release files / didww_verification-1.0.0-py3-none-any.whl
| Download URL | didww_verification-1.0.0-py3-none-any.whl |
|---|---|
| Size | 35.1 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
c742ba69078fec993638f356762ca9f768d51b4961b42f413cc39cd9b9c752bf
|
|
BLAKE2b-256 checksum How to use checksums |
eaa26785698742b9cfa65e5cc089568f5da104acf10fdd6f629dbb1bfdd5a283
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.13.5
|