Duva API (v1)
Base URL: https://api.duva.ca
API reference. Available endpoints: POST /v1/{domain}/messages,
GET /v1/{domain}/messages/{id}, GET /v1/{domain}/events, suppressions, webhooks and statistics.
Sending status. The message is accepted, validated and queued (
queued). Duva then hands it to the sending server, one copy per recipient (each copy'sTocontains only that recipient):sentmeans "handed to the sending server". Events from the sending server then move each recipient todelivered(accepted by the recipient's server) orbounced(permanent refusal, immediate or received afterwards). Open and click tracking applies to the messages that ask for it (see "Open and click tracking").
Authentication
One API key per domain, in the Authorization: Bearer dv_... header. The key only opens the domain it was
created for (the "API keys" screen of the dashboard), and the {domain} in the path must be that domain.
- No key presented (or a scheme other than
Bearer): 401. - Key presented but unknown, wrong, revoked, or belonging to another domain; unknown domain; closed account: 404, always the same response, without saying which of these causes applies.
POST /v1/{domain}/messages
Accepts a message. The response is always asynchronous: it never confirms a delivery.
curl -X POST https://api.duva.ca/v1/soumissio.ca/messages \
-H "Authorization: Bearer dv_xxxxxxxxxx_..." \
-H "Idempotency-Key: quote-4821" \
-H "Content-Type: application/json" \
-d '{
"from": "Soumissio <[email protected]>",
"to": ["[email protected]"],
"subject": "Your quote",
"html": "<p>Hello</p>",
"text": "Hello",
"tags": ["quote"]
}'
Response 202 Accepted
{ "id": "msg_9f1c2d3e4a5b46c78d9e0f1a2b3c4d5e", "status": "queued" }
status is queued, except when all recipients are on the suppression list: failed.
Header Location: /v1/{domain}/messages/{id}.
Request body
Unknown fields are rejected (422), so that a typo such as bcc is not silently ignored.
| Field | Type | Rules |
|---|---|---|
from |
text | Required. address or Name <address>. The domain must be the one in the path (subdomains excluded). |
to |
list of texts | Required. Bare addresses only (no Name <address>). Deduplicated case-insensitively. Maximum depends on the plan (5 in sandbox). |
subject |
text | Required, a single line, 998 characters at most, no control characters. |
html, text |
text | At least one of the two. 2 MB in total. No NUL character. |
tags |
list of texts | 10 at most, 64 characters each, no leading/trailing whitespace. Deduplicated. |
tracking |
object | {"opens": bool, "clicks": bool}, off by default. Asking for tracking on a domain that has not enabled it: 422 (see below). |
reply_to |
text | address or Name <address>. |
headers |
object | 20 headers at most, single-line values. Allowlist: List-Unsubscribe, List-Unsubscribe-Post, List-Id, In-Reply-To, References, Auto-Submitted, Precedence, Importance, Feedback-ID, and any X-* except the prefixes reserved by the platform (X-Duva* and a few others, rejected with 422). From, To, Cc, Bcc, Return-Path, Message-ID, DKIM-Signature, etc. are rejected. |
Addresses whose local part is not ASCII are not supported. The domain is normalized (lowercase, punycode).
The whole request cannot exceed 4 MB (413).
Idempotency
Optional Idempotency-Key header (1 to 255 printable ASCII characters, no spaces), unique per domain.
- Same key and same request: 202 with the same
idand the headerIdempotent-Replayed: true. No quota is consumed again, even if it is exhausted. - Same key, different request: 409
idempotency_conflict. - Two identical simultaneous requests with the same key create a single message.
Keys do not expire as long as the message exists.
Suppression lists
A recipient on the domain's suppression list is not sent to and consumes no quota.
The request is still accepted (202); the per-recipient detail is read with GET /messages/{id} (status suppressed).
Errors
All errors have the same shape, with no internal detail and no received value:
{ "error": { "code": "invalid_request", "message": "The request is invalid.",
"fields": [{ "field": "to[0]", "message": "the local part or the @ is missing" }] } }
code is the contract; rely on it, and on fields[].field, in your code. message (and fields[].message) is
human-readable text in the language of the Accept-Language header: en for English, fr for French. Without the
header, or with any other language, the text is in French. The wording may change; the status, code and
fields[].field are the same in both languages. Errors carry Vary: Accept-Language.
curl -X POST https://api.duva.ca/v1/soumissio.ca/messages \
-H "Authorization: Bearer dv_xxxxxxxxxx_..." \
-H "Accept-Language: en" \
-H "Content-Type: application/json" \
-d '{"from": "[email protected]", "to": ["not-an-address"], "subject": "Test", "text": "Hello"}'
The fixed texts that Duva itself writes in a webhook's disabled_reason and last_error follow the same header;
the text of your server's response is never returned.
| Status | code |
Cause |
|---|---|---|
| 401 | unauthorized |
No key presented. Header WWW-Authenticate: Bearer. |
| 404 | not_found |
Key unusable for this domain (see Authentication); or, on reads, message not found. |
| 403 | domain_not_verified |
The domain's DNS is not verified yet. |
| 403 | sending_not_allowed |
Suspended account or domain, or an account in a state that forbids sending. |
| 409 | idempotency_conflict |
Idempotency key already used for a different request. |
| 409 | limit_reached |
Limit reached (webhooks per domain). |
| 413 | payload_too_large |
Request larger than 4 MB. |
| 422 | invalid_request |
Invalid body or parameter; fields names the field (from, to[1], headers.Bcc, body if the JSON is unreadable, limit, cursor...). |
| 429 | quota_exceeded |
Daily or monthly limit reached. Retry-After gives the seconds until the next period (UTC). |
| 500 | internal_error |
Internal error. |
Authentication comes before validation: a wrong key can never be used to probe the validation rules.
GET /v1/{domain}/messages/{id}
Status of a message and of each of its recipients.
{
"id": "msg_9f1c2d3e4a5b46c78d9e0f1a2b3c4d5e",
"status": "sent",
"from": "[email protected]",
"subject": "Your quote",
"tags": ["quote"],
"tracking": { "opens": false, "clicks": false },
"created_at": "2026-09-19T14:03:21.512Z",
"recipients": [
{ "email": "[email protected]", "status": "delivered", "updated_at": "2026-09-19T14:03:24.101Z" },
{ "email": "[email protected]", "status": "sent", "updated_at": "2026-09-19T14:03:22.870Z" }
]
}
Statuses of a recipient: queued (waiting to be sent), sent (handed to the sending server, delivery in progress),
delivered, bounced, failed (given up: attempts exhausted, or expired in the sending queue),
suppressed (address on the suppression list, never sent). A recipient never moves backwards; the only
exception is a bounce received afterwards (delivered then bounced).
Status of the message: that of its least advanced recipient (queued < sent < delivered <
bounced < failed); failed only if no recipient was delivered or bounced. A message with a recipient still
being delivered therefore stays sent.
A message that does not exist, is malformed, or belongs to another domain or another tenant: the same 404.
The message body (html, text) and the headers are never returned. The reason for a failed
decided by Duva (attempts exhausted...) is not exposed yet.
GET /v1/{domain}/events
Log of the domain's delivery events, newest first, in pages.
curl "https://api.duva.ca/v1/soumissio.ca/events?type=bounced&limit=20" \
-H "Authorization: Bearer dv_xxxxxxxxxx_..."
{
"data": [
{
"id": "evt_0b1c2d3e4f5a46b78c9d0e1f2a3b4c5d",
"type": "bounced",
"message_id": "msg_9f1c2d3e4a5b46c78d9e0f1a2b3c4d5e",
"recipient": "[email protected]",
"occurred_at": "2026-09-19T14:03:25.310Z",
"detail": { "code": 550, "enhanced_code": "5.1.1", "message": "user unknown",
"classification": "InvalidRecipient", "attempts": 0 }
}
],
"next_cursor": "MjAyNi0wOS0xOVQxNDowMzoyNS4zMTAr..."
}
| Parameter | Purpose |
|---|---|
message_id |
Only the events of this message (msg_...). |
type |
delivered, bounced, deferred, expired, complained, opened or clicked. |
recipient |
Only this address (case does not matter). |
since |
Only events that occurred from this ISO 8601 instant, time zone required (2026-09-19T12:00:00Z); between 1970 and 2200, otherwise 422. |
limit |
1 to 100 (50 by default). |
cursor |
The next_cursor of the previous page; null = last page. Opaque value. |
Types: delivered (the recipient's server accepted the message), bounced (permanent refusal, including a
bounce received afterwards), deferred (temporary failure, the sending server will retry: several may precede the
final event), expired (the sending server gave up after 24 h), complained (the recipient reported the message; the address is
then suppressed). A bounce caused by the address itself (unknown recipient, 5.1.x) adds the address to the domain's
suppression list; a reputation or content block (5.7.x) does not. opened and clicked come from
Duva's tracking, not from the sending server (see "Open and click tracking").
detail only contains selected fields: code, enhanced_code, message (first line of the remote server's
response, 500 characters at most: external text, never to be interpreted), classification,
attempts, feedback_type for a complaint, url for a click. Never the content of the message or its headers.
A message_id from another domain or tenant gives an empty list, not an error. Events are delivered
at least once by the sending server: Duva deduplicates them, each one appears only once.
GET /v1/{domain}/suppressions
The addresses this domain no longer writes to. They get there through a bounce caused by the address, a complaint, or by hand. An address suppressed in one domain is not suppressed in another.
{ "data": [ { "email": "[email protected]", "reason": "bounce", "message_id": "msg_9f1c…", "created_at": "2026-09-19T14:03:25.310Z" } ],
"next_cursor": null }
reason: bounce, complaint, unsubscribe or manual. Parameters: reason (filter), limit (1 to 100, 50 by
default), cursor (the next_cursor of the previous page, opaque value). Newest first.
POST /v1/{domain}/suppressions
Adds an address by hand ({"email": "[email protected]"}, reason manual): it will no longer receive anything from this
domain. 201 if it is added, 200 if it was already there (with the existing entry).
DELETE /v1/{domain}/suppressions/{email}
Removes an address from the list (204); it can receive again. 404 if it is not there (or if it is only on another domain's list: indistinguishable). Removing a bounce or a complaint is your responsibility: writing again to an address that bounces damages the sending reputation. Additions and removals are recorded in the account's audit log.
Webhooks
Duva sends each delivery event (see GET /events) to the URL of your choice, as a signed JSON POST.
POST /v1/{domain}/webhooks
curl -X POST https://api.duva.ca/v1/soumissio.ca/webhooks \
-H "Authorization: Bearer dv_xxxxxxxxxx_..." -H "Content-Type: application/json" \
-d '{ "url": "https://soumissio.ca/hooks/duva", "events": ["delivered", "bounced"] }'
201 with the webhook, including secret (whsec_…): shown only once, keep it to verify
signatures. events empty or absent: all types (delivered, bounced, deferred, expired, complained, opened, clicked).
The URL must be https:// (no credentials, port 443 or ≥ 1024) and point to the public Internet: private,
local or metadata addresses (127.0.0.1, 10.x, 169.254.169.254, ::1...) and internal names are
rejected (422). This is checked again on every delivery, after the name is resolved. At most five webhooks per domain
(409 limit_reached).
GET /v1/{domain}/webhooks, GET /v1/{domain}/webhooks/{id}, DELETE /v1/{domain}/webhooks/{id}
List, read (without the secret) and delete (204). A webhook from another domain or tenant: 404.
status is active or disabled; disabled_reason says why (410 Gone, too many consecutive failures).
GET /v1/{domain}/webhooks/{id}/deliveries
The latest deliveries (limit, 50 by default): status (pending, delivered, failed), attempts,
last_status_code, last_error (fixed text: HTTP 500, timeout, connection failed...). Never the
body of your response.
What you receive
POST /hooks/duva HTTP/1.1
content-type: application/json
webhook-id: evt_0b1c2d3e4f5a46b78c9d0e1f2a3b4c5d
webhook-timestamp: 1789843104
webhook-signature: v1,g0hM9SsE+OeSQNLzdvDx0IHdBBu58Z8ZGkTmDd9wSQ4=
{"data":{"detail":{"code":550,"enhanced_code":"5.1.1"},"message_id":"msg_9f1c…","occurred_at":"2026-09-19T14:03:25.310Z","recipient":"[email protected]"},"domain":"soumissio.ca","id":"evt_0b1c…","type":"bounced"}
Signature in the "Standard Webhooks" format (the standard-webhooks libraries verify it):
base64(HMAC-SHA256(secret, "<webhook-id>.<webhook-timestamp>.<raw body>")), where the secret is the base64 part of
whsec_<base64>. Verify the signature on the raw body and reject a webhook-timestamp older than 5 minutes.
- Response:
2xx= received.410disables the webhook. Any other response, a 10 s timeout, a connection or certificate error: retry (30 s, then doubling up to 1 h, 10 attempts). Redirects are not followed (a redirect is a failure). - At least once: the same event may arrive several times (identical
webhook-id, new timestamp and signature); deduplicate onwebhook-id. No ordering guaranteed between events. - After 100 consecutive failures, the webhook is disabled and its pending deliveries are abandoned.
GET /v1/{domain}/stats
The domain's counters per period, computed from the accepted recipients and the event log.
curl "https://api.duva.ca/v1/soumissio.ca/stats?granularity=day&since=2026-09-01T00:00:00Z" \
-H "Authorization: Bearer dv_xxxxxxxxxx_..."
{ "granularity": "day", "since": "2026-09-01T00:00:00.000Z", "until": "2026-09-19T20:00:00.000Z",
"data": [ { "period": "2026-09-19T00:00:00.000Z", "accepted": 120, "suppressed": 3, "delivered": 110,
"bounced": 4, "deferred": 9, "expired": 0, "complained": 1, "opened": 40, "clicked": 12 } ],
"totals": { "accepted": 120, "suppressed": 3, "delivered": 110, "bounced": 4, "deferred": 9, "expired": 0, "complained": 1, "opened": 40, "clicked": 12 } }
| Parameter | Purpose |
|---|---|
granularity |
day (default) or hour. Periods are in UTC. |
since, until |
ISO 8601 interval (time zone required): since inclusive, until exclusive. Default: the last 30 days (last 24 hours by hour), up to now. |
At most 366 days, or 7 days by hour (422 beyond that). Periods with no activity are present, at zero.
accepted counts the recipients accepted on the message's acceptance date (excluding suppressed ones, counted in
suppressed); the other counters count events on the date they occurred.
Open and click tracking
Off by default (privacy, Quebec's Law 25). Two conditions to use it:
- A DNS record:
CNAME track.<your domain>pointing to the target shown on your domain's DNS screen ("recommended" record, only needed for tracking). Duva then obtains a TLS certificate for that name. - Activation by Duva (on request, once the CNAME is published). As long as tracking is not
enabled, a message that asks for it is rejected (422, field
tracking): never broken links or tracking silently missing.
With "tracking": {"opens": true, "clicks": true} (each option separately), at send time:
clicks: everyhttp(s)://…link in the HTML part is replaced by a linkhttps://track.<domain>/c/<token>that redirects (302) to the original address.mailto:andtel:links, anchors and relative links are not touched, nor is the text part.opens: a transparent 1×1 pixel is added at the end of the HTML.- Links are specific to each recipient (a token signed per message and recipient): none is usable by another. The destination is in the signed token: a link cannot be diverted to another address.
- Each visit creates an
openedorclickedevent (detail.urlfor a click), visible inGET /events, sent to webhooks and counted byGET /stats. At most one event per link, recipient and minute (security bots and prefetching repeat visits). Tracking never changes the status of a message or a recipient. - It is not exact: an email client that preloads images or a security gateway that opens links counts as an open or a click. Treat these numbers as indications.
- Nothing about the visitor is kept: no IP address, no user agent, no referrer.
- If tracking is turned off after sending, links already sent keep redirecting, recording nothing.
Health
GET /health: 200 {"status": "ok"}, or 503 if the database is unreachable. No authentication.