---
title: "API REST"
weight: 80
description: "Clés d'API, isolation, points d'entrée et webhooks signés de l'API /api/v1."
---

# 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`, `domain` ou `list` ;
- 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`, `unsubscribed` ou `removed` ;
- `delivery_mode` : `regular`, `digest` ou `nomail` ;
- `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_confirmation` ou `pending_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 un `force=true`) ;
- `403` : liste fermée à l'abonnement, ou droits insuffisants.

Les modes de livraison acceptés sont `regular`, `nomail` et `digest` (voir
[Listes](/difuzio/admin/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](/difuzio/admin/moderation) 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-link` répond **toujours** `202`, 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_counters` action
  `self_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 sur `GET /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`).
