# Python SDK

The Python SDK (`didww-verification`) is a server-side client for the
[Verification API](../api-reference/index.html#otp-verification-api-reference). 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](../authentication.html#otp-verification-authentication),
and verifies inbound [callback](../callbacks.html#otp-verification-callbacks) signatures.

- **Source:** [GitHub repository](https://github.com/didww/didww-verification-python-sdk)
- **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](../getting-started.html#otp-verification-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](../authentication.html#otp-verification-authentication) 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](../callbacks.html#otp-verification-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](#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](../verification-methods/sms.html#otp-verification-sms-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](../verification-methods/phone-call.html#otp-verification-callout-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](../getting-started.html#otp-verification-environments) 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](#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](../authentication.html#otp-verification-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](../callbacks.html#otp-verification-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](../api-reference/get-verification-status.html#otp-verification-retention). |
| `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](../api-reference/errors.html#otp-verification-api-errors).

```
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.

On this page
