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.

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. BasicAuth requires the application key and secret. ApplicationAuth also requires both and signs each request. PublicAuth uses 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 allow or deny. 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_verification(...)

POST /verifications

Start a verification for one phone number.

report_verification(id, ...)

PATCH /verifications/{id}

Submit a code for a specific verification.

get_verification(id)

GET /verifications/{id}

Retrieve a specific verification.

report_verification_by_number(number, ...)

PATCH /verifications/by_number/{number}

Submit a code without providing a verification ID.

get_verification_by_number(number)

GET /verifications/by_number/{number}

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

sms

code=

code="123456"

callout

code=

code="123456"

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

languages

Preferred message-template languages as BCP 47 tags, ordered from most to least preferred. See Supported languages.

app_hash

Android SMS Retriever application hash. It must contain exactly 11 characters from A-Z, a-z, 0-9, +, and /. Omit this field when automatic Android SMS capture is not used.

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

languages

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

id

str

Verification identifier.

destination

str

Destination number normalized without a leading +.

delivery_method

str

Delivery method used for the verification.

fee

Decimal or None

Quoted verification fee.

status

str

Current verification status.

error_code

str or None

Machine-readable reason for a failed, expired, or denied verification.

error_detail

str or None

Human-readable text associated with error_code.

expires_at

datetime or None

Verification expiration time.

sms

SmsInfo or None

SMS-specific response fields. It is None for phone call.

callout

CalloutInfo or None

Phone call response fields. It is None for SMS.

is_finished

bool

Property. True once the verification reached a terminal state.

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

Environment.PRODUCTION (default)

https://verification.didww.com

Environment.SANDBOX

https://verification-sandbox.didww.com

base_url= override

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

environment

Environment.PRODUCTION

Which published environment to address.

base_url

None

An origin that overrides environment.

timeout

30.0

Request timeout in seconds.

retry

RetryPolicy()

Read-retry policy. See Retries.

user_agent

didww-verification-python/<version>

Value sent in the User-Agent header.

keep_raw_payload

False

Retain the decoded response envelope on each Verification.

http_client

None

Supply your own httpx2.Client for proxies, TLS settings, or connection limits.

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

BasicAuth(key, secret)

Basic base64(key:secret)

Required

Server-to-server integrations.

PublicAuth(key)

Application <key>

Not used

Untrusted clients controlled through a request callback.

ApplicationAuth(key, secret)

Application <key>:<signature> and x-timestamp

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.com and https://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

DidwwConfigurationError

Not applicable

An invalid signing secret or an unusable base URL, raised when the client is built.

DidwwUnauthorizedError

401

Authentication failed or the authentication mode is not allowed.

DidwwBalanceInsufficientError

402

The account balance is insufficient.

DidwwNotFoundError

404

The verification could not be found, either because no such verification exists for the OTP application or because it passed the retention period.

DidwwValidationError

400 or 422

The request is invalid.

DidwwRateLimitedError

429

destination_in_cooldown: a start for the same number came too soon after the previous one. retry_after holds the Retry-After header in seconds, or None. Wait that long before starting again for that number; a start is never retried automatically.

DidwwServerError

5xx

The API, or infrastructure in front of it, returned a server error.

DidwwApiError

Other

Any other unsuccessful status. Base class of the six above.

DidwwTransportError

Not applicable

No response at all: connection failure, timeout, or TLS error.

DidwwDecodingError

2xx

A successful response this SDK could not read.

Every DidwwApiError carries the following:

Attribute

Description

status

HTTP response status.

errors

Tuple of ErrorItem objects from the API response.

codes

Machine-readable codes from all returned errors, in order.

has_code(code)

True when the envelope carries code anywhere.

body

The raw response body, or None.

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.