API Duva (v1)
Adresse de base : https://api.duva.ca
Référence de l'API. Endpoints disponibles : POST /v1/{domaine}/messages,
GET /v1/{domaine}/messages/{id}, GET /v1/{domaine}/events, les suppressions, les webhooks et les statistiques. L'envoi par SMTP (smtp.duva.ca) est décrit à la section « Envoi par SMTP ».
La spécification OpenAPI 3.1 de l'API (chaque route, schéma, erreur et l'événement de webhook signé) est publiée
sur ce site : /openapi.json. Elle permet de générer un client dans le langage de votre choix.
État de l'envoi. Le message est accepté, validé et mis en file (
queued). Duva l'envoie ensuite au serveur d'envoi, une copie par adresse (chaque copie est envoyée à une seule adresse ; les en-têtesToetCcy montrent lestoet lesccdu message, voir « Destinataires ») :sentsignifie « remis au serveur d'envoi ». Les événements du serveur d'envoi font ensuite avancer chaque destinataire versdelivered(accepté par le serveur du destinataire) oubounced(refus définitif, immédiat ou reçu après coup). Le suivi des ouvertures et des clics s'applique aux messages qui le demandent (voir « Suivi des ouvertures et des clics »).Vérification. Un volume inhabituel pour le compte (un compte récent qui envoie beaucoup, ou un débit très supérieur à l'habitude) peut faire retenir un message pour vérification avant son envoi : la réponse est la même (
202,queued) et le message restequeuedjusqu'à la décision. Un message qui n'a pas été libéré dans les 24 heures est abandonné (failed). Pour éviter ce délai, montez le volume progressivement, ou écrivez-nous avant un envoi massif.
Authentification
Une clé API par domaine, dans l'en-tête Authorization: Bearer dv_.... La clé n'ouvre que le domaine pour
lequel elle a été créée (écran « Clés API » du dashboard), et le {domaine} du chemin doit être celui-là.
- Aucune clé présentée (ou schéma autre que
Bearer) : 401. - Clé présentée mais inconnue, fausse, révoquée, expirée, ou d'un autre domaine ; domaine inexistant ; compte fermé : 404, toujours la même réponse, sans dire laquelle de ces causes s'applique.
POST /v1/{domaine}/messages
Accepte un message. La réponse est toujours asynchrone : elle ne confirme jamais une livraison.
curl
curl -X POST https://api.duva.ca/v1/soumissio.ca/messages \
-H "Authorization: Bearer dv_xxxxxxxxxx_..." \
-H "Idempotency-Key: soumission-4821" \
-H "Content-Type: application/json" \
-d '{
"from": "Soumissio <[email protected]>",
"to": ["[email protected]"],
"subject": "Votre soumission",
"html": "<p>Bonjour</p>",
"text": "Bonjour",
"tags": ["soumission"]
}'
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: "Votre soumission",
html: "<p>Bonjour</p>",
text: "Bonjour",
tags: ["soumission"],
idempotencyKey: "soumission-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="Votre soumission",
html="<p>Bonjour</p>",
text="Bonjour",
tags=["soumission"],
idempotency_key="soumission-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" => "Votre soumission",
"html" => "<p>Bonjour</p>",
"text" => "Bonjour",
"tags" => ["soumission"],
"idempotency_key" => "soumission-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: "Votre soumission",
HTML: "<p>Bonjour</p>",
Text: "Bonjour",
Tags: []string{"soumission"},
IdempotencyKey: "soumission-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: "Votre soumission",
html: "<p>Bonjour</p>",
text: "Bonjour",
tags: ["soumission"],
idempotency_key: "soumission-4821"
)
puts "#{result.id} #{result.status}"
Réponse 202 Accepted
{ "id": "msg_9f1c2d3e4a5b46c78d9e0f1a2b3c4d5e", "status": "queued" }
status vaut queued, sauf si tous les destinataires sont sur la liste de suppression : failed.
En-tête Location: /v1/{domaine}/messages/{id}.
Corps de la requête
Les champs inconnus sont refusés (422), pour qu'une faute de frappe comme replyto ne soit pas ignorée en silence.
| Champ | Type | Règles |
|---|---|---|
from |
texte | Requis. adresse ou Nom <adresse>. Le domaine doit être celui du chemin (sous-domaines exclus). |
to |
liste de textes | Requis. adresse ou Nom <adresse>. Dédoublonnées sans tenir compte de la casse. to, cc et bcc comptent ensemble dans le maximum du forfait (5 en bac à sable). Une copie par adresse ; chaque copie montre tous les to et tous les cc. Voir « Destinataires ». |
cc |
liste de textes | Facultatif, même format que to. Visible de tous les destinataires. Une adresse déjà présente dans to est ignorée. |
bcc |
liste de textes | Facultatif, même format que to. Jamais visible des autres destinataires. Une adresse déjà présente dans to ou cc est ignorée. |
subject |
texte | Requis, une ligne, 998 caractères maximum, sans caractère de contrôle. |
html, text |
texte | Au moins l'un des deux. 2 Mo au total. Pas de caractère NUL. |
tags |
liste de textes | 10 maximum, 64 caractères chacune, sans espace en début/fin. Dédoublonnées. |
tracking |
objet | {"opens": bool, "clicks": bool}, désactivés par défaut. Exige un html (voir plus bas) ; demander le suivi sans html, ou sur un domaine qui ne l'a pas activé : 422 (voir plus bas). |
reply_to |
texte | adresse ou Nom <adresse>. |
headers |
objet | 20 en-têtes maximum, valeurs sur une ligne. Liste blanche : List-Unsubscribe, List-Unsubscribe-Post, List-Id, In-Reply-To, References, Auto-Submitted, Precedence, Importance, Feedback-ID, et tout X-* sauf les préfixes réservés à la plateforme (X-Duva* et quelques autres, refusés en 422). From, To, Cc, Bcc, Return-Path, Message-ID, DKIM-Signature, etc. sont refusés. Valeurs contrôlées : List-Unsubscribe (liens <https://…> ou <mailto:…>, 3 au plus), List-Unsubscribe-Post (exactement List-Unsubscribe=One-Click, avec un lien https), Precedence (bulk, list, junk), Auto-Submitted (no, auto-generated, auto-replied, auto-notified), Importance (high, normal, low). |
metadata |
objet | Texte vers texte, jamais envoyé au destinataire : renvoyé par GET /messages/{id}, dans chaque événement et dans les webhooks de ce message. 10 clés au plus (lettres, chiffres, _, ., -, 40 caractères), valeurs de 500 caractères sur une ligne. |
attachments |
liste d'objets | 10 au plus, 5 Mo décodés en tout. Voir « Pièces jointes ». Réservé aux comptes en production. |
Les adresses dont la partie locale n'est pas en ASCII ne sont pas prises en charge. Le domaine est normalisé (minuscules, punycode).
Ce que Duva ajoute au message. Si vous n'envoyez que html, Duva y joint une version texte tirée de ce HTML
(paragraphes, listes et liens écrits « libellé (adresse) ») : un message HTML seul est plus souvent classé comme
pourriel. Envoyez votre propre text pour la maîtriser ; un HTML sans aucun texte reste HTML seul. Duva ajoute aussi un
en-tête Feedback-ID (<domaine>:transactional:duva), que Gmail utilise pour ventiler ses rapports de réputation, sauf
si vous en fournissez un dans headers. Chaque message est signé en DKIM avec votre domaine ; Duva peut y ajouter une
seconde signature DKIM au nom d'un domaine de la plateforme, qui lui permet de recevoir les plaintes des fournisseurs de
courriel et de suivre la réputation de son service. Votre DMARC n'en dépend pas : il s'aligne toujours sur la signature
de votre domaine.
La requête entière ne peut pas dépasser 4 Mo (413), sauf l'envoi d'un message : 9 Mo, pour ses pièces jointes.
Destinataires : to, cc et bcc
Les trois champs acceptent une adresse ([email protected]) ou un nom suivi de l'adresse
(Jean Tremblay <[email protected]>). Le nom ne peut contenir ni @, ni guillemet, ni antislash, ni caractère de
contrôle (200 caractères au plus). to est requis ; cc et bcc sont facultatifs.
- Une copie par adresse. Duva envoie une copie distincte à chaque adresse, quel que soit son champ : chacune reçoit
UN seul message et a son propre statut (
GET /messages/{id}). - Les
toet lesccse voient. Toutes les copies portent les mêmes en-têtesTo(tous lesto) etCc(tous lescc), y compris la copie d'unbcc. - Un
bccreste caché. Son adresse ne figure sur la copie d'aucun autre destinataire. Sa propre copie porte un en-têteBccqui ne contient que son adresse : il comprend ainsi pourquoi il reçoit le message. - Un seul plafond, pour le total.
to,ccetbcccomptent ensemble dans le maximum de destinataires du forfait et dans le quota d'envoi. Une adresse sur la liste de suppression n'est pas envoyée et ne compte pas. - Une adresse n'existe qu'une fois. Si elle figure dans plusieurs champs (la casse ne compte pas), la première
occurrence l'emporte dans l'ordre
to,cc,bcc, avec son nom. Le plafond dépassé donne 422, sur le champtosi seultoest fourni, sinon surrecipients. - Les champs
ccetbccremplacent les en-têtesCcetBccdeheaders, qui restent refusés.
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}"
Pièces jointes
Chaque élément de attachments : filename (requis, 255 caractères au plus, sans chemin), content (requis, en base64), content_type (facultatif, application/octet-stream par défaut) et content_id (facultatif : la pièce jointe devient une image « inline » que le HTML désigne par cid:).
"attachments": [
{ "filename": "Facture-2026-001.pdf", "content": "JVBERi0xLjQK...", "content_type": "application/pdf" }
]
Les extensions exécutables (.exe, .bat, .js, .vbs, .scr, .msi, etc.) sont refusées. Toutes les pièces jointes d'un message pèsent 5 Mo décodés au plus. Un compte en bac à sable n'en envoie pas (422). Elles ne sont jamais renvoyées par l'API et sont supprimées avec le corps du message.
Idempotence
En-tête optionnel Idempotency-Key (1 à 255 caractères ASCII imprimables, sans espace), unique par domaine.
- Même clé et même requête : 202 avec le même
idet l'en-têteIdempotent-Replayed: true. Aucun quota n'est consommé de nouveau, même s'il est épuisé. - Même clé, requête différente : 409
idempotency_conflict. - Deux requêtes identiques simultanées avec la même clé ne créent qu'un seul message.
Les clés n'expirent pas tant que le message existe.
Listes de suppression
Un destinataire qui figure sur la liste de suppression du domaine n'est pas envoyé et ne consomme pas de quota.
La requête reste acceptée (202) ; le détail par destinataire se lit avec GET /messages/{id} (statut suppressed).
Erreurs
Toutes les erreurs ont la même forme, sans détail interne ni valeur reçue :
{ "error": { "code": "invalid_request", "message": "La requête est invalide.",
"fields": [{ "field": "to[0]", "message": "il manque la partie locale ou l'@" }] } }
code est le contrat : appuyez-vous sur lui, et sur fields[].field, dans votre code. message (et
fields[].message) est un texte lisible, dans la langue de l'en-tête Accept-Language : en pour l'anglais, fr pour
le français. Sans l'en-tête, ou avec une autre langue, le texte est en français. La formulation peut changer ; le
statut, code et fields[].field ne changent pas d'une langue à l'autre. Les erreurs portent 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": ["pas-une-adresse"], "subject": "Test", "text": "Bonjour"}'
Les textes fixes que Duva écrit lui-même dans disabled_reason et last_error des webhooks suivent la même en-tête ;
le texte de la réponse de votre serveur n'est jamais renvoyé.
| Statut | code |
Cause |
|---|---|---|
| 401 | unauthorized |
Aucune clé présentée. En-tête WWW-Authenticate: Bearer. |
| 404 | not_found |
Clé inutilisable pour ce domaine (voir Authentification) ; ou, en lecture, message introuvable. |
| 403 | domain_not_verified |
Le DNS du domaine n'est pas encore vérifié. |
| 403 | sending_not_allowed |
Compte ou domaine suspendu, ou compte dans un état qui interdit l'envoi. |
| 409 | idempotency_conflict |
Clé d'idempotence déjà utilisée pour une requête différente. |
| 409 | limit_reached |
Plafond atteint (webhooks par domaine). |
| 413 | payload_too_large |
Requête de plus de 4 Mo (9 Mo pour l'envoi d'un message). |
| 422 | invalid_request |
Corps ou paramètre invalide ; fields désigne le champ (from, to[1], cc[0], bcc[2], recipients, headers.Bcc, body si le JSON est illisible, limit, cursor...). |
| 429 | quota_exceeded |
Plafond journalier ou mensuel atteint. Retry-After indique les secondes avant la prochaine période (UTC). |
| 429 | rate_limited |
Trop de requêtes avec cette clé en peu de temps (limite de débit, distincte du quota d'envoi). Retry-After indique les secondes à attendre. |
| 500 | internal_error |
Erreur interne. |
L'authentification passe avant la validation : une clé fausse ne permet jamais de sonder les règles de validation.
Une limite de débit par clé protège le service : un usage normal ne l'atteint pas. Si vous recevez un 429, attendez le nombre de secondes de Retry-After avant de réessayer, sans rafale de nouvelles tentatives. Un 429 rate_limited ne consomme pas votre quota d'envoi.
GET /v1/{domaine}/messages/{id}
Statut d'un message et de chacun de ses destinataires.
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": "Votre soumission",
"tags": ["soumission"],
"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 vaut to, cc ou bcc (le champ de la requête où l'adresse figurait) ; name est le nom donné avec
Nom <adresse>, null pour une adresse seule.
Statuts d'un destinataire : queued (en attente d'envoi), sent (remis au serveur d'envoi, livraison en cours),
delivered, bounced, failed (abandonné : tentatives épuisées, ou expiré dans la file d'envoi),
suppressed (adresse sur la liste de suppression, jamais envoyée). Un destinataire ne recule jamais ; la seule
exception est un rebond reçu après coup (delivered puis bounced).
Statut du message : celui de son destinataire le moins avancé (queued < sent < delivered <
bounced < failed) ; failed seulement si aucun destinataire n'a été livré ni n'a rebondi. Un message dont un
destinataire est encore en cours de livraison reste donc sent.
Un message inexistant, malformé, d'un autre domaine ou d'un autre tenant : le même 404.
Le corps du message (html, text) et les en-têtes ne sont jamais renvoyés. Le motif d'un failed
décidé par Duva (tentatives épuisées...) n'est pas encore exposé.
GET /v1/{domaine}/events
Journal des événements de livraison du domaine, du plus récent au plus ancien, par 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..."
}
| Paramètre | Rôle |
|---|---|
message_id |
Seulement les événements de ce message (msg_...). |
type |
delivered, bounced, deferred, expired, complained, opened ou clicked. |
recipient |
Seulement cette adresse (la casse n'importe pas). |
since |
Seulement les événements survenus à partir de cet instant ISO 8601, fuseau obligatoire (2026-09-19T12:00:00Z) ; entre 1970 et 2200, sinon 422. |
limit |
1 à 100 (50 par défaut). |
cursor |
Le next_cursor de la page précédente ; null = dernière page. Valeur opaque. |
Types : delivered (le serveur du destinataire a accepté le message), bounced (refus définitif, y compris un
rebond reçu après coup), deferred (échec temporaire, le serveur d'envoi réessaiera : plusieurs peuvent précéder l'événement
final), expired (le serveur d'envoi a renoncé après 24 h), complained (le destinataire a signalé le message ; l'adresse est
alors supprimée). Un rebond dû à l'adresse elle-même (destinataire inconnu, 5.1.x) ajoute l'adresse à la liste de
suppression du domaine ; un blocage de réputation ou de contenu (5.7.x) ne le fait pas. opened et clicked viennent du
suivi de Duva, pas du serveur d'envoi (voir « Suivi des ouvertures et des clics »).
detail ne contient que des champs choisis : code, enhanced_code, message (première ligne de la réponse du
serveur distant, 500 caractères au plus : texte externe, à ne jamais interpréter), classification,
attempts, feedback_type pour une plainte, url pour un clic. Jamais le contenu du message ni ses en-têtes.
Un message_id d'un autre domaine ou tenant donne une liste vide, pas une erreur. Les événements sont livrés
au moins une fois par le serveur d'envoi : Duva les déduplique, chacun n'apparaît qu'une fois.
GET /v1/{domaine}/suppressions
Les adresses auxquelles ce domaine n'écrit plus. Elles y arrivent par un rebond dû à l'adresse, une plainte, ou à la main. Une adresse supprimée dans un domaine ne l'est pas dans un autre.
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 ou manual. Paramètres : reason (filtre), limit (1 à 100, 50 par
défaut), cursor (le next_cursor de la page précédente, valeur opaque). Du plus récent au plus ancien.
POST /v1/{domaine}/suppressions
Ajoute une adresse à la main ({"email": "[email protected]"}, raison manual) : elle ne recevra plus rien de ce
domaine. 201 si elle est ajoutée, 200 si elle y était déjà (avec l'entrée existante).
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/{domaine}/suppressions/{email}
Retire une adresse de la liste (204) ; elle pourra de nouveau recevoir. 404 si elle n'y est pas (ou si elle n'est que dans la liste d'un autre domaine : indiscernable). Retirer un rebond ou une plainte relève de votre responsabilité : réécrire à une adresse qui rebondit abîme la réputation d'envoi. L'ajout et le retrait sont journalisés dans l'audit du compte.
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 envoie chaque événement de livraison (voir GET /events) à l'URL de votre choix, en POST JSON signé.
POST /v1/{domaine}/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 avec le webhook, dont secret (whsec_…) : montré une seule fois, à conserver pour vérifier les
signatures. events vide ou absent : tous les types (delivered, bounced, deferred, expired, complained, opened, clicked).
L'URL doit être en https:// (sans identifiants, port 443 ou ≥ 1024) et viser Internet public : les adresses
privées, locales ou de métadonnées (127.0.0.1, 10.x, 169.254.169.254, ::1...) et les noms internes sont
refusés (422). Cela est revérifié à chaque envoi, après résolution du nom. Cinq webhooks au plus par domaine
(409 limit_reached).
GET /v1/{domaine}/webhooks, GET /v1/{domaine}/webhooks/{id}, DELETE /v1/{domaine}/webhooks/{id}
Liste, lecture (sans le secret) et suppression (204). Un webhook d'un autre domaine ou tenant : 404.
status vaut active ou disabled ; disabled_reason dit pourquoi (410 Gone, trop d'échecs consécutifs).
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/{domaine}/webhooks/{id}/deliveries
Les dernières livraisons (limit, 50 par défaut) : status (pending, delivered, failed), attempts,
last_status_code, last_error (texte fixe : HTTP 500, délai dépassé, connexion impossible...). Jamais le
corps de votre réponse.
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
Ce que vous recevez
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 au format « Standard Webhooks » (les bibliothèques standard-webhooks la vérifient) :
base64(HMAC-SHA256(secret, "<webhook-id>.<webhook-timestamp>.<corps brut>")), où le secret est la partie base64 de
whsec_<base64>. Vérifiez la signature sur le corps brut et refusez un webhook-timestamp de plus de 5 minutes.
- Réponse :
2xx= reçu.410désactive le webhook. Toute autre réponse, un délai de 10 s dépassé, une erreur de connexion ou de certificat : nouvelle tentative (30 s, puis en doublant jusqu'à 1 h, 10 tentatives). Les redirections ne sont pas suivies (une redirection est un échec). - Au moins une fois : le même événement peut arriver plusieurs fois (
webhook-ididentique, horodatage et signature neufs) ; dédupliquez surwebhook-id. Aucun ordre garanti entre événements. - Après 100 échecs consécutifs, le webhook est désactivé et ses livraisons en attente sont abandonnées.
GET /v1/{domaine}/stats
Compteurs du domaine par période, calculés à partir des destinataires acceptés et du journal d'événements.
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 } }
| Paramètre | Rôle |
|---|---|
granularity |
day (défaut) ou hour. Les périodes sont en UTC. |
since, until |
Intervalle ISO 8601 (fuseau obligatoire) : since inclus, until exclu. Défaut : les 30 derniers jours (24 dernières heures par heure), jusqu'à maintenant. |
Au plus 366 jours, ou 7 jours par heure (422 au-delà). Les périodes sans activité sont présentes, à zéro.
accepted compte les destinataires acceptés à la date d'acceptation du message (sans les supprimés, comptés dans
suppressed) ; les autres compteurs comptent les événements à leur date de survenue.
Suivi des ouvertures et des clics
Désactivé par défaut (vie privée, Loi 25). Deux conditions pour l'utiliser :
- Un enregistrement DNS :
CNAME track.<votre domaine>vers la cible indiquée à l'écran DNS de votre domaine (enregistrement « recommandé », nécessaire seulement pour le suivi). Duva obtient alors un certificat TLS pour ce nom. - L'activation par Duva (sur demande, une fois le CNAME publié). Tant que le suivi n'est pas
activé, un message qui le demande est refusé (422, champ
tracking) : jamais de liens cassés ni de suivi silencieusement absent.
Le suivi exige aussi un corps html : les liens et le pixel ne réécrivent que le HTML, jamais le texte. Un
message tracking sans html est refusé (422, champ tracking), pour la même raison.
Avec "tracking": {"opens": true, "clicks": true} (chaque option séparément), au moment de l'envoi :
clicks: chaque lienhttp(s)://…de la partie HTML est remplacé par un lienhttps://track.<domaine>/c/<jeton>qui redirige (302) vers l'adresse d'origine. Les liensmailto:,tel:, les ancres et les liens relatifs ne sont pas touchés, ni la partie texte.opens: un pixel transparent de 1×1 est ajouté à la fin du HTML.- Les liens sont propres à chaque destinataire (un jeton signé par message et destinataire) : aucun ne sert à un autre. La destination est dans le jeton signé : un lien ne peut pas être détourné vers une autre adresse.
- Chaque visite crée un événement
openedouclicked(detail.urlpour un clic), visible dansGET /events, envoyé aux webhooks et compté parGET /stats. Au plus un événement par lien, destinataire et minute (les robots de sécurité et les préchargements répètent les visites). Le suivi ne change jamais le statut d'un message ni d'un destinataire. - Ce n'est pas exact : un client de courriel qui précharge les images ou une passerelle de sécurité qui ouvre les liens compte comme une ouverture ou un clic. Traitez ces nombres comme des indications.
- Rien sur le visiteur n'est conservé : ni adresse IP, ni agent utilisateur, ni référent.
- Si le suivi est coupé après l'envoi, les liens déjà envoyés continuent de rediriger, sans rien enregistrer.
Envoi par SMTP
En plus de l'API HTTPS, Duva reçoit les messages par SMTP : tout logiciel, cadriciel ou service qui sait envoyer des
courriels par SMTP (Laravel, Django, Rails, Nodemailer, WordPress, un fournisseur d'authentification...) peut envoyer par Duva
sans écrire de code propre à Duva. Un message reçu par SMTP suit les mêmes règles que celui de l'API (domaine vérifié,
quota, bac à sable, adresses supprimées, idempotence, vérification d'un volume inhabituel) et se retrouve aux mêmes endroits :
GET /messages/{id}, les événements, les webhooks et les statistiques.
Connexion
| Réglage | Valeur |
|---|---|
| Serveur | smtp.duva.ca |
| Port 587 | STARTTLS (recommandé) |
| Port 465 | TLS implicite |
| Utilisateur | votre domaine d'envoi, par exemple soumissio.ca |
| Mot de passe | la clé API de ce domaine (dv_...) |
| Authentification | PLAIN ou LOGIN, après le chiffrement |
- Le chiffrement est obligatoire : aucune authentification ni aucun message en clair. Le port 25 n'est pas offert.
- La clé est vérifiée à chaque message : une clé révoquée ou expirée cesse de fonctionner tout de suite, même sur une
connexion ouverte. Toute cause d'échec donne la même réponse (
535), sans dire laquelle.
Ce qui est lu du message
- Destinataires : ceux de l'enveloppe SMTP (
RCPT TO). Les en-têtesToetCcne servent qu'à les classer ; toute autre adresse reçoit une copie cachée (bcc). Un en-têteBccdu message n'est jamais lu. Mêmes plafonds que l'API (forfait de votre compte), et 100 destinataires au plus par transaction. - Expéditeur : l'en-tête
From, qui doit appartenir au domaine de la clé (sinon553). L'adresse d'enveloppe (MAIL FROM) est ignorée : Duva utilise sa propre adresse de retour. - Contenu : sujet, texte, HTML,
Reply-To, pièces jointes (images en ligne comprises, parContent-ID), avec les limites de l'API (10 pièces jointes, 5 Mo décodés en tout, extensions exécutables refusées, pas de pièces jointes en bac à sable). - En-têtes : ceux que l'API permet (voir « Corps de la requête »), 20 au plus ; les autres sont ignorés.
- Taille : 10 Mo au plus par message (
552). - Messages signés ou chiffrés (S/MIME, PGP/MIME) : refusés. Duva reconstruit chaque message, ce qui casserait la signature ; la signature DKIM est celle de Duva.
En-têtes propres à Duva
Ces en-têtes sont lus puis retirés : le destinataire ne les voit jamais.
| En-tête | Rôle |
|---|---|
X-Duva-Tags |
Étiquettes, séparées par des virgules. |
X-Duva-Metadata |
Métadonnées, sous forme d'objet JSON (valeurs en texte). |
X-Duva-Track |
opens, clicks ou les deux, séparés par une virgule (mêmes conditions que le suivi décrit plus bas). |
X-Idempotency-Key |
Clé d'idempotence. |
Idempotence et nouvelles tentatives
Sans X-Idempotency-Key, Duva en calcule une à partir du Message-ID et des destinataires : un logiciel qui renvoie le même
message après une coupure ne crée pas de doublon, et reçoit la même réponse. Le même Message-ID pour d'autres destinataires est
un autre message. Une réponse 4xx invite à réessayer ; une réponse 5xx est définitive pour ce message.
Réponses
250 signifie que le message est accepté pour livraison (il est en file), pas qu'il est livré : suivez son statut par l'API,
les événements ou les webhooks. L'identifiant msg_... est dans la réponse. Duva n'envoie aucun courriel de non-remise à
l'expéditeur : un rebond n'apparaît que dans les événements.
| Réponse | Signification |
|---|---|
250 |
Accepté pour livraison. |
451 |
Réessayez plus tard (limite de débit ou panne passagère). |
452 |
Trop de destinataires dans cette transaction. |
454 |
Trop d'essais d'authentification : attendez. |
530 |
Authentification (ou chiffrement) requise. |
535 |
Identifiants invalides (toute cause). |
550 |
Refusé : quota atteint (5.4.5), envoi impossible pour ce compte ou ce domaine (5.7.1), conflit d'idempotence (5.5.4). |
552 |
Message trop gros. |
553 |
Expéditeur hors du domaine de la clé, ou adresse invalide. |
554 |
Message refusé : contenu invalide (par exemple sans corps), destinataire invalide. |
Des limites de connexions et d'essais refusés s'appliquent par adresse IP (réponses 421 et 454) ; elles ne gênent pas un
usage normal.
Configurer votre logiciel
Essai en ligne de commande, avec 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 },
});
Pour les cadriciels qui ont une intégration officielle (voir les bibliothèques et intégrations), elle donne aussi accès aux étiquettes, aux métadonnées et au suivi sans écrire d'en-têtes. L'API reste le choix le plus riche : réponse immédiate avec le statut, lecture des messages et des événements.
Exemples de code
Des exemples complets, à copier : l'envoi d'un message et la vérification de la signature d'un webhook. La clé API est lue dans
la variable d'environnement DUVA_API_KEY (un simple usage, pas une exigence de Duva). Des
bibliothèques clientes officielles existent pour Node.js, Python, Ruby, Go et PHP ; pour tout autre langage,
aucune bibliothèque n'est requise : l'API est un simple POST JSON (exemple curl), et la
spécification OpenAPI permet de générer un client complet dans le langage de votre choix.
Envoyer un message
Chaque exemple envoie un message texte avec une clé d'idempotence, puis lit l'identifiant et le statut (202). Les
bibliothèques officielles (Node.js, Python, Ruby, Go, PHP) lèvent une exception (ou renvoient une erreur,
en Go) en cas d'erreur — voir
« Erreurs » — et réessaient automatiquement les échecs passagers pour cet appel ; l'exemple curl lit directement l'enveloppe
{"error": {"code", "message"}} (sur un 429, attendez Retry-After secondes avant de réessayer).
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}"
Vérifier la signature d'un webhook
Chaque bibliothèque officielle expose une fonction qui reçoit le secret (whsec_…), les en-têtes de la requête et le
corps brut exact, vérifie que la signature v1 est valide et que l'horodatage a moins de 5 minutes (protection contre
le rejeu), puis lève une exception (ou renvoie une erreur, en Go) si la vérification échoue — sinon elle renvoie l'événement
déjà décodé. Elle accepte plusieurs secrets (rotation) et plusieurs signatures dans l'en-tête, et compare en temps constant.
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
Lisez le corps brut, avant tout décodage JSON : le ré-encoder change les octets et invalide la signature. Par exemple :
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). Répondez 2xx seulement après avoir vérifié la signature, puis
dédupliquez sur webhook-id.
Santé
GET /health : 200 {"status": "ok"}, ou 503 si la base est injoignable. Sans authentification.
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"]
Historique
| Date | Changement |
|---|---|
| 2026-09-27 | Première publication de la spécification OpenAPI 3.1 (/openapi.json), couvrant toutes les routes ci-dessus. |
| 2026-10-05 | Changement de comportement, en vigueur dès cette date. Les destinataires d'un message voient désormais les autres destinataires to et cc (en-têtes To et Cc de chaque copie) ; avant, chaque copie ne montrait que son propre destinataire. N'envoyez plus plusieurs adresses dans un même to si elles ne doivent pas se voir : faites un envoi par destinataire, ou utilisez bcc. Les champs cc et bcc et le format Nom <adresse> sont ajoutés (voir « Destinataires »). |
Un changement compatible (nouveau champ, nouvelle valeur d'énumération, nouvelle route) n'est jamais annoncé ici : ignorez les champs et les valeurs inconnus plutôt que de les refuser. Un changement non compatible (champ retiré ou renommé, contrainte resserrée) serait annoncé ici au moins 6 mois avant que l'ancienne forme cesse d'être acceptée.