Menu de la documentation

L'API et le serveur MCP

Envoyer des e-mails, gérer vos abonnés et vos campagnes depuis votre code ou depuis un agent IA.

Plumail se pilote de l'extérieur : depuis votre application, depuis un script, ou depuis un agent comme Claude Code, ChatGPT ou Codex. Deux portes, la même clé.

Pour quiAdresse
API RESTDu code — n'importe quel langage sait faire une requête HTTP.https://plumail.fr/api/v1
Serveur MCPLes agents IA, qui découvrent seuls les outils disponibles.https://plumail.fr/api/mcp

Votre première clé

Dans votre espace Plumail : Réglages → API → Nouvelle clé. Vous lui donnez un nom et vous cochez ce qu'elle a le droit de faire.

La clé complète s'affiche une seule fois. Nous n'en gardons qu'une empreinte : si vous la perdez, personne ne peut vous la rendre — vous en créez une autre et vous coupez l'ancienne. C'est le prix à payer pour qu'un vol de notre base ne donne aucune clé utilisable, et c'est le bon prix.

Rangez-la comme un mot de passe : dans les variables d'environnement de votre service, jamais dans du code partagé ni dans une page publique.

Les droits d'une clé

DroitCe qu'il ouvre
emails:sendEnvoyer des e-mails unitaires, et lire leur état.
subscribers:readLire les abonnés et la liste de suppression.
subscribers:writeAjouter, modifier et désinscrire des abonnés.
campaigns:readLire les campagnes et leurs statistiques.
campaigns:writeCréer et envoyer des campagnes.

Ne cochez que le nécessaire. Un appel hors du périmètre de la clé répond 403, et rien ne le contourne — c'est le seul garde-fou qui tienne face à un agent autonome : on ne compte pas sur sa prudence, on lui retire le bouton.

Les droits se choisissent à la création et ne bougent plus. Une clé dont on peut élargir le périmètre après coup ne veut plus rien dire : celui qui l'a reçue croit détenir un accès en lecture et se retrouve avec le droit d'envoyer, sans avoir été prévenu.

Envoyer un e-mail

C'est le point d'entrée le plus utilisé : la facture, l'alerte, le mot de passe oublié — tout ce que votre application écrit à une personne à la fois.

curl -X POST https://plumail.fr/api/v1/emails \
  -H "Authorization: Bearer plm_live_…" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: facture-2026-0412" \
  -d '{
    "from": "Votre marque <[email protected]>",
    "to": "[email protected]",
    "subject": "Votre facture de septembre",
    "html": "<p>La voici.</p>"
  }'

La réponse arrive en 202 :

{
  "id": "cmu651xf200021n7nm68sikfw",
  "object": "email",
  "from": "[email protected]",
  "to": ["[email protected]"],
  "subject": "Votre facture de septembre",
  "created_at": "2026-09-18T07:12:44.102Z"
}

202, et pas 200 : notre fournisseur d'envoi a accepté le message, il n'est pas encore dans une boîte aux lettres. La remise se constate quelques secondes plus tard :

curl https://plumail.fr/api/v1/emails/cmu651xf200021n7nm68sikfw \
  -H "Authorization: Bearer plm_live_…"

Le champ status passe de sent à delivered, ou à bounced si l'adresse n'existe pas, ou à complained si la personne a signalé le message comme indésirable. Dans ces deux derniers cas, l'adresse entre automatiquement en liste de suppression : votre application n'a pas à s'en occuper.

Les cinq règles de tout envoi

Elles ne sont pas contournables, et ce sont les mêmes que pour une campagne envoyée depuis l'interface.

1. from doit être sur un domaine vérifié de votre espace. Sinon 422 unverified_from_domain, avec la liste de vos domaines vérifiés dans le message. GET /api/v1/me vous les donne aussi.

2. Une adresse en liste de suppression est refusée, avec son motif — désinscription, adresse morte, plainte. L'appel entier échoue, y compris les autres destinataires : un envoi partiel dont vous ne sauriez rien est le pire des résultats possibles, parce que vous croiriez avoir prévenu tout le monde.

3. Le quota mensuel de votre offre compte ces envois comme les campagnes. C'est le même compte d'envoi, la même facture.

4. Un taux de rebonds ou de plaintes trop élevé suspend l'envoi. Les seuils sont ceux d'Amazon : 5 % de rebonds, 0,1 % de plaintes. Une application qui écrit à des adresses inventées fait les mêmes dégâts qu'une campagne sur liste achetée.

