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.
The OpenAPI 3.1 specification of the API (every route, schema, error and the signed webhook event) is published
on this site: /openapi.json. Use it to generate a client in the language of your choice.
Sending status. The message is accepted, validated and queued (
queued). Duva then hands it to the sending server, one copy per address (each copy is sent to a single address; theToandCcheaders show thetoandccrecipients of the message, see "Recipients"):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").Verification. An unusual volume for the account (a recent account sending a lot, or a rate far above its usual) may cause a message to be held for verification before it is sent: the response is the same (
202,queued) and the message staysqueueduntil a decision is made. A message that has not been released within 24 hours is abandoned (failed). To avoid this delay, ramp your volume up gradually, or write to us before a large send.
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, expired, 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
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"]
}'
Node.js (duva-mail)
import { Duva } from "duva-mail";
const duva = new Duva({ apiKey: process.env.DUVA_API_KEY, domain: "soumissio.ca" });
const message = await duva.messages.send({
from: "Soumissio <[email protected]>",
to: ["[email protected]"],
subject: "Your quote",
html: "<p>Hello</p>",
text: "Hello",
tags: ["quote"],
idempotencyKey: "quote-4821",
});
console.log(message.id, message.status);
Python (duva-mail)
import os
from duva import Duva
duva = Duva(api_key=os.environ["DUVA_API_KEY"], domain="soumissio.ca")
message = duva.messages.send(
from_="Soumissio <[email protected]>",
to=["[email protected]"],
subject="Your quote",
html="<p>Hello</p>",
text="Hello",
tags=["quote"],
idempotency_key="quote-4821",
)
print(message.id, message.status)
PHP (duva-mail/duva)
<?php
use Duva\Client;
$duva = new Client(apiKey: getenv("DUVA_API_KEY"), domain: "soumissio.ca");
$message = $duva->messages->send([
"from" => "Soumissio <[email protected]>",
"to" => ["[email protected]"],
"subject" => "Your quote",
"html" => "<p>Hello</p>",
"text" => "Hello",
"tags" => ["quote"],
"idempotency_key" => "quote-4821",
]);
echo $message->id . " " . $message->status . "\n";
Go (duva-go)
package main
import (
"context"
"fmt"
"os"
"github.com/duva-mail/duva-go"
)
func main() {
client, err := duva.New(os.Getenv("DUVA_API_KEY"), "soumissio.ca")
if err != nil {
panic(err)
}
result, err := client.Messages.Send(context.Background(), duva.SendMessageParams{
From: "Soumissio <[email protected]>",
To: []string{"[email protected]"},
Subject: "Your quote",
HTML: "<p>Hello</p>",
Text: "Hello",
Tags: []string{"quote"},
IdempotencyKey: "quote-4821",
})
if err != nil {
panic(err)
}
fmt.Println(result.ID, result.Status)
}
Ruby (duva-mail)
require "duva"
duva = Duva::Client.new(api_key: ENV.fetch("DUVA_API_KEY"), domain: "soumissio.ca")
result = duva.messages.deliver(
from: "Soumissio <[email protected]>",
to: ["[email protected]"],
subject: "Your quote",
html: "<p>Hello</p>",
text: "Hello",
tags: ["quote"],
idempotency_key: "quote-4821"
)
puts "#{result.id} #{result.status}"
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 replyto 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. address or Name <address>. Deduplicated case-insensitively. to, cc and bcc count together against the plan's maximum (5 in sandbox). One copy per address; every copy shows all the to and all the cc. See "Recipients". |
cc |
list of texts | Optional, same format as to. Visible to every recipient. An address already in to is ignored. |
bcc |
list of texts | Optional, same format as to. Never visible to the other recipients. An address already in to or cc is ignored. |
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. Requires html (see below); asking for tracking without html, or 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. Checked values: List-Unsubscribe (<https://…> or <mailto:…> links, 3 at most), List-Unsubscribe-Post (exactly List-Unsubscribe=One-Click, with an https link), Precedence (bulk, list, junk), Auto-Submitted (no, auto-generated, auto-replied, auto-notified), Importance (high, normal, low). |
metadata |
object | Text to text, never sent to the recipient: returned by GET /messages/{id}, in every event and in the webhooks of this message. 10 keys at most (letters, digits, _, ., -, 40 characters), values of 500 characters on a single line. |
attachments |
list of objects | 10 at most, 5 MB decoded in total. See “Attachments”. Production accounts only. |
Addresses whose local part is not ASCII are not supported. The domain is normalized (lowercase, punycode).
What Duva adds to the message. If you send only html, Duva attaches a plain-text version derived from that HTML
(paragraphs, lists and links written as “label (address)”): an HTML-only message is more often classified as spam.
Send your own text to control it; an HTML with no text at all stays HTML-only. Duva also adds a Feedback-ID header
(<domain>:transactional:duva), which Gmail uses to break down its reputation reports, unless you supply one in
headers. Every message is DKIM-signed with your domain; Duva may add a second DKIM signature on behalf of a platform
domain, which lets it receive mailbox providers' complaints and follow its service's reputation. Your DMARC does not
depend on it: it always aligns on your domain's signature.
The whole request cannot exceed 4 MB (413), except sending a message: 9 MB, for its attachments.
Recipients: to, cc and bcc
The three fields accept an address ([email protected]) or a name followed by the address
(Jean Tremblay <[email protected]>). The name cannot contain @, a quote, a backslash or a control character (200
characters at most). to is required; cc and bcc are optional.
- One copy per address. Duva sends a separate copy to each address, whichever field it is in: each one receives ONE
message and has its own status (
GET /messages/{id}). toandccsee each other. Every copy carries the sameTo(all theto) andCc(all thecc) headers, including the copy of abcc.- A
bccstays hidden. Its address is on no other recipient's copy. Its own copy carries aBccheader that contains only its address, so it understands why it receives the message. - One ceiling, for the total.
to,ccandbcccount together against the plan's maximum number of recipients and the sending quota. An address on the suppression list is not sent and does not count. - An address exists once. If it appears in several fields (case does not matter), the first occurrence wins in the
order
to,cc,bcc, with its name. Exceeding the ceiling gives 422, on thetofield if onlytois given, onrecipientsotherwise. - The
ccandbccfields replace theCcandBccentries ofheaders, which are still refused.
curl
curl -X POST https://api.duva.ca/v1/soumissio.ca/messages \
-H "Authorization: Bearer dv_xxxxxxxxxx_..." \
-H "Content-Type: application/json" \
-d '{
"from": "Soumissio <[email protected]>",
"to": ["Jean Tremblay <[email protected]>"],
"cc": ["[email protected]"],
"bcc": ["[email protected]"],
"subject": "Votre soumission",
"text": "Bonjour"
}'
Node.js (duva-mail)
import { Duva } from "duva-mail";
const duva = new Duva({ apiKey: process.env.DUVA_API_KEY, domain: "soumissio.ca" });
const message = await duva.messages.send({
from: "Soumissio <[email protected]>",
to: ["Jean Tremblay <[email protected]>"],
cc: ["[email protected]"],
bcc: ["[email protected]"],
subject: "Votre soumission",
text: "Bonjour",
});
console.log(message.id, message.status);
Python (duva-mail)
import os
from duva import Duva
duva = Duva(api_key=os.environ["DUVA_API_KEY"], domain="soumissio.ca")
message = duva.messages.send(
from_="Soumissio <[email protected]>",
to=["Jean Tremblay <[email protected]>"],
cc=["[email protected]"],
bcc=["[email protected]"],
subject="Votre soumission",
text="Bonjour",
)
print(message.id, message.status)
PHP (duva-mail/duva)
<?php
use Duva\Client;
$duva = new Client(apiKey: getenv("DUVA_API_KEY"), domain: "soumissio.ca");
$message = $duva->messages->send([
"from" => "Soumissio <[email protected]>",
"to" => ["Jean Tremblay <[email protected]>"],
"cc" => ["[email protected]"],
"bcc" => ["[email protected]"],
"subject" => "Votre soumission",
"text" => "Bonjour",
]);
echo $message->id . " " . $message->status . "\n";
Go (duva-go)
package main
import (
"context"
"fmt"
"os"
"github.com/duva-mail/duva-go"
)
func main() {
client, err := duva.New(os.Getenv("DUVA_API_KEY"), "soumissio.ca")
if err != nil {
panic(err)
}
result, err := client.Messages.Send(context.Background(), duva.SendMessageParams{
From: "Soumissio <[email protected]>",
To: []string{"Jean Tremblay <[email protected]>"},
CC: []string{"[email protected]"},
BCC: []string{"[email protected]"},
Subject: "Votre soumission",
Text: "Bonjour",
})
if err != nil {
panic(err)
}
fmt.Println(result.ID, result.Status)
}
Ruby (duva-mail)
require "duva"
duva = Duva::Client.new(api_key: ENV.fetch("DUVA_API_KEY"), domain: "soumissio.ca")
result = duva.messages.deliver(
from: "Soumissio <[email protected]>",
to: ["Jean Tremblay <[email protected]>"],
cc: ["[email protected]"],
bcc: ["[email protected]"],
subject: "Votre soumission",
text: "Bonjour"
)
puts "#{result.id} #{result.status}"
Attachments
Each attachments item: filename (required, 255 characters at most, no path), content (required, base64), content_type (optional, application/octet-stream by default) and content_id (optional: the attachment becomes an “inline” image that the HTML refers to with cid:).
"attachments": [
{ "filename": "Facture-2026-001.pdf", "content": "JVBERi0xLjQK...", "content_type": "application/pdf" }
]
Executable extensions (.exe, .bat, .js, .vbs, .scr, .msi, etc.) are rejected. All the attachments of a message weigh 5 MB decoded at most. An account in the sandbox cannot send any (422). They are never returned by the API and are deleted with the message body.
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 (9 MB when sending a message). |
| 422 | invalid_request |
Invalid body or parameter; fields names the field (from, to[1], cc[0], bcc[2], recipients, 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). |
| 429 | rate_limited |
Too many requests with this key in a short time (a rate limit, distinct from the sending quota). Retry-After gives the seconds to wait. |
| 500 | internal_error |
Internal error. |
Authentication comes before validation: a wrong key can never be used to probe the validation rules.
A per-key rate limit protects the service: normal usage does not reach it. If you receive a 429, wait the number of seconds in Retry-After before retrying, without a burst of new attempts. A rate_limited 429 does not consume your sending quota.
GET /v1/{domain}/messages/{id}
Status of a message and of each of its recipients.
curl
curl https://api.duva.ca/v1/example.com/messages/msg_9f1c2d3e4a5b46c78d9e0f1a2b3c4d5e \
-H "Authorization: Bearer $DUVA_API_KEY"
Node.js (duva-mail)
import { Duva } from "duva-mail";
const duva = new Duva({ apiKey: process.env.DUVA_API_KEY, domain: "example.com" });
const message = await duva.messages.get("msg_9f1c2d3e4a5b46c78d9e0f1a2b3c4d5e");
console.log(message.status);
Python (duva-mail)
import os
from duva import Duva
duva = Duva(api_key=os.environ["DUVA_API_KEY"], domain="example.com")
message = duva.messages.get("msg_9f1c2d3e4a5b46c78d9e0f1a2b3c4d5e")
print(message.status)
PHP (duva-mail/duva)
<?php
use Duva\Client;
$duva = new Client(apiKey: getenv("DUVA_API_KEY"), domain: "example.com");
$message = $duva->messages->get("msg_9f1c2d3e4a5b46c78d9e0f1a2b3c4d5e");
echo $message->status;
Go (duva-go)
package main
import (
"context"
"fmt"
"os"
"github.com/duva-mail/duva-go"
)
func main() {
client, err := duva.New(os.Getenv("DUVA_API_KEY"), "example.com")
if err != nil {
panic(err)
}
message, err := client.Messages.Get(context.Background(), "msg_9f1c2d3e4a5b46c78d9e0f1a2b3c4d5e")
if err != nil {
panic(err)
}
fmt.Println(message.Status)
}
Ruby (duva-mail)
require "duva"
duva = Duva::Client.new(api_key: ENV.fetch("DUVA_API_KEY"), domain: "example.com")
message = duva.messages.get("msg_9f1c2d3e4a5b46c78d9e0f1a2b3c4d5e")
puts message.status
{
"id": "msg_9f1c2d3e4a5b46c78d9e0f1a2b3c4d5e",
"status": "sent",
"from": "[email protected]",
"subject": "Your quote",
"tags": ["quote"],
"metadata": { "order_id": "A-1042" },
"tracking": { "opens": false, "clicks": false },
"created_at": "2026-09-19T14:03:21.512Z",
"recipients": [
{ "email": "[email protected]", "type": "to", "name": "Alex Martin", "status": "delivered", "updated_at": "2026-09-19T14:03:24.101Z" },
{ "email": "[email protected]", "type": "cc", "name": null, "status": "sent", "updated_at": "2026-09-19T14:03:22.870Z" }
]
}
type is to, cc or bcc (the request field the address was in); name is the name given with Name <address>,
null for a bare address.
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
curl "https://api.duva.ca/v1/soumissio.ca/events?type=bounced&limit=20" \
-H "Authorization: Bearer dv_xxxxxxxxxx_..."
Node.js (duva-mail)
import { Duva } from "duva-mail";
const duva = new Duva({ apiKey: process.env.DUVA_API_KEY, domain: "soumissio.ca" });
const page = await duva.events.list({ type: "bounced", limit: 20 });
console.log(page.data.length, page.nextCursor);
Python (duva-mail)
import os
from duva import Duva
duva = Duva(api_key=os.environ["DUVA_API_KEY"], domain="soumissio.ca")
page = duva.events.list(type="bounced", limit=20)
print(len(page.data), page.next_cursor)
PHP (duva-mail/duva)
<?php
use Duva\Client;
$duva = new Client(apiKey: getenv("DUVA_API_KEY"), domain: "soumissio.ca");
$page = $duva->events->list(type: "bounced", limit: 20);
echo count($page->data) . " " . ($page->nextCursor ?? "");
Go (duva-go)
package main
import (
"context"
"fmt"
"os"
"github.com/duva-mail/duva-go"
)
func main() {
client, err := duva.New(os.Getenv("DUVA_API_KEY"), "soumissio.ca")
if err != nil {
panic(err)
}
page, err := client.Events.List(context.Background(), duva.ListEventsParams{Type: "bounced", Limit: 20})
if err != nil {
panic(err)
}
fmt.Println(len(page.Data), page.NextCursor)
}
Ruby (duva-mail)
require "duva"
duva = Duva::Client.new(api_key: ENV.fetch("DUVA_API_KEY"), domain: "soumissio.ca")
page = duva.events.list(type: "bounced", limit: 20)
puts "#{page.data.length} #{page.next_cursor}"
{
"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 },
"metadata": { "order_id": "A-1042" }
}
],
"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.
curl
curl "https://api.duva.ca/v1/example.com/suppressions?reason=bounce&limit=20" \
-H "Authorization: Bearer $DUVA_API_KEY"
Node.js (duva-mail)
import { Duva } from "duva-mail";
const duva = new Duva({ apiKey: process.env.DUVA_API_KEY, domain: "example.com" });
const page = await duva.suppressions.list({ reason: "bounce", limit: 20 });
console.log(page.data.length, page.nextCursor);
Python (duva-mail)
import os
from duva import Duva
duva = Duva(api_key=os.environ["DUVA_API_KEY"], domain="example.com")
page = duva.suppressions.list(reason="bounce", limit=20)
print(len(page.data), page.next_cursor)
PHP (duva-mail/duva)
<?php
use Duva\Client;
$duva = new Client(apiKey: getenv("DUVA_API_KEY"), domain: "example.com");
$page = $duva->suppressions->list(reason: "bounce", limit: 20);
echo count($page->data) . " " . ($page->nextCursor ?? "");
Go (duva-go)
package main
import (
"context"
"fmt"
"os"
"github.com/duva-mail/duva-go"
)
func main() {
client, err := duva.New(os.Getenv("DUVA_API_KEY"), "example.com")
if err != nil {
panic(err)
}
page, err := client.Suppressions.List(context.Background(), duva.ListSuppressionsParams{Reason: "bounce", Limit: 20})
if err != nil {
panic(err)
}
fmt.Println(len(page.Data), page.NextCursor)
}
Ruby (duva-mail)
require "duva"
duva = Duva::Client.new(api_key: ENV.fetch("DUVA_API_KEY"), domain: "example.com")
page = duva.suppressions.list(reason: "bounce", limit: 20)
puts "#{page.data.length} #{page.next_cursor}"
{ "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).
curl
curl -X POST https://api.duva.ca/v1/example.com/suppressions \
-H "Authorization: Bearer $DUVA_API_KEY" -H "Content-Type: application/json" \
-d '{ "email": "[email protected]" }'
Node.js (duva-mail)
import { Duva } from "duva-mail";
const duva = new Duva({ apiKey: process.env.DUVA_API_KEY, domain: "example.com" });
const suppression = await duva.suppressions.add("[email protected]");
console.log(suppression.reason);
Python (duva-mail)
import os
from duva import Duva
duva = Duva(api_key=os.environ["DUVA_API_KEY"], domain="example.com")
suppression = duva.suppressions.add("[email protected]")
print(suppression.reason)
PHP (duva-mail/duva)
<?php
use Duva\Client;
$duva = new Client(apiKey: getenv("DUVA_API_KEY"), domain: "example.com");
$suppression = $duva->suppressions->add("[email protected]");
echo $suppression->reason;
Go (duva-go)
package main
import (
"context"
"fmt"
"os"
"github.com/duva-mail/duva-go"
)
func main() {
client, err := duva.New(os.Getenv("DUVA_API_KEY"), "example.com")
if err != nil {
panic(err)
}
suppression, created, err := client.Suppressions.Add(context.Background(), "[email protected]")
if err != nil {
panic(err)
}
fmt.Println(suppression.Reason, created)
}
Ruby (duva-mail)
require "duva"
duva = Duva::Client.new(api_key: ENV.fetch("DUVA_API_KEY"), domain: "example.com")
suppression = duva.suppressions.add("[email protected]")
puts suppression.reason
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.
curl
curl -X DELETE https://api.duva.ca/v1/example.com/suppressions/[email protected] \
-H "Authorization: Bearer $DUVA_API_KEY"
Node.js (duva-mail)
import { Duva } from "duva-mail";
const duva = new Duva({ apiKey: process.env.DUVA_API_KEY, domain: "example.com" });
await duva.suppressions.remove("[email protected]");
Python (duva-mail)
import os
from duva import Duva
duva = Duva(api_key=os.environ["DUVA_API_KEY"], domain="example.com")
duva.suppressions.remove("[email protected]")
PHP (duva-mail/duva)
<?php
use Duva\Client;
$duva = new Client(apiKey: getenv("DUVA_API_KEY"), domain: "example.com");
$duva->suppressions->remove("[email protected]");
Go (duva-go)
package main
import (
"context"
"os"
"github.com/duva-mail/duva-go"
)
func main() {
client, err := duva.New(os.Getenv("DUVA_API_KEY"), "example.com")
if err != nil {
panic(err)
}
if err := client.Suppressions.Remove(context.Background(), "[email protected]"); err != nil {
panic(err)
}
}
Ruby (duva-mail)
require "duva"
duva = Duva::Client.new(api_key: ENV.fetch("DUVA_API_KEY"), domain: "example.com")
duva.suppressions.remove("[email protected]")
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
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"] }'
Node.js (duva-mail)
import { Duva } from "duva-mail";
const duva = new Duva({ apiKey: process.env.DUVA_API_KEY, domain: "soumissio.ca" });
const webhook = await duva.webhooks.create({
url: "https://soumissio.ca/hooks/duva",
events: ["delivered", "bounced"],
});
console.log(webhook.id, webhook.secret); // secret: shown only in this response
Python (duva-mail)
import os
from duva import Duva
duva = Duva(api_key=os.environ["DUVA_API_KEY"], domain="soumissio.ca")
webhook = duva.webhooks.create("https://soumissio.ca/hooks/duva", events=["delivered", "bounced"])
print(webhook.id, webhook.secret) # secret: shown only in this response
PHP (duva-mail/duva)
<?php
use Duva\Client;
$duva = new Client(apiKey: getenv("DUVA_API_KEY"), domain: "soumissio.ca");
$webhook = $duva->webhooks->create(url: "https://soumissio.ca/hooks/duva", events: ["delivered", "bounced"]);
echo $webhook->id . " " . $webhook->secret; // secret: shown only in this response
Go (duva-go)
package main
import (
"context"
"fmt"
"os"
"github.com/duva-mail/duva-go"
)
func main() {
client, err := duva.New(os.Getenv("DUVA_API_KEY"), "soumissio.ca")
if err != nil {
panic(err)
}
webhook, err := client.Webhooks.Create(context.Background(), duva.CreateWebhookParams{
URL: "https://soumissio.ca/hooks/duva",
Events: []string{"delivered", "bounced"},
})
if err != nil {
panic(err)
}
fmt.Println(webhook.Id, *webhook.Secret) // Secret: shown only in this response
}
Ruby (duva-mail)
require "duva"
duva = Duva::Client.new(api_key: ENV.fetch("DUVA_API_KEY"), domain: "soumissio.ca")
webhook = duva.webhooks.create("https://soumissio.ca/hooks/duva", events: ["delivered", "bounced"])
puts "#{webhook.id} #{webhook.secret}" # secret: shown only in this response
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).
Node.js (duva-mail)
import { Duva } from "duva-mail";
const duva = new Duva({ apiKey: process.env.DUVA_API_KEY, domain: "soumissio.ca" });
const webhooks = await duva.webhooks.list();
const webhook = await duva.webhooks.get(webhooks[0].id);
await duva.webhooks.delete(webhook.id);
Python (duva-mail)
import os
from duva import Duva
duva = Duva(api_key=os.environ["DUVA_API_KEY"], domain="soumissio.ca")
webhooks = duva.webhooks.list()
webhook = duva.webhooks.get(webhooks[0].id)
duva.webhooks.delete(webhook.id)
PHP (duva-mail/duva)
<?php
use Duva\Client;
$duva = new Client(apiKey: getenv("DUVA_API_KEY"), domain: "soumissio.ca");
$webhooks = $duva->webhooks->list();
$webhook = $duva->webhooks->get($webhooks[0]->id);
$duva->webhooks->delete($webhook->id);
Go (duva-go)
package main
import (
"context"
"os"
"github.com/duva-mail/duva-go"
)
func main() {
client, err := duva.New(os.Getenv("DUVA_API_KEY"), "soumissio.ca")
if err != nil {
panic(err)
}
ctx := context.Background()
webhooks, err := client.Webhooks.List(ctx)
if err != nil {
panic(err)
}
webhook, err := client.Webhooks.Get(ctx, webhooks[0].Id)
if err != nil {
panic(err)
}
if err := client.Webhooks.Delete(ctx, webhook.Id); err != nil {
panic(err)
}
}
Ruby (duva-mail)
require "duva"
duva = Duva::Client.new(api_key: ENV.fetch("DUVA_API_KEY"), domain: "soumissio.ca")
webhooks = duva.webhooks.list
webhook = duva.webhooks.get(webhooks[0].id)
duva.webhooks.delete(webhook.id)
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.
curl
curl "https://api.duva.ca/v1/example.com/webhooks/wh_9f1c2d3e4a5b46c78d9e0f1a2b3c4d5e/deliveries?limit=20" \
-H "Authorization: Bearer $DUVA_API_KEY"
Node.js (duva-mail)
import { Duva } from "duva-mail";
const duva = new Duva({ apiKey: process.env.DUVA_API_KEY, domain: "example.com" });
const deliveries = await duva.webhooks.deliveries("wh_9f1c2d3e4a5b46c78d9e0f1a2b3c4d5e", 20);
console.log(deliveries.length);
Python (duva-mail)
import os
from duva import Duva
duva = Duva(api_key=os.environ["DUVA_API_KEY"], domain="example.com")
deliveries = duva.webhooks.deliveries("wh_9f1c2d3e4a5b46c78d9e0f1a2b3c4d5e", limit=20)
print(len(deliveries))
PHP (duva-mail/duva)
<?php
use Duva\Client;
$duva = new Client(apiKey: getenv("DUVA_API_KEY"), domain: "example.com");
$deliveries = $duva->webhooks->deliveries("wh_9f1c2d3e4a5b46c78d9e0f1a2b3c4d5e", limit: 20);
echo count($deliveries);
Go (duva-go)
package main
import (
"context"
"fmt"
"os"
"github.com/duva-mail/duva-go"
)
func main() {
client, err := duva.New(os.Getenv("DUVA_API_KEY"), "example.com")
if err != nil {
panic(err)
}
deliveries, err := client.Webhooks.Deliveries(context.Background(), "wh_9f1c2d3e4a5b46c78d9e0f1a2b3c4d5e", 20)
if err != nil {
panic(err)
}
fmt.Println(len(deliveries))
}
Ruby (duva-mail)
require "duva"
duva = Duva::Client.new(api_key: ENV.fetch("DUVA_API_KEY"), domain: "example.com")
deliveries = duva.webhooks.deliveries("wh_9f1c2d3e4a5b46c78d9e0f1a2b3c4d5e", limit: 20)
puts deliveries.length
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…","metadata":{"order_id":"A-1042"},"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
curl "https://api.duva.ca/v1/soumissio.ca/stats?granularity=day&since=2026-09-01T00:00:00Z" \
-H "Authorization: Bearer dv_xxxxxxxxxx_..."
Node.js (duva-mail)
import { Duva } from "duva-mail";
const duva = new Duva({ apiKey: process.env.DUVA_API_KEY, domain: "soumissio.ca" });
const stats = await duva.stats.get({ granularity: "day", since: new Date("2026-09-01T00:00:00Z") });
console.log(stats.totals);
Python (duva-mail)
import os
from datetime import UTC, datetime
from duva import Duva
duva = Duva(api_key=os.environ["DUVA_API_KEY"], domain="soumissio.ca")
stats = duva.stats.get(granularity="day", since=datetime(2026, 9, 1, tzinfo=UTC))
print(stats.totals)
PHP (duva-mail/duva)
<?php
use Duva\Client;
$duva = new Client(apiKey: getenv("DUVA_API_KEY"), domain: "soumissio.ca");
$stats = $duva->stats->get(granularity: "day", since: new DateTimeImmutable("2026-09-01T00:00:00Z"));
print_r($stats->totals);
Go (duva-go)
package main
import (
"context"
"fmt"
"os"
"time"
"github.com/duva-mail/duva-go"
)
func main() {
client, err := duva.New(os.Getenv("DUVA_API_KEY"), "soumissio.ca")
if err != nil {
panic(err)
}
since := time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC)
stats, err := client.Stats.Get(context.Background(), duva.GetStatsParams{Granularity: "day", Since: &since})
if err != nil {
panic(err)
}
fmt.Println(stats.Totals)
}
Ruby (duva-mail)
require "duva"
duva = Duva::Client.new(api_key: ENV.fetch("DUVA_API_KEY"), domain: "soumissio.ca")
stats = duva.stats.get(granularity: "day", since: Time.utc(2026, 9, 1))
puts stats.totals
{ "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.
Tracking also requires an html body: links and the pixel only rewrite HTML, never plain text. A
tracking message without html is rejected (422, field tracking), for the same reason.
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.
Sending by SMTP
Besides the HTTPS API, Duva accepts messages by SMTP: any software, framework or service that can send email over SMTP
(Laravel, Django, Rails, Nodemailer, WordPress, an authentication provider...) can send through Duva without any Duva-specific
code. A message received by SMTP follows the same rules as an API message (verified domain, quota, sandbox, suppressed
addresses, idempotency, the unusual-volume check) and shows up in the same places: GET /messages/{id}, events, webhooks and
statistics.
Connection
| Setting | Value |
|---|---|
| Server | smtp.duva.ca |
| Port 587 | STARTTLS (recommended) |
| Port 465 | Implicit TLS |
| Username | your sending domain, for example soumissio.ca |
| Password | the API key of that domain (dv_...) |
| Authentication | PLAIN or LOGIN, after encryption |
- Encryption is mandatory: no authentication and no message in clear text. Port 25 is not offered.
- The key is checked on every message: a revoked or expired key stops working at once, even on an open connection. Every
cause of failure gets the same reply (
535), without saying which one.
What is read from the message
- Recipients: those of the SMTP envelope (
RCPT TO). TheToandCcheaders only classify them; any other address gets a blind copy (bcc). ABccheader in the message is never read. Same limits as the API (your account's plan), and at most 100 recipients per transaction. - Sender: the
Fromheader, which must belong to the key's domain (otherwise553). The envelope address (MAIL FROM) is ignored: Duva uses its own return address. - Content: subject, text, HTML,
Reply-To, attachments (inline images included, byContent-ID), with the API's limits (10 attachments, 5 MB decoded in total, executable extensions refused, no attachments in the sandbox). - Headers: those the API allows (see "Request body"), at most 20; others are ignored.
- Size: at most 10 MB per message (
552). - Signed or encrypted messages (S/MIME, PGP/MIME): refused. Duva rebuilds every message, which would break the signature; the DKIM signature is Duva's.
Duva-specific headers
These headers are read and then removed: the recipient never sees them.
| Header | Purpose |
|---|---|
X-Duva-Tags |
Tags, separated by commas. |
X-Duva-Metadata |
Metadata, as a JSON object (text values). |
X-Duva-Track |
opens, clicks or both, separated by a comma (same conditions as the tracking described below). |
X-Idempotency-Key |
Idempotency key. |
Idempotency and retries
Without X-Idempotency-Key, Duva derives one from the Message-ID and the recipients: software that resends the same message
after a dropped connection creates no duplicate and gets the same reply. The same Message-ID with other recipients is another
message. A 4xx reply invites a retry; a 5xx reply is final for that message.
Replies
250 means the message is accepted for delivery (it is queued), not that it is delivered: follow its status through the API,
events or webhooks. The msg_... identifier is in the reply. Duva sends no bounce email back to the sender: a bounce only
appears in events.
| Reply | Meaning |
|---|---|
250 |
Accepted for delivery. |
451 |
Try again later (rate limit or temporary failure). |
452 |
Too many recipients in this transaction. |
454 |
Too many authentication attempts: wait. |
530 |
Authentication (or encryption) required. |
535 |
Invalid credentials (any cause). |
550 |
Refused: quota reached (5.4.5), sending not possible for this account or domain (5.7.1), idempotency conflict (5.5.4). |
552 |
Message too large. |
553 |
Sender outside the key's domain, or invalid address. |
554 |
Message refused: invalid content (for example no body), invalid recipient. |
Limits on connections and on refused attempts apply per IP address (421 and 454 replies); they do not get in the way of
normal use.
Configure your software
Command-line test with swaks:
swaks --server smtp.duva.ca:587 -tls \
--auth PLAIN --auth-user soumissio.ca --auth-password "$DUVA_API_KEY" \
--from [email protected] --to [email protected]
Laravel (.env):
MAIL_MAILER=smtp
MAIL_HOST=smtp.duva.ca
MAIL_PORT=587
MAIL_USERNAME=soumissio.ca
MAIL_PASSWORD=dv_...
[email protected]
Django (settings.py):
EMAIL_HOST = "smtp.duva.ca"
EMAIL_PORT = 587
EMAIL_USE_TLS = True
EMAIL_HOST_USER = "soumissio.ca"
EMAIL_HOST_PASSWORD = os.environ["DUVA_API_KEY"]
DEFAULT_FROM_EMAIL = "[email protected]"
Rails (config/environments/production.rb):
config.action_mailer.delivery_method = :smtp
config.action_mailer.smtp_settings = {
address: "smtp.duva.ca", port: 587, authentication: :plain, enable_starttls_auto: true,
user_name: "soumissio.ca", password: ENV.fetch("DUVA_API_KEY")
}
Nodemailer:
const transport = nodemailer.createTransport({
host: "smtp.duva.ca", port: 587, requireTLS: true,
auth: { user: "soumissio.ca", pass: process.env.DUVA_API_KEY },
});
For frameworks with an official integration (see libraries and integrations), it also gives access to tags, metadata and tracking without writing headers. The API remains the richest choice: an immediate reply with the status, and reading of messages and events.
Code examples
Complete examples to copy: sending a message and verifying a webhook signature. The API key is read from the DUVA_API_KEY
environment variable (a mere convention, not a Duva requirement). Official client libraries exist for
Node.js, Python, Ruby, Go and PHP; for any other language, no library is required: the API is a plain JSON POST (curl
example), and the OpenAPI specification lets you generate a full client in the language of your choice.
Send a message
Each example sends a text message with an idempotency key, then reads the id and status (202). The
official libraries (Node.js, Python, Ruby, Go, PHP) raise a typed exception (or return an error, in Go)
on error — see "Errors" — and
automatically retry transient failures for this call; the curl example reads the {"error": {"code", "message"}} envelope
directly (on a 429, wait Retry-After seconds before retrying).
curl
curl -X POST https://api.duva.ca/v1/example.com/messages \
-H "Authorization: Bearer $DUVA_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order-4821" \
-d '{
"from": "Example <[email protected]>",
"to": ["[email protected]"],
"subject": "Your order",
"text": "Thank you for your order."
}'
Node.js (duva-mail)
import { Duva } from "duva-mail";
const duva = new Duva({ apiKey: process.env.DUVA_API_KEY, domain: "example.com" });
const message = await duva.messages.send({
from: "Example <[email protected]>",
to: ["[email protected]"],
subject: "Your order",
text: "Thank you for your order.",
idempotencyKey: "order-4821",
});
console.log(message.id, message.status);
Python (duva-mail)
import os
from duva import Duva
duva = Duva(api_key=os.environ["DUVA_API_KEY"], domain="example.com")
message = duva.messages.send(
from_="Example <[email protected]>",
to=["[email protected]"],
subject="Your order",
text="Thank you for your order.",
idempotency_key="order-4821",
)
print(message.id, message.status)
PHP (duva-mail/duva)
<?php
use Duva\Client;
$duva = new Client(apiKey: getenv("DUVA_API_KEY"), domain: "example.com");
$message = $duva->messages->send([
"from" => "Example <[email protected]>",
"to" => ["[email protected]"],
"subject" => "Your order",
"text" => "Thank you for your order.",
"idempotency_key" => "order-4821",
]);
echo $message->id . " " . $message->status . "\n";
Go (duva-go)
package main
import (
"context"
"fmt"
"os"
"github.com/duva-mail/duva-go"
)
func main() {
client, err := duva.New(os.Getenv("DUVA_API_KEY"), "example.com")
if err != nil {
panic(err)
}
result, err := client.Messages.Send(context.Background(), duva.SendMessageParams{
From: "Example <[email protected]>",
To: []string{"[email protected]"},
Subject: "Your order",
Text: "Thank you for your order.",
IdempotencyKey: "order-4821",
})
if err != nil {
panic(err)
}
fmt.Println(result.ID, result.Status)
}
Ruby (duva-mail)
require "duva"
duva = Duva::Client.new(api_key: ENV.fetch("DUVA_API_KEY"), domain: "example.com")
result = duva.messages.deliver(
from: "Example <[email protected]>",
to: ["[email protected]"],
subject: "Your order",
text: "Thank you for your order.",
idempotency_key: "order-4821"
)
puts "#{result.id} #{result.status}"
Verify a webhook signature
Each official library exposes a function that takes the secret (whsec_…), the request headers and the exact raw body,
verifies that the v1 signature is valid and that the timestamp is less than 5 minutes old (replay protection), then raises
an exception (or returns an error, in Go) if verification fails — otherwise it returns the already-decoded event. It accepts
several secrets (rotation) and several signatures in the header, and compares in constant time.
Node.js (duva-mail)
import { constructEvent, WebhookSignatureError } from "duva-mail";
// req.headers and the raw body exactly as received (not a re-encoded JSON body)
try {
const event = constructEvent(process.env.DUVA_WEBHOOK_SECRET, req.headers, rawBody);
console.log(event.type, event.data);
} catch (err) {
if (err instanceof WebhookSignatureError) {
res.status(400).end();
return;
}
throw err;
}
Python (duva-mail)
import os
from duva import WebhookSignatureError, construct_event
try:
event = construct_event(os.environ["DUVA_WEBHOOK_SECRET"], request.headers, raw_body)
print(event.type, event.data)
except WebhookSignatureError:
return "", 400
PHP (duva-mail/duva)
<?php
use Duva\Exceptions\WebhookSignatureException;
use Duva\Webhooks;
try {
$event = Webhooks::constructEvent(getenv("DUVA_WEBHOOK_SECRET"), $headers, $rawBody);
echo $event->type . " " . ($event->data["message_id"] ?? "");
} catch (WebhookSignatureException) {
http_response_code(400);
}
Go (duva-go)
package main
import (
"fmt"
"net/http"
"os"
"github.com/duva-mail/duva-go"
)
func verifyWebhook(headers http.Header, rawBody []byte) {
event, err := duva.ConstructEvent([]string{os.Getenv("DUVA_WEBHOOK_SECRET")}, headers, rawBody, nil)
if err != nil {
// respond 400
return
}
fmt.Println(event.Type, event.Data["message_id"])
}
Ruby (duva-mail)
require "duva"
begin
event = Duva::Webhooks.construct_event(ENV.fetch("DUVA_WEBHOOK_SECRET"), headers, raw_body)
puts "#{event.type} #{event.data}"
rescue Duva::WebhookSignatureError
# respond with 400
end
Read the raw body, before any JSON decoding: re-encoding it changes the bytes and invalidates the signature. For example:
express.raw({ type: "application/json" }) (Node.js/Express), request.get_data() (Flask), file_get_contents("php://input") (PHP),
io.ReadAll(r.Body) (Go), request.raw_post (Rails). Answer 2xx only after verifying the signature, then de-duplicate on webhook-id.
Health
GET /health: 200 {"status": "ok"}, or 503 if the database is unreachable. No authentication.
curl
curl https://api.duva.ca/health
Node.js (duva-mail)
import { Duva } from "duva-mail";
const duva = new Duva({ apiKey: process.env.DUVA_API_KEY, domain: "example.com" });
const health = await duva.health();
console.log(health.status);
Python (duva-mail)
import os
from duva import Duva
duva = Duva(api_key=os.environ["DUVA_API_KEY"], domain="example.com")
health = duva.health()
print(health["status"])
PHP (duva-mail/duva)
<?php
use Duva\Client;
$duva = new Client(apiKey: getenv("DUVA_API_KEY"), domain: "example.com");
$health = $duva->health();
echo $health["status"];
Go (duva-go)
package main
import (
"context"
"fmt"
"os"
"github.com/duva-mail/duva-go"
)
func main() {
client, err := duva.New(os.Getenv("DUVA_API_KEY"), "example.com")
if err != nil {
panic(err)
}
health, err := client.Health(context.Background())
if err != nil {
panic(err)
}
fmt.Println(health.Status)
}
Ruby (duva-mail)
require "duva"
duva = Duva::Client.new(api_key: ENV.fetch("DUVA_API_KEY"), domain: "example.com")
health = duva.health
puts health["status"]
Changelog
| Date | Change |
|---|---|
| 2026-09-27 | First publication of the OpenAPI 3.1 specification (/openapi.json), covering every route above. |
| 2026-10-05 | Change of behavior, effective from this date. The recipients of a message now see the other to and cc recipients (the To and Cc headers of every copy); before, each copy showed only its own recipient. Do not put several addresses in one to if they must not see each other: send one message per recipient, or use bcc. The cc and bcc fields and the Name <address> format are added (see "Recipients"). |
A compatible change (a new field, a new enum value, a new route) is never announced here: ignore unknown fields and values rather than rejecting them. A breaking change (a field removed or renamed, a tightened constraint) would be announced here at least 6 months before the old shape stops being accepted.