Duva for Python
The official Duva client library for Python. Duva is a transactional email API hosted in Canada.
pip install duva-mail
# or: uv add duva-mail
Requires Python 3.10 or later. Fully typed (py.typed); both a synchronous and an asynchronous
client are provided.
Sending a message
from duva import Duva
duva = Duva(api_key="dv_...", domain="example.com") # or DUVA_API_KEY / DUVA_DOMAIN
message = duva.messages.send(
from_="Example <notifications@example.com>",
to=["client@example.org"],
subject="Your order",
text="Thank you for your order.",
)
print(message.id, message.status) # "queued": always asynchronous
The same call, asynchronously:
from duva import AsyncDuva
async with AsyncDuva(api_key="dv_...", domain="example.com") as duva:
message = await duva.messages.send(
from_="Example <notifications@example.com>",
to=["client@example.org"],
subject="Your order",
text="Thank you for your order.",
)
To, Cc and Bcc
to, cc and bcc take addresses or Name <address>. Every copy shows all the to and all the
cc; a bcc address appears only on its own copy. The three lists together count against your
plan's recipient maximum.
duva.messages.send(
from_="Example <notifications@example.com>",
to=["Jean Tremblay <jean@example.org>"],
cc=["accounting@example.org"],
bcc=["archive@example.com"],
subject="Your order",
text="Thank you for your order.",
)
Reading events and pagination
for event in duva.events.list_all(type="bounced"):
print(event.type, event.detail.get("recipient"))
events.list() and suppressions.list() return one page (.data, .next_cursor);
events.list_all() and suppressions.list_all() are generators that follow next_cursor for
you, optionally bounded with max_items=. AsyncDuva's equivalents (list_all) are async
generators (async for).
Verifying a webhook
from duva import WebhookSignatureError, construct_event
try:
event = construct_event(secret, request.headers, raw_body)
print(event.type, event.data.get("message_id"))
except WebhookSignatureError:
# respond 400
...
raw_body must be the exact bytes Duva sent (your framework's raw-body option, not a
re-serialized parsed body): re-encoding it changes the bytes and invalidates the signature.
Rotating your webhook secret? Pass a list — construct_event([old_secret, new_secret], ...) —
while both are active.
Errors
Every error Duva answers with is a DuvaError subclass; rely on .code (the contract), never on
the exception message (its wording can change):
from duva import NotFoundError, QuotaExceededError, ValidationError
try:
duva.messages.send(...)
except ValidationError as error:
print(error.fields) # [{"field": "to[0]", "message": "..."}]
except QuotaExceededError as error:
print(f"retry in {error.retry_after}s")
except NotFoundError:
... # the API key, domain or resource could not be found
Network failures and timeouts raise DuvaConnectionError / DuvaTimeoutError instead (no HTTP
response was ever received). Reads and messages.send (idempotency-key protected) are retried
automatically on a transient failure; suppressions.add/remove and webhooks.create/delete
are not, because the outcome of a timed-out first attempt is unknown. A 429 quota_exceeded is
never retried automatically (its retry_after can be hours); a 429 rate_limited is, as long as
the wait fits within max_retry_wait_seconds (30s by default).
Attachments
from duva import Attachment
attachment = Attachment.from_file("./invoice.pdf")
duva.messages.send(..., attachments=[attachment])
Attachment.from_bytes(filename, content, content_type=None, content_id=None) works from data
already in memory; content_id turns the attachment into an inline image the HTML references
with cid:.
Configuration
| Argument | Default | |
|---|---|---|
api_key |
DUVA_API_KEY |
Required. |
domain |
DUVA_DOMAIN |
Required: the domain this key was created for. |
base_url |
https://api.duva.ca |
|
timeout |
10 (seconds) |
|
max_retries |
2 |
Network failures / 5xx on a safe-to-retry call. |
max_retry_wait_seconds |
30 |
A 429 rate_limited with a longer wait is not retried. |
language |
unset | "en" or "fr": the language of error.message. |
http_client |
a new httpx.Client/AsyncClient |
Inject your own (proxying, tests). |
Full reference
The complete API surface and the OpenAPI specification this library is generated from: https://duva.ca/en/docs and https://duva.ca/openapi.json.
Development
uv sync --extra dev
uv run --extra dev python scripts/generate.py --local # regenerate _generated/models.py from a local ../duva checkout
uv run mypy
uv run pytest # unit tests
uv run --extra dev python scripts/fetch_conformance.py && uv run pytest tests/conformance
uv build
This library's request/response models are generated from Duva's OpenAPI specification
(src/duva/_generated/, never edited by hand); the client itself (retries, pagination, errors,
webhooks) is hand-written and checked against the shared fixtures published in
duva-mail/duva-conformance.
License
MIT, see LICENSE.
Metadata
Release files for duva-mail 0.2.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 | |
|---|---|---|---|
| duva_mail-0.2.0.tar.gz | 75.0 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| duva_mail-0.2.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 98.3 kB
Release files / duva_mail-0.2.0.tar.gz
| Download URL | duva_mail-0.2.0.tar.gz |
|---|---|
| Size | 75.0 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
de5b586872609c648702d20b20cdc9a3ddc34448052316a96b279e0a5dbb2515
|
|
BLAKE2b-256 checksum How to use checksums |
dbf9f2024adba3fe9ce0f1126ac8bd01410d46b7d1e0cb64e6f0c2c7b555133a
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 5, 2026.
Transparency logRelease files / duva_mail-0.2.0-py3-none-any.whl
| Download URL | duva_mail-0.2.0-py3-none-any.whl |
|---|---|
| Size | 23.3 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
4aa547f0267e45e1a3872381ce91dc090f3bb28b562be40a7a96af5e8daa837d
|
|
BLAKE2b-256 checksum How to use checksums |
d65f978244ac939eee9ce992c9ea4b5ada20ffa63ca9ae21e601ca67c95276af
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 5, 2026.
Transparency log