5. Rien ne contourne le double opt-in. Un abonné ajouté par l'API reçoit une confirmation, sauf double_opt_in: false explicite — et c'est alors vous qui répondez du consentement.

Ne jamais envoyer deux fois

Une bibliothèque HTTP qui n'a pas reçu notre réponse rejoue l'appel. C'est son travail, et sans précaution votre client reçoit deux fois la même facture.

Ajoutez l'en-tête Idempotency-Key avec une valeur unique par envoi — le numéro de la facture, l'identifiant de la commande, un UUID :

Idempotency-Key: facture-2026-0412

Rejouer le même appel rend la même réponse, avec le même id, sans second envoi. L'en-tête Idempotent-Replay: true vous dit que c'est un rejeu. La même clé avec un corps différent répond 409 : ce n'est pas un réessai, c'est une erreur de votre côté, et vous renvoyer la réponse d'un autre envoi serait pire que de vous le dire.

Les abonnés

# Ajouter — la personne reçoit une confirmation et entre en « pending »
curl -X POST https://plumail.fr/api/v1/subscribers \
  -H "Authorization: Bearer plm_live_…" \
  -H "Content-Type: application/json" \
  -d '{"email":"[email protected]","firstName":"Marie","tags":["clients"]}'

# Lister, page par page
curl "https://plumail.fr/api/v1/subscribers?limit=50&status=subscribed" \
  -H "Authorization: Bearer plm_live_…"

# Désinscrire
curl -X DELETE https://plumail.fr/api/v1/subscribers/marie%40exemple.fr \
  -H "Authorization: Bearer plm_live_…"

DELETE n'efface pas la fiche : la personne passe en unsubscribed et son adresse entre en liste de suppression. Effacer la trace la ferait revenir au prochain import de fichier — on aurait respecté le verbe HTTP et trahi la personne.

Une adresse désinscrite ne se réinscrit pas par l'API. Seule la personne peut revenir, par un formulaire. Une désinscription qu'un programme peut annuler ne vaut rien.

La pagination

Les listes rendent { data, has_more, next_cursor }. Passez next_cursor en ?cursor= pour la page suivante.

Pas de numéro de page, volontairement : sur une liste où l'on écrit en même temps qu'on lit — ce qui est exactement le cas d'une API — page=2 saute des lignes et en montre d'autres deux fois. Le curseur, lui, ne bouge pas.

Les campagnes

Créer et envoyer sont deux gestes séparés. Ce n'est pas une lourdeur : c'est ce qui permet de faire relire une lettre avant qu'elle parte à trois mille personnes.

# 1. Le brouillon — rien ne part
curl -X POST https://plumail.fr/api/v1/campaigns \
  -H "Authorization: Bearer plm_live_…" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Lettre de septembre",
    "subject": "Ce que nous avons appris cet été",
    "text": "# Bonjour\n\nVoici les nouvelles du mois."
  }'

# 2. L'envoi — irréversible
curl -X POST https://plumail.fr/api/v1/campaigns/CAMP_ID/send \
  -H "Authorization: Bearer plm_live_…"

# 3. Le suivi
curl https://plumail.fr/api/v1/campaigns/CAMP_ID \
  -H "Authorization: Bearer plm_live_…"

Dans text, une ligne vide sépare deux paragraphes et # en début de ligne fait un titre. Pour la mise en page complète (images, boutons, séparateurs), passez content avec les blocs de l'éditeur.

L'envoi répond 202 avec queued : les destinataires sont figés, les messages partent ensuite au débit autorisé par notre fournisseur. Un envoi de cinquante mille e-mails ne tient pas dans une requête HTTP, et prétendre le contraire vous donnerait un « envoyé » pour un travail qui commence à peine.

Les taux d'ouverture et de clic sont calculés sur les messages délivrés, jamais sur le nombre de destinataires : une adresse morte ne doit pas faire baisser le taux de ceux qui ont bien reçu.

Le rapport complet

GET /api/v1/campaigns/CAMP_ID/stats rend tout ce que la fiche campagne de Plumail montre, pour l'afficher dans votre propre logiciel — un CRM, un tableau de bord :

