Metadata-Version: 2.4
Name: didww-verification
Version: 1.3.0
Summary: Python SDK for the DIDWW Verification API
Author-email: DIDWW <support@didww.com>
License-Expression: MIT
Project-URL: Homepage, https://github.com/didww/didww-verification-python-sdk
Project-URL: Documentation, https://doc.didww.com/otp-verification/index.html
Project-URL: Repository, https://github.com/didww/didww-verification-python-sdk
Project-URL: Issues, https://github.com/didww/didww-verification-python-sdk/issues
Project-URL: Changelog, https://github.com/didww/didww-verification-python-sdk/blob/main/CHANGELOG.md
Keywords: didww,otp,verification,sms,2fa,telephony,api,sdk
Classifier: Development Status :: 5 - Production/Stable
Classifier: Intended Audience :: Developers
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: Communications :: Telephony
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Typing :: Typed
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: httpx2<3,>=2.0
Requires-Dist: anyio>=4.0
Provides-Extra: dev
Requires-Dist: pytest>=8.0; extra == "dev"
Requires-Dist: pytest-cov>=5.0; extra == "dev"
Requires-Dist: anyio>=4.0; extra == "dev"
Requires-Dist: mypy>=1.11; extra == "dev"
Requires-Dist: pyright>=1.1.400; extra == "dev"
Requires-Dist: ruff>=0.6; extra == "dev"
Requires-Dist: typing_extensions>=4.12; extra == "dev"
Requires-Dist: build>=1.2; extra == "dev"
Requires-Dist: twine>=6.1; extra == "dev"
Requires-Dist: starlette>=0.37; extra == "dev"
Requires-Dist: django>=4.2; extra == "dev"
Requires-Dist: django-stubs>=5.0; extra == "dev"
Dynamic: license-file

# DIDWW Verification SDK for Python

