API REST
difuzio expose une API REST sous /api/v1, servie par le même processus que
l'interface web (serve-web). Elle partage la couche service et donc les mêmes
règles d'autorisation : le comportement est identique quel que soit le point
d'entrée.
Authentification
L'API accepte deux justificatifs, un seul à la fois par requête.
Clé d'API au format dfz_<prefix>_<secret>, transmise soit dans l'en-tête
Authorization: Bearer dfz_..., soit dans X-API-Key: dfz_.... Une clé est :
- liée à un utilisateur (ses droits bornent ceux de la clé) ;
- attachée à un périmètre (
scope_kind) :user,domainoulist; - dotée d'un jeu de permissions (
perms) :read,members:write,send,moderate,manage; - éventuellement datée d'expiration, et révocable à tout moment ;
- soumise à son quota
rate_limit_per_min(dépassement :429).
Cookie de session de l'interface web (difuzio_session), utilisé par la
console d'administration : c'est la même session que les pages HTML, donc un
rôle révoqué prend effet immédiatement. Dans ce mode, toute requête mutante
(POST, PATCH, DELETE) doit renvoyer le secret synchroniseur de la session dans
l'en-tête X-CSRF-Token, sans quoi elle est refusée par 403 csrf_failed. Une
requête portant une clé d'API ne présente aucun justificatif ambiant : elle
reste exemptée de CSRF. Le cookie et le secret s'obtiennent par
POST /api/v1/auth/login.
Un justificatif absent, inconnu, révoqué, expiré, ou dont l'utilisateur n'est
plus actif, est rejeté par un 401 uniforme ; le motif précis est
journalisé côté serveur et jamais renvoyé au client, pour ne pas offrir un
oracle d'énumération.
Création de clé
Une clé se frappe en ligne de commande avec create-api-key, ou par
POST /api/v1/apikeys. Dans les deux cas le secret en clair
dfz_<prefix>_<secret> n'est affiché qu'une seule fois (seul un hash est stocké) :
difuzio create-api-key --email u@example.org --scope user \
--perms read,members:write,send,moderate,manage --name "ci"
--scope vaut user, domain ou list (avec --domain/--list selon le cas).
--perms est une liste séparée par des virgules parmi read, members:write,
send, moderate, manage. Une clé ne peut jamais être plus large que
l'utilisateur qui la porte.
Isolation
L'API applique la même règle d'isolation que le reste du produit : une ressource
hors du périmètre de la clé renvoie 404 Not Found (et non 403), pour ne pas
révéler son existence.
Enveloppes de réponse
Toutes les réponses sont en JSON et enveloppées :
-
succès :
{ "data": ..., "meta": { "request_id": "...", "api_version": "v1" } } -
erreur :
{ "error": { "code": "...", "message": "..." }, "meta": { ... } }
Chaque erreur est journalisée (avec request_id, route, statut, code) avant
d'être écrite : aucune erreur n'est silencieuse.
Une collection ajoute ses compteurs de pagination à meta :
{ "data": [ ... ], "meta": { "request_id": "...", "api_version": "v1",
"total": 128, "limit": 50, "offset": 0 } }
Toutes les collections se paginent avec les deux mêmes paramètres, limit
(1 à 500, défaut 50) et offset (défaut 0) ; total compte les lignes qui
satisfont le filtre, indépendamment de la fenêtre. Une valeur hors bornes est
refusée par un 422 explicite, jamais ramenée silencieusement dans les bornes :
un limit rogné ferait croire au client qu'il a lu toute la page.
Statuts communs : 400 corps mal formé ou trop gros, 401 non authentifié,
403 permission absente ou CSRF, 404 inconnu ou hors périmètre, 409 conflit,
413 document trop volumineux, 422 valeur invalide, 429 quota, 500 erreur
interne. Un identifiant de chemin qui n'est pas un entier positif est un 422
sur toutes les routes.
Points d'entrée
Sonde de santé
GET /api/v1/system/health
Non authentifiée. Renvoie {"data":{"status":"ok"}, ...}.
Identité du binaire en service
GET /api/v1/system/version
Authentifiée (clé d'API ou session), sans permission particulière : c'est la version du binaire qui répond, pas une donnée de périmètre. Elle n'est PAS publique, une version en clair servie à un anonyme étant une empreinte offerte à qui cherche une release vulnérable connue.
{
"data": {
"version": "0.5.0-dev",
"commit": "26e2218...",
"dirty": false,
"built_at": "2026-08-11T10:16:38Z",
"commit_at": "2026-08-11T10:00:33Z",
"go_version": "go1.23.5",
"platform": "linux/amd64",
"backend": "MariaDB >= 10.6"
}
}
built_at date l'édition de liens et n'est renseigné que si la compilation l'a
injecté ; commit_at date le commit source et vient de l'empreinte que Go pose
lui-même quand le binaire est compilé depuis un dépôt git. commit est vide si
le binaire ne porte aucune de ces deux traces. C'est ce point d'entrée que la
console interroge pour afficher la version du serveur en pied de barre latérale.
Lister les listes d'un domaine
GET /api/v1/domains/{domaine}/lists
Nécessite la permission read. Renvoie un tableau des listes du domaine
visibles par la clé (id, name, display_name, type, status,
send_suspended). Une clé au périmètre list ne voit que sa liste ; une clé
au périmètre user voit les listes que son porteur gère (propriétaire,
modérateur ou domain_admin du domaine). Un domaine hors du périmètre de la
clé renvoie 404 (règle d'isolation).
Lister les abonnés d'une liste
GET /api/v1/domains/{domaine}/lists/{liste}/members
Nécessite la permission read et la capacité de voir les abonnés sur cette liste.
Renvoie un tableau d'abonnés triés par id croissant (ordre stable, calculé par
le serveur, donc la pagination ne remélange jamais les lignes). Chaque abonné
porte : id, email, display_name, status, delivery_mode, bounce_score,
subscribed_at, last_bounce_at, disabled_reason, disabled_at,
consent_source, consent_at.
Les champs qui peuvent ne pas être renseignés valent null, jamais une chaîne
vide ni une date à zéro : last_bounce_at: null signifie "jamais rebondi" et se
distingue d'un score nul. Les preuves de consentement (consent_ip,
identifiant du jeton, texte de consentement, consent_evidence) ne sont jamais
exposées : seul le marqueur de base légale (consent_source, consent_at) est
renvoyé, ce dont un administrateur a besoin à l'écran (minimisation RGPD).
Paramètres de requête, tous appliqués en SQL (le total de l'enveloppe est
donc bien le nombre d'abonnés filtrés, pas la taille de la page) :
limit,offset: pagination (défaut 50, maximum 500) ;status:pending,active,disabled,unsubscribedouremoved;delivery_mode:regular,digestounomail;min_bounce_score: ne garde que les abonnés dont le score de rebond atteint ce plancher (tri des adresses qui abîment la délivrabilité) ;email_contains: sous-chaîne de l'adresse, insensible à la casse, 2 caractères minimum ; les jokers%et_sont cherchés littéralement.
Une valeur invalide (statut inconnu, plancher non numérique, recherche d'un seul
caractère) renvoie 422 avec la raison : un filtre ignoré silencieusement
laisserait croire qu'aucun abonné ne correspond.
Consulter un abonné
GET /api/v1/domains/{domaine}/lists/{liste}/members/{membre}
Nécessite la permission read et la capacité de voir les abonnés. {membre}
est l'identifiant numérique ou l'adresse encodée dans l'URL. Renvoie le même
objet abonné que la liste. Un abonné d'une autre liste renvoie 404 (règle
d'isolation).
Ajouter / abonner un membre
POST /api/v1/domains/{domaine}/lists/{liste}/members
Nécessite la permission members:write. Corps JSON :
{
"email": "personne@example.org",
"name": "Nom affiché",
"delivery_mode": "regular",
"force": false,
"consent_source": "api",
"consent_evidence": "trace de consentement"
}
Réponses :
201: abonné actif ({"status":"active","member_id":...}) ;202: en attente (pending_confirmationoupending_moderation) selon la politique d'abonnement de la liste ;409: déjà abonné actif ;422: validation (mode de livraison inconnu, ou preuve de consentement manquante sur unforce=true) ;403: liste fermée à l'abonnement, ou droits insuffisants.
Les modes de livraison acceptés sont regular, nomail et digest (voir
Listes).
Le header Idempotency-Key (sur POST .../members et POST .../sends) rend le
double-envoi sûr : la clé est réservée atomiquement avant l'exécution, donc deux
requêtes concurrentes ne s'exécutent jamais deux fois (la seconde reçoit 409 si
la première est en cours, ou rejoue la réponse stockée une fois finalisée). La
même clé avec un corps différent renvoie 409 idempotency_conflict. La réponse
est rejouée pendant 24 h (Idempotent-Replay: true), puis la clé expire. Le
registre de rejeu est indexé par clé d'API : une requête authentifiée par cookie
de session n'a nulle part où enregistrer sa réservation, un Idempotency-Key y
est donc refusé explicitement (422 idempotency_unsupported) plutôt que d'être
ignoré en silence, ce qui ferait croire à une déduplication inexistante.
Créer un webhook
POST /api/v1/webhooks
Nécessite la permission manage. Corps JSON :
{ "url": "https://...", "events": ["member.added"], "domain_id": 1, "list_id": 0 }
L'URL passe par une garde anti-SSRF (HTTPS obligatoire, résolution DNS et liste
d'exclusion d'adresses internes) ; une URL refusée renvoie 422. En cas de
succès, 201 avec {"id":..., "secret":...} (le secret de signature, à
conserver).
Les événements reconnus (webhooks.events) sont :
member.added, member.removed, member.bounced, subscription.pending,
subscription.confirmed, send.submitted, send.completed, send.failed,
message.held, list.suspended.
Les webhooks se listent (GET /api/v1/webhooks), se suppriment
(DELETE /api/v1/webhooks/{id}) et se testent (POST /api/v1/webhooks/{id}/test,
qui envoie un ping signé).
Livraison des webhooks
Le worker de livraison émet chaque événement par un POST signé
X-Difuzio-Signature: sha256=hmac(secret, body) (plus X-Difuzio-Event), en
at-least-once avec backoff, et journalise les échecs. La livraison passe par un
dialer durci : HTTPS seulement, IP re-résolue à l'envoi et vérifiée contre la
liste d'exclusion (RFC1918, loopback, link-local/metadata, CGNAT 100.64/10, ULA
IPv6, NAT64 64:ff9b::/96), anti DNS-rebinding, sans suivi de redirection. Une
livraison bloquée (worker mort en plein POST) est réenfilée automatiquement.
Les dix événements sont émis : les huit de cycle de vie (member.added,
member.removed, member.bounced, subscription.pending,
subscription.confirmed, send.submitted, message.held, list.suspended) et
les deux d'agrégat d'envoi (send.completed, send.failed), ces derniers une
fois que toutes les copies d'un envoi ont atteint un état terminal (tâche
périodique du worker, émission exactement une fois via la table send_events).
Endpoints d'envoi et de modération
La permission send ouvre les endpoints d'envoi, moderate ceux de modération :
POST /api/v1/domains/{domaine}/lists/{liste}/sends (send) submit
GET /api/v1/domains/{domaine}/lists/{liste}/sends/{send} (read) statut
POST .../sends/{send}/cancel (send) annulation
GET/POST .../moderation/messages|subscriptions[...] (moderate)
POST /sends soumet un message (corps {subject, text, html}) qui passe par le
MÊME pipeline que les posts LMTP ; la réponse 202 porte le send_id et une
estimation de destinataires. GET /sends/{id} renvoie les comptes par état ;
POST /sends/{id}/cancel annule les copies encore en file (409 si rien à
annuler). Voir Modération pour les endpoints de modération.
Endpoints d'administration
Ces points d'entrée couvrent tout ce que la ligne de commande sait faire en
administration ; ils sont consommés par la console web, mais restent utilisables
avec une clé d'API. La permission indiquée est le minimum exigé de la clé ; le
rôle de l'utilisateur porteur borne toujours la portée réelle, et une ressource
hors périmètre répond 404.
POST /api/v1/auth/login (aucune) ouvre une session
POST /api/v1/auth/logout (aucune) ferme la session
GET /api/v1/auth/session (auth) profil, rôle, portées, capacités
GET /api/v1/dashboard (read) synthèse adaptée au rôle
GET /api/v1/system/version (auth) version, révision et date du binaire
GET /api/v1/system/queue (read) profondeur de la file sortante
GET /api/v1/audit (read) journal d'audit, filtré et paginé
GET /api/v1/domains (read) domaines à portée, filtre ?status=
POST /api/v1/domains (manage) création (état pending_mta)
GET /api/v1/domains/{domaine} (read) détail et valeurs par défaut
PATCH /api/v1/domains/{domaine} (manage) mise à jour partielle (réglages et statut)
POST /api/v1/domains/{domaine}/dns-check (manage) relance la vérification SPF / DKIM / _dmarc
POST /api/v1/domains/{d}/lists (manage) création avec son propriétaire
GET /api/v1/domains/{d}/lists/{l} (read) détail et politiques
PATCH /api/v1/domains/{d}/lists/{l} (manage) mise à jour partielle des politiques
POST /api/v1/domains/{d}/lists/{l}/suspend (manage) coupe-circuit de diffusion
POST /api/v1/domains/{d}/lists/{l}/resume (manage) reprise de la diffusion
POST /api/v1/domains/{d}/lists/{l}/members/import (members:write) import CSV
GET /api/v1/domains/{d}/lists/{l}/members/export (read) export CSV
GET /api/v1/domains/{d}/lists/{l}/credentials (read) comptes de messagerie (sans mot de passe)
PUT /api/v1/domains/{d}/lists/{l}/credentials/{kind} (manage) enregistrement d'un compte
DELETE /api/v1/domains/{d}/lists/{l}/credentials/{kind} (manage) suppression d'un compte
POST /api/v1/domains/{d}/lists/{l}/credentials/{kind}/test (manage) tentative de connexion réelle
GET /api/v1/users (read) comptes à portée
POST /api/v1/users (manage) création (sans mot de passe = invitation)
GET /api/v1/users/{id} (read) compte et rôles détenus
PATCH /api/v1/users/{id} (manage) active / disabled
POST /api/v1/users/{id}/password (manage) nouveau mot de passe (204)
GET /api/v1/users/{id}/roles (read) rôles visibles par l'appelant
POST /api/v1/users/{id}/roles (manage) attribution d'un rôle
DELETE /api/v1/users/{id}/roles (manage) révocation (corps JSON)
GET /api/v1/apikeys (manage) clés administrables
POST /api/v1/apikeys (manage) frappe d'une clé (secret montré une fois)
DELETE /api/v1/apikeys/{id} (manage) révocation, idempotente
GET /auth/session renvoie le profil, le rôle effectif, les domaines et listes à
portée et les drapeaux de capacité : la console s'en sert pour masquer ce que le
rôle ne permet pas, mais le serveur reste le seul juge et revérifie chaque
opération.
PATCH .../lists/{l} est partiel : seuls les champs présents sont appliqués, les
autres gardent leur valeur. Une chaîne vide sur subject_tag, footer_text ou
footer_html efface la valeur.
Vérification DNS d'un domaine
POST /api/v1/domains/{domaine}/dns-check relance la vérification SPF, sélecteur
DKIM et _dmarc du domaine, exactement celle de difuzio domain check-dns (même
code partagé, donc jamais de divergence entre la ligne de commande et la
console). Le verdict est enregistré sur le domaine (dns_ok, dns_checked_at)
AVANT d'être renvoyé : une relecture de GET /api/v1/domains juste après montre
donc le même état.
La réponse ne se limite pas à un booléen : elle porte un verdict par
enregistrement (records), avec le nom interrogé, ce qui a été trouvé (found),
ce qui était attendu (expected) et, quand la résolution elle-même a échoué, le
message du résolveur (error) -- c'est ce qui distingue une zone injoignable d'un
enregistrement simplement absent. Une ligne checked: false signale un contrôle
qui n'a pas eu lieu (DKIM sur un domaine sans sélecteur configuré) : ce n'est pas
un échec et ne doit pas être affiché comme tel.
L'autorisation est celle des autres mutations de domaine : qui administre le
domaine. Hors de portée, 404 (jamais 403, qui révélerait l'existence du
domaine) ; à portée mais sans l'autorité, 403. Les deux sont journalisés et
audités.
Comme l'appel fait résoudre le serveur à la demande, il est borné : délai maximal
par requête, et limitation de débit par domaine vérifié et par appelant. Au-delà,
429 rate_limited -- il ne s'agit pas d'une panne, mais du garde-fou qui
empêche de marteler les serveurs de noms d'un tiers.
Comptes de messagerie d'une liste
{kind} vaut ingest (boîte IMAP relevée) ou submission (compte SMTP d'envoi) ;
toute autre valeur répond 404. Ces routes ne concernent que les installations
qui relaient par la boîte d'un hébergeur.
Le mot de passe est en écriture seule : aucune réponse ne le renvoie, quelle que
soit la route. Sur un PUT visant un compte existant, omettre le champ
password conserve celui déjà enregistré ; l'envoyer vide est refusé (422)
plutôt qu'interprété comme un effacement.
POST .../test ouvre une vraie session (connexion, TLS, authentification, et
ouverture du dossier pour l'IMAP) sans envoyer ni relever de message. Un refus de
l'hébergeur répond 422 en reprenant son message : c'est ce qui distingue un mot
de passe erroné d'un port bloqué. Le résultat est enregistré et relu par
GET .../credentials (last_ok_at, last_error, last_error_at).
Une installation sans security.credential_keyring répond 503
credential_keyring_missing sur les écritures : elle ne peut pas chiffrer, et
n'enregistrera pas un mot de passe en clair. Un hôte qui ne résout pas vers une
adresse publique est refusé (422) : un administrateur de domaine ne doit pas
pouvoir sonder le réseau interne depuis le bouton de test.
Import et export CSV des abonnés
L'import accepte deux formes de corps : le document seul
(Content-Type: text/csv, en-tête email,display_name,delivery_mode), ou un
envoi multipart/form-data portant le document dans une partie file. La preuve
de consentement RGPD est obligatoire : soit le paramètre de requête
consent_evidence, soit un champ consent_evidence placé AVANT la partie file
(les parties sont lues dans l'ordre). Sans elle, 422.
La réponse est un rapport {total, added, skipped, errors, mode_coerced, error_samples} : chaque ligne est validée pour elle-même, donc une ligne fautive
est comptée, pas fatale. Le document est plafonné à 25 Mio ; au-delà, 413 -- et
le message le dit explicitement : les lignes lues avant la coupure ONT été
importées, il faut découper le document et réimporter le reste.
L'export répond en text/csv (et non dans l'enveloppe JSON), en flux, avec les
colonnes email,display_name,delivery_mode,status,subscribed_at. Les trois
premières sont exactement celles que l'import relit : un export se réimporte tel
quel. Les filtres status et delivery_mode sont acceptés.
Libre-service de l'abonné
Les routes /api/v1/me/... sont les seules dont le porteur n'est ni une clé
d'API ni une session d'administration : elles acceptent uniquement la session
d'abonné, ouverte par un lien à usage unique envoyé à l'adresse elle-même
(cookie difuzio_subscriber, schéma de sécurité subscriberCookie dans le
document OpenAPI). Cette session ne porte aucune autorité d'administration et
n'est acceptée par aucune autre route ; réciproquement, une clé d'API ou une
session d'administration est refusée ici (401).
POST /api/v1/me/login-link (aucune) envoie le lien magique
POST /api/v1/me/session (aucune) consomme le jeton du lien
GET /api/v1/me/session (abonné) adresse, expiration, jeton CSRF
POST /api/v1/me/logout (aucune) ferme la session
GET /api/v1/me/subscriptions (abonné) ses abonnements
PATCH /api/v1/me/subscriptions/{list_id} (abonné) change le mode de réception
DELETE /api/v1/me/subscriptions/{list_id} (abonné) se désabonne
Points à connaître en exploitation :
POST /me/login-linkrépond toujours202, adresse connue ou non. La réponse ne doit jamais varier : elle deviendrait un oracle listant qui est abonné où. Le résultat réel est journalisé côté serveur.- Cette même route est limitée en débit par adresse (3 par heure) et par source
(5 par minute, 20 par heure), sur le compteur
rate_countersactionself_service, faute de quoi elle servirait de canon à courrier contre un tiers. Dépassement :429. - Le jeton du lien est à usage unique et expire au bout d'une heure. La session qui en résulte glisse d'une heure d'inactivité, avec un plafond absolu de douze heures.
- La portée d'une session d'abonné est une adresse, appliquée en SQL : le
client ne transmet jamais que l'identifiant de liste, jamais l'adresse. Une
liste où l'adresse n'est pas abonnée répond
404, comme une liste inconnue. - Les mutations exigent le jeton CSRF de la session dans l'en-tête
X-CSRF-Token, lisible surGET /me/session.
Les mêmes opérations existent en pages web sous /p/me (voir le guide de
l'abonné) : les deux surfaces appellent la même couche service.
Document OpenAPI
Le contrat est servi en OpenAPI 3.1 sur GET /api/v1/openapi.json (généré depuis
le registre de routes ; un test CI échoue si le document committé docs/openapi.json
diverge du généré).
Chaque route y nomme un schéma de requête et un schéma de réponse : le document
est la source des types TypeScript de la console d'administration, un schéma
approximatif dégraderait tout le client. Les deux seules réponses hors enveloppe
JSON sont le document lui-même et l'export CSV (text/csv).