{
  "campaign": { "id": "…", "name": "…", "subject": "…", "status": "sent", "kind": "newsletter",
                "fromName": "…", "fromEmail": "…", "sentAt": "…", "scheduledAt": null, "updatedAt": "…" },
  "counts": { "recipients": 66, "delivered": 60, "opened": 30, "clicked": 10,
              "bounced": 6, "complained": 1, "unsubscribed": 0 },
  "rates": { "delivered": 0.9091, "opened": 0.5, "clicked": 0.1667, "bounced": 0.0909 },
  "timeline": [ { "label": "+0h", "opened": 12, "clicked": 5 }, … ],
  "audience": { "total": 30, "proxiedShare": 0.4,
                "device": [ { "label": "Téléphone", "count": 15, "share": 0.5 }, … ],
                "os": [ … ], "client": [ … ] },
  "links": [ { "url": "https://…", "clicks": 6 }, … ],
  "html": "<!doctype html>…"
}

Les parts (rates, share, proxiedShare) sont entre 0 et 1, et null tant qu'il n'y a rien à diviser. timeline compte les ouvertures et les clics par tranche de six heures pendant les 48 premières heures après l'envoi — vide tant que la campagne n'est pas partie. audience ne garde que les cinq premières lignes de chaque répartition ; proxiedShare est la part des ouvertures venues d'un relais de confidentialité (Apple Mail, Gmail) : reçues, pas forcément lues. links va du plus au moins cliqué. html est le message tel qu'il est parti, chaîne vide sinon.

Les erreurs

Toujours la même forme, avec deux lectures possibles du même contenu :

{
  "error": {
    "code": "unverified_from_domain",
    "message": "Le domaine « exemple.fr » n'est pas vérifié dans cet espace. Domaines vérifiés : votredomaine.fr.",
    "details": { "from": "[email protected]", "verifiedDomains": ["votredomaine.fr"] }
  },
  "statusCode": 422,
  "message": "Le domaine « exemple.fr » n'est pas vérifié dans cet espace. Domaines vérifiés : votredomaine.fr.",
  "name": "unverified_from_domain"
}

error est la forme de Plumail : structurée, avec dans details ce qu'il faut pour corriger. Les trois champs à plat — statusCode, message, name — sont ceux de Resend, et ils sont là pour que du code écrit contre l'ancienne API affiche un message juste sans être relu.

