Skip to content

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's To contains only that recipient): sent means "handed to the sending server". Events from the sending server then move each recipient to delivered (accepted by the recipient's server) or bounced (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 id and the header Idempotent-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. 410 disables 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 on webhook-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:

  1. 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.
  2. 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: every http(s)://… link in the HTML part is replaced by a link https://track.<domain>/c/<token> that redirects (302) to the original address. mailto: and tel: 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 opened or clicked event (detail.url for a click), visible in GET /events, sent to webhooks and counted by GET /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.