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
pip install didww-verificationfrom 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.
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.
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:
raiseWhen 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.
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 inThe 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_lengthfrom didww_verification import Environment, VerificationClient
VerificationClient(auth, environment=Environment.SANDBOX)
VerificationClient(auth, base_url="http://localhost:3000") # an origin, no pathbase_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.
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 headerPublicAuthcarries 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.
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.
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")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.
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.
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.
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.
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)) # offStarts 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 start too soon after a non-denied one for the same destination is refused with 429
and destination_in_cooldown, as DidwwRateLimitedError. The SDK never retries it
automatically: wait retry_after seconds — None when the response carried no
Retry-After header — then start again yourself.
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)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
)pip install -e ".[dev]"
pytest
ruff check . && mypy && pyright