Écrivez votre logique contre code (ou name, c'est la même valeur), jamais contre message. Le message est fait pour être lu par un humain, et nous nous autorisons à le reformuler.

CodeStatutCe que ça veut dire
missing_api_key401Aucun en-tête Authorization.
invalid_api_key401Clé inconnue.
revoked_api_key401Clé coupée dans les réglages.
insufficient_scope403La clé n'a pas le droit demandé.
reputation_blocked403Vos envois sont suspendus : trop de rebonds ou de plaintes.
quota_exceeded402Le quota mensuel de l'offre est atteint.
not_found404L'objet n'existe pas dans cet espace.
conflict409État incompatible : campagne déjà partie, abonné sorti.
idempotency_key_reused409Même Idempotency-Key, corps différent.
validation_error422Un champ est absent ou mal formé.
unverified_from_domain422Le domaine de from n'est pas vérifié.
suppressed_recipient422Un destinataire est en liste de suppression.
rate_limit_exceeded429Plus de 600 requêtes par minute (voir Retry-After).
send_failed502Notre fournisseur d'envoi a refusé le message.
internal_error500Une panne de notre côté.

Limite de débit

600 requêtes par minute et par clé. Chaque réponse porte X-RateLimit-Limit, X-RateLimit-Remaining et X-RateLimit-Reset ; un refus porte en plus Retry-After, en secondes.

Besoin de plus ? Écrivez-nous : on regarde votre cas plutôt que de vous laisser réessayer en boucle.

Le serveur MCP

MCP — Model Context Protocol — est la façon dont un agent IA découvre les outils d'un logiciel et s'en sert. Plumail expose un serveur MCP hébergé : rien à installer, une adresse et votre clé.

Claude Code

claude mcp add plumail \
  --transport http \
  --url https://plumail.fr/api/mcp \
  --header "Authorization: Bearer plm_live_…"

ChatGPT, Cursor, Codex, Claude Desktop — tous lisent la même fiche de connecteur :

{
  "mcpServers": {
    "plumail": {
      "type": "http",
      "url": "https://plumail.fr/api/mcp",
      "headers": { "Authorization": "Bearer plm_live_…" }
    }
  }
}

Puis, dans votre agent : « Quel espace Plumail vois-tu, et quels domaines d'envoi sont vérifiés ? ». Il appellera get_account, qui ne modifie rien — c'est la bonne façon de vérifier un branchement.

Les outils exposés

OutilCe qu'il fait
get_accountL'espace, les droits, le quota restant, les domaines vérifiés.
send_emailEnvoie un e-mail unitaire. Irréversible.
get_emailL'état d'un e-mail parti.
list_subscribersListe les abonnés, page par page.
add_subscriberAjoute ou met à jour un abonné.
remove_subscriberDésinscrit et écarte l'adresse. Irréversible.
list_suppressionLes adresses qui ne recevront plus rien.
add_suppressionÉcarte une adresse. Irréversible.
list_campaignsLes campagnes de l'espace.
create_campaignCrée un brouillon. Rien ne part.
send_campaignEnvoie à tous les abonnés actifs. Irréversible.
get_campaign_statsChiffres et état d'une campagne.

Chaque outil est un appel à l'API ci-dessus, rien de plus : mêmes droits, même quota, même liste de suppression, mêmes refus. Un second chemin d'accès avec sa propre logique serait un second jeu de règles, et le jour où l'un des deux changerait, MCP deviendrait la porte de service.

Aucun outil ne retire une adresse de la liste de suppression. C'est le seul geste du produit qui fait suspendre une capacité d'envoi, et un agent à qui l'on dirait « nettoie la liste » le ferait sans hésiter. Il se fait à la main, dans votre espace.

La ressource plumail://docs donne à l'agent la référence complète : il n'a pas besoin de la connaître à l'avance.

Ce qui change si vous venez de Resend

Les champs de POST /v1/emails et la réponse { id } sont les mêmes. En pratique : l'adresse de base et la clé.

Deux façons de basculer.

Avec le client minimal — un fichier à copier, aucune dépendance, la même signature que le SDK de Resend. Récupérez-le : plumail.fr/plumail-client.ts.

// avant
const resend = new Resend(process.env.RESEND_API_KEY);

// après
const plumail = new Plumail(process.env.PLUMAIL_API_KEY);

// le reste de votre code ne bouge pas
const { data, error } = await plumail.emails.send({ from, to, subject, html, text });
if (error) throw new Error(`Email delivery failed: ${error.message}`);
return { providerId: data?.id ?? null };

Il ne lève jamais : une panne réseau devient elle aussi un error, avec name: "network_error". C'est voulu — une méthode qui lèverait là où l'ancienne rendait un objet transformerait « changer deux lignes » en « relire chaque appel », et les appels qu'on oublie de relire sont justement les chemins d'erreur.

Sans rien copier — un fetch nu suffit :

const res = await fetch("https://plumail.fr/api/v1/emails", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.PLUMAIL_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({ from, to, subject, html }),
});
const body = await res.json();
if (!res.ok) throw new Error(body.message); // le message est en français, lisible
const id = body.id;

Trois différences à connaître, toutes assumées :

  • Le domaine de from doit être vérifié dans votre espace Plumail, pas chez Resend. Ajoutez-le dans Domaines et posez les enregistrements DNS.
  • La liste de suppression est appliquée aux envois unitaires. Resend ne le fait pas. Une adresse qui s'est désinscrite de votre newsletter ne recevra pas non plus vos e-mails transactionnels depuis le même espace — si ce n'est pas ce que vous voulez, séparez les deux dans deux espaces.
  • Le quota mensuel est partagé avec vos campagnes.

Où vivent ces e-mails

Les e-mails partis par l'API ne rejoignent pas vos campagnes : ils vivent à part, et ce n'est pas un détail technique.

Un destinataire de facture n'est pas un abonné. Le ranger avec vos abonnés l'aurait inscrit dans votre liste sans qu'il ait jamais consenti à recevoir votre newsletter — compté sur votre tableau de bord, et ciblé par votre prochaine campagne. Vos chiffres d'abonnés restent donc ceux de vos vrais abonnés.

Ce qui est partagé, en revanche : le quota mensuel, la liste de suppression, et la surveillance des rebonds. Ce sont les trois choses qui engagent votre réputation d'expéditeur, et elle est la même des deux côtés.

La description technique

Le fichier OpenAPI 3.1 est servi tel quel : plumail.fr/openapi.json. Il décrit chaque point d'entrée, chaque champ et chaque erreur — de quoi engendrer un client dans votre langage, ou le donner à un agent.