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.
É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 destinataire (leTode chaque copie ne contient que lui) :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 »).
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, 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 -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"]
}'
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 bcc 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. Adresses seules (pas de Nom <adresse>). Dédoublonnées sans tenir compte de la casse. Maximum selon le forfait (5 en bac à sable). |
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. Demander le suivi 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. |
Les adresses dont la partie locale n'est pas en ASCII ne sont pas prises en charge. Le domaine est normalisé (minuscules, punycode).
La requête entière ne peut pas dépasser 4 Mo (413).
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'@" }] } }
| 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. |
| 422 | invalid_request |
Corps ou paramètre invalide ; fields désigne le champ (from, to[1], 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). |
| 500 | internal_error |
Erreur interne. |
L'authentification passe avant la validation : une clé fausse ne permet jamais de sonder les règles de validation.
GET /v1/{domaine}/messages/{id}
Statut d'un message et de chacun de ses destinataires.
{
"id": "msg_9f1c2d3e4a5b46c78d9e0f1a2b3c4d5e",
"status": "sent",
"from": "[email protected]",
"subject": "Votre soumission",
"tags": ["soumission"],
"tracking": { "opens": false, "clicks": false },
"created_at": "2026-09-19T14:03:21.512Z",
"recipients": [
{ "email": "[email protected]", "status": "delivered", "updated_at": "2026-09-19T14:03:24.101Z" },
{ "email": "[email protected]", "status": "sent", "updated_at": "2026-09-19T14:03:22.870Z" }
]
}
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 "https://api.duva.ca/v1/soumissio.ca/events?type=bounced&limit=20" \
-H "Authorization: Bearer dv_xxxxxxxxxx_..."
{
"data": [
{
"id": "evt_0b1c2d3e4f5a46b78c9d0e1f2a3b4c5d",
"type": "bounced",
"message_id": "msg_9f1c2d3e4a5b46c78d9e0f1a2b3c4d5e",
"recipient": "[email protected]",
"occurred_at": "2026-09-19T14:03:25.310Z",
"detail": { "code": 550, "enhanced_code": "5.1.1", "message": "user unknown",
"classification": "InvalidRecipient", "attempts": 0 }
}
],
"next_cursor": "MjAyNi0wOS0xOVQxNDowMzoyNS4zMTAr..."
}
| 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.
{ "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).
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.
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 -X POST https://api.duva.ca/v1/soumissio.ca/webhooks \
-H "Authorization: Bearer dv_xxxxxxxxxx_..." -H "Content-Type: application/json" \
-d '{ "url": "https://soumissio.ca/hooks/duva", "events": ["delivered", "bounced"] }'
201 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).
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.
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…","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 "https://api.duva.ca/v1/soumissio.ca/stats?granularity=day&since=2026-09-01T00:00:00Z" \
-H "Authorization: Bearer dv_xxxxxxxxxx_..."
{ "granularity": "day", "since": "2026-09-01T00:00:00.000Z", "until": "2026-09-19T20:00:00.000Z",
"data": [ { "period": "2026-09-19T00:00:00.000Z", "accepted": 120, "suppressed": 3, "delivered": 110,
"bounced": 4, "deferred": 9, "expired": 0, "complained": 1, "opened": 40, "clicked": 12 } ],
"totals": { "accepted": 120, "suppressed": 3, "delivered": 110, "bounced": 4, "deferred": 9, "expired": 0, "complained": 1, "opened": 40, "clicked": 12 } }
| 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.
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.
Santé
GET /health : 200 {"status": "ok"}, ou 503 si la base est injoignable. Sans authentification.