Plumail
通过代理运行Plumail邮件工作区:订阅者、细分、营销活动、事务性发送、投递统计和抑制列表。托管远程服务器,欧盟托管,作用域API密钥。
托管 MCP 服务器
npx add-mcp 'https://plumail.fr/api/mcp'可安装到 Claude Code、Codex、Cursor 等客户端
文档
L'API REST et le serveur MCP Mailcheer: envoi d'e-mails, gestion des abonnés et des campagnes depuis votre application ou un agent IA. Référence complète.
Mailcheer se pilote de l'extérieur: depuis votre application, depuis un script, ou depuis un agent comme Claude Code, Codex ou Cursor. Deux portes, la même clé.
| Pour qui | Adresse | |
|---|---|---|
| API REST | Du code — n'importe quel langage sait faire une requête HTTP. | https://mailcheer.com/api/v1 |
| Serveur MCP | Les agents IA, qui découvrent seuls les outils disponibles. | https://mailcheer.com/api/mcp |
Votre première clé
Dans votre espace Mailcheer: Compte → API & agents IA → 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.
Deux sortes de clés: une clé réelle (mch_live_…) envoie pour de vrai; une clé de test (mch_test_…) vérifie tout et n'envoie rien — voir Le mode test.
Les droits d'une clé
| Droit | Ce qu'il ouvre |
|---|---|
emails:send | Envoyer des e-mails unitaires, et lire leur état. |
subscribers:read | Lire les abonnés et la liste de suppression. |
subscribers:write | Ajouter, modifier et désinscrire des abonnés. |
campaigns:read | Lire les campagnes et leurs statistiques. |
campaigns:write | Créer et envoyer des campagnes, supprimer un brouillon. |
webhooks:read | Lire les abonnements aux événements et leur journal. |
webhooks:write | Créer, modifier et supprimer des abonnements aux événements. |
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.
Le plafond et la langue d'une clé
Deux réglages d'une clé peuvent changer après sa création — Réglages → API, Régler —, parce qu'aucun des deux ne donne plus de pouvoir à qui la détient.
Le plafond mensuel (facultatif): « cette clé ne peut pas dépasser N e-mails par mois ». Il compte les e-mails de la clé et les campagnes qu'elle lance (POST /api/v1/campaigns/{id}/send), chaque adresse copies comprises, ce qui attend en file compris, sur le mois UTC — le mois du quota. Il s'ajoute au quota de l'espace, il ne le remplace jamais: il empêche un usage de manger la place d'un autre. Un CRM qui envoie ses codes de connexion et sa newsletter depuis le même espace plafonne la clé de la newsletter, et les codes ont toujours de la place.
Au-delà, l'envoi est refusé en entier avant que rien ne parte — 402 key_quota_exceeded, distinct du quota_exceeded de l'espace: une autre clé de l'espace peut encore envoyer.
{
"error": {
"code": "key_quota_exceeded",
"message": "Plafond de la clé atteint : la clé « Newsletter » ne peut pas envoyer plus de 2 400 e-mails par mois. …",
"details": {
"key_id": "cmu8k1a2b00001n7nkey0news",
"key_name": "Newsletter",
"limit": 2400,
"used": 2380,
"remaining": 20,
"requested": 551,
"resets_at": "2026-10-01T00:00:00.000Z"
}
},
"statusCode": 402,
"message": "…",
"name": "key_quota_exceeded"
}
Le dry_run d'une campagne l'annonce de la même façon (would_send: false, blocked_code: "key_quota_exceeded"). Une clé plafonnée porte aussi trois en-têtes sur chaque réponse, à côté des Mailcheer-Quota-* de l'espace:
| En-tête | Valeur |
|---|---|
Mailcheer-Key-Quota-Limit | Le plafond mensuel de la clé. |
Mailcheer-Key-Quota-Used | Ce que la clé a envoyé ce mois-ci, plus ce qu'elle a encore en file. |
Mailcheer-Key-Quota-Remaining | Ce qu'elle peut encore envoyer; jamais négatif. |
Le compteur de la clé repart avec celui de l'espace (Mailcheer-Quota-Reset), et GET /api/v1/me le rend dans key.monthly_limit. Une clé de test n'a pas de plafond: elle n'envoie rien.
La langue des réponses: anglais (par défaut) ou français — celle des messages quand un appel n'en demande aucune. Voir La langue des réponses.
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://mailcheer.com/api/v1/emails \
-H "Authorization: Bearer mch_live_…" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: facture-2026-0412" \
-d '{
"from": "Votre marque <bonjour@votredomaine.fr>",
"to": "client@exemple.fr",
"subject": "Votre facture de septembre",
"html": "<p>La voici.</p>"
}'
La réponse arrive en 202:
{
"id": "cmu651xf200021n7nm68sikfw",
"object": "email",
"from": "bonjour@votredomaine.fr",
"to": ["client@exemple.fr"],
"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://mailcheer.com/api/v1/emails/cmu651xf200021n7nm68sikfw \
-H "Authorization: Bearer mch_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.
Avec plusieurs adresses, status suit les destinataires de to: une copie (cc, bcc) qui revient en erreur ne fait pas passer l'e-mail de votre destinataire pour bounced. Chaque adresse a son propre état dans recipients: email, type (to, cc ou bcc), status (sent tant qu'Amazon n'en a rien dit, puis delivered, bounced ou complained; queued ou failed tant que l'e-mail lui-même l'est) et reason (le type de rebond donné par Amazon: Permanent, Transient ou Undetermined, null sinon). Les webhooks suivent la même règle: chaque événement email.* nomme UNE adresse dans email, et dit dans recipient_type si c'est un destinataire (to) ou une copie (cc, bcc). L'ouverture reste sur l'e-mail (opened_at): le pixel est le même dans toutes les copies, il ne dit pas qui a ouvert.
La désinscription en un clic
Si vous écrivez à des gens qui ne vous ont pas écrit les premiers — une lettre d'information, une alerte à laquelle on s'abonne, une page de statut — Gmail et Yahoo attendent un lien de désinscription dans les en-têtes du message, et pas seulement en bas de page. C'est leur exigence depuis février 2024.
Donnez-en l'adresse avec unsubscribe_url, et Mailcheer pose les deux en-têtes qui vont ensemble:
curl -X POST https://mailcheer.com/api/v1/emails \
-H "Authorization: Bearer mch_live_…" \
-H "Content-Type: application/json" \
-d '{
"from": "Votre marque <alertes@votredomaine.fr>",
"to": "client@exemple.fr",
"subject": "Votre rapport du mois",
"html": "<p>Le voici.</p>",
"unsubscribe_url": "https://votredomaine.fr/desinscription/abc123"
}'
Votre adresse doit accepter un POST et désinscrire sans demander de confirmation: c'est le principe du « un clic ». Un GET sur la même adresse peut mener à une page lisible, pour les clients de messagerie qui font l'un ou l'autre.
Sans cet en-tête, la seule sortie offerte au destinataire est le bouton « Spam » — et c'est la réputation de votre domaine d'envoi qui la paie, pas celle du message.
⚠️ List-Unsubscribe reste refusé dans headers: c'est Mailcheer qui l'écrit, vous n'en donnez que la cible. Cela garantit que List-Unsubscribe-Post l'accompagne toujours — sans ce second en-tête, Gmail n'affiche aucun bouton.
unsubscribe_url dit aussi ce qu'est l'envoi. Avec lui, c'est une lettre ou une prospection: une adresse désinscrite de votre espace est refusée (422 suppressed_recipient). Sans lui, c'est un e-mail individuel — une réponse, une facture, un rendez-vous — et une désinscription ne l'arrête pas. Un rebond, une plainte ou un retrait à la main arrêtent les deux.
Les pièces jointes
Un devis, une facture, une plaquette: donnez-les dans attachments, au format de Resend — filename et content, le fichier encodé en base64.
curl -X POST https://mailcheer.com/api/v1/emails \
-H "Authorization: Bearer mch_live_…" \
-H "Content-Type: application/json" \
-d '{
"from": "Votre marque <bonjour@votredomaine.fr>",
"to": "client@exemple.fr",
"subject": "Votre devis",
"text": "Le devis est en pièce jointe.",
"attachments": [
{
"filename": "devis-2026-09.pdf",
"content": "JVBERi0xLjQKJcfsj6IK…",
"content_type": "application/pdf"
}
]
}'
En Node, le contenu se prépare en une ligne:
import { readFileSync } from "node:fs";
const devis = {
filename: "devis-2026-09.pdf",
content: readFileSync("./devis-2026-09.pdf").toString("base64"),
};
content_type est facultatif: il se déduit de l'extension (.pdf → application/pdf), et vaut application/octet-stream en dernier recours. Vingt pièces au plus par message.
La limite est celle d'Amazon, notre fournisseur d'envoi: 40 Mo par message une fois encodé, soit environ 30 Mo de fichiers réels — le base64 ajoute un tiers. Au-delà, l'appel est refusé en 422, avec le nom du fichier, son poids et le poids atteint. C'est délibéré: un refus qui explique vaut mieux qu'un message qui part sans sa pièce.
Sont refusés de la même façon, et toujours en le disant:
- les extensions que les messageries rejettent —
.exe,.bat,.js,.vbs,.scr… Mettez le fichier dans une archive.zip, ou envoyez un lien de téléchargement; - un
contentqui n'est pas du base64 valide; - un champ
path, qui donnerait une URL à aller chercher: notre serveur ne suit pas une adresse que vous choisissez. Encodez le fichier.
Quand un message porte une pièce, il part en MIME complet plutôt qu'en message simple. Tout le reste ne bouge pas: cc, bcc, reply_to, vos en-têtes, la désinscription en un clic et vos étiquettes fonctionnent à l'identique — et le bcc n'apparaît toujours dans aucun en-tête du message reçu.
Les images intégrées
Un logo, une photo de produit qui doivent s'afficher dans le message, pas en pièce à télécharger: donnez à la pièce un content_id, et appelez-la dans le HTML par cid:.
{
"html": "<p><img src=\"cid:logo\" alt=\"Votre marque\" width=\"120\"></p><p>Merci pour votre commande.</p>",
"attachments": [
{ "filename": "logo.png", "content": "iVBORw0KGgo…", "content_type": "image/png", "content_id": "logo" }
]
}
La pièce part avec l'en-tête Content-ID, à côté du HTML, et les messageries l'affichent à sa place. content_id (ou contentId) accepte lettres, chiffres, ., _, - et @, sans espace; deux pièces ne peuvent pas porter le même. Les mêmes limites que les autres pièces s'appliquent: c'est un fichier du message comme un autre.
Les copies
cc et bcc acceptent une adresse ou un tableau de 1 à 50, comme to — mais 50 adresses au plus en tout, to, cc et bcc ensemble: c'est la limite de notre fournisseur d'envoi par message. Au-delà, 422 validation_error et rien ne part; découpez en plusieurs appels, ou passez par une campagne. Les deux comptent dans votre quota et passent par les mêmes contrôles — une adresse que la liste de suppression refuse fait échouer l'appel entier, qu'elle soit destinataire ou en copie.
La mesure des ouvertures
Un e-mail en HTML porte une image invisible d'un pixel qui compte les ouvertures. Sans track_opens dans la requête, c'est le réglage Mesurer les ouvertures de votre espace qui décide (écran Espace de l'application); track_opens: true ou false l'emporte pour cet e-mail. Un e-mail en texte seul ne porte aucun pixel. GET /api/v1/emails/{id} rend track_opens: quand il vaut false, opened_at reste null parce que l'ouverture n'est pas mesurée, pas parce que personne n'a ouvert.
En France, la CNIL considère depuis sa recommandation du 14 avril 2026 que mesurer les ouvertures par un pixel, pour suivre les performances des campagnes, demande le consentement préalable du destinataire. Un code de connexion ou une réinitialisation de mot de passe n'a aucune raison d'être suivi: envoyez track_opens: false.
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 — adresse morte, plainte, retrait à la main, et désinscription quand l'envoi porte unsubscribe_url. Un e-mail individuel, sans unsubscribe_url, part vers une adresse désinscrite: se retirer de votre lettre n'est pas refuser la réponse à sa propre demande. 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 — et ce qui attend déjà en file. C'est le même compte d'envoi, la même facture. Un envoi qui ne tient pas répond 402 quota_exceeded (voir plus bas). Sur l'offre gratuite, les e-mails offerts d'un domaine d'envoi servent à un seul espace par mois: ailleurs, c'est 402 free_plan_domain_used.
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. Une même adresse reçoit au plus une confirmation toutes les 2 minutes et 3 sur 24 heures, toutes voies confondues (formulaire, API, MCP): un nouvel appel met la fiche à jour sans renvoyer d'e-mail, et la réponse dit pourquoi (confirmation_sent: false, confirmation_not_sent_reason, confirmation_retry_at). Au-delà, chaque relance est une plainte possible contre votre domaine.
Le mode test
Créez une clé de test: Réglages → API → Nouvelle clé, interrupteur Clé de test allumé. Elle commence par mch_test_. Avec elle, chaque envoi est vérifié exactement comme un vrai — from sur un domaine vérifié, destinataires, liste de suppression, statut d'envoi et plafond du jour de l'espace, quota du mois, règle de l'offre gratuite par domaine, Idempotency-Key — et reçoit la même réponse, refus compris. Mais rien ne part, et votre quota n'est pas touché: les en-têtes Mailcheer-Quota-* donnent les vrais chiffres, inchangés.
curl -X POST https://mailcheer.com/api/v1/emails \
-H "Authorization: Bearer mch_test_…" \
-H "Content-Type: application/json" \
-d '{
"from": "Votre marque <bonjour@votredomaine.fr>",
"to": "bounced@simulator.mailcheer.com",
"subject": "Votre facture de septembre",
"html": "<p>La voici.</p>"
}'
Même 202, même corps. Chaque réponse authentifiée dit quelle sorte de clé a répondu — Mailcheer-Mode: test ou Mailcheer-Mode: live — et GET /api/v1/me le rend dans key.mode. Vérifiez-le une fois au démarrage de votre service: une clé de test déployée en production par erreur croit envoyer, et rien ne part.
Ce qui se passe ensuite
Deux secondes plus tard, l'e-mail reçoit ses retours, comme un vrai: GET /api/v1/emails/{id} passe de sent à son issue, adresse par adresse, et vos webhooks reçoivent les événements — de vrais appels, signés, réessayés — marqués "test": true à côté de type (voir Les événements du mode test).
Toute adresse se comporte comme remise. Pour les autres issues, écrivez à:
| Adresse | Événements |
|---|---|
delivered@simulator.mailcheer.com | email.delivered |
bounced@simulator.mailcheer.com | email.bounced, avec reason: "Permanent" |
complained@simulator.mailcheer.com | email.delivered, puis email.complained |
Une étiquette peut suivre un + pour distinguer vos essais: bounced+inscription@simulator.mailcheer.com. Ces adresses ne servent qu'avec une clé de test: une clé réelle est refusée avec un 422, puisqu'un vrai e-mail y rebondirait.
Un rebond ou une plainte simulés n'ajoutent pas l'adresse à votre liste de suppression: le mode test ne change rien de réel.
Ce qu'une clé de test ne fait pas
Une clé de test n'a qu'un droit, emails:send, et il est simulé. Elle ne lit ni ne modifie aucune donnée réelle de l'espace — abonnés, campagnes, liste de suppression, webhooks: ces appels rendent 403 insufficient_scope, avec details.mode: "test". Une clé de test finit dans un dépôt, une intégration continue, un exemple partagé: elle ne doit pouvoir divulguer l'adresse de personne, ni glisser un abonné d'essai dans une vraie campagne. Les campagnes ont déjà leur façon d'essayer sans envoyer: dry_run et POST /api/v1/audience.
Avec une clé de test, GET /api/v1/emails/{id} ne trouve que les e-mails de test; une clé réelle ne les voit jamais. Les e-mails de test sont gardés 30 jours.
Deux différences avec un vrai envoi, toutes deux voulues:
- la relecture de sécurité du contenu, que passent les premiers envois d'un espace neuf, n'a pas lieu: rien ne part, il n'y a rien à protéger;
- au plus 1 000 adresses d'essai par 24 heures et par espace,
to,ccetbcccompris. Au-delà:429 rate_limit_exceeded, avecRetry-After.
Le serveur MCP suit la clé: avec une clé de test, send_email est vérifié et simulé, et get_account rend mode: "test".
Où en est votre espace
GET /api/v1/me est le premier appel à faire, et le bon réflexe avant un envoi important: il dit à qui appartient la clé, avec quelles adresses écrire — et si l'envoi tiendra. Aucun droit particulier n'est exigé.
curl https://mailcheer.com/api/v1/me -H "Authorization: Bearer mch_live_…"
{
"object": "account",
"organization": { "id": "org_3f9", "name": "Votre marque", "slug": "votre-marque" },
"key": {
"id": "cmu8k1a2b00001n7nkey0prod",
"name": "Production",
"scopes": ["emails:send", "subscribers:read"],
"mode": "live",
"language": "en",
"monthly_limit": null
},
"plan": { "id": "free", "name": "Découverte", "emails_per_month": 3000 },
"usage": {
"period": "2026-09",
"emails_sent": 2410,
"emails_in_flight": 120,
"emails_remaining": 470,
"resets_at": "2026-10-01T00:00:00.000Z"
},
"billing": {
"status": "none",
"subscribed_plan": null,
"current_period_end": null,
"cancel_at_period_end": false,
"trial_ends_at": null,
"scheduled_change": null,
"manage_url": "https://mailcheer.com/fr/reglages/facturation"
},
"limits": {
"members": { "used": 1, "pending_invitations": 0, "max": 1 },
"sending_domains": { "used": 1, "max": 1 },
"daily": null
},
"subscribers": 551,
"sending_domains": [{ "domain": "votredomaine.fr", "verified": true }],
"senders": [{ "id": "snd_71a", "from": "bonjour@votredomaine.fr", "name": "Votre marque", "default": true }]
}
key— la clé qui a fait l'appel: sesscopes, sonmode(live, outestpour une clé de test), salanguage(la langue de ses messages quand un appel n'en demande aucune) et sonmonthly_limit—{ "limit", "used", "remaining", "resets_at" }, ounullsans plafond.usage—emails_sent: ce qui est parti ce mois-ci, toutes voies confondues.emails_in_flight: ce qui attend en file (une campagne en cours, des envois d'automatisation réservés) — c'est déjà promis.emails_remaining: ce qui peut encore partir, file déduite, jamais négatif (nullpour une offre illimitée).resets_at: la remise à zéro du compteur, le 1er du mois suivant à 00:00 UTC (2 h à Paris en été, 1 h en hiver).billing—statusvautnonesans abonnement payant; sinon celui du paiement:trialing(la semaine offerte du premier passage à une offre payante: l'offre s'applique, rien n'est encore prélevé;trial_ends_atdit quand tombe le premier prélèvement),active,past_due(un prélèvement a échoué, l'offre reste ouverte pendant les relances),unpaid(relances abandonnées: l'espace tourne aux limites de Découverte),canceled…subscribed_plannomme l'offre facturée — elle peut différer deplan.idaprès un impayé.scheduled_changeannonce une descente ou une résiliation programmée ({ "plan": "free", "effective_at": "…" }).manage_urlest l'écran où le propriétaire ou un administrateur change d'offre.limits— les membres (une invitation en attente prend une place), les domaines d'envoi, etdaily: le plafond sur 24 heures glissantes d'un espace neuf (100 e-mails les trois premiers jours, 500 jusqu'au septième),nullquand il ne s'applique pas.
Un logiciel branché sur Mailcheer — un CRM qui envoie pour ses utilisateurs, par exemple — peut ainsi afficher « il vous reste 470 e-mails jusqu'au 1er octobre » plutôt que de découvrir le refus. Le propriétaire et les administrateurs de l'espace, eux, reçoivent un e-mail à 50 %, 80 % et 95 % du quota, une fois par mois chacun — pas d'e-mail à 100 %: le refus le dit.
Le quota dans chaque réponse
Pas besoin d'appeler GET /api/v1/me avant chaque envoi: chaque réponse authentifiée de l'API — succès ou erreur, 402 compris — et du serveur MCP porte l'état du quota du mois.
| En-tête | Valeur |
|---|---|
Mailcheer-Quota-Limit | E-mails par mois de votre offre, ou unlimited. |
Mailcheer-Quota-Used | Partis ce mois-ci plus ce qui attend en file (emails_sent + emails_in_flight). |
Mailcheer-Quota-Remaining | Ce qui peut encore partir, file déduite, jamais négatif — ou unlimited. |
Mailcheer-Quota-Reset | La remise à zéro, en ISO 8601: le 1er du mois suivant, 00:00 UTC. |
curl -i https://mailcheer.com/api/v1/emails -H "Authorization: Bearer mch_live_…" …
# HTTP/1.1 202 Accepted
# Mailcheer-Quota-Limit: 3000
# Mailcheer-Quota-Used: 2531
# Mailcheer-Quota-Remaining: 469
# Mailcheer-Quota-Reset: 2026-10-01T00:00:00.000Z
La réponse d'un envoi accepté compte déjà cet envoi. Une réponse sans clé valide (401) n'en porte pas: elle ne connaît pas votre espace. Si le quota ne peut pas être lu à cet instant, la réponse part quand même, sans ces en-têtes.
Une clé qui a son propre plafond mensuel porte aussi Mailcheer-Key-Quota-Limit, -Used et -Remaining — voir Le plafond et la langue d'une clé.
Être prévenu: le webhook quota.threshold_reached
Abonnez une adresse à l'événement quota.threshold_reached (POST /api/v1/webhooks, ou Réglages → API): Mailcheer l'appelle quand le quota du mois atteint 50, 80, 95 et 100 %, une fois par mois et par seuil, au moment où l'e-mail qui franchit le seuil est accepté. Si un seul envoi en franchit plusieurs, seul le plus haut part. Le corps est signé et réessayé comme tout événement (voir Les webhooks):
{
"id": "evt_3kT9xQ2mV7aB1cD4",
"type": "quota.threshold_reached",
"created_at": "2026-09-24T16:02:11.000Z",
"data": {
"threshold": 80,
"plan": "free",
"quota": 3000,
"sent": 2400,
"in_flight": 35,
"remaining": 565,
"resets_at": "2026-10-01T00:00:00.000Z",
"period": "2026-09"
}
}
threshold est le seuil atteint, en pour cent; les autres champs ont le sens de details d'un refus 402. À 100 %, seul le webhook part (aucun e-mail): dès lors, les envois répondent 402 quota_exceeded jusqu'à resets_at.
Quand le quota ne suffit pas
Un envoi qui ne tient pas dans le reste du mois est refusé entier, avant de partir: rien n'est envoyé, rien n'est mis en file. La réponse est un 402 de code quota_exceeded, sur POST /api/v1/emails comme sur POST /api/v1/campaigns/CAMP_ID/send, et l'outil MCP qui fait le même geste rend la même erreur:
{
"error": {
"code": "quota_exceeded",
"message": "…",
"details": {
"plan": "free",
"quota": 3000,
"sent": 2940,
"in_flight": 20,
"remaining": 40,
"requested": 250,
"resets_at": "2026-10-01T00:00:00.000Z"
}
},
"statusCode": 402,
"message": "…",
"name": "quota_exceeded"
}
remaining vaut quota − sent − in_flight; requested est ce que l'appel demandait (destinataires, copies comprises). Deux chemins: changer d'offre (billing.manage_url), ou attendre resets_at. Réessayer le même appel avant l'un des deux rendra le même refus.
L'offre gratuite: un domaine, un espace par mois
Sur l'offre gratuite, les 3 000 e-mails du mois sont liés au domaine d'envoi, pas au compte. Un domaine enregistré (acme.com, sous-domaines compris) ne sert l'offre gratuite qu'à un seul espace par mois UTC: le premier qui envoie avec lui. Un autre espace gratuit qui envoie depuis ce domaine, ou l'un de ses sous-domaines, le même mois reçoit un autre 402, refusé entier lui aussi:
{
"error": {
"code": "free_plan_domain_used",
"message": "L'offre gratuite vaut pour un domaine d'envoi, pas pour un compte : ce domaine (news.acme.com, de acme.com) l'a déjà utilisée ce mois-ci dans un autre espace. Passez à une offre payante pour envoyer depuis plusieurs espaces, ou attendez le 1er octobre à 0 h UTC (2 h à Paris).",
"details": {
"domain": "news.acme.com",
"root_domain": "acme.com",
"period": "2026-09",
"resets_at": "2026-10-01T00:00:00.000Z"
}
},
"statusCode": 402,
"message": "…",
"name": "free_plan_domain_used"
}
Distinguez les deux 402 par error.code. Passer à une offre payante lève celui-ci aussitôt; les offres payantes ne sont pas concernées.
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 un appel réussi 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. Une clé est gardée 24 heures après le premier appel; au-delà, le même appel est un nouvel envoi.
Un refus n'est pas gardé. Un 402 (quota), un 423 (relecture de sécurité), un 429 (plafond du jour) ou un 422 (champ invalide) n'ont rien envoyé: une fois la cause levée, renvoyez le même appel avec la même clé, il part. C'est ce que doit faire votre logique de réessai, sans nouvelle clé.
Deux appels identiques au même instant n'envoient qu'une fois: le second attend le premier et rend sa réponse. Si le premier tourne encore après quelques secondes, le second reçoit 409 idempotency_key_in_use avec Retry-After: renvoyez-le tel quel. La même clé avec un corps différent répond 409 idempotency_key_reused: 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://mailcheer.com/api/v1/subscribers \
-H "Authorization: Bearer mch_live_…" \
-H "Content-Type: application/json" \
-d '{"email":"marie@exemple.fr","firstName":"Marie","tags":["clients"]}'
# Lister, page par page
curl "https://mailcheer.com/api/v1/subscribers?limit=50&status=subscribed" \
-H "Authorization: Bearer mch_live_…"
# Désinscrire
curl -X DELETE https://mailcheer.com/api/v1/subscribers/marie%40exemple.fr \
-H "Authorization: Bearer mch_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.
Désinscrite, la personne ne reçoit plus vos campagnes, vos automatisations ni vos envois d'API qui portent unsubscribe_url. Vos e-mails individuels — une réponse, une facture — lui parviennent toujours.
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.
Seulement ce qui a changé: updated_since
Pour garder une copie de votre liste à jour sans tout relire, passez updated_since (ISO 8601): vous ne recevez que les fiches créées ou modifiées depuis — statut (confirmation, désinscription, rebond), nom, champs personnalisés, étiquettes.
curl "https://mailcheer.com/api/v1/subscribers?updated_since=2026-09-25T08:00:00Z" \
-H "Authorization: Bearer mch_live_…"
Chaque fiche porte updated_at. Gardez la plus grande reçue et, la fois suivante, passez-la moins une minute: elle vient de notre horloge, pas de la vôtre, et la minute laisse une seconde chance à une modification en cours d'écriture au moment précis de votre lecture. Une fiche vue deux fois est simplement une mise à jour.
Les étiquettes
POST /api/v1/subscribers pose aussi des étiquettes, mais c'est une inscription: sur une personne encore pending, il renvoie l'e-mail de confirmation. Pour changer des étiquettes, et rien qu'elles, passez par ces appels — ni inscription, ni e-mail, ni statut touché:
# Un abonné : poser et retirer en un appel
curl -X POST https://mailcheer.com/api/v1/subscribers/marie%40example.com/tags \
-H "Authorization: Bearer mch_live_…" \
-H "Content-Type: application/json" \
-d '{ "add": ["client"], "remove": ["prospect"] }'
# Une étiquette, plusieurs abonnés : jusqu'à 500 adresses dans chaque liste
curl -X POST https://mailcheer.com/api/v1/tags/client/subscribers \
-H "Authorization: Bearer mch_live_…" \
-H "Content-Type: application/json" \
-d '{ "add": ["marie@example.com", "paul@example.com"], "remove": ["ancien@example.com"] }'
Le premier répond avec la fiche et ce qui a vraiment changé (added, removed): poser une étiquette déjà posée, ou retirer une étiquette absente, n'est pas une erreur — c'est là qu'une faute de frappe se voit. La fiche doit exister: une adresse inconnue répond 404 et rien n'est créé. Le second répond avec des comptes (added, removed, unchanged) et les adresses qui ne sont pas des abonnés de l'espace (not_found), jamais créées non plus. Une étiquette nouvelle est créée quand add la nomme.
Une étiquette se désigne par son id ou par son nom, encodé dans l'adresse (/api/v1/tags/clients%20VIP).
| Appel | Ce qu'il fait |
|---|---|
GET /api/v1/tags | Toutes les étiquettes, avec subscribers (combien la portent) et subscribed (combien d'entre eux sont actifs). |
PATCH /api/v1/tags/{tag} | La renomme: { "name": "…" }. Les abonnés la gardent sous son nouveau nom; un nom déjà pris répond 409. |
DELETE /api/v1/tags/{tag} | La supprime. Les abonnés restent, seule l'étiquette leur est retirée. Refusée en 409 tant qu'une automatisation, un segment ou un formulaire d'inscription s'en sert — details.used_by les nomme: sans elle, ils continueraient sans rien dire, en ne touchant plus personne. |
Ajouter par lots
POST /api/v1/subscribers/batch ajoute ou met à jour jusqu'à 500 abonnés en un appel. Chaque entrée passe par exactement le chemin de POST /api/v1/subscribers — double opt-in par défaut, garde des confirmations, liste de suppression, désinscrits qu'on ne fait pas revenir.
curl -X POST https://mailcheer.com/api/v1/subscribers/batch \
-H "Authorization: Bearer mch_live_…" \
-H "Content-Type: application/json" \
-d '{ "subscribers": [
{ "email": "marie@example.com", "firstName": "Marie", "tags": ["clients"] },
{ "email": "paul@example.com", "tags": ["clients"] }
] }'
Une ligne refusée ne fait pas échouer les autres. La réponse rend une ligne par entrée, dans l'ordre: created ou updated avec les champs de l'ajout unitaire (subscriber, confirmation_sent …), ou rejected avec l' error qu'aurait rendue l'ajout unitaire — code, message, précisions. Un summary compte les trois. La même adresse deux fois dans un lot: la seconde ligne est refusée.
Un lot de N compte pour N requêtes dans la limite de débit: il épargne des allers-retours, il ne relève pas la limite. Un lot qui ne tient pas dans la minute est refusé en entier, sans rien consommer (429, Retry-After).
Effacer une personne (RGPD)
Quand une personne demande l'effacement de ses données — pas seulement de ne plus recevoir d'e-mails —, c'est:
curl -X POST https://mailcheer.com/api/v1/subscribers/marie%40example.com/erase \
-H "Authorization: Bearer mch_live_…"
# → { "object": "subscriber", "email": "marie@example.com", "erased": true, "suppressed": false }
Irréversible. La fiche, le nom, les champs personnalisés, la preuve de consentement et les étiquettes sont supprimés. Ce qui était promis à la personne et n'est pas parti ne partira pas; ses parcours d'automatisation s'arrêtent. Les statistiques des campagnes déjà parties ne bougent pas: chaque message reste compté, sans plus rien qui le relie à elle.
C'est une route à part, pas un paramètre de DELETE: ce DELETE désinscrit et garde la fiche, et un paramètre mal écrit ferait en silence l'autre geste, irréversible. La liste de suppression n'est pas touchée — une adresse qui y figure y reste (suppressed: true), c'est ce qui garantit que plus rien ne lui partira. Retirer une adresse de cette liste se fait à la main, dans votre espace.
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://mailcheer.com/api/v1/campaigns \
-H "Authorization: Bearer mch_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://mailcheer.com/api/v1/campaigns/CAMP_ID/send \
-H "Authorization: Bearer mch_live_…"
# 3. Le suivi
curl https://mailcheer.com/api/v1/campaigns/CAMP_ID \
-H "Authorization: Bearer mch_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.
Les ouvertures ne se comptent que sur les messages qui portaient le pixel de mesure. Quand le réglage Mesurer les ouvertures de l'espace était coupé pour tout l'envoi, opens_tracked vaut false, et open_rate et human_open_rate valent null — jamais un 0 % trompeur.
Chaque taux existe deux fois: open_rate et click_rate comptent les robots, comme la plupart des outils; human_open_rate et human_click_rate les écartent. Un robot, c'est une ouverture ou un clic dans les deux minutes qui suivent la remise (les passerelles de sécurité des messageries d'entreprise visitent chaque lien à l'arrivée du message, les relais de confidentialité préchargent les images), ou venant d'un robot qui se déclare dans son agent, ou d'une plage d'adresses que son opérateur publie (Google, Bing). Les relais de confidentialité (Apple Mail, Gmail, Yahoo) ne sont pas des robots en eux-mêmes. La règle est volontairement stricte: une personne qui ouvre dans la minute est comptée robot — un taux un peu bas plutôt qu'un taux gonflé.
Supprimer un brouillon
curl -X DELETE https://mailcheer.com/api/v1/campaigns/CAMP_ID \
-H "Authorization: Bearer mch_live_…"
Répond { "object": "campaign", "id": "…", "deleted": true }. Seul un brouillon (draft) se supprime, et c'est définitif. Une campagne programmée, en cours, partie ou archivée répond 409 conflict, avec son statut dans details.status: ce qui est parti, ou partira, garde ses envois et ses statistiques.
Modifier un brouillon
curl -X PATCH https://mailcheer.com/api/v1/campaigns/CAMP_ID \
-H "Authorization: Bearer mch_live_…" \
-H "Content-Type: application/json" \
-d '{ "subject": "Ce que nous avons appris cet été (bis)", "text": "# Bonjour\n\nLa nouvelle version." }'
Les champs de la création, tous facultatifs: name, subject, preheader, kind, from ou sender_id, et le contenu — text ou content, pas les deux. Un nouveau contenu remplace l'ancien; sans preheader, il garde celui du brouillon. La réponse est la campagne, telle que GET la rend. Rien ne part.
Seul un brouillon se modifie: tout autre statut répond 409 conflict avec details.status, et rien n'est modifié. Un champ inconnu est refusé (422), à la différence de la création: un subjet mal écrit, ignoré en silence, laisserait partir l'ancien objet à toute la liste.
Écrire à un groupe plutôt qu'à toute la liste
Sans corps, l'envoi part à toute la cible de la campagne: tous les abonnés actifs de l'espace (ou du segment choisi dans l'interface), moins la liste de suppression. Pour n'écrire qu'à une partie — ceux qui n'ont pas répondu, vos clients, les inscrits d'un atelier — passez leurs adresses dans to:
curl -X POST https://mailcheer.com/api/v1/campaigns/CAMP_ID/send \
-H "Authorization: Bearer mch_live_…" \
-H "Content-Type: application/json" \
-d '{ "to": ["claire@exemple.fr", "marc@exemple.fr", "ancien@exemple.fr"] }'
to ne peut que restreindre. La lettre part aux adresses demandées qui sont aussi des abonnés actifs de la cible, et jamais à une adresse de la liste de suppression: une personne désinscrite, dont l'adresse a rebondi ou qui s'est plainte ne reçoit rien, même si son adresse est dans to. La réponse dit qui est retenu, qui est écarté, et pourquoi:
{
"id": "cmp_8d2", "object": "campaign", "status": "sending", "queued": 2,
"audience": {
"requested": 3, "duplicates": 0, "retained": 2, "excluded": 1,
"reasons": { "invalid": 0, "not_in_list": 0, "pending": 0, "unsubscribed": 1,
"bounced": 0, "complained": 0, "suppressed": 0, "outside_segment": 0 },
"excluded_addresses": [ { "email": "ancien@exemple.fr", "reason": "unsubscribed" } ]
},
"note": "2 destinataire(s) mis en file. …"
}
Le compte tombe toujours juste: requested = duplicates + retained + excluded. La casse et les espaces sont ignorés (Claire@Exemple.fr est la même personne). Les raisons: invalid (pas une adresse), not_in_list (aucun abonné de l'espace ne la porte), pending (inscription pas encore confirmée), unsubscribed, bounced, complained, suppressed (abonné, mais sur la liste de suppression) et outside_segment (hors du segment que vise la campagne).
Trois règles à connaître:
- Une liste vide n'est pas « pas de liste ».
"to": []n'écrit à personne: l'envoi est refusé (422). Pour écrire à toute la liste, n'envoyez pas de champtodu tout. - Pas de
tosur une campagne avec test A/B. La version gagnante part des heures plus tard, calculée sur toute la cible de la campagne; la liste d'adresses n'y survivrait pas. L'envoi est refusé plutôt que de partir à tout le monde. - Les champs inconnus sont ignorés, sauf les sosies de
dry_runet deto.dryRun,dry-run,DRY_RUN,test,simulate,preview… etTo,TO,recipients,emails,to_emails,destinataires,adresses,audience… sont refusés (422) en nommant le bon champ: ignorés, les premiers feraient partir la campagne pour de vrai, les seconds la feraient partir à toute la liste.POST /api/v1/audiencerefuse tout champ inconnu.
Jusqu'à 50 000 adresses par appel.
Simuler avant d'envoyer
"dry_run": true fait tous les contrôles d'un vrai envoi — expéditeur, contenu, réputation, quota, destinataires — et ne met rien en file. La réponse est un 200:
{ "id": "cmp_8d2", "object": "send_preview", "dry_run": true,
"would_send": true, "blocked_reason": null, "recipients": 2,
"audience": { "requested": 3, "retained": 2, "excluded": 1, … } }
Si le vrai envoi serait refusé, would_send vaut false et blocked_reason donne le message exact qu'il rendrait. C'est le nombre à montrer à la personne avant qu'elle confirme.
Pour poser la même question avant d'avoir créé la campagne — pendant qu'on choisit à qui écrire, dans votre propre logiciel — POST /api/v1/audience rend le même bilan sans rien créer (droit subscribers:read):
curl -X POST https://mailcheer.com/api/v1/audience \
-H "Authorization: Bearer mch_live_…" \
-H "Content-Type: application/json" \
-d '{ "to": ["claire@exemple.fr", "marc@exemple.fr"] }'
# → { "object": "audience", "recipients": 2, "audience": { … } }
Sans to, il rend le nombre d'abonnés qu'atteindrait une campagne envoyée à toute la liste. Les contrôles propres à une campagne (objet, contenu, quota) restent ceux de dry_run.
Le rapport complet
GET /api/v1/campaigns/CAMP_ID/stats rend tout ce que la fiche campagne de Mailcheer 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,
"humanOpened": 22, "humanClicked": 7,
"bounced": 6, "complained": 1, "unsubscribed": 0 },
"rates": { "delivered": 0.9091, "opened": 0.5, "clicked": 0.1667,
"humanOpened": 0.3667, "humanClicked": 0.1167, "bounced": 0.0909 },
"bots": { "opened": 9, "clicked": 4, "delay": 12, "scanner": 1 },
"timeline": [ { "label": "+0h", "opened": 12, "clicked": 5, "humanOpened": 4, "humanClicked": 1 }, … ],
"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. opened et clicked comptent les robots, humanOpened et humanClicked les écartent; untracked compte les messages délivrés partis sans pixel de mesure des ouvertures — les ouvertures et leurs taux ne portent que sur les autres, et rates.opened vaut null s'il n'en reste aucun; bots dit combien d'ouvertures et de clics ont été écartés, en événements, et pourquoi (delay: dans les deux minutes qui suivent la remise; scanner: un robot qui se déclare ou une adresse publiée par son opérateur). timeline compte les ouvertures et les clics par tranche de six heures pendant les 48 premières heures après l'envoi, avec et sans robots — vide tant que la campagne n'est pas partie. audience ne compte que les ouvertures de personnes et ne garde que les cinq premières lignes de chaque répartition; proxiedShare est la part de ces ouvertures venues d'un relais de confidentialité (Apple Mail, Gmail): reçues, pas forcément lues. links va du plus au moins cliqué, par des personnes. html est le message tel qu'il est parti, chaîne vide sinon.
Qui a ouvert: les destinataires
GET /api/v1/campaigns/CAMP_ID/recipients rend une ligne par personne à qui la campagne est partie, paginée par curseur comme /subscribers (?limit= jusqu'à 100, ?cursor= = le next_cursor de la page précédente):
curl "https://mailcheer.com/api/v1/campaigns/CAMP_ID/recipients?status=opened" \
-H "Authorization: Bearer mch_live_…"
{
"object": "list",
"data": [
{ "object": "recipient", "id": "…", "email": "claire@exemple.fr",
"first_name": "Claire", "last_name": "Martin", "status": "clicked",
"sent_at": "…", "delivered_at": "…", "opened_at": "…", "clicked_at": "…",
"human_opened": true, "human_clicked": true, "unsubscribed": false },
{ "object": "recipient", "id": "…", "email": null, … }
],
"has_more": true,
"next_cursor": "…"
}
?status= filtre sur opened, clicked, not_opened (parti, sans rebond, jamais ouvert), bounced, complained ou unsubscribed. opened_at et clicked_at comptent les robots, comme opened et clicked du rapport; human_opened et human_clicked disent si une personne a ouvert ou cliqué. email vaut null quand le contact a été supprimé après l'envoi: la ligne reste, elle compte dans les chiffres de la campagne. unsubscribed vaut true quand la personne s'est désinscrite par le lien de ce message: le lien de désinscription désigne la lettre qui le porte, et une désinscription faite ailleurs (par l'API, dans l'application, depuis une automatisation) ne compte sur aucune campagne. Pour une lettre partie avant le 24/09/2026, dont le lien ne désignait que la personne, la désinscription reste attribuée à la dernière lettre reçue avant elle. Le rapport (/stats) compte les mêmes dans counts.unsubscribed.
Les webhooks
L'API répond quand on l'interroge; un webhook prévient sans qu'on demande. Abonnez une adresse à vous — POST /api/v1/webhooks avec une clé qui a le droit webhooks:write, ou Réglages → API —, choisissez ses événements, et Mailcheer lui envoie un POST avec un corps JSON chaque fois que l'un d'eux se produit.
curl -X POST https://mailcheer.com/api/v1/webhooks \
-H "Authorization: Bearer mch_live_…" \
-H "Content-Type: application/json" \
-d '{
"name": "Mon CRM",
"url": "https://votre-service.fr/mailcheer/evenements",
"events": [
"email.bounced",
"email.complained",
"subscriber.unsubscribed"
]
}'
La réponse porte le secret de signature de l'abonnement (whsec_…). Gardez-le: c'est lui qui permet à votre service de savoir qu'un appel vient bien de Mailcheer. L'adresse doit être en https, et jamais une adresse de réseau privé.
Tous les événements
| Événement | Envoyé quand |
|---|---|
email.sent | Un e-mail envoyé par l'API (POST /api/v1/emails, l'outil MCP send_email) a été accepté par notre fournisseur d'envoi — un événement par adresse, to, cc et bcc. E-mails d'API seulement. |
email.failed | Un e-mail envoyé par l'API a été enregistré mais n'est pas parti; reason dit pourquoi. Un refus qui n'a rien enregistré (quota, domaine, liste de suppression…) n'en produit aucun: il n'y a pas d'e-mail, seulement une réponse d'erreur. |
email.delivered | Le serveur de messagerie du destinataire a accepté l'e-mail. |
email.opened | L'e-mail a été ouvert — le pixel de mesure s'est chargé, robots compris, comme opened_at. |
email.clicked | Un lien de l'e-mail a été cliqué; url dit lequel. |
email.bounced | L'e-mail est revenu; reason donne le type. Un rebond Permanent met l'adresse en liste de suppression. |
email.complained | Le destinataire a signalé l'e-mail comme indésirable. L'adresse entre en liste de suppression. |
email.received | Un destinataire a répondu, sur reply.<domaine> d'un domaine où la réception des réponses est allumée — voir plus bas. |
subscriber.created | Un abonné a été ajouté: formulaire d'inscription, API ou MCP, ou à la main dans l'application. |
subscriber.unsubscribed | Un abonné est parti: lien de désinscription d'un e-mail, API ou MCP, ou application. |
quota.threshold_reached | Le quota du mois a atteint 50, 80, 95 ou 100 % — voir Être prévenu. |
Tous les corps ont la même enveloppe: id (evt_…, le même à chaque tentative et à chaque rejeu de l'événement), type, created_at (le moment de l'événement, en ISO 8601 UTC — pas celui de la remise) et data, dont les champs dépendent de l'événement. Un événement produit par une clé de test porte en plus "test": true, entre created_at et data — voir Les événements du mode test.
Les champs des événements email.*
| Champ | Présent | Sens |
|---|---|---|
message_id | toujours | Avec source: "api", l' id rendu par POST /api/v1/emails. Avec source: "campaign", l' id de la ligne du destinataire dans GET /api/v1/campaigns/{id}/recipients. |
email | toujours | L'adresse concernée par l'événement. Pour un e-mail de campagne dont le contact a été supprimé après l'envoi, une chaîne vide. |
source | toujours | campaign ou api. |
campaign_id, campaign_name | e-mails de campagne | La campagne qui a envoyé l'e-mail. |
subscriber_id | e-mails de campagne dont le contact existe encore | L'abonné qui l'a reçu. |
subject | quand l'e-mail en a un | L'objet de la campagne ou de l'e-mail. |
url | email.clicked | Le lien cliqué. |
reason | email.bounced, email.failed | Pour un rebond, le type rapporté par Amazon SES: Permanent (l'adresse n'existe pas), Transient (boîte pleine, serveur indisponible) ou Undetermined. Pour un échec: provider_rejected (notre fournisseur d'envoi a refusé le message), safety_review (confié à une relecture de sécurité humaine — l'appel a répondu 423: renvoyez-le après la décision), suspended (l'espace a été suspendu par la relecture) ou interrupted (une panne de notre côté avant l'envoi). |
recipient_type | e-mails d'API (source: "api") | to si l'adresse était un destinataire, cc ou bcc si c'était une copie. |
{
"id": "evt_Ls2kPq8Rw4Tn6Vx1",
"type": "email.sent",
"created_at": "2026-09-25T08:31:05.118Z",
"data": {
"message_id": "cmu651xf200021n7nm68sikfw",
"email": "client@example.com",
"source": "api",
"subject": "Votre facture de septembre",
"recipient_type": "to"
}
}
{
"id": "evt_Fm9sQw3Er7Ty1Ui4",
"type": "email.failed",
"created_at": "2026-09-25T08:40:12.604Z",
"data": {
"message_id": "cmu652bq900031n7n0x9h2rst",
"email": "client@example.com",
"source": "api",
"subject": "Votre facture de septembre",
"reason": "provider_rejected",
"recipient_type": "to"
}
}
{
"id": "evt_8fQm2LxT0aVb7KcN",
"type": "email.delivered",
"created_at": "2026-09-25T08:31:07.412Z",
"data": {
"message_id": "cmu651xf200021n7nm68sikfw",
"email": "client@exemple.fr",
"source": "api",
"subject": "Votre facture de septembre"
}
}
{
"id": "evt_Jr4uWc9ZpQe1sHa2",
"type": "email.opened",
"created_at": "2026-09-25T09:02:44.108Z",
"data": {
"message_id": "cmu7a2k9q000b1n7n3v5c8xyz",
"email": "claire@exemple.fr",
"source": "campaign",
"campaign_id": "cmu79z1ab00001n7nqk2d4efg",
"campaign_name": "Lettre d'octobre",
"subscriber_id": "cmu5x0p3r00041n7n8m2t6hij",
"subject": "Les nouveautés du mois"
}
}
{
"id": "evt_Tn7bKx2VdMf0gLq5",
"type": "email.clicked",
"created_at": "2026-09-25T09:03:12.550Z",
"data": {
"message_id": "cmu7a2k9q000b1n7n3v5c8xyz",
"email": "claire@exemple.fr",
"source": "campaign",
"campaign_id": "cmu79z1ab00001n7nqk2d4efg",
"campaign_name": "Lettre d'octobre",
"subscriber_id": "cmu5x0p3r00041n7n8m2t6hij",
"subject": "Les nouveautés du mois",
"url": "https://votredomaine.fr/offre"
}
}
{
"id": "evt_Ab3cD9eF0gH1iJ2k",
"type": "email.bounced",
"created_at": "2026-09-25T08:31:09.020Z",
"data": {
"message_id": "cmu651xf200021n7nm68sikfw",
"email": "personne@exemple.fr",
"source": "api",
"subject": "Votre facture de septembre",
"reason": "Permanent"
}
}
{
"id": "evt_Pw6yRz1Xc0Vb8Nm3",
"type": "email.complained",
"created_at": "2026-09-25T10:15:31.774Z",
"data": {
"message_id": "cmu7a2k9q000c1n7n0w4d7abc",
"email": "marc@exemple.fr",
"source": "campaign",
"campaign_id": "cmu79z1ab00001n7nqk2d4efg",
"campaign_name": "Lettre d'octobre",
"subscriber_id": "cmu5x0p3r00051n7n2k9s1klm",
"subject": "Les nouveautés du mois"
}
}
Les champs de email.received
Une réponse d'un destinataire, reçue sur reply.<votre domaine>. L'événement n'existe que pour un domaine où la réception des réponses est allumée: dans l'application, écran du domaine → Recevoir les réponses, une ligne DNS à poser.
| Type | Nom | Valeur | Priorité |
|---|---|---|---|
| MX | reply.votredomaine.fr | inbound-smtp.eu-west-1.amazonaws.com | 10 |
Cette ligne ne touche pas à votre messagerie: elle ne vaut que pour le sous-domaine reply. Une fois lue, chaque e-mail d'API de ce domaine envoyé sans reply_to part avec l'adresse de réponse <partie locale>+<id>@reply.<domaine> — l' id est celui que rend POST /api/v1/emails. Un reply_to donné dans l'appel est toujours respecté, et toute adresse en reply.<domaine> que vous choisissez vous-même (dossier-4821@reply.votredomaine.fr) est reçue aussi. Tant que la ligne n'est pas lue, rien ne change: les réponses vont à l'adresse d'expédition. Éteindre la réception arrête l'adresse de réponse automatique des nouveaux e-mails; les réponses aux e-mails déjà partis continuent d'arriver tant que la ligne existe.
| Champ | Présent | Sens |
|---|---|---|
reply_id | toujours | L'identifiant de la réponse dans Mailcheer. |
email | toujours | L'adresse de la personne qui a répondu — la même que from, pour que data.email désigne le contact dans tous les email.*. |
from | toujours | L'expéditeur de la réponse. |
from_name | quand il a un nom | Son nom affiché. |
to | toujours | Les adresses en reply.<domaine> qui ont reçu la réponse. |
cc | quand il y en a | Les adresses en copie visible. |
subject | toujours | L'objet, décodé. |
text | quand la réponse a une version texte | Le texte, tel quel, citation de votre e-mail comprise. |
html | quand la réponse a une version HTML | Le HTML, tel quel. Il vient d'un inconnu: ne l'affichez jamais sans le nettoyer. |
received_at | toujours | L'heure de réception par Amazon, en ISO 8601 UTC. |
headers | toujours | message_id, in_reply_to, references (tableau), date et auto_submitted — null quand ils manquent. |
attachments | toujours, parfois vide | Pour chaque pièce jointe: filename, content_type, size (en octets), content_id (image intégrée), url — un lien de téléchargement signé, qui ne demande aucune clé — et url_expires_at: le lien vaut 7 jours. |
message_id | quand l'envoi d'origine est retrouvé | L' id de l'e-mail de Mailcheer auquel la personne répond, comme dans les autres email.*. Retrouvé par l'adresse de réponse, ou par In-Reply-To et References — jamais deviné. |
source | avec message_id | api, campaign ou automation. |
campaign_id | réponse à un e-mail de campagne | La campagne. |
automation_id | réponse à un e-mail d'automatisation | L'automatisation. |
tags | réponse à un e-mail d'API qui en portait | Les tags de l'envoi d'origine, pour ranger la réponse chez vous. |
spam | toujours | true quand le filtre d'Amazon juge la réponse indésirable. |
automatic | toujours | true pour une réponse automatique: absence, accusé de réception, rapport de remise (Auto-Submitted, Precedence …). |
authentication | toujours | spf, dkim et dmarc: le verdict d'Amazon sur l'expéditeur (PASS, FAIL, GRAY …). |
{
"id": "evt_R3pL9yQ2wE5tY8uI",
"type": "email.received",
"created_at": "2026-09-25T15:02:11.520Z",
"data": {
"reply_id": "cmu8r2d5k00031n7nq4w9pxyz",
"email": "claire@exemple.fr",
"from": "claire@exemple.fr",
"from_name": "Claire Martin",
"to": [
"facturation+cmu651xf200021n7nm68sikfw@reply.votredomaine.fr"
],
"subject": "Re: Votre facture de septembre",
"text": "Bonjour, pouvez-vous l'envoyer aussi à notre comptable ?\n\nLe 25 sept. 2026, Votre entreprise a écrit :\n> Vous trouverez votre facture en pièce jointe.",
"received_at": "2026-09-25T15:02:11.482Z",
"headers": {
"message_id": "CAF3k9x@mail.gmail.com",
"in_reply_to": "0102019a7c3e1b2f-3d1e-000000@eu-west-1.amazonses.com",
"references": [
"0102019a7c3e1b2f-3d1e-000000@eu-west-1.amazonses.com"
],
"date": "Thu, 25 Sep 2026 17:02:08 +0200",
"auto_submitted": null
},
"attachments": [
{
"filename": "bon-de-commande.pdf",
"content_type": "application/pdf",
"size": 48213,
"url": "https://mailcheer.com/api/reponses/pj/Y211OHIy…~kQ3v…",
"url_expires_at": "2026-10-02T15:02:11.520Z"
}
],
"message_id": "cmu651xf200021n7nm68sikfw",
"source": "api",
"tags": {
"client": "4821"
},
"spam": false,
"automatic": false,
"authentication": {
"spf": "PASS",
"dkim": "PASS",
"dmarc": "PASS"
}
}
}
Trois limites à connaître:
- 150 Ko au plus par réponse, en-têtes et pièces jointes encodées comprises. Au-delà, Amazon la refuse, et son expéditeur reçoit un message d'erreur: elle n'arrive jamais.
- Un message que l'antivirus d'Amazon condamne n'est ni gardé ni annoncé.
- Les réponses restent 30 jours dans l'écran Réponses de l'application; ensuite, seul votre logiciel en garde la copie.
Les champs des événements subscriber.*
| Champ | Présent | Sens |
|---|---|---|
subscriber_id | toujours | L'identifiant de l'abonné. |
email | toujours | Son adresse. |
status | toujours | pending (en attente de confirmation), subscribed ou unsubscribed. |
first_name, last_name | à la création, quand ils sont connus | Le prénom et le nom. |
source | à la création | form — un formulaire d'inscription, status: "pending" jusqu'à ce que la personne confirme. api — l'API ou le MCP: pending, ou subscribed avec double_opt_in: false. manual — ajouté dans l'application: subscribed, ou unsubscribed si l'adresse est en liste de suppression. |
reason | à la désinscription | unsubscribe (le lien d'un e-mail, DELETE /api/v1/subscribers/{email}, l'application), ou le motif donné à l'entrée en liste de suppression: manual, unsubscribe, bounce ou complaint. |
campaign_id, message_id | désinscription par le lien d'un e-mail de campagne | La campagne, et la ligne du destinataire (message_id) dont le lien a servi. |
automation_id | désinscription par le lien d'un e-mail d'automatisation | L'automatisation qui l'a envoyé. |
{
"id": "evt_Hs5tUv6Wx7Yz8Ab9",
"type": "subscriber.created",
"created_at": "2026-09-25T11:20:05.301Z",
"data": {
"subscriber_id": "cmu7c4n2p00071n7n5r8t2def",
"email": "lea@exemple.fr",
"status": "pending",
"first_name": "Léa",
"last_name": "Morel",
"source": "form"
}
}
{
"id": "evt_Kq1wE2rT3yU4iO5p",
"type": "subscriber.unsubscribed",
"created_at": "2026-09-25T12:41:58.866Z",
"data": {
"subscriber_id": "cmu5x0p3r00041n7n8m2t6hij",
"email": "claire@exemple.fr",
"status": "unsubscribed",
"reason": "unsubscribe",
"campaign_id": "cmu79z1ab00001n7nqk2d4efg",
"message_id": "cmu7a2k9q000b1n7n3v5c8xyz"
}
}
Trois choses que ces événements ne font pas:
- La confirmation d'un abonné
pendingne produit pas d'événement. LisezGET /api/v1/subscribers/{email}: sonstatuspasse àsubscribed. - Un rebond ou une plainte ne produit pas
subscriber.unsubscribed. Il arrive enemail.bouncedouemail.complained. - La même personne peut être annoncée désinscrite plus d'une fois — un second clic sur le lien, par exemple. Traitez
subscriber.unsubscribedcomme idempotent.
Ce que porte chaque appel
Un POST avec Content-Type: application/json, User-Agent: Mailcheer-Webhooks/1.0 (+https://mailcheer.com/docs/api), et ces en-têtes:
| En-tête | Valeur |
|---|---|
Mailcheer-Signature | t=<horodatage unix>,v1=<hex> — voir plus bas. |
Mailcheer-Timestamp | Le même t, en secondes. |
Mailcheer-Event-Id | L' id de l'événement: le même à chaque tentative et à chaque rejeu. |
Mailcheer-Event-Type | Le type de l'événement. |
Mailcheer-Delivery-Id | Cette remise. Un rejeu à la main en reçoit une nouvelle. |
Mailcheer-Attempt | Le numéro de la tentative, à partir de 1. |
Vérifier la signature
v1 est le HMAC-SHA256, en hexadécimal, de "<t>.<corps brut>", avec le secret de l'abonnement pour clé. Trois règles:
- Calculez-le sur le corps brut, tel qu'il est arrivé. Un corps lu puis réécrit change d'une espace ou d'un ordre de clés, et la signature avec lui.
- Comparez en temps constant.
- Refusez un
tà plus de 300 secondes de votre horloge. La signature reste valable pour toujours, l'horodatage non: c'est ce qui empêche de rejouer plus tard un appel intercepté.
La fonction avec laquelle Mailcheer vérifie lui-même ses signatures, prête à copier (Node.js):
import { createHmac, timingSafeEqual } from "node:crypto";
// secret : "whsec_…"
// entete : l'en-tête Mailcheer-Signature
// corpsBrut : le corps tel qu'il est arrivé, intact
export function verifierSignature(
secret,
entete,
corpsBrut,
maintenantS = Math.floor(Date.now() / 1000),
) {
if (!entete) return false;
const parts = new Map();
for (const morceau of entete.split(",")) {
const i = morceau.indexOf("=");
if (i > 0) {
parts.set(morceau.slice(0, i).trim(), morceau.slice(i + 1));
}
}
const t = Number(parts.get("t"));
const v1 = parts.get("v1");
if (!Number.isFinite(t) || !v1) return false;
if (Math.abs(maintenantS - t) > 300) return false;
const attendu = Buffer.from(
createHmac("sha256", secret)
.update(\`${t}.${corpsBrut}\`, "utf8")
.digest("hex"),
"utf8",
);
const recu = Buffer.from(v1, "utf8");
if (attendu.length !== recu.length) return false;
return timingSafeEqual(attendu, recu);
}
Dans une route Next.js, lisez le corps en texte avant toute chose:
export async function POST(req) {
const brut = await req.text();
const ok = verifierSignature(
process.env.MAILCHEER_WEBHOOK_SECRET,
req.headers.get("mailcheer-signature"),
brut,
);
if (!ok) return new Response("signature invalide", { status: 401 });
const evenement = JSON.parse(brut);
if (evenement.data.test === true) {
return new Response(null, { status: 204 }); // l'essai
}
// ignorez un evenement.id déjà traité,
// puis traitez evenement.type
return new Response(null, { status: 204 });
}
Avec Express, la même chose demande express.raw({ type: "application/json" }) sur cette route.
Répondre, réessais et doublons
Répondez 2xx en moins de 15 secondes. Tout le reste est un échec: un autre statut, une redirection (elle n'est pas suivie), pas de réponse au bout de 15 secondes, une erreur réseau. Faites le travail en arrière-plan et répondez tout de suite — un traitement long ressemble trait pour trait à une panne.
Après un échec, Mailcheer réessaie: 5 tentatives en tout. La première part tout de suite, les suivantes 30 s, 2 min, 10 min et 1 h après l'échec précédent. Les réessais sont relevés toutes les 15 secondes: l'un d'eux peut arriver quelques secondes plus tard. Après le cinquième échec, la remise est failed et n'est plus réessayée d'elle-même — et le propriétaire de l'espace reçoit un e-mail qui nomme l'abonnement, l'événement et la dernière réponse, au plus un par abonnement et par jour: rejouez-la depuis Réglages → API (Rejouer), ou par POST /api/v1/webhooks/{id}/deliveries/{deliveryId}/replay. GET /api/v1/webhooks/{id}/deliveries liste les remises: statut, tentatives, votre code de réponse, les 500 premiers caractères de votre réponse, la prochaine tentative.
Le même événement peut vous arriver plus d'une fois: un réessai après une réponse perdue, un rejeu à la main. Son id — aussi dans Mailcheer-Event-Id — ne change pas: gardez-le, et ignorez un id déjà traité. Mailcheer-Delivery-Id change avec un rejeu; il ne peut pas servir à ça.
Les événements peuvent arriver dans le désordre: un événement réessayé pendant une heure arrive après ceux qui l'ont suivi. Rangez-les par created_at.
L'événement d'essai
POST /api/v1/webhooks/{id}/test — ou le bouton Envoyer un essai de Réglages → API — envoie un événement d'essai par exactement le même chemin qu'un vrai: même signature, mêmes en-têtes, même journal, mêmes réessais. Mailcheer attend votre réponse, et la sienne vous dit tout de suite ce qu'il en est (status, response_status, error).
⚠️ L'événement d'essai ne porte aucun des champs du vrai événement. Son type est le premier événement de l'abonnement, mais son data ne contient que test: true et un message:
{
"id": "evt_Zx9cV8bN7mL6kJ5h",
"type": "subscriber.unsubscribed",
"created_at": "2026-09-25T14:07:22.190Z",
"data": {
"test": true,
"message": "Événement d'essai envoyé depuis Mailcheer."
}
}
Pas d' email, pas de subscriber_id, pas de message_id. Vérifiez data.test === true avant de lire quoi que ce soit d'autre, et répondez 2xx sans le traiter. Sinon votre code cherche des champs absents, échoue, et l'essai passe pour un branchement cassé. Le message suit la langue de l'appel (Accept-Language) ou de l'écran d'où l'essai est parti.
Les événements du mode test
Un e-mail envoyé avec une clé de test (voir Le mode test) produit ses événements comme un vrai: même chemin, même signature, mêmes réessais. Un seul champ les distingue, "test": true en haut du corps, à côté de type:
{
"id": "evt_Qp3rS8tU1vW4xY7z",
"type": "email.bounced",
"created_at": "2026-09-25T09:41:07.512Z",
"test": true,
"data": {
"message_id": "cmu7t3kq80004mc0t9e1vx2ab",
"email": "bounced@simulator.mailcheer.com",
"source": "api",
"subject": "Votre facture de septembre",
"reason": "Permanent",
"recipient_type": "to"
}
}
À la différence de l'événement d'essai ci-dessus, son data est exactement celui d'un vrai événement: votre code de traitement tourne comme en production, et c'est tout l'intérêt. Un vrai événement n'a pas de champ test. Pour garder les événements de test hors de vos données, vérifiez event.test === true — jamais data.test, que seul l'événement d'essai porte.
La langue des réponses
Les messages d'erreur suivent votre en-tête Accept-Language: français si vous le demandez, anglais par défaut.
curl https://mailcheer.com/api/v1/me
# {"error":{"code":"missing_api_key","message":"Missing API key. Add the header …"}}
curl https://mailcheer.com/api/v1/me -H "Accept-Language: fr"
# {"error":{"code":"missing_api_key","message":"Clé d'API absente. Ajoutez l'en-tête …"}}
Cela vaut pour tout ce qu'un programme lit: les messages d'erreur de l'API, les outils du serveur MCP (leur nom, ce qu'ils font, leurs paramètres), la référence servie sur mailcheer://docs, la carte GET /api/v1 et le message de l'événement d'essai des webhooks.
Seule la première préférence est lue: fr-FR,fr;q=0.9,en;q=0.8 demande du français, même si l'anglais y figure — et en-US,fr;q=0.9 demande de l'anglais.
Une clé peut avoir sa propre langue — Réglages → API, Régler. Elle répond quand l'appel n'en demande aucune: pas d' Accept-Language, ou un dont la première préférence n'est ni l'anglais ni le français (*, de-DE …). Un Accept-Language en anglais ou en français l'emporte toujours sur elle. GET /api/v1/me la rend dans key.language.
⚠️ Le code, lui, ne change jamais de langue — c'est contre lui qu'on écrit sa logique, jamais contre le message.
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": "bonjour@exemple.fr", "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 Mailcheer: 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.
| Code | Statut | Ce que ça veut dire |
|---|---|---|
missing_api_key | 401 | Aucun en-tête Authorization. |
invalid_api_key | 401 | Clé inconnue. |
revoked_api_key | 401 | Clé coupée dans les réglages. |
insufficient_scope | 403 | La clé n'a pas le droit demandé. |
reputation_blocked | 403 | Vos envois sont bloqués: trop de rebonds ou de plaintes. |
sending_blocked | 403 | Les envois de l'espace sont arrêtés (suspendu, bloqué ou banni): rien ne part, par aucune voie. details.reason dit lequel. |
commitment_required | 403 | L'engagement anti-spam n'est pas accepté: il s'ouvre au premier envoi depuis l'espace, ou sur la page /fr/engagement. |
sending_paused | 423 | L'espace est en pause le temps d'une relecture de sécurité. L'appel n'a rien de faux: renvoyez-le tel quel après la décision. Retry-After donne un intervalle de relance; tant que la relecture dure, l'appel répond de nouveau 423 et n'écrit rien. |
daily_quota_exceeded | 429 | Un espace neuf a atteint son plafond du jour: 100 e-mails par 24 heures glissantes pendant ses trois premiers jours, 500 jusqu'au septième. Retry-After et details disent quand réessayer. |
quota_exceeded | 402 | Le quota mensuel de l'offre ne couvre pas l'envoi: rien n'est parti. details donne plan, quota, sent, in_flight, remaining, requested et resets_at. |
free_plan_domain_used | 402 | Espace gratuit: le domaine d'envoi a déjà servi l'offre gratuite ce mois-ci dans un autre espace. Rien n'est parti. details donne domain, root_domain, period et resets_at. |
key_quota_exceeded | 402 | Cette clé a atteint son propre plafond mensuel (Réglages → API); l'espace, lui, peut avoir encore de la place. Rien n'est parti. details donne key_id, key_name, limit, used, remaining, requested et resets_at. |
not_found | 404 | L'objet n'existe pas dans cet espace. |
conflict | 409 | État incompatible: campagne déjà partie, abonné sorti. |
idempotency_key_reused | 409 | Même Idempotency-Key, corps différent. |
idempotency_key_in_use | 409 | Un appel identique, même Idempotency-Key, est encore en cours. Renvoyez-le tel quel après Retry-After: vous recevez sa réponse, sans second envoi. |
validation_error | 422 | Un champ est absent ou mal formé. |
unverified_from_domain | 422 | Le domaine de from n'est pas vérifié. |
suppressed_recipient | 422 | Un destinataire est écarté: rebond, plainte, retrait à la main, ou désinscription si l'envoi porte unsubscribe_url. |
rate_limit_exceeded | 429 | Plus de 600 requêtes par minute — un lot de N abonnés compte pour N (voir Retry-After). |
send_failed | 502 | Notre fournisseur d'envoi a refusé le message. |
internal_error | 500 | Une panne de notre côté. |
Tout 429 et tout 423 portent Retry-After, en secondes: attendez ce délai avant de renvoyer le même appel.
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. Un lot de N abonnés (POST /api/v1/subscribers/batch) compte pour N requêtes. Ne confondez pas ces en-têtes avec ceux du quota du mois (Mailcheer-Quota-*, plus haut): le débit se compte à la minute, le quota au mois.
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. Mailcheer expose un serveur MCP hébergé: rien à installer, une adresse et votre clé dans l'en-tête Authorization.
Claude Code
claude mcp add --transport http mailcheer https://mailcheer.com/api/mcp \
--header "Authorization: Bearer mch_live_…"
Ajoutez --scope user pour que le branchement vaille dans tous vos projets, et non seulement le dossier courant.
Codex — il lit la clé dans une variable d'environnement et l'envoie lui-même dans l'en-tête Authorization:
export MAILCHEER_API_KEY="mch_live_…"
codex mcp add mailcheer \
--url https://mailcheer.com/api/mcp \
--bearer-token-env-var MAILCHEER_API_KEY
La commande écrit ceci dans ~/.codex/config.toml, que vous pouvez aussi écrire à la main:
[mcp_servers.mailcheer]
url = "https://mailcheer.com/api/mcp"
bearer_token_env_var = "MAILCHEER_API_KEY"
Cursor — dans .cursor/mcp.json (un projet) ou ~/.cursor/mcp.json (tous vos projets):
{
"mcpServers": {
"mailcheer": {
"url": "https://mailcheer.com/api/mcp",
"headers": { "Authorization": "Bearer mch_live_…" }
}
}
}
Puis, dans votre agent: « Quel espace Mailcheer 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
| Outil | Ce qu'il fait |
|---|---|
get_account | L'espace, les droits, la consommation du mois (file et remise à zéro comprises), le paiement, les limites, les domaines vérifiés. |
send_email | Envoie un e-mail unitaire. Irréversible. |
get_email | L'état d'un e-mail parti. |
list_subscribers | Liste les abonnés, page par page. |
add_subscriber | Ajoute ou met à jour un abonné. |
remove_subscriber | Désinscrit et écarte l'adresse. Irréversible. |
list_tags | Les étiquettes, avec le nombre d'abonnés qui portent chacune. |
tag_subscriber | Pose et retire des étiquettes sur un abonné, sans inscription ni e-mail. |
erase_subscriber | Efface une personne à sa demande (RGPD). Irréversible. |
list_suppression | Les adresses écartées, avec leur motif. |
add_suppression | Écarte une adresse. Irréversible. |
list_campaigns | Les campagnes de l'espace. |
create_campaign | Crée un brouillon. Rien ne part. |
update_campaign | Modifie un brouillon. Rien ne part. |
preview_campaign_send | Dit si la campagne partirait et à combien de personnes, adresse par adresse avec to. Rien ne part. |
send_campaign | Envoie à tous les abonnés actifs, ou aux seuls abonnés actifs parmi les adresses de to. Irréversible. |
get_campaign_stats | Chiffres 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 mailcheer://docs donne à l'agent la référence complète: il n'a pas besoin de la connaître à l'avance.
Le SDK Node.js
Le paquet officiel pour Node.js et TypeScript enveloppe cette API: des types pour chaque appel et chaque événement, des codes d'erreur stables, des réessais qui n'envoient jamais deux fois, le quota lu dans chaque réponse et la signature des webhooks vérifiée en une ligne. Aucune dépendance; ESM et CommonJS. mailcheer sur npm.
npm install mailcheer # Node.js 20+, Bun, Deno
import { Mailcheer } from "mailcheer";
const mailcheer = new Mailcheer(process.env.MAILCHEER_API_KEY, { language: "fr" });
const { data, error, meta } = await mailcheer.emails.send(
{
from: "Ma Boutique <factures@ma-boutique.fr>",
to: "client@exemple.fr",
subject: "Votre facture de septembre",
html: "<p>La voici.</p>",
},
{ idempotencyKey: "facture-2026-0042" },
);
if (error) console.error(error.code, error.message);
else console.log(data.id, meta.quota?.remaining);
Ce qu'il fait pour vous:
- Des réessais qui n'envoient jamais deux fois. Après une panne réseau, un délai dépassé, un
429ou un5xx, le SDK réessaie — deux fois par défaut — et attendRetry-Afterquand l'API le donne. Un appel qui modifie quelque chose n'est réessayé que s'il porte une clé anti-doublon, ou si l'API l'a refusé avant de le lire (rate_limit_exceeded). - Des erreurs sur lesquelles brancher votre code. Chaque méthode rend
{ data, error, meta }et ne lève jamais;errorest uneMailcheerErrorqui porte lecodestable, lesdetailsetretryAfter. Messages en anglais par défaut, en français aveclanguage: "fr". - Votre quota.
meta.quotaetmailcheer.lastQuotagardent les chiffresMailcheer-Quota-*;meta.keyQuotaceux d'une clé qui a son propre plafond mensuel;meta.modevauttestpour une clé de test (mch_test_…). - Les webhooks.
constructWebhookEvent(secret, entete, corpsBrut)vérifie la signature et la fenêtre de cinq minutes exactement comme le décrit Vérifier la signature, puis rend l'événement, typé selon sontype. Sur un environnement « edge » sansnode:crypto,constructWebhookEventAsync()fait la même chose avec Web Crypto. - Toutes les routes de cette page — e-mails, abonnés et lots, étiquettes, liste de suppression, campagnes et brouillons, webhooks — et
request()pour une route plus récente que la version que vous utilisez.
Le SDK Python
Le paquet officiel pour Python enveloppe la même API: un client bloquant et un client asyncio aux mêmes méthodes, des réponses et des erreurs typées, des réessais qui n'envoient jamais deux fois, le quota lu dans chaque réponse et la signature des webhooks vérifiée en une ligne. Une seule dépendance, httpx. mailcheer sur PyPI.
pip install mailcheer # Python 3.9+
from mailcheer import Mailcheer, MailcheerError
mailcheer = Mailcheer(language="fr") # lit la variable MAILCHEER_API_KEY
try:
email = mailcheer.emails.send(
{
"from": "Ma Boutique <factures@ma-boutique.fr>",
"to": "client@exemple.fr",
"subject": "Votre facture de septembre",
"html": "<p>La voici.</p>",
},
idempotency_key="facture-2026-0042",
)
print(email["id"], mailcheer.last_quota)
except MailcheerError as error:
print(error.code, error.message)
Ce qu'il fait pour vous:
- Des réessais qui n'envoient jamais deux fois. Après une panne réseau, un délai dépassé, un
429ou un5xx, le SDK réessaie — deux fois par défaut (max_retries) — et attendRetry-Afterquand l'API le donne. Un appel qui modifie quelque chose n'est réessayé que s'il porte une clé anti-doublon, ou si l'API l'a refusé avant de le lire (rate_limit_exceeded). - Une exception par famille de refus. À la différence du SDK Node.js, une méthode lève:
MailcheerError, ou la sous-classe de son statut —AuthenticationError(401),QuotaExceededError(402),ValidationError(422),RateLimitError(429)… Chacune porte lecodestable, lesdetailsetretry_after. Messages en anglais par défaut, en français aveclanguage="fr". - Votre quota.
mailcheer.last_quotagarde les chiffresMailcheer-Quota-*;mailcheer.last_key_quotaceux d'une clé qui a son propre plafond mensuel;mailcheer.last_response.modevaut"test"pour une clé de test (mch_test_…). asyncio.AsyncMailcheera les mêmes méthodes, à attendre avecawait;list_all()parcourt toutes les pages, avecforouasync for.- Toutes les routes de cette page — e-mails, abonnés et lots, étiquettes, liste de suppression, campagnes et brouillons, webhooks — et
request()pour une route plus récente que la version que vous utilisez.
Pour les webhooks, construct_webhook_event() vérifie la signature et la fenêtre de cinq minutes exactement comme le décrit Vérifier la signature, puis rend l'événement — ou lève WebhookSignatureError. Donnez-lui le corps brut, jamais le JSON décodé, qu'il refuse: request.get_data() avec Flask, request.body avec Django, await request.body() avec FastAPI.
from mailcheer import WebhookSignatureError, construct_webhook_event, is_test_event
@app.post("/webhooks/mailcheer") # Flask
def webhook_mailcheer():
try:
evenement = construct_webhook_event(
os.environ["MAILCHEER_WEBHOOK_SECRET"],
request.headers.get("Mailcheer-Signature"),
request.get_data(),
)
except WebhookSignatureError:
return "signature invalide", 401
if is_test_event(evenement):
return "", 204 # l'essai
# ignorez un evenement["id"] déjà traité, puis traitez evenement["type"]
return "", 204
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é.
Trois façons de basculer.
Avec le SDK — npm install mailcheer: la même forme { data, error } que le SDK de Resend, avec en plus les types, les réessais et le quota (voir Le SDK Node.js).
- const resend = new Resend(process.env.RESEND_API_KEY);
+ const mailcheer = new Mailcheer(process.env.MAILCHEER_API_KEY);
Avec le client minimal — un fichier à copier, aucune dépendance, la même signature que le SDK de Resend. Récupérez-le: mailcheer.com/mailcheer-client.fr.ts — la version anglaise, canonique, est sur /mailcheer-client.ts.
// avant
const resend = new Resend(process.env.RESEND_API_KEY);
// après
const mailcheer = new Mailcheer(process.env.MAILCHEER_API_KEY);
// le reste de votre code ne bouge pas
const { data, error } = await mailcheer.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://mailcheer.com/api/v1/emails", {
method: "POST",
headers: {
Authorization: \`Bearer ${process.env.MAILCHEER_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 lisible par un humain
const id = body.id;
Trois points à connaître:
- Le domaine de
fromdoit être vérifié dans votre espace Mailcheer, pas chez Resend. Ajoutez-le dans Domaines et posez les enregistrements DNS. - Un seul espace suffit pour vos e-mails transactionnels et votre newsletter. Une désinscription de la newsletter n'arrête que les envois en nombre: vos factures et vos réinitialisations de mot de passe partent toujours vers cette adresse. Un rebond ou une plainte arrêtent tout. Si vous envoyez aussi une lettre par l'API, passez
unsubscribe_url: un désinscrit ne la recevra pas. - 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, en français: mailcheer.com/openapi.fr.json — la version anglaise, canonique, est sur /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.