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.

É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 (le To de chaque copie ne contient que lui) : 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 »).

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 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'@" }] } }
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. 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 "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 :

  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.

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.

Santé

GET /health : 200 {"status": "ok"}, ou 503 si la base est injoignable. Sans authentification.