{
  "openapi": "3.1.0",
  "info": {
    "title": "API Mailcheer",
    "version": "1.0.0",
    "summary": "Envoi d'e-mails, abonnés, listes de suppression et campagnes.",
    "description": "L'API publique de Mailcheer.\n\nElle couvre deux usages : l'envoi d'e-mails unitaires (transactionnels) depuis votre application, et le pilotage de vos listes et campagnes — depuis du code ou depuis un agent IA, via le serveur MCP.\n\n## Se brancher en trois minutes\n\n1. **Créez une clé** dans Réglages → API de votre espace. Elle commence par `mch_live_` et porte des droits que vous choisissez.\n2. **Appelez `GET /api/v1/me`.** Il vous rend l'espace, les droits de la clé, le quota restant, les domaines **vérifiés** et les expéditeurs enregistrés. C'est ce qui dit avec quelle adresse écrire : sans lui, votre premier envoi échoue sur `from`.\n3. **Envoyez** avec `POST /api/v1/emails`.\n\n```bash\ncurl https://mailcheer.com/api/v1/emails \\\n  -H \"Authorization: Bearer mch_live_…\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\n    \"from\": \"Votre marque <bonjour@votredomaine.fr>\",\n    \"to\": \"client@exemple.fr\",\n    \"subject\": \"Votre facture de septembre\",\n    \"html\": \"<p>La voici.</p>\"\n  }'\n```\n\n## Ce qui vaut pour tous les appels\n\n- **Adresse de base** `https://mailcheer.com` ; tous les chemins commencent par `/api/v1`. Corps et réponses en JSON (`Content-Type: application/json`).\n- **Authentification** `Authorization: Bearer mch_live_…`, ou l'en-tête `X-Api-Key` si votre passerelle mange le premier.\n- **Débit** 600 requêtes par minute et par clé. Au-delà : 429, avec `Retry-After` en secondes.\n- **Erreurs** toujours la même forme — lisez `error.code` (ou `name`), jamais `message`, qui est écrit pour un humain et peut changer.\n- **Langue** les messages suivent votre en-tête `Accept-Language` : **français si vous le demandez, anglais par défaut** — et seule la première préférence est lue. Le `code`, lui, ne change jamais de langue.\n- **Un espace par clé.** Rien ne traverse d'un espace à l'autre.\n\n## Vous venez de Resend\n\nLes champs et la réponse de `POST /api/v1/emails` sont calqués sur les siens : `reply_to`, `tags` en tableau `{name, value}`, et une erreur qui porte aussi `statusCode` / `message` / `name`. Une migration se résume à changer l'adresse de base et la clé. Une différence à connaître : la réponse est **202**, pas 200 — le message est accepté, pas encore remis.\n\n## Depuis un agent IA (MCP)\n\nLe serveur MCP est à `https://mailcheer.com/api/mcp` — JSON-RPC en **POST**, transport Streamable HTTP, sans session : chaque requête porte la clé dans le même en-tête `Authorization`. Il n'ouvre aucun flux (un `GET` répond 405, ce que les clients savent lire). Les outils rappellent cette API-ci avec votre clé : mêmes droits, même quota, mêmes refus.\n\n## Cinq règles s'appliquent à tout envoi, sans exception\n\n1. `from` doit être sur un domaine vérifié de l'espace ;\n2. une adresse en liste de suppression est refusée, avec son motif — et l'appel entier échoue, jamais un envoi partiel en silence ;\n3. le quota mensuel de l'offre compte ces envois comme les campagnes ;\n4. un taux de rebonds ou de plaintes trop élevé suspend l'envoi ;\n5. 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.\n\n## Webhooks — Mailcheer vous appelle\nL'API répond quand on l'interroge ; un webhook prévient sans qu'on demande. Abonnez une adresse à vous (`POST /api/v1/webhooks`, ou Réglages → API) et Mailcheer y poste un JSON à chaque événement choisi.\n\n**Vérifiez la signature, toujours.** Chaque appel porte l'en-tête `Mailcheer-Signature: t=<horodatage unix>,v1=<hex>` où `v1` est le HMAC-SHA256 de `\"<t>.<corps brut>\"` signé avec le secret de l'abonnement. Comparez à temps constant, et refusez un `t` vieux de plus de 300 secondes — sans ce second contrôle, un appel intercepté reste rejouable indéfiniment.\n\n```js\nimport { createHmac, timingSafeEqual } from \"node:crypto\";\n\nfunction valide(secret, entete, corpsBrut) {\n  const [t, v1] = [/t=(\\d+)/.exec(entete)?.[1], /v1=([a-f0-9]+)/.exec(entete)?.[1]];\n  if (!t || !v1) return false;\n  if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false;\n  const attendu = createHmac(\"sha256\", secret).update(`${t}.${corpsBrut}`).digest(\"hex\");\n  return timingSafeEqual(Buffer.from(attendu), Buffer.from(v1));\n}\n```\n\n**Répondez 2xx en moins de quinze secondes.** Tout le reste est un échec : Mailcheer réessaie quatre fois (30 s, 2 min, 10 min, 1 h) puis abandonne en le journalisant. Faites votre travail en arrière-plan et répondez tout de suite — un traitement long est indiscernable d'une panne.\n\n**Un même événement peut arriver deux fois** (un réessai après une réponse perdue). `id` est stable d'une tentative à l'autre : gardez-le et ignorez les doublons.\n\nLes corps ont tous la même forme : `{ \"id\": \"evt_…\", \"type\": \"…\", \"created_at\": \"…\", \"data\": { … } }`.",
    "contact": {
      "name": "Mailcheer",
      "url": "https://mailcheer.com/docs/api",
      "email": "contact@mailcheer.com"
    },
    "license": {
      "name": "Conditions générales Mailcheer",
      "url": "https://mailcheer.com/cgv"
    }
  },
  "servers": [
    {
      "url": "https://mailcheer.com",
      "description": "Production. Il n'y a pas d'environnement de test séparé : essayez sur un espace à vous, avec une adresse à vous. Tous les chemins ci-dessous incluent déjà le préfixe `/api/v1`."
    }
  ],
  "security": [
    {
      "bearerAuth": []
    },
    {
      "apiKeyHeader": []
    }
  ],
  "tags": [
    {
      "name": "E-mails",
      "description": "Envoi unitaire et suivi."
    },
    {
      "name": "Abonnés",
      "description": "Les personnes inscrites à vos listes."
    },
    {
      "name": "Suppression",
      "description": "Les adresses qui ne recevront plus rien."
    },
    {
      "name": "Campagnes",
      "description": "Brouillons, envoi et statistiques."
    },
    {
      "name": "Espace",
      "description": "À qui appartient la clé."
    },
    {
      "name": "Webhooks",
      "description": "Mailcheer appelle VOTRE service quand il se passe quelque chose."
    }
  ],
  "paths": {
    "/api/v1": {
      "get": {
        "operationId": "getApiMap",
        "tags": ["Espace"],
        "summary": "La carte de l'API",
        "description": "Sans clé : liste les points d'entrée, les droits et l'adresse de la documentation.",
        "security": [],
        "responses": {
          "200": {
            "description": "La carte.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Carte"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/me": {
      "get": {
        "operationId": "getAccount",
        "tags": ["Espace"],
        "summary": "À qui appartient cette clé",
        "description": "Espace, droits de la clé, offre, e-mails restants ce mois-ci, domaines d'envoi et expéditeurs. À appeler en premier : c'est ce qui dit avec quelle adresse écrire. Aucun droit particulier n'est exigé.",
        "responses": {
          "200": {
            "description": "L'état de l'espace.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Account"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/NonAuthentifie"
          },
          "429": {
            "$ref": "#/components/responses/TropDAppels"
          }
        }
      }
    },
    "/api/v1/emails": {
      "post": {
        "operationId": "sendEmail",
        "tags": ["E-mails"],
        "summary": "Envoyer un e-mail",
        "description": "Droit requis : `emails:send`.\n\nRépond **202** : Amazon a accepté le message, il n'est pas encore remis. La remise se constate quelques secondes plus tard avec `GET /api/v1/emails/{id}`.\n\nUtilisez l'en-tête `Idempotency-Key` : rejouer le même appel rend la même réponse sans second envoi.",
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/EmailRequest"
              },
              "examples": {
                "simple": {
                  "summary": "Un destinataire",
                  "value": {
                    "from": "Votre marque <bonjour@votredomaine.fr>",
                    "to": "client@exemple.fr",
                    "subject": "Votre facture de septembre",
                    "html": "<p>La voici, en pièce jointe de ce message.</p>"
                  }
                },
                "complet": {
                  "summary": "Plusieurs destinataires, réponse et étiquettes",
                  "value": {
                    "from": "bonjour@votredomaine.fr",
                    "to": ["marie@exemple.fr", "paul@exemple.fr"],
                    "subject": "Votre commande est prête",
                    "html": "<p>Elle vous attend.</p>",
                    "text": "Elle vous attend.",
                    "replyTo": "sav@votredomaine.fr",
                    "headers": {
                      "X-Entity-Ref-ID": "cmd-4192"
                    },
                    "tags": {
                      "type": "commande"
                    }
                  }
                }
              }
            }
          }
        },
        "responses": {
          "202": {
            "description": "Accepté par notre fournisseur d'envoi.",
            "headers": {
              "Idempotent-Replay": {
                "description": "`true` si la réponse est celle d'un appel identique déjà traité.",
                "schema": {
                  "type": "string"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Email"
                },
                "examples": {
                  "accepte": {
                    "summary": "Accepté — à relire quelques secondes plus tard",
                    "value": {
                      "id": "eml_5c1a9",
                      "object": "email",
                      "from": "bonjour@votredomaine.fr",
                      "to": ["client@exemple.fr"],
                      "subject": "Votre facture de septembre",
                      "created_at": "2026-09-18T09:41:12.000Z"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/NonAuthentifie"
          },
          "402": {
            "$ref": "#/components/responses/QuotaAtteint"
          },
          "403": {
            "$ref": "#/components/responses/Interdit"
          },
          "409": {
            "$ref": "#/components/responses/Conflit"
          },
          "422": {
            "$ref": "#/components/responses/Refuse"
          },
          "429": {
            "$ref": "#/components/responses/TropDAppels"
          },
          "502": {
            "$ref": "#/components/responses/EnvoiEchoue"
          }
        }
      }
    },
    "/api/v1/emails/{id}": {
      "get": {
        "operationId": "getEmail",
        "tags": ["E-mails"],
        "summary": "État d'un e-mail",
        "description": "Droit requis : `emails:send`. Le statut évolue après l'envoi, au rythme des retours de notre fournisseur.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "L'e-mail.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/EmailDetail"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/NonAuthentifie"
          },
          "403": {
            "$ref": "#/components/responses/Interdit"
          },
          "404": {
            "$ref": "#/components/responses/Introuvable"
          }
        }
      }
    },
    "/api/v1/subscribers": {
      "get": {
        "operationId": "listSubscribers",
        "tags": ["Abonnés"],
        "summary": "Lister les abonnés",
        "description": "Droit requis : `subscribers:read`.",
        "parameters": [
          {
            "$ref": "#/components/parameters/Limit"
          },
          {
            "$ref": "#/components/parameters/Cursor"
          },
          {
            "name": "status",
            "in": "query",
            "schema": {
              "type": "string",
              "enum": [
                "pending",
                "subscribed",
                "unsubscribed",
                "bounced",
                "complained"
              ]
            }
          },
          {
            "name": "q",
            "in": "query",
            "description": "Recherche sur l'adresse, le prénom ou le nom.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "La page demandée.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Liste"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "type": "array",
                          "items": {
                            "$ref": "#/components/schemas/Subscriber"
                          }
                        }
                      }
                    }
                  ]
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/NonAuthentifie"
          },
          "403": {
            "$ref": "#/components/responses/Interdit"
          }
        }
      },
      "post": {
        "operationId": "upsertSubscriber",
        "tags": ["Abonnés"],
        "summary": "Ajouter ou mettre à jour un abonné",
        "description": "Droit requis : `subscribers:write`.\n\nPar défaut la personne entre en `pending` et reçoit un e-mail de confirmation. `double_opt_in: false` l'inscrit directement — à n'utiliser que si le consentement a été recueilli ailleurs, et c'est alors l'appelant qui en répond.\n\nUne adresse en liste de suppression, désinscrite ou revenue en rebond définitif est refusée.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/SubscriberRequest"
              },
              "examples": {
                "simple": {
                  "summary": "Une adresse et un prénom",
                  "value": {
                    "email": "marie@exemple.fr",
                    "firstName": "Marie"
                  }
                },
                "complet": {
                  "summary": "Avec étiquettes et champs personnalisés",
                  "value": {
                    "email": "marie@exemple.fr",
                    "firstName": "Marie",
                    "lastName": "Dupont",
                    "tags": ["client", "boutique-paris"],
                    "fields": {
                      "ville": "Ajaccio"
                    }
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Créé.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SubscriberResult"
                }
              }
            }
          },
          "200": {
            "description": "L'abonné existait : il a été mis à jour.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SubscriberResult"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/NonAuthentifie"
          },
          "403": {
            "$ref": "#/components/responses/Interdit"
          },
          "409": {
            "$ref": "#/components/responses/Conflit"
          },
          "422": {
            "$ref": "#/components/responses/Refuse"
          }
        }
      }
    },
    "/api/v1/subscribers/{email}": {
      "get": {
        "operationId": "getSubscriber",
        "tags": ["Abonnés"],
        "summary": "Une fiche d'abonné",
        "description": "Droit requis : `subscribers:read`.",
        "parameters": [
          {
            "$ref": "#/components/parameters/EmailPath"
          }
        ],
        "responses": {
          "200": {
            "description": "La fiche.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Subscriber"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/NonAuthentifie"
          },
          "403": {
            "$ref": "#/components/responses/Interdit"
          },
          "404": {
            "$ref": "#/components/responses/Introuvable"
          }
        }
      },
      "delete": {
        "operationId": "unsubscribeSubscriber",
        "tags": ["Abonnés"],
        "summary": "Désinscrire",
        "description": "Droit requis : `subscribers:write`.\n\n**N'efface pas la fiche** : la personne passe en `unsubscribed` et son adresse entre en liste de suppression. Effacer la trace ferait revenir l'adresse au prochain import de fichier.",
        "parameters": [
          {
            "$ref": "#/components/parameters/EmailPath"
          }
        ],
        "responses": {
          "200": {
            "description": "Désinscrit.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "object": {
                      "type": "string",
                      "const": "subscriber"
                    },
                    "email": {
                      "type": "string"
                    },
                    "status": {
                      "type": "string",
                      "const": "unsubscribed"
                    },
                    "suppressed": {
                      "type": "boolean"
                    },
                    "deleted": {
                      "type": "boolean",
                      "const": false,
                      "description": "Toujours `false` : la fiche est conservée."
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/NonAuthentifie"
          },
          "403": {
            "$ref": "#/components/responses/Interdit"
          },
          "404": {
            "$ref": "#/components/responses/Introuvable"
          }
        }
      }
    },
    "/api/v1/suppression": {
      "get": {
        "operationId": "listSuppressions",
        "tags": ["Suppression"],
        "summary": "Les adresses écartées",
        "description": "Droit requis : `subscribers:read`. Le champ `scope` distingue les adresses écartées pour cet espace (`organization`) de celles écartées pour toute la plateforme (`platform`).",
        "parameters": [
          {
            "$ref": "#/components/parameters/Limit"
          },
          {
            "$ref": "#/components/parameters/Cursor"
          },
          {
            "name": "email",
            "in": "query",
            "description": "Vérifier une adresse précise.",
            "schema": {
              "type": "string",
              "format": "email"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "La page demandée.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Liste"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "type": "array",
                          "items": {
                            "$ref": "#/components/schemas/Suppression"
                          }
                        }
                      }
                    }
                  ]
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/NonAuthentifie"
          },
          "403": {
            "$ref": "#/components/responses/Interdit"
          }
        }
      },
      "post": {
        "operationId": "createSuppression",
        "tags": ["Suppression"],
        "summary": "Écarter une adresse",
        "description": "Droit requis : `subscribers:write`. L'abonné correspondant, s'il existe, passe en `unsubscribed` dans la même opération.\n\n**Aucun point d'entrée ne retire une adresse de cette liste**, et c'est délibéré : c'est le geste qui fait suspendre une capacité d'envoi. Il se fait à la main, dans l'espace Mailcheer.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["email"],
                "properties": {
                  "email": {
                    "type": "string",
                    "format": "email"
                  },
                  "reason": {
                    "type": "string",
                    "enum": ["unsubscribe", "bounce", "complaint", "manual"],
                    "default": "manual"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Écartée.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Suppression"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/NonAuthentifie"
          },
          "403": {
            "$ref": "#/components/responses/Interdit"
          },
          "422": {
            "$ref": "#/components/responses/Refuse"
          }
        }
      }
    },
    "/api/v1/campaigns": {
      "get": {
        "operationId": "listCampaigns",
        "tags": ["Campagnes"],
        "summary": "Lister les campagnes",
        "description": "Droit requis : `campaigns:read`.",
        "parameters": [
          {
            "$ref": "#/components/parameters/Limit"
          },
          {
            "$ref": "#/components/parameters/Cursor"
          },
          {
            "name": "status",
            "in": "query",
            "schema": {
              "type": "string",
              "enum": ["draft", "scheduled", "sending", "sent", "archived"]
            }
          }
        ],
        "responses": {
          "200": {
            "description": "La page demandée.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Liste"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "type": "array",
                          "items": {
                            "$ref": "#/components/schemas/Campaign"
                          }
                        }
                      }
                    }
                  ]
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/NonAuthentifie"
          },
          "403": {
            "$ref": "#/components/responses/Interdit"
          }
        }
      },
      "post": {
        "operationId": "createCampaign",
        "tags": ["Campagnes"],
        "summary": "Créer un brouillon",
        "description": "Droit requis : `campaigns:write`. **Rien ne part** : l'envoi est un second geste, volontairement séparé.\n\nLe contenu s'écrit soit en blocs (`content`), soit en texte simple (`text`) : une ligne vide sépare deux paragraphes, `# ` en début de ligne fait un titre.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/CampaignRequest"
              },
              "examples": {
                "texte": {
                  "summary": "Une lettre écrite en texte simple",
                  "value": {
                    "name": "Lettre de septembre",
                    "subject": "Ce que nous avons changé ce mois-ci",
                    "preheader": "Trois nouveautés, et une qu'on vous devait.",
                    "from": "bonjour@votredomaine.fr",
                    "text": "# Trois nouveautés\n\nVoici ce qui a changé ce mois-ci.\n\nBonne lecture."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Brouillon créé.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "id": {
                      "type": "string"
                    },
                    "object": {
                      "type": "string",
                      "const": "campaign"
                    },
                    "name": {
                      "type": "string"
                    },
                    "status": {
                      "type": "string",
                      "const": "draft"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/NonAuthentifie"
          },
          "403": {
            "$ref": "#/components/responses/Interdit"
          },
          "404": {
            "$ref": "#/components/responses/Introuvable"
          },
          "422": {
            "$ref": "#/components/responses/Refuse"
          }
        }
      }
    },
    "/api/v1/campaigns/{id}": {
      "get": {
        "operationId": "getCampaign",
        "tags": ["Campagnes"],
        "summary": "État et statistiques",
        "description": "Droit requis : `campaigns:read`. Les taux sont calculés sur les messages délivrés, pas sur les destinataires : une adresse morte ne doit pas faire baisser le taux d'ouverture de ceux qui ont reçu.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "La campagne.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Campaign"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/NonAuthentifie"
          },
          "403": {
            "$ref": "#/components/responses/Interdit"
          },
          "404": {
            "$ref": "#/components/responses/Introuvable"
          }
        }
      }
    },
    "/api/v1/campaigns/{id}/stats": {
      "get": {
        "operationId": "getCampaignStats",
        "tags": ["Campagnes"],
        "summary": "Le rapport complet",
        "description": "Droit requis : `campaigns:read`. Tout ce que la fiche campagne de Mailcheer montre, pour l'afficher dans un autre logiciel : compteurs, taux (parts entre 0 et 1, `null` tant que rien n'est parti), courbe des 48 premières heures en huit tranches de six heures (vide si la campagne n'est pas partie), sur quoi les gens lisent (cinq premières lignes, parts entre 0 et 1, `proxiedShare` = part des ouvertures venues d'un relais de confidentialité comme Apple Mail ou Gmail), liens du plus au moins cliqué, et le HTML rendu à l'envoi (chaîne vide sinon). Les taux d'ouverture et de clic sont calculés sur les messages délivrés ; ceux de délivrance et de rebond sur les destinataires.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Le rapport.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/CampaignStats"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/NonAuthentifie"
          },
          "403": {
            "$ref": "#/components/responses/Interdit"
          },
          "404": {
            "$ref": "#/components/responses/Introuvable"
          }
        }
      }
    },
    "/api/v1/campaigns/{id}/send": {
      "post": {
        "operationId": "sendCampaign",
        "tags": ["Campagnes"],
        "summary": "Lancer l'envoi",
        "description": "Droit requis : `campaigns:write`. **Irréversible** (sauf `dry_run`).\n\n⚠️ **Sans corps, la campagne part à TOUS les abonnés de sa cible au statut `subscribed`** (toute la liste, ou le segment choisi dans l'interface) — moins ceux qui sont en liste de suppression, recalculée au dernier moment.\n\n**`to` restreint l'envoi à des adresses.** Il ne peut que restreindre : la lettre part aux adresses demandées qui sont des abonnés actifs de la cible, jamais à une adresse de la liste de suppression. La réponse porte `audience` : qui est retenu, qui est écarté, et pourquoi. Une liste vide n'écrit à personne (422) ; une campagne avec test A/B refuse `to` (la version gagnante partirait plus tard à toute la cible).\n\n**`dry_run: true` fait tous les contrôles et ne met rien en file** : réponse `200` avec `would_send`, `blocked_reason` et le nombre de destinataires. C'est le nombre à montrer AVANT de confirmer. Les autres champs sont ignorés, comme avant — sauf les sosies de `dry_run` (`dryRun`, `dry-run`, `test`, `simulate`…) et de `to` (`To`, `recipients`, `emails`, `to_emails`, `destinataires`, `audience`…), refusés en 422 : ignorés, les premiers feraient partir pour de vrai, les seconds à toute la liste.\n\nL'appel fige la liste des destinataires et remplit la file. Les messages partent ensuite au débit autorisé par notre fournisseur : la réponse dit `sending` et un nombre `queued`, jamais `sent`. Suivez l'avancement avec `GET /api/v1/campaigns/{id}`.\n\nUne campagne déjà en `sending` ou `sent` répond 409 : on ne réexpédie pas, on duplique. Un quota mensuel insuffisant, un espace à la réputation abîmée, aucun expéditeur ou une liste vide répondent 422 avec le motif.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "required": false,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "to": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    },
                    "maxItems": 50000,
                    "description": "Facultatif. Restreint l'envoi à ces adresses — uniquement celles qui sont des abonnés actifs de la cible, jamais une adresse de la liste de suppression. Absent : toute la cible. Vide (`[]`) : personne, l'envoi est refusé."
                  },
                  "dry_run": {
                    "type": "boolean",
                    "description": "`true` : tout vérifier et tout compter, ne rien mettre en file."
                  }
                }
              },
              "examples": {
                "groupe": {
                  "summary": "Écrire à un groupe",
                  "value": {
                    "to": ["claire@exemple.fr", "marc@exemple.fr"]
                  }
                },
                "simulation": {
                  "summary": "Simuler d'abord",
                  "value": {
                    "to": ["claire@exemple.fr", "marc@exemple.fr"],
                    "dry_run": true
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Envoi à blanc (`dry_run`) : rien n'a été mis en file.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "id": {
                      "type": "string"
                    },
                    "object": {
                      "type": "string",
                      "const": "send_preview"
                    },
                    "dry_run": {
                      "type": "boolean",
                      "const": true
                    },
                    "would_send": {
                      "type": "boolean",
                      "description": "Le vrai envoi, lancé maintenant, partirait-il ?"
                    },
                    "blocked_reason": {
                      "type": ["string", "null"],
                      "description": "Sinon, le message exact que rendrait le vrai envoi."
                    },
                    "recipients": {
                      "type": ["integer", "null"],
                      "description": "Combien seraient mis en file ; `null` si le refus arrive avant de compter."
                    },
                    "audience": {
                      "oneOf": [
                        {
                          "$ref": "#/components/schemas/Audience"
                        },
                        {
                          "type": "null"
                        }
                      ],
                      "description": "Le détail par adresse, quand `to` est fourni."
                    }
                  }
                }
              }
            }
          },
          "202": {
            "description": "Destinataires figés et mis en file.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "id": {
                      "type": "string"
                    },
                    "object": {
                      "type": "string",
                      "const": "campaign"
                    },
                    "status": {
                      "type": "string",
                      "const": "sending"
                    },
                    "queued": {
                      "type": "integer",
                      "description": "Le nombre de destinataires réellement figés dans la file."
                    },
                    "audience": {
                      "$ref": "#/components/schemas/Audience",
                      "description": "Présent quand l'envoi a été restreint par `to`."
                    },
                    "note": {
                      "type": "string",
                      "description": "Ce qu'il reste à faire, en clair. Écrit pour un humain ; n'écrivez pas de logique dessus."
                    }
                  }
                },
                "examples": {
                  "file": {
                    "summary": "Mise en file",
                    "value": {
                      "id": "cmp_8d2",
                      "object": "campaign",
                      "status": "sending",
                      "queued": 3182,
                      "note": "3182 destinataire(s) mis en file. Les messages partent ensuite au débit autorisé par notre fournisseur d'envoi ; suivez l'avancement avec GET /v1/campaigns/cmp_8d2."
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/NonAuthentifie"
          },
          "403": {
            "$ref": "#/components/responses/Interdit"
          },
          "404": {
            "$ref": "#/components/responses/Introuvable"
          },
          "409": {
            "$ref": "#/components/responses/Conflit"
          },
          "422": {
            "$ref": "#/components/responses/Refuse"
          }
        }
      }
    },
    "/api/v1/audience": {
      "post": {
        "operationId": "previewAudience",
        "tags": ["Campaigns"],
        "summary": "Qui recevrait, sans créer de campagne",
        "description": "Droit requis : `subscribers:read`. **Lecture seule** : rien n'est créé, rien n'est envoyé.\n\nRend ce qu'une campagne envoyée maintenant à toute la liste atteindrait — restreinte à `to` s'il est fourni, avec le même bilan adresse par adresse que l'envoi. Pour afficher « 12 recevront, 3 sont écartés » pendant qu'on choisit à qui écrire, sans créer de brouillon.\n\nLes contrôles propres à une campagne (objet, contenu, quota, segment) restent ceux de `dry_run` sur `POST /api/v1/campaigns/{id}/send`.",
        "requestBody": {
          "required": false,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "additionalProperties": false,
                "properties": {
                  "to": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    },
                    "maxItems": 50000,
                    "description": "Facultatif. Restreint l'envoi à ces adresses — uniquement celles qui sont des abonnés actifs de la cible, jamais une adresse de la liste de suppression. Absent : toute la cible. Vide (`[]`) : personne, l'envoi est refusé."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Le nombre de destinataires, et le bilan par adresse si `to` est fourni.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "object": {
                      "type": "string",
                      "const": "audience"
                    },
                    "recipients": {
                      "type": "integer"
                    },
                    "audience": {
                      "oneOf": [
                        {
                          "$ref": "#/components/schemas/Audience"
                        },
                        {
                          "type": "null"
                        }
                      ]
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/NonAuthentifie"
          },
          "403": {
            "$ref": "#/components/responses/Interdit"
          },
          "422": {
            "$ref": "#/components/responses/Refuse"
          }
        }
      }
    },
    "/api/v1/webhooks": {
      "get": {
        "operationId": "listWebhooks",
        "tags": ["Webhooks"],
        "summary": "Vos abonnements",
        "description": "Droit requis : `webhooks:read`. Les secrets ne figurent pas dans la liste — lisez l'abonnement pour l'obtenir.",
        "responses": {
          "200": {
            "description": "Les abonnements de l'espace.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Liste"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "type": "array",
                          "items": {
                            "$ref": "#/components/schemas/Webhook"
                          }
                        }
                      }
                    }
                  ]
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/NonAuthentifie"
          },
          "403": {
            "$ref": "#/components/responses/Interdit"
          }
        }
      },
      "post": {
        "operationId": "createWebhook",
        "tags": ["Webhooks"],
        "summary": "Créer un abonnement",
        "description": "Droit requis : `webhooks:write`. La réponse porte le `secret` — rangez-le comme un mot de passe, il sert à vérifier chaque appel.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/WebhookRequest"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Créé.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Webhook"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/NonAuthentifie"
          },
          "403": {
            "$ref": "#/components/responses/Interdit"
          },
          "422": {
            "$ref": "#/components/responses/Refuse"
          }
        }
      }
    },
    "/api/v1/webhooks/{id}": {
      "parameters": [
        {
          "name": "id",
          "in": "path",
          "required": true,
          "description": "L'identifiant de l'abonnement.",
          "schema": {
            "type": "string"
          }
        }
      ],
      "get": {
        "operationId": "getWebhook",
        "tags": ["Webhooks"],
        "summary": "Un abonnement, secret compris",
        "description": "Droit requis : `webhooks:read`. Le secret est lisible, contrairement à une clé d'API : il ne donne accès à rien chez nous, et un service qu'on redéploie en a besoin pour vérifier les signatures.",
        "responses": {
          "200": {
            "description": "L'abonnement.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Webhook"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/NonAuthentifie"
          },
          "403": {
            "$ref": "#/components/responses/Interdit"
          },
          "404": {
            "$ref": "#/components/responses/Introuvable"
          }
        }
      },
      "patch": {
        "operationId": "updateWebhook",
        "tags": ["Webhooks"],
        "summary": "Modifier un abonnement",
        "description": "Droit requis : `webhooks:write`. Passez `enabled: false` pour suspendre sans perdre le journal.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "name": {
                    "type": "string",
                    "maxLength": 80
                  },
                  "url": {
                    "type": "string",
                    "format": "uri"
                  },
                  "events": {
                    "type": "array",
                    "minItems": 1,
                    "items": {
                      "type": "string",
                      "enum": [
                        "email.delivered",
                        "email.opened",
                        "email.clicked",
                        "email.bounced",
                        "email.complained",
                        "subscriber.created",
                        "subscriber.unsubscribed"
                      ]
                    }
                  },
                  "enabled": {
                    "type": "boolean"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Modifié.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Webhook"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/NonAuthentifie"
          },
          "403": {
            "$ref": "#/components/responses/Interdit"
          },
          "404": {
            "$ref": "#/components/responses/Introuvable"
          },
          "422": {
            "$ref": "#/components/responses/Refuse"
          }
        }
      },
      "delete": {
        "operationId": "deleteWebhook",
        "tags": ["Webhooks"],
        "summary": "Supprimer un abonnement",
        "description": "Droit requis : `webhooks:write`. Le journal des remises part avec lui.",
        "responses": {
          "200": {
            "description": "Supprimé.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "object": {
                      "type": "string",
                      "const": "webhook"
                    },
                    "id": {
                      "type": "string"
                    },
                    "deleted": {
                      "type": "boolean"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/NonAuthentifie"
          },
          "403": {
            "$ref": "#/components/responses/Interdit"
          },
          "404": {
            "$ref": "#/components/responses/Introuvable"
          }
        }
      }
    },
    "/api/v1/webhooks/{id}/test": {
      "parameters": [
        {
          "name": "id",
          "in": "path",
          "required": true,
          "description": "L'identifiant de l'abonnement.",
          "schema": {
            "type": "string"
          }
        }
      ],
      "post": {
        "operationId": "testWebhook",
        "tags": ["Webhooks"],
        "summary": "Envoyer un événement d'essai",
        "description": "Droit requis : `webhooks:write`. Même signature, mêmes en-têtes, même journal qu'un vrai événement — et la réponse de votre service est ATTENDUE, pour que vous sachiez tout de suite. Le corps porte `\"data\": { \"test\": true }`.",
        "responses": {
          "200": {
            "description": "L'essai a été tenté — lisez `status`.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "object": {
                      "type": "string",
                      "const": "webhook_test"
                    },
                    "delivery_id": {
                      "type": "string"
                    },
                    "status": {
                      "type": "string",
                      "enum": ["delivered", "pending", "failed"]
                    },
                    "attempts": {
                      "type": "integer"
                    },
                    "response_status": {
                      "type": ["integer", "null"]
                    },
                    "error": {
                      "type": ["string", "null"]
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/NonAuthentifie"
          },
          "403": {
            "$ref": "#/components/responses/Interdit"
          },
          "404": {
            "$ref": "#/components/responses/Introuvable"
          }
        }
      }
    },
    "/api/v1/webhooks/{id}/deliveries": {
      "parameters": [
        {
          "name": "id",
          "in": "path",
          "required": true,
          "description": "L'identifiant de l'abonnement.",
          "schema": {
            "type": "string"
          }
        }
      ],
      "get": {
        "operationId": "listWebhookDeliveries",
        "tags": ["Webhooks"],
        "summary": "Le journal des remises",
        "description": "Droit requis : `webhooks:read`. C'est ce qui répond à « je ne reçois rien » : on y voit la tentative, le code rendu par votre service et le début de sa réponse.",
        "parameters": [
          {
            "$ref": "#/components/parameters/Limit"
          }
        ],
        "responses": {
          "200": {
            "description": "Les dernières remises.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Liste"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "type": "array",
                          "items": {
                            "$ref": "#/components/schemas/WebhookDelivery"
                          }
                        }
                      }
                    }
                  ]
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/NonAuthentifie"
          },
          "403": {
            "$ref": "#/components/responses/Interdit"
          },
          "404": {
            "$ref": "#/components/responses/Introuvable"
          }
        }
      }
    },
    "/api/v1/webhooks/{id}/deliveries/{deliveryId}/replay": {
      "parameters": [
        {
          "name": "id",
          "in": "path",
          "required": true,
          "description": "L'identifiant de l'abonnement.",
          "schema": {
            "type": "string"
          }
        },
        {
          "name": "deliveryId",
          "in": "path",
          "required": true,
          "description": "L'identifiant de la remise à rejouer.",
          "schema": {
            "type": "string"
          }
        }
      ],
      "post": {
        "operationId": "replayWebhookDelivery",
        "tags": ["Webhooks"],
        "summary": "Rejouer une remise",
        "description": "Droit requis : `webhooks:write`. Le corps rejoué est celui d'origine, à l'octet près — donc le même `id` d'événement. Si votre service l'avait finalement reçu, il reconnaîtra un doublon.",
        "responses": {
          "200": {
            "description": "Rejoué — lisez `status`.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "object": {
                      "type": "string",
                      "const": "webhook_delivery"
                    },
                    "id": {
                      "type": "string"
                    },
                    "status": {
                      "type": "string",
                      "enum": ["delivered", "pending", "failed"]
                    },
                    "attempts": {
                      "type": "integer"
                    },
                    "response_status": {
                      "type": ["integer", "null"]
                    },
                    "error": {
                      "type": ["string", "null"]
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/NonAuthentifie"
          },
          "403": {
            "$ref": "#/components/responses/Interdit"
          },
          "404": {
            "$ref": "#/components/responses/Introuvable"
          }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "bearerAuth": {
        "type": "http",
        "scheme": "bearer",
        "description": "Une clé d'API de l'espace, créée dans Réglages → API de votre espace Mailcheer. Forme : `mch_live_…`.\n\n`Authorization: Bearer mch_live_…`\n\nLa clé porte des droits (`scopes`) : chaque opération dit lequel elle exige, et `GET /api/v1/me` dit ceux que porte la vôtre. Une clé vaut pour UN espace — il n'y a pas d'appel inter-espaces."
      },
      "apiKeyHeader": {
        "type": "apiKey",
        "in": "header",
        "name": "X-Api-Key",
        "description": "La même clé, dans un en-tête dédié. Strictement équivalent au Bearer : certains clients d'entreprise et passerelles ne laissent pas passer `Authorization`. N'envoyez pas les deux."
      }
    },
    "parameters": {
      "Limit": {
        "name": "limit",
        "in": "query",
        "description": "Nombre d'éléments par page, de 1 à 100.",
        "schema": {
          "type": "integer",
          "minimum": 1,
          "maximum": 100,
          "default": 50
        }
      },
      "Cursor": {
        "name": "cursor",
        "in": "query",
        "description": "Le `next_cursor` de la réponse précédente.",
        "schema": {
          "type": "string"
        }
      },
      "EmailPath": {
        "name": "email",
        "in": "path",
        "required": true,
        "description": "L'adresse, encodée pour l'URL.",
        "schema": {
          "type": "string",
          "format": "email"
        }
      },
      "IdempotencyKey": {
        "name": "Idempotency-Key",
        "in": "header",
        "description": "Une valeur unique par envoi. Rejouer le même appel rend la même réponse, sans second envoi. La même clé avec un corps différent répond 409.",
        "schema": {
          "type": "string",
          "maxLength": 200
        }
      }
    },
    "schemas": {
      "EmailRequest": {
        "type": "object",
        "required": ["from", "to", "subject"],
        "description": "Le corps de `POST /api/v1/emails`.\n\n`from`, `to` et `subject` sont obligatoires, et **au moins l'un de `html` ou `text`** — un corps vide est refusé en 422.\n\nLes pièces jointes passent par `attachments`, au format de Resend.\n\nLes clés inconnues sont ignorées en silence : un corps écrit pour Resend passe tel quel.",
        "properties": {
          "from": {
            "type": "string",
            "minLength": 3,
            "maxLength": 320,
            "description": "**Obligatoire.** Adresse sur un domaine vérifié de l'espace — sinon 422 `unverified_from_domain`, qui vous rend la liste des domaines utilisables. `GET /api/v1/me` la donne aussi, avant d'essayer. Les deux formes sont acceptées : l'adresse seule, ou « Nom <adresse@domaine.fr> » pour que le nom s'affiche dans la boîte de réception.",
            "examples": [
              "Votre marque <bonjour@votredomaine.fr>",
              "bonjour@votredomaine.fr"
            ]
          },
          "to": {
            "type": ["string", "array"],
            "description": "**Obligatoire.** Un destinataire, ou un tableau de 1 à 50 destinataires. Au-delà de 50, découpez en plusieurs appels — ou passez par une campagne. Chaque entrée accepte la forme « Nom <adresse@domaine.fr> » comme l'adresse seule, et fait de 3 à 320 caractères.\n\n⚠️ **Les destinataires se voient entre eux** : ils partent dans le même message, pas en copie cachée. Pour que chacun reçoive le sien, faites un appel par personne.",
            "oneOf": [
              {
                "title": "Un seul destinataire",
                "type": "string",
                "minLength": 3,
                "maxLength": 320,
                "examples": [
                  "client@exemple.fr",
                  "Marie Dupont <marie@exemple.fr>"
                ]
              },
              {
                "title": "Plusieurs destinataires",
                "type": "array",
                "items": {
                  "type": "string",
                  "minLength": 3,
                  "maxLength": 320
                },
                "minItems": 1,
                "maxItems": 50,
                "examples": [["marie@exemple.fr", "paul@exemple.fr"]]
              }
            ],
            "examples": [
              "client@exemple.fr",
              ["marie@exemple.fr", "paul@exemple.fr"]
            ]
          },
          "subject": {
            "type": "string",
            "minLength": 1,
            "maxLength": 998,
            "description": "**Obligatoire.** L'objet du message. Le plafond de 998 caractères est celui de la norme e-mail ; en pratique une boîte de réception en montre de 40 à 60.",
            "examples": ["Votre facture de septembre"]
          },
          "html": {
            "type": "string",
            "maxLength": 400000,
            "description": "Le corps en HTML. `html` ou `text` : au moins l'un des deux, les deux si possible — un message sans version texte est mal noté par les filtres.",
            "examples": ["<p>La voici, en pièce jointe de ce message.</p>"]
          },
          "text": {
            "type": "string",
            "maxLength": 400000,
            "description": "Le corps en texte simple, pour les clients qui n'affichent pas le HTML.",
            "examples": ["La voici, en pièce jointe de ce message."]
          },
          "cc": {
            "type": ["string", "array"],
            "oneOf": [
              {
                "title": "Une adresse",
                "type": "string",
                "minLength": 3,
                "maxLength": 320,
                "examples": ["compta@exemple.fr"]
              },
              {
                "title": "Plusieurs adresses",
                "type": "array",
                "items": {
                  "type": "string",
                  "minLength": 3,
                  "maxLength": 320
                },
                "minItems": 1,
                "maxItems": 50,
                "examples": [["compta@exemple.fr", "direction@exemple.fr"]]
              }
            ],
            "description": "Copie visible. Une adresse ou un tableau de 1 à 50. Les destinataires en copie voient et sont vus. Elles comptent dans le quota et passent par les mêmes contrôles que `to` — liste de suppression comprise.",
            "examples": ["compta@exemple.fr"]
          },
          "bcc": {
            "type": ["string", "array"],
            "oneOf": [
              {
                "title": "Une adresse",
                "type": "string",
                "minLength": 3,
                "maxLength": 320,
                "examples": ["archive@exemple.fr"]
              },
              {
                "title": "Plusieurs adresses",
                "type": "array",
                "items": {
                  "type": "string",
                  "minLength": 3,
                  "maxLength": 320
                },
                "minItems": 1,
                "maxItems": 50,
                "examples": [["archive@exemple.fr", "copie@exemple.fr"]]
              }
            ],
            "description": "Copie cachée. Une adresse ou un tableau de 1 à 50. Elle n'apparaît dans aucun en-tête du message reçu, y compris quand l'envoi porte des pièces jointes — elle reste dans l'enveloppe. Même quota et mêmes contrôles que `to`.",
            "examples": ["archive@exemple.fr"]
          },
          "replyTo": {
            "type": ["string", "array"],
            "description": "Adresse à laquelle répondront les destinataires, quand elle diffère de `from`. Une seule, ou un tableau de 1 à 10. Même forme que `to` : l'adresse seule ou « Nom <adresse> ».",
            "oneOf": [
              {
                "title": "Une adresse",
                "type": "string",
                "minLength": 3,
                "maxLength": 320,
                "examples": ["sav@votredomaine.fr"]
              },
              {
                "title": "Plusieurs adresses",
                "type": "array",
                "items": {
                  "type": "string",
                  "minLength": 3,
                  "maxLength": 320
                },
                "minItems": 1,
                "maxItems": 10,
                "examples": [["sav@votredomaine.fr", "compta@votredomaine.fr"]]
              }
            ],
            "examples": ["sav@votredomaine.fr"]
          },
          "reply_to": {
            "type": ["string", "array"],
            "description": "Alias de `replyTo`, pour les clients venus de Resend. Adresse à laquelle répondront les destinataires, quand elle diffère de `from`. Une seule, ou un tableau de 1 à 10. Même forme que `to` : l'adresse seule ou « Nom <adresse> ».\n\nNe renseignez qu'un seul des deux.",
            "oneOf": [
              {
                "title": "Une adresse",
                "type": "string",
                "minLength": 3,
                "maxLength": 320,
                "examples": ["sav@votredomaine.fr"]
              },
              {
                "title": "Plusieurs adresses",
                "type": "array",
                "items": {
                  "type": "string",
                  "minLength": 3,
                  "maxLength": 320
                },
                "minItems": 1,
                "maxItems": 10,
                "examples": [["sav@votredomaine.fr", "compta@votredomaine.fr"]]
              }
            ],
            "examples": ["sav@votredomaine.fr"]
          },
          "headers": {
            "type": "object",
            "additionalProperties": {
              "type": "string"
            },
            "description": "En-têtes libres, en paires nom → valeur. Ceux qui portent l'identité du message (From, To, Reply-To, List-Unsubscribe…) sont ignorés : c'est nous qui en répondons.",
            "examples": [
              {
                "X-Entity-Ref-ID": "cmd-4192"
              }
            ]
          },
          "tags": {
            "type": ["object", "array"],
            "description": "Étiquettes libres pour votre suivi ; elles reviennent dans les notifications de remise et sur `GET /api/v1/emails/{id}`. Deux écritures au choix : un objet clé → valeur, ou un tableau `{name, value}` comme chez Resend (10 entrées au plus dans cette seconde forme).\n\nNoms et valeurs sont **réécrits, pas refusés** : tout caractère hors `A-Z a-z 0-9 _ -` devient `_`, et au-delà de 256 caractères la valeur est coupée. Un accent ou un espace passe donc sans erreur, mais pas tel quel.",
            "oneOf": [
              {
                "title": "Objet clé/valeur",
                "type": "object",
                "additionalProperties": {
                  "type": "string"
                },
                "examples": [
                  {
                    "type": "commande",
                    "canal": "boutique"
                  }
                ]
              },
              {
                "title": "Tableau {name, value} (forme Resend)",
                "type": "array",
                "items": {
                  "type": "object",
                  "required": ["name", "value"],
                  "properties": {
                    "name": {
                      "type": "string",
                      "examples": ["type"]
                    },
                    "value": {
                      "type": "string",
                      "examples": ["commande"]
                    }
                  }
                },
                "maxItems": 10,
                "examples": [
                  [
                    {
                      "name": "type",
                      "value": "commande"
                    }
                  ]
                ]
              }
            ],
            "examples": [
              {
                "type": "commande"
              }
            ]
          },
          "unsubscribe_url": {
            "type": "string",
            "format": "uri",
            "maxLength": 2048,
            "description": "L'adresse de désinscription en un clic. Mailcheer pose alors `List-Unsubscribe` ET `List-Unsubscribe-Post` — les deux vont ensemble, sans le second Gmail n'affiche pas le bouton. Gmail et Yahoo attendent cet en-tête depuis février 2024 sur tout envoi à des gens qui ne vous ont pas écrit les premiers ; sans lui, leur seule sortie est le bouton « Spam », et c'est la réputation de votre domaine qui paie. Doit être en https. L'en-tête lui-même reste refusé dans `headers` : c'est Mailcheer qui l'écrit, vous n'en donnez que la cible.",
            "example": "https://votredomaine.fr/desinscription/abc123"
          },
          "attachments": {
            "type": "array",
            "items": {
              "type": "object",
              "required": ["filename", "content"],
              "properties": {
                "filename": {
                  "type": "string",
                  "minLength": 1,
                  "maxLength": 255,
                  "description": "Le nom du fichier tel qu'il s'affichera dans la boîte de réception. Les retours à la ligne et les séparateurs de chemin en sont retirés.",
                  "examples": ["devis-2026-09.pdf"]
                },
                "content": {
                  "type": "string",
                  "description": "Le fichier, encodé en **base64**. C'est le seul moyen accepté.",
                  "examples": ["JVBERi0xLjQKJcfsj6IK…"]
                },
                "content_type": {
                  "type": "string",
                  "maxLength": 127,
                  "description": "Le type MIME. Facultatif : déduit de l'extension quand il manque (`.pdf` → `application/pdf`), et `application/octet-stream` en dernier recours.",
                  "examples": ["application/pdf"]
                },
                "contentType": {
                  "type": "string",
                  "maxLength": 127,
                  "description": "Alias de `content_type`."
                }
              }
            },
            "maxItems": 20,
            "description": "Les pièces jointes, au format de Resend : `filename` + `content` en base64. Vingt au plus.\n\n**La limite est celle d'Amazon SES : 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 — aucun message ne part amputé de sa pièce.\n\nSont refusées, en le disant : les extensions que les messageries rejettent (`.exe`, `.bat`, `.js`, `.vbs`…), un `content` qui n'est pas du base64 valide, et le champ `path` de Resend — notre serveur n'ira pas chercher un fichier à une URL que vous choisissez ; encodez-le.\n\nSans pièce jointe, rien ne change dans la façon dont le message part.",
            "examples": [
              [
                {
                  "filename": "devis-2026-09.pdf",
                  "content": "JVBERi0xLjQKJcfsj6IK…",
                  "content_type": "application/pdf"
                }
              ]
            ]
          }
        }
      },
      "Email": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string",
            "description": "L'identifiant à repasser à `GET /api/v1/emails/{id}` pour connaître la remise."
          },
          "object": {
            "type": "string",
            "const": "email"
          },
          "from": {
            "type": "string"
          },
          "to": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "Toujours un tableau en réponse, même si vous aviez passé une seule adresse."
          },
          "subject": {
            "type": "string"
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "EmailDetail": {
        "allOf": [
          {
            "$ref": "#/components/schemas/Email"
          },
          {
            "type": "object",
            "properties": {
              "status": {
                "type": "string",
                "enum": [
                  "queued",
                  "sent",
                  "delivered",
                  "bounced",
                  "complained",
                  "failed"
                ],
                "description": "`queued` à l'acceptation, puis `sent`, `delivered`, `bounced`, `complained` ou `failed` au fil des retours de notre fournisseur. Comptez quelques secondes avant `delivered`."
              },
              "error": {
                "type": ["string", "null"],
                "description": "Le motif du refus, quand `status` vaut `bounced`, `complained` ou `failed`. `null` sinon."
              },
              "tags": {
                "type": ["object", "null"],
                "description": "Les étiquettes de l'envoi, après réécriture des caractères interdits.",
                "additionalProperties": {
                  "type": "string"
                }
              },
              "sent_at": {
                "type": ["string", "null"],
                "format": "date-time"
              },
              "delivered_at": {
                "type": ["string", "null"],
                "format": "date-time"
              }
            }
          }
        ]
      },
      "SubscriberRequest": {
        "type": "object",
        "required": ["email"],
        "properties": {
          "email": {
            "type": "string",
            "format": "email",
            "maxLength": 320,
            "description": "**Obligatoire.** Mise en minuscules à l'enregistrement.",
            "examples": ["marie@exemple.fr"]
          },
          "firstName": {
            "type": "string",
            "maxLength": 120,
            "examples": ["Marie"]
          },
          "lastName": {
            "type": "string",
            "maxLength": 120,
            "examples": ["Dupont"]
          },
          "prenom": {
            "type": "string",
            "maxLength": 120,
            "examples": ["Marie"],
            "description": "Alias de `firstName`. N'en renseignez qu'un des deux."
          },
          "nom": {
            "type": "string",
            "maxLength": 120,
            "examples": ["Dupont"],
            "description": "Alias de `lastName`. N'en renseignez qu'un des deux."
          },
          "tags": {
            "type": "array",
            "items": {
              "type": "string",
              "minLength": 1,
              "maxLength": 60
            },
            "description": "Étiquettes de l'espace. Celles qui n'existent pas encore sont créées.",
            "maxItems": 20,
            "examples": [["client", "boutique-paris"]]
          },
          "fields": {
            "type": "object",
            "additionalProperties": {
              "type": "string",
              "maxLength": 500
            },
            "description": "Champs personnalisés, par leur clé technique. Les clés inconnues ne créent rien : elles reviennent dans `ignored_fields`.",
            "examples": [
              {
                "ville": "Ajaccio"
              }
            ]
          },
          "double_opt_in": {
            "type": "boolean",
            "default": true,
            "description": "`false` inscrit directement, sans e-mail de confirmation. L'appelant répond alors du consentement.",
            "examples": [true]
          }
        }
      },
      "Subscriber": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string"
          },
          "object": {
            "type": "string",
            "const": "subscriber"
          },
          "email": {
            "type": "string"
          },
          "first_name": {
            "type": ["string", "null"]
          },
          "last_name": {
            "type": ["string", "null"]
          },
          "status": {
            "type": "string",
            "enum": [
              "pending",
              "subscribed",
              "unsubscribed",
              "bounced",
              "complained"
            ]
          },
          "source": {
            "type": ["string", "null"]
          },
          "tags": {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "fields": {
            "type": "object",
            "additionalProperties": {
              "type": "string"
            }
          },
          "confirmed_at": {
            "type": ["string", "null"],
            "format": "date-time"
          },
          "unsubscribed_at": {
            "type": ["string", "null"],
            "format": "date-time"
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "SubscriberResult": {
        "type": "object",
        "properties": {
          "subscriber": {
            "$ref": "#/components/schemas/Subscriber"
          },
          "updated": {
            "type": "boolean",
            "description": "`true` si l'abonné existait déjà."
          },
          "confirmation_sent": {
            "type": "boolean",
            "description": "`true` si un e-mail de confirmation est réellement parti."
          },
          "ignored_fields": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "Clés de `fields` inconnues de cet espace, écrites nulle part."
          }
        }
      },
      "Suppression": {
        "type": "object",
        "properties": {
          "object": {
            "type": "string",
            "const": "suppression"
          },
          "email": {
            "type": "string"
          },
          "reason": {
            "type": "string",
            "enum": ["unsubscribe", "bounce", "complaint", "manual"]
          },
          "scope": {
            "type": "string",
            "enum": ["organization", "platform"]
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "CampaignRequest": {
        "type": "object",
        "required": ["name"],
        "properties": {
          "name": {
            "type": "string",
            "minLength": 1,
            "maxLength": 160,
            "description": "**Obligatoire.** Nom interne, jamais vu par les destinataires.",
            "examples": ["Lettre de septembre"]
          },
          "subject": {
            "type": "string",
            "maxLength": 400,
            "description": "L'objet. Peut rester vide au brouillon, mais l'envoi le refusera.",
            "examples": ["Ce que nous avons changé ce mois-ci"]
          },
          "preheader": {
            "type": "string",
            "maxLength": 400,
            "description": "Aperçu affiché après l'objet dans la boîte de réception.",
            "examples": ["Trois nouveautés, et une qu'on vous devait."]
          },
          "kind": {
            "type": "string",
            "maxLength": 40,
            "default": "newsletter",
            "description": "Votre propre étiquette de classement. Texte libre, aucune valeur imposée.",
            "examples": ["newsletter"]
          },
          "from": {
            "type": "string",
            "maxLength": 320,
            "description": "L'adresse d'un expéditeur **déjà enregistré** dans l'espace — pas n'importe quelle adresse sur un domaine vérifié, contrairement à l'envoi unitaire. Inconnue : 404, avec la liste des adresses disponibles. `GET /api/v1/me` les donne dans `senders`.",
            "examples": ["bonjour@votredomaine.fr"]
          },
          "sender_id": {
            "type": "string",
            "maxLength": 60,
            "description": "L'identifiant de l'expéditeur, si vous le connaissez (`senders[].id` de `GET /api/v1/me`). Prioritaire sur `from`.\n\nSans `from` ni `sender_id`, Mailcheer prend l'expéditeur par défaut de l'espace ; s'il n'y en a pas et qu'un seul expéditeur existe, celui-là. Si plusieurs existent sans défaut, la campagne est créée **sans expéditeur** et c'est l'envoi qui échouera : désignez-en un.",
            "examples": ["snd_71a"]
          },
          "text": {
            "type": "string",
            "maxLength": 200000,
            "description": "Le contenu en texte simple — la forme à préférer depuis du code ou un agent. Une ligne vide sépare deux paragraphes, `# ` en début de ligne fait un titre, `## ` un sous-titre. Ignoré si `content` est fourni.",
            "examples": [
              "# Trois nouveautés\n\nVoici ce qui a changé ce mois-ci.\n\nBonne lecture."
            ]
          },
          "content": {
            "type": "object",
            "description": "Le contenu en blocs, la forme complète de l'éditeur. N'écrivez ceci que si vous reprenez le contenu d'une campagne existante : pour composer, `text` fait le même travail sans qu'il faille fabriquer des identifiants de blocs.",
            "properties": {
              "preheader": {
                "type": "string"
              },
              "blocks": {
                "type": "array",
                "description": "Blocs de l'éditeur, chacun avec son `id` et son `type` (`heading`, `text`, …).",
                "items": {
                  "type": "object",
                  "additionalProperties": true
                }
              }
            }
          }
        },
        "description": "Le corps de `POST /api/v1/campaigns`. Seul `name` est obligatoire — mais un brouillon sans objet ni contenu ne pourra pas être envoyé. Donnez le contenu par `text` (simple) ou par `content` (blocs), jamais les deux."
      },
      "Campaign": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string"
          },
          "object": {
            "type": "string",
            "const": "campaign"
          },
          "name": {
            "type": "string"
          },
          "subject": {
            "type": ["string", "null"]
          },
          "kind": {
            "type": "string"
          },
          "status": {
            "type": "string",
            "enum": ["draft", "scheduled", "sending", "sent", "archived"]
          },
          "from": {
            "type": ["string", "null"]
          },
          "scheduled_at": {
            "type": ["string", "null"],
            "format": "date-time"
          },
          "sent_at": {
            "type": ["string", "null"],
            "format": "date-time"
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "schedule_error": {
            "type": ["string", "null"],
            "description": "Pourquoi un envoi programmé n'est pas parti."
          },
          "stats": {
            "type": "object",
            "properties": {
              "recipients": {
                "type": "integer"
              },
              "sent": {
                "type": "integer"
              },
              "delivered": {
                "type": "integer"
              },
              "opened": {
                "type": "integer"
              },
              "clicked": {
                "type": "integer"
              },
              "bounced": {
                "type": "integer"
              },
              "complained": {
                "type": "integer"
              },
              "open_rate": {
                "type": ["number", "null"]
              },
              "click_rate": {
                "type": ["number", "null"]
              }
            }
          }
        }
      },
      "CampaignStats": {
        "type": "object",
        "properties": {
          "campaign": {
            "type": "object",
            "properties": {
              "id": {
                "type": "string"
              },
              "name": {
                "type": "string"
              },
              "subject": {
                "type": "string"
              },
              "status": {
                "type": "string",
                "enum": [
                  "draft",
                  "scheduled",
                  "sending",
                  "testing",
                  "sent",
                  "archived"
                ]
              },
              "kind": {
                "type": "string"
              },
              "fromName": {
                "type": "string"
              },
              "fromEmail": {
                "type": "string"
              },
              "sentAt": {
                "type": ["string", "null"],
                "format": "date-time"
              },
              "scheduledAt": {
                "type": ["string", "null"],
                "format": "date-time"
              },
              "updatedAt": {
                "type": "string",
                "format": "date-time"
              }
            }
          },
          "counts": {
            "type": "object",
            "properties": {
              "recipients": {
                "type": "integer"
              },
              "delivered": {
                "type": "integer"
              },
              "opened": {
                "type": "integer"
              },
              "clicked": {
                "type": "integer"
              },
              "bounced": {
                "type": "integer"
              },
              "complained": {
                "type": "integer"
              },
              "unsubscribed": {
                "type": "integer"
              }
            }
          },
          "rates": {
            "type": "object",
            "description": "Parts entre 0 et 1 ; `null` tant que le dénominateur est vide.",
            "properties": {
              "delivered": {
                "type": ["number", "null"],
                "minimum": 0,
                "maximum": 1
              },
              "opened": {
                "type": ["number", "null"],
                "minimum": 0,
                "maximum": 1
              },
              "clicked": {
                "type": ["number", "null"],
                "minimum": 0,
                "maximum": 1
              },
              "bounced": {
                "type": ["number", "null"],
                "minimum": 0,
                "maximum": 1
              }
            }
          },
          "timeline": {
            "type": "array",
            "description": "Huit tranches de six heures depuis l'envoi, `+0h` à `+42h`. Vide si la campagne n'est pas partie.",
            "items": {
              "type": "object",
              "properties": {
                "label": {
                  "type": "string",
                  "example": "+6h"
                },
                "opened": {
                  "type": "integer"
                },
                "clicked": {
                  "type": "integer"
                }
              }
            }
          },
          "audience": {
            "type": "object",
            "properties": {
              "total": {
                "type": "integer",
                "description": "Ouvertures dont l'appareil est connu."
              },
              "proxiedShare": {
                "type": ["number", "null"],
                "minimum": 0,
                "maximum": 1,
                "description": "Part des ouvertures venues d'un relais de confidentialité (Apple Mail, Gmail) : reçues, pas forcément lues."
              },
              "device": {
                "type": "array",
                "description": "Cinq lignes au plus, de la plus à la moins fréquente.",
                "items": {
                  "type": "object",
                  "properties": {
                    "label": {
                      "type": "string"
                    },
                    "count": {
                      "type": "integer"
                    },
                    "share": {
                      "type": "number",
                      "minimum": 0,
                      "maximum": 1
                    }
                  }
                }
              },
              "os": {
                "type": "array",
                "description": "Cinq lignes au plus, de la plus à la moins fréquente.",
                "items": {
                  "type": "object",
                  "properties": {
                    "label": {
                      "type": "string"
                    },
                    "count": {
                      "type": "integer"
                    },
                    "share": {
                      "type": "number",
                      "minimum": 0,
                      "maximum": 1
                    }
                  }
                }
              },
              "client": {
                "type": "array",
                "description": "Cinq lignes au plus, de la plus à la moins fréquente.",
                "items": {
                  "type": "object",
                  "properties": {
                    "label": {
                      "type": "string"
                    },
                    "count": {
                      "type": "integer"
                    },
                    "share": {
                      "type": "number",
                      "minimum": 0,
                      "maximum": 1
                    }
                  }
                }
              }
            }
          },
          "links": {
            "type": "array",
            "description": "Du plus au moins cliqué.",
            "items": {
              "type": "object",
              "properties": {
                "url": {
                  "type": "string"
                },
                "clicks": {
                  "type": "integer"
                }
              }
            }
          },
          "html": {
            "type": "string",
            "description": "Le HTML rendu à l'envoi ; chaîne vide tant qu'il n'existe pas."
          }
        }
      },
      "Audience": {
        "type": "object",
        "description": "Le bilan d'un envoi restreint par `to`. Le compte tombe toujours juste : `requested = duplicates + retained + excluded`.",
        "properties": {
          "requested": {
            "type": "integer",
            "description": "Entrées reçues dans `to`, telles quelles."
          },
          "duplicates": {
            "type": "integer",
            "description": "Entrées qui répétaient une adresse déjà vue (casse et espaces ignorés)."
          },
          "retained": {
            "type": "integer",
            "description": "Adresses qui reçoivent (ou recevraient)."
          },
          "excluded": {
            "type": "integer",
            "description": "Adresses écartées."
          },
          "reasons": {
            "type": "object",
            "description": "Le nombre d'adresses écartées par raison ; toutes les raisons sont présentes, même à zéro.",
            "properties": {
              "invalid": {
                "type": "integer",
                "description": "Pas une adresse e-mail."
              },
              "not_in_list": {
                "type": "integer",
                "description": "Aucun abonné de l'espace ne porte cette adresse."
              },
              "pending": {
                "type": "integer",
                "description": "Inscription pas encore confirmée (double opt-in)."
              },
              "unsubscribed": {
                "type": "integer"
              },
              "bounced": {
                "type": "integer"
              },
              "complained": {
                "type": "integer"
              },
              "suppressed": {
                "type": "integer",
                "description": "Abonné actif, mais l'adresse est sur la liste de suppression."
              },
              "outside_segment": {
                "type": "integer",
                "description": "Abonné actif, hors du segment que vise la campagne."
              }
            }
          },
          "excluded_addresses": {
            "type": "array",
            "description": "Chaque adresse écartée, avec sa raison.",
            "items": {
              "type": "object",
              "properties": {
                "email": {
                  "type": "string"
                },
                "reason": {
                  "type": "string",
                  "enum": [
                    "invalid",
                    "not_in_list",
                    "pending",
                    "unsubscribed",
                    "bounced",
                    "complained",
                    "suppressed",
                    "outside_segment"
                  ]
                }
              }
            }
          }
        }
      },
      "Account": {
        "type": "object",
        "description": "Ce que la clé donne accès à, et ce qu'il reste à consommer. Le premier appel à faire : il donne les domaines vérifiés et les expéditeurs enregistrés, c'est-à-dire les seules adresses avec lesquelles vos envois passeront.",
        "properties": {
          "object": {
            "type": "string",
            "const": "account"
          },
          "organization": {
            "type": "object",
            "description": "L'espace auquel la clé appartient. `null` si l'espace a été supprimé.",
            "properties": {
              "id": {
                "type": "string"
              },
              "name": {
                "type": "string"
              },
              "slug": {
                "type": "string"
              }
            }
          },
          "key": {
            "type": "object",
            "description": "La clé utilisée pour cet appel. Son secret n'est jamais rendu.",
            "properties": {
              "name": {
                "type": "string",
                "description": "Le nom que vous lui avez donné."
              },
              "scopes": {
                "type": "array",
                "items": {
                  "type": "string",
                  "enum": [
                    "emails:send",
                    "subscribers:read",
                    "subscribers:write",
                    "campaigns:read",
                    "campaigns:write"
                  ]
                },
                "description": "Les droits de CETTE clé. Un appel hors de cette liste répond 403."
              }
            }
          },
          "plan": {
            "type": "object",
            "properties": {
              "id": {
                "type": "string"
              },
              "name": {
                "type": "string"
              },
              "emails_per_month": {
                "type": ["integer", "null"],
                "description": "`null` = illimité."
              }
            }
          },
          "usage": {
            "type": "object",
            "properties": {
              "period": {
                "type": "string",
                "description": "Le mois en cours, `AAAA-MM`.",
                "examples": ["2026-09"]
              },
              "emails_sent": {
                "type": "integer",
                "description": "Envois de ce mois, API et campagnes confondus."
              },
              "emails_remaining": {
                "type": ["integer", "null"],
                "description": "`null` = illimité. À 0, les envois répondent 402."
              }
            }
          },
          "subscribers": {
            "type": "integer",
            "description": "Abonnés au statut `subscribed` — ceux qu'une campagne toucherait."
          },
          "sending_domains": {
            "type": "array",
            "description": "Les domaines de l'espace. Seuls ceux à `verified: true` peuvent servir de `from`.",
            "items": {
              "type": "object",
              "properties": {
                "domain": {
                  "type": "string",
                  "examples": ["votredomaine.fr"]
                },
                "verified": {
                  "type": "boolean"
                }
              }
            }
          },
          "senders": {
            "type": "array",
            "description": "Les expéditeurs enregistrés. Une CAMPAGNE ne peut partir que de l'un d'eux ; un e-mail unitaire, lui, accepte n'importe quelle adresse sur un domaine vérifié.",
            "items": {
              "type": "object",
              "properties": {
                "id": {
                  "type": "string",
                  "description": "À passer en `sender_id` à la création d'une campagne."
                },
                "from": {
                  "type": "string",
                  "format": "email"
                },
                "name": {
                  "type": ["string", "null"],
                  "description": "Le nom affiché dans la boîte de réception."
                },
                "default": {
                  "type": "boolean",
                  "description": "Celui qu'une campagne prend si vous n'en désignez aucun."
                }
              }
            }
          }
        },
        "examples": [
          {
            "object": "account",
            "organization": {
              "id": "org_3f9",
              "name": "Votre marque",
              "slug": "votre-marque"
            },
            "key": {
              "name": "Production",
              "scopes": ["emails:send", "subscribers:read"]
            },
            "plan": {
              "id": "pro",
              "name": "Pro",
              "emails_per_month": 50000
            },
            "usage": {
              "period": "2026-09",
              "emails_sent": 1240,
              "emails_remaining": 48760
            },
            "subscribers": 3182,
            "sending_domains": [
              {
                "domain": "votredomaine.fr",
                "verified": true
              }
            ],
            "senders": [
              {
                "id": "snd_71a",
                "from": "bonjour@votredomaine.fr",
                "name": "Votre marque",
                "default": true
              }
            ]
          }
        ]
      },
      "Liste": {
        "type": "object",
        "properties": {
          "object": {
            "type": "string",
            "const": "list"
          },
          "data": {
            "type": "array",
            "items": {
              "type": "object"
            }
          },
          "has_more": {
            "type": "boolean",
            "description": "Encore une page après celle-ci."
          },
          "next_cursor": {
            "type": ["string", "null"],
            "description": "À repasser en `?cursor=` pour la page suivante. `null` sur la dernière page."
          }
        },
        "description": "L'enveloppe de toutes les listes. Pour parcourir : tant que `has_more` vaut `true`, rappelez la même adresse avec `?cursor=<next_cursor>`. L'ordre est stable ; une page rend au plus `limit` éléments (50 par défaut, 100 au plus)."
      },
      "Erreur": {
        "type": "object",
        "properties": {
          "error": {
            "type": "object",
            "properties": {
              "code": {
                "type": "string",
                "enum": [
                  "missing_api_key",
                  "invalid_api_key",
                  "revoked_api_key",
                  "insufficient_scope",
                  "rate_limit_exceeded",
                  "validation_error",
                  "unverified_from_domain",
                  "suppressed_recipient",
                  "quota_exceeded",
                  "reputation_blocked",
                  "not_found",
                  "conflict",
                  "idempotency_key_reused",
                  "send_failed",
                  "internal_error"
                ],
                "description": "Écrivez votre code contre ce champ, jamais contre `message`."
              },
              "message": {
                "type": "string",
                "description": "Écrit pour être lu par un humain ; peut être reformulé sans préavis."
              },
              "details": {
                "type": "object",
                "description": "De quoi corriger sans deviner : `issues` (le champ fautif et pourquoi) sur une erreur de validation, `verifiedDomains` sur un domaine refusé, `available` sur un expéditeur inconnu.",
                "additionalProperties": true
              }
            }
          },
          "statusCode": {
            "type": "integer",
            "examples": [422]
          },
          "message": {
            "type": "string",
            "description": "Le même texte que `error.message` — écrit pour un humain."
          },
          "name": {
            "type": "string",
            "enum": [
              "missing_api_key",
              "invalid_api_key",
              "revoked_api_key",
              "insufficient_scope",
              "rate_limit_exceeded",
              "validation_error",
              "unverified_from_domain",
              "suppressed_recipient",
              "quota_exceeded",
              "reputation_blocked",
              "not_found",
              "conflict",
              "idempotency_key_reused",
              "send_failed",
              "internal_error"
            ],
            "description": "La même valeur que `error.code`. Écrivez votre logique contre lui."
          }
        },
        "description": "Deux lectures du même contenu. `error` est la forme de Mailcheer, structurée. `statusCode`, `message` et `name` reprennent la forme de Resend, pour que du code écrit contre cette API-là affiche un message juste sans être relu. `name` porte la même valeur que `error.code`, et `message` le même texte que `error.message`.",
        "examples": [
          {
            "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"
          }
        ]
      },
      "Carte": {
        "type": "object",
        "description": "Le sommaire de l'API, lisible sans clé.",
        "properties": {
          "name": {
            "type": "string",
            "examples": ["Mailcheer API"]
          },
          "version": {
            "type": "string",
            "examples": ["1"]
          },
          "documentation": {
            "type": "string",
            "format": "uri"
          },
          "openapi": {
            "type": "string",
            "format": "uri",
            "description": "Ce document."
          },
          "mcp": {
            "type": "string",
            "format": "uri",
            "description": "L'adresse du serveur MCP."
          },
          "authentication": {
            "type": "object",
            "properties": {
              "type": {
                "type": "string"
              },
              "header": {
                "type": "string"
              },
              "where": {
                "type": "string"
              }
            }
          },
          "scopes": {
            "type": "array",
            "items": {
              "type": "string",
              "enum": [
                "emails:send",
                "subscribers:read",
                "subscribers:write",
                "campaigns:read",
                "campaigns:write"
              ]
            },
            "description": "Tous les droits qu'une clé peut porter."
          },
          "rate_limit": {
            "type": "string",
            "examples": ["600 requêtes par minute et par clé"]
          },
          "endpoints": {
            "type": "array",
            "description": "Chaque point d'entrée et le droit qu'il exige.",
            "items": {
              "type": "object",
              "properties": {
                "method": {
                  "type": "string"
                },
                "path": {
                  "type": "string"
                },
                "scope": {
                  "type": "string",
                  "description": "`—` si aucun droit particulier n'est exigé."
                }
              }
            }
          }
        }
      },
      "Webhook": {
        "type": "object",
        "properties": {
          "object": {
            "type": "string",
            "const": "webhook"
          },
          "id": {
            "type": "string"
          },
          "name": {
            "type": "string",
            "description": "Le nom que vous lui donnez."
          },
          "url": {
            "type": "string",
            "format": "uri"
          },
          "events": {
            "type": "array",
            "items": {
              "type": "string",
              "enum": [
                "email.delivered",
                "email.opened",
                "email.clicked",
                "email.bounced",
                "email.complained",
                "subscriber.created",
                "subscriber.unsubscribed"
              ]
            }
          },
          "enabled": {
            "type": "boolean"
          },
          "secret": {
            "type": "string",
            "description": "Le secret de signature (`whsec_…`). Rendu à la création, à la lecture d'un abonnement précis et après un changement de secret — **jamais dans la liste**, qu'on journalise volontiers en entier."
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "updated_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "WebhookRequest": {
        "type": "object",
        "required": ["name", "url", "events"],
        "properties": {
          "name": {
            "type": "string",
            "maxLength": 80,
            "example": "Mon CRM"
          },
          "url": {
            "type": "string",
            "format": "uri",
            "description": "`https` obligatoire, et jamais une adresse de réseau privé.",
            "example": "https://mon-service.fr/mailcheer/evenements"
          },
          "events": {
            "type": "array",
            "minItems": 1,
            "items": {
              "type": "string",
              "enum": [
                "email.delivered",
                "email.opened",
                "email.clicked",
                "email.bounced",
                "email.complained",
                "subscriber.created",
                "subscriber.unsubscribed"
              ]
            }
          }
        }
      },
      "WebhookDelivery": {
        "type": "object",
        "properties": {
          "object": {
            "type": "string",
            "const": "webhook_delivery"
          },
          "id": {
            "type": "string"
          },
          "webhook_id": {
            "type": "string"
          },
          "event_id": {
            "type": "string",
            "description": "Stable d'une tentative à l'autre : c'est la clé de déduplication."
          },
          "event_type": {
            "type": "string",
            "enum": [
              "email.delivered",
              "email.opened",
              "email.clicked",
              "email.bounced",
              "email.complained",
              "subscriber.created",
              "subscriber.unsubscribed"
            ]
          },
          "status": {
            "type": "string",
            "enum": ["pending", "delivered", "failed"]
          },
          "attempts": {
            "type": "integer"
          },
          "response_status": {
            "type": ["integer", "null"],
            "description": "Le code HTTP rendu par votre service."
          },
          "response_body": {
            "type": ["string", "null"],
            "description": "Les 500 premiers caractères de sa réponse."
          },
          "error": {
            "type": ["string", "null"]
          },
          "next_attempt_at": {
            "type": ["string", "null"],
            "format": "date-time"
          },
          "delivered_at": {
            "type": ["string", "null"],
            "format": "date-time"
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      }
    },
    "responses": {
      "NonAuthentifie": {
        "description": "Clé absente, inconnue ou révoquée.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Erreur"
            }
          }
        }
      },
      "Interdit": {
        "description": "La clé n'a pas le droit demandé, ou les envois de l'espace sont suspendus.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Erreur"
            }
          }
        }
      },
      "Introuvable": {
        "description": "L'objet n'existe pas dans cet espace.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Erreur"
            }
          }
        }
      },
      "Conflit": {
        "description": "État incompatible, ou clé d'idempotence réutilisée avec un corps différent.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Erreur"
            }
          }
        }
      },
      "Refuse": {
        "description": "Champ mal formé, domaine non vérifié, ou destinataire en liste de suppression.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Erreur"
            }
          }
        }
      },
      "QuotaAtteint": {
        "description": "Le quota mensuel de l'offre est atteint.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Erreur"
            }
          }
        }
      },
      "TropDAppels": {
        "description": "Plus de 600 requêtes par minute pour cette clé.",
        "headers": {
          "Retry-After": {
            "description": "Secondes à attendre.",
            "schema": {
              "type": "integer"
            }
          },
          "X-RateLimit-Limit": {
            "schema": {
              "type": "integer"
            }
          },
          "X-RateLimit-Remaining": {
            "schema": {
              "type": "integer"
            }
          },
          "X-RateLimit-Reset": {
            "schema": {
              "type": "integer"
            }
          }
        },
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Erreur"
            }
          }
        }
      },
      "EnvoiEchoue": {
        "description": "Notre fournisseur d'envoi a refusé le message.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Erreur"
            }
          }
        }
      }
    }
  },
  "externalDocs": {
    "description": "La documentation rédigée, avec les exemples par langage.",
    "url": "https://mailcheer.com/docs/api"
  }
}