Python client for the [DIDWW](https://www.didww.com/) 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.typed` included
- One runtime dependency (`httpx2`) plus `anyio`
- [Verification API documentation](https://doc.didww.com/otp-verification/index.html)

## Installation

```sh
pip install didww-verification
```

## Quick start

```python
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`:

```python
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:

```python
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](#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`.

```python
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:

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

```python
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"]),
)
```

SMS autofill is set with `autofill`: `Autofill.app_hash("<11-char hash>")` frames the
message for the Android SMS Retriever, `Autofill.domain()` adds a domain-bound line for
the application's configured SMS domain, `Autofill.none()` sends no marker, and leaving
it unset uses the application default. The verification echoes it back as
`verification.sms.autofill`, only when a hash or domain marker was stored; for
`domain`, `value` is the domain. `Autofill.domain()` on an application with no SMS
domain is refused with `sms_domain_not_configured`, and a malformed `autofill` with
`autofill_invalid`.

```python
from didww_verification import Autofill

SmsOptions(autofill=Autofill.app_hash("abcdefghijk"))
```

`SmsOptions(app_hash=...)` and `SmsInfo.app_hash` still work but are deprecated in favour
of `autofill`. The alias is sent as `autofill`, and passing it together with a different
`autofill` raises `ValueError`.

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:

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

Each response also reports the generated code's length, 4–8:

```python
verification.sms.code_length
verification.callout.code_length
```

### Template groups

`template_group` picks one of your template groups instead of the application's
default. An unknown name is refused with `template_group_not_found`.

```python
SmsOptions(template_group="checkout")
CalloutOptions(template_group="checkout")
```

Both `sms` and `callout` in the response report what happened:

```python
verification.sms.template_group  # the group applied, or None
verification.sms.template_group_used  # True when the group's template was used
verification.sms.template_fallback_reason  # why not: language_missing, version_pending, ...
```

When no template group applies at all, `template_group_used` is `False` and
`template_fallback_reason` is `None`. `group_missing` means the requested or default
group no longer exists, and comes with `template_group` set to `None`.

`template_fallback_reason` is an open vocabulary (`TEMPLATE_FALLBACK_REASONS`); a
reason added later arrives as a plain string.

## Passing data to your callback

`custom` is a free-text string of up to 4096 characters, sent at the top level of the
request rather than inside a channel block:

```python
client.start_verification(
    destination="+37112345678",
    delivery_method="sms",
    custom="order-42",
)
```

It reaches your callback server unchanged as `data.custom` (see below) and is not
returned by any response. Longer values are refused with `custom_too_long`; an empty
string is treated as unset.

## Environments

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

## Testing

The sandbox environment sends no real SMS and places no real calls. Only the reserved
test numbers give a predefined result, such as delivered, failed or expired, and a
delivered one has a fixed code. Any other number is never delivered to, and its
verification ends as failed.

```python
VerificationClient(auth, environment=Environment.SANDBOX)
```

The test numbers, their outcomes and the code to submit are listed in
[Testing on sandbox](https://doc.didww.com/otp-verification/testing.html).

## Authentication

Three schemes, ranked `public < basic < application`. Each application has a minimum;
anything below it is rejected with 401.

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

- **`PublicAuth`** carries 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.
- **`BasicAuth`** is server-to-server only; the secret is recoverable from anything
  that ships it.
- **`ApplicationAuth`** signs 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.

```python
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.com` and `https://example.com/` are different signatures. Do not
  normalise the trailing slash.

The request body is the `verification_request` event. `data.custom` holds the value
passed to `start_verification`; the key is absent when none was given.

```python
custom = payload["data"].get("custom")
```

**Treat `custom` as untrusted.** When the start request comes from a mobile app, the
app controls it, so validate it against your own records instead of acting on it as
given.

Importing `didww_verification.callback` pulls in no HTTP client, so a service that
only receives callbacks pays nothing for one.

### Rotating the application secret

During a rotation the service also sends an `x-signature-previous` header: the same
signature, made with the previous secret. The verifier accepts a callback when either
signature matches the configured secret, so you can switch your receiver from the old
secret to the new one at any time in the grace period, without downtime.

The FastAPI and Django adapters read the header for you. **Upgrade the SDK before you
rotate**: an older verifier reads only `Authorization`, which is signed with the new
secret, so a receiver still configured with the old secret would reject every callback.

### FastAPI / Starlette

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

```python
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:

```python
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(...)
    previous_signature=request.headers.get("x-signature-previous"),  # optional
)
```

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

```python
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 422 with `channel_blocked` means the account can't use the requested delivery method;
no verification is created. Contact DIDWW support.

Some starts are refused as an outcome rather than an error: a 201 whose `status` is
`denied` or `failed`, with `error_code` set to `application_paused`,
`destination_country_disabled` or `destination_not_supported_for_channel`.

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:

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

A 429 raises `DidwwRateLimitedError`, and its code says which limit was hit:

- `destination_in_cooldown`: a verification for this destination was requested too
  recently.
- `rate_limit_per_ip`: too many requests from this IP address.
- `rate_limit_failed_auth`: too many failed authentication attempts from this IP
  address.
- `rate_limit_per_account`: too many requests for this account.
- `rate_limit_public_per_account`: too many public requests for this account.
- `volume_limit_reached`: the application reached its daily or monthly verification
  limit. `Retry-After` can be long (days); surface it to the user instead of retrying,
  or contact support to raise the limit.
- `destination_prefix_rate_limited`: too many verifications to this number range; try
  again later. Wait `Retry-After`. A suggested message for your user: "Too many
  verification requests right now. Please try again later."

The SDK never retries a 429 automatically: wait `retry_after` seconds — `None` when the response carried no
`Retry-After` header — then try again 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:

```python
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:

```python
client.report_verification_raw(
    verification.id, delivery_method=verification.delivery_method, code=value
)
```

## Development

```sh
pip install -e ".[dev]"
pytest
ruff check . && mypy && pyright
```
