Aller au contenu

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êtes To et Cc y montrent les to et les cc du message, voir « Destinataires ») : sent signifie « remis au serveur d'envoi ». Les événements du serveur d'envoi font ensuite avancer chaque destinataire vers delivered (accepté par le serveur du destinataire) ou bounced (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 reste queued jusqu'à 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 to et les cc se voient. Toutes les copies portent les mêmes en-têtes To (tous les to) et Cc (tous les cc), y compris la copie d'un bcc.
  • Un bcc reste caché. Son adresse ne figure sur la copie d'aucun autre destinataire. Sa propre copie porte un en-tête Bcc qui ne contient que son adresse : il comprend ainsi pourquoi il reçoit le message.
  • Un seul plafond, pour le total. to, cc et bcc comptent 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 champ to si seul to est fourni, sinon sur recipients.
  • Les champs cc et bcc remplacent les en-têtes Cc et Bcc de headers, 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 id et l'en-tête Idempotent-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. 410 dé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-id identique, horodatage et signature neufs) ; dédupliquez sur webhook-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 :

  1. 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.
  2. 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 lien http(s)://… de la partie HTML est remplacé par un lien https://track.<domaine>/c/<jeton> qui redirige (302) vers l'adresse d'origine. Les liens mailto:, 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 opened ou clicked (detail.url pour un clic), visible dans GET /events, envoyé aux webhooks et compté par GET /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êtes To et Cc ne servent qu'à les classer ; toute autre adresse reçoit une copie cachée (bcc). Un en-tête Bcc du 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é (sinon 553). 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, par Content-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.