API et webhooks

API publique V1, idempotence, webhooks signés et compatibilité intégrations.

Authentification

L’API publique V1 utilise des clés Bearer génériques mp_live_* ou mp_test_*. Les clés sont isolées par organisation MailPulse. Les anciennes clés mp_filon_* restent acceptées comme compatibilité, mais les nouveaux clients doivent utiliser les clés MailPulse.

Authorization: Bearer mp_live_xxx
Idempotency-Key: source_event_unique_id

Contacts

POST /api/v1/contacts
GET /api/v1/contacts/{contact_id}
PATCH /api/v1/contacts/{contact_id}/preferences

Les contacts restent génériques. Les identifiants métier du client doivent aller dans metadata, par exemple external_user_id ou external_tenant_id.

Messages

POST /api/v1/messages
GET /api/v1/messages/{message_id}

Exemple :

{
  "channel": "whatsapp",
  "recipient": { "type": "phone", "value": "+2250700000000" },
  "content": {
    "type": "template",
    "template_key": "absence_alert",
    "locale": "fr_CI",
    "variables": { "customer_name": "M. Kouassi" }
  },
  "metadata": {
    "external_user_id": "user_123",
    "external_event_id": "event_987",
    "source_app": "application_client"
  },
  "idempotency_key": "application_client_event_987_recipient_123"
}

Statuts possibles : queued, retrying, sent, delivered, read, failed, cancelled, template_required.

Un email refusé par le fournisseur passe en failed et error_code en donne la cause : email_bounced (rebond), email_complained (plainte), email_suppressed (adresse sur la liste de suppression du fournisseur) ou email_failed (échec d'envoi, motif dans error_message). Un événement tardif ne fait jamais reculer un message déjà délivré, lu ou en échec. Une plainte reçue après la remise désabonne le contact mais laisse le message à delivered ou read.

GET /api/v1/messages/{message_id} renvoie aussi delivery_delays, les retards de remise signalés par le fournisseur (20 au plus, du plus récent au plus ancien). Un retard ne change pas le statut : le message reste sent tant que le fournisseur réessaie.

"delivery_delays": [
  { "occurred_at": "2026-09-23T10:45:00.000Z" }
]

Numéro WhatsApp d'envoi

Un message WhatsApp part d'un numéro de l'application à laquelle la clé est rattachée. Une application peut avoir plusieurs numéros (une école, ses services) ; l'un d'eux est le numéro par défaut. Une clé sans application, ou rattachée à une application qui n'a jamais eu de numéro, envoie depuis le numéro de l'organisation. Les numéros appartiennent à l'application, pas à la clé : une nouvelle clé créée sous le même nom qu'une clé rattachée rejoint la même application, et une rotation garde donc les numéros.

Pour choisir le numéro, passez sender_id (WhatsApp seulement) :

{
  "channel": "whatsapp",
  "recipient": { "type": "phone", "value": "+2250700000000" },
  "content": { "type": "text", "text": "Bonjour" },
  "sender_id": "cmabc123numero"
}

Sans sender_id, le numéro par défaut part. GET /api/v1/whatsapp/numbers liste les numéros que la clé peut nommer :

{
  "numbers": [
    { "id": "cmabc123numero", "label": "Scolarité", "transport": "whatsapp_web", "active": true, "default": true }
  ],
  "default_sender_id": "cmabc123numero"
}

Le message passe en failed avec error_code sender_unavailable si le numéro nommé est désactivé, ou si, sans numéro nommé, tous les numéros de l'application sont désactivés. MailPulse n'envoie jamais depuis le numéro d'une autre application. Un sender_id inconnu, d'une autre application, ou donné par une clé sans application répond 422 sender_id_inconnu ; sur un autre canal, 400. Un destinataire qui a répondu NON ou STOP à un numéro de l'application ne reçoit plus rien de ce numéro, que le message vienne de l'API ou d'une commande signée : error_code consent_refused.

Les codes de vérification (/api/v1/verifications) suivent la même règle et acceptent aussi sender_id. Seul un numéro WhatsApp Web peut envoyer un code : sans sender_id, si le numéro par défaut est sur Meta, le code part d'un autre numéro WhatsApp Web de la même application ; un numéro nommé qui ne peut pas envoyer de code, ou l'absence de tout numéro capable, répond whatsapp_indisponible. Pour un numéro d'application, la session WhatsApp Web n'est pas vérifiée à l'avance : une session déconnectée se révèle à l'envoi, comme un échec d'envoi.

Les numéros se gèrent dans Messagerie, onglet Numéros : ajout par QR code, numéro par défaut, nom, désactivation, déplacement vers une autre application, et l'identifiant à passer en sender_id. Un numéro déplacé garde son identifiant : la nouvelle application l'utilise avec le même sender_id, l'ancienne ne peut plus le nommer. Ses réponses et les refus déjà reçus (STOP) le suivent ; l'historique de ses envois reste dans l'ancienne application. Si des envois de ce numéro n'ont pas abouti, la fenêtre de déplacement les liste et propose de les arrêter : une commande jamais partie est refusée avec sender_moved (l'application reçoit message.failed), une demande d'accord sans réponse expire, un envoi à l'issue inconnue est clos sans être renvoyé. Le rattachement d'une clé à une application se fait dans Plateforme, onglet Clés API et intégrations. Seuls les propriétaires et administrateurs de l'organisation peuvent changer une clé, un expéditeur ou un numéro.

Le numéro utilisé est enregistré sur le message au moment de sa création : un rejeu repart de ce numéro, et l'historique ne change pas si le numéro par défaut change ensuite.

Rejouer une requête

Rejouer POST /api/v1/messages avec la même Idempotency-Key et le même corps ne crée jamais de second message. La réponse décrit l'état actuel du message, pas celui de la première réponse : un message queued reprend son envoi, un message déjà soumis ou terminé est seulement relu, sans nouvel appel au fournisseur ni nouvel évènement webhook.

Durée d'un envoi WhatsApp Web

Sur WhatsApp Web, chaque message est précédé d'une saisie simulée de 2 à 10 secondes, proportionnelle à la longueur du texte : MailPulse ne répond qu'après l'envoi. Prévoyez un délai d'attente d'au moins 20 secondes côté client. Les liens ne génèrent pas d'aperçu.

Vérification de numéro

POST /api/v1/verifications
POST /api/v1/verifications/{id}/check
GET /api/v1/verifications/{id}

Code à usage unique envoyé sur WhatsApp. Détails, limites et erreurs dans Vérification de numéro.

Templates

POST /api/v1/templates
GET /api/v1/templates
PATCH /api/v1/templates/{template_key}

Les templates peuvent contenir du vocabulaire métier client, mais MailPulse ne modélise pas ce métier. Pour WhatsApp, les messages libres hors fenêtre de 24h sont refusés avec template_required; les templates WhatsApp doivent être approuvés.

Conversations

GET /api/v1/conversations
GET /api/v1/conversations/{conversation_id}/messages

MailPulse regroupe les messages par conversation et conserve la fenêtre de service WhatsApp lorsqu’elle existe.

Webhooks sortants

POST /api/v1/webhooks
GET /api/v1/webhooks
DELETE /api/v1/webhooks/{webhook_id}
POST /api/v1/webhooks/{webhook_id}/rotate-secret

Chaque webhook sortant contient :

{
  "event_id": "evt_xxx",
  "type": "message.queued",
  "tenant_id": "org_xxx",
  "created_at": "2026-07-02T10:00:00.000Z",
  "data": {}
}

Les requêtes sont signées avec HMAC SHA-256 :

mailpulse-event-id: evt_xxx
mailpulse-timestamp: 1782996000
mailpulse-signature: v1=<signature>

La signature se calcule sur timestamp + "." + rawBody avec le secret affiché une seule fois lors de la création du webhook.

Rotation du secret

POST /api/v1/webhooks/{webhook_id}/rotate-secret

La réponse contient le nouveau secret (signing_secret), affiché une seule fois. Pendant 24 heures, chaque requête porte deux signatures, avec le nouveau secret puis l'ancien :

mailpulse-signature: v1=<signature_nouveau>,v1=<signature_ancien>

Séparez l'en-tête sur les virgules et acceptez la requête si l'une des signatures correspond à votre secret : vous changez de secret sans perdre d'événement.

Avant votre première rotation, vérifiez que votre application accepte un en-tête qui contient plusieurs signatures. Une vérification qui compare l'en-tête entier à v1=<signature> refuserait toutes les livraisons pendant les 24 heures de recouvrement. Une seconde rotation dans ces 24 heures remplace l'ancien secret par le précédent nouveau.

Livraison et nouvelles tentatives

  • L'adresse doit être en HTTPS et publique : une adresse locale, interne ou privée est refusée, et les redirections ne sont pas suivies.
  • Votre serveur doit répondre en 2xx en moins de 10 secondes.
  • Un délai dépassé, une erreur réseau, une réponse 408, 429 ou 5xx est retentée après 1 min, 5 min, 30 min, 2 h puis 6 h, soit six tentatives au total. Toute autre réponse arrête la livraison.
  • Une nouvelle tentative renvoie le même event_id : utilisez-le pour ignorer un doublon.
  • Les livraisons sont consultables, et renvoyables par un administrateur, dans Plateforme › Webhooks.

Applications externes

POST /api/v1/external-applications/{application_key}/commands
POST /api/webhooks/whatsapp/meta/{application_key}

Chaque requête de commande contient les en-têtes x-external-organization-id, x-external-timestamp et x-external-signature. La signature HMAC est calculée sur timestamp + "." + corps_brut et porte un identifiant de clé :

x-external-signature: v1:key_id=<signature>

Le corps d'une commande utilise operation_key et une clé d'idempotence fournie par l'application cliente :

{
  "operation_key": "notification",
  "channel": "whatsapp",
  "recipient": { "type": "phone", "value": "+2250700000000" },
  "content": { "type": "text", "text": "Votre notification est prête." },
  "metadata": { "idempotency_key": "client_event_987_recipient_123" }
}

Les applications clientes génèrent leurs propres clés d'idempotence, construisent le contenu métier et transmettent les identifiants externes dans metadata. MailPulse gère uniquement les contraintes techniques : canaux, modèles, statuts, webhooks, tentatives et isolation par organisation.

Pour une commande WhatsApp de type text, MailPulse autorise l'envoi seulement pendant une fenêtre de conversation externe active de 24 heures. Un message texte entrant Meta signé ouvre ou prolonge cette fenêtre. Hors fenêtre, la commande est rejetée durablement avec HTTP 422 et rejection_code: "whatsapp_service_window_closed"; utilisez une commande de template approuvée. Les templates restent autorisés hors fenêtre.

Document

Une commande peut envoyer un fichier :

{
  "type": "document",
  "url": "https://fichiers.exemple.com/releve.pdf",
  "filename": "releve.pdf",
  "mimeType": "application/pdf",
  "caption": "Texte facultatif"
}

url doit être en HTTPS. filename compte au plus 120 caractères, sans / ni \. mimeType (ou mime_type) est application/pdf, image/jpeg ou image/png. caption est facultative, 1024 caractères au plus. Le destinataire voit le vrai nom du fichier. Sur Meta, un document est du contenu libre et suit la règle de la fenêtre de 24 heures, comme text.

Accord du destinataire

Une commande peut exiger l'accord explicite du destinataire avant de lui écrire. Le texte de la demande est écrit par l'application cliente :

{
  "operation_key": "notification",
  "channel": "whatsapp",
  "recipient": { "type": "phone", "value": "+2250700000000" },
  "content": { "type": "text", "text": "Votre notification est prête." },
  "metadata": { "idempotency_key": "client_event_987_recipient_123" },
  "consent": {
    "request": { "text": "Bonjour, acceptez-vous de recevoir nos messages ? Répondez OUI ou NON." },
    "expiresInSeconds": 172800
  }
}

request.text compte de 1 à 1024 caractères. expiresInSeconds (ou expires_in_seconds) vaut 172800 (48 h) par défaut et est ramené entre 3600 et 604800. L'échéance court à partir de l'envoi réel de la demande. L'accord est tenu par application, par compte d'envoi et par destinataire.

État de l'accordCe que fait MailPulseRéponse
déjà accordéenvoie le contenu tout de suitecomme sans accord (202 accepté)
refusé (NON ou STOP)n'envoie rien409 { "code": "consent_refused" }
aucun, ou expirémet la demande en file et garde le contenu chiffré202 { "status": "consent_pending", "operationId": "…" }
une demande déjà en attenteajoute le contenu à la même attente, sans seconde demande202 { "status": "consent_pending", "operationId": "…" }

Un destinataire qui a répondu NON ou STOP ne reçoit plus aucune commande de l'application par ce compte, avec ou sans consent. Un nouveau OUI de sa part réactive l'accord. Une commande dont l'accord a expiré est refusée avec 422 et rejection_code: "consent_expired"; renvoyez-la avec une nouvelle clé d'idempotence pour poser une nouvelle demande.

Les réponses sont lues sans tenir compte de la casse, des accents, de la ponctuation ni des espaces. OUI, YES, OK, D'ACCORD et J'ACCEPTE accordent ; NON, NO, STOP, ARRÊT, STOPPER et DÉSABONNER refusent. Une réponse qui tranche une demande envoyée n'est pas transmise comme message entrant.

Sur Meta, la demande est du texte libre : elle exige une fenêtre de conversation ouverte, sinon la commande est refusée avec whatsapp_service_window_closed. Sur WhatsApp Web, les envois soumis à accord suivent un rythme décrit dans la page WhatsApp.

Callbacks vers l'application

MailPulse pousse les événements du fournisseur vers les points de terminaison enregistrés pour l'application. Chaque appel porte les en-têtes x-external-event, x-external-timestamp et x-external-signature, signés avec le secret du point de terminaison dans le même format v1:key_id=....

Deux événements existent, et un point de terminaison ne reçoit que ceux auxquels il est abonné :

{
  "event_id": "wamid.ABC",
  "sender": { "phone": "+2250700000000" },
  "message": { "text": "NOTES" },
  "timestamp": "1750030073"
}
{
  "event_id": "wamid.ABC:delivered",
  "operation_id": "cmd_123",
  "idempotency_key": "votre_cle_d_idempotence",
  "message": { "provider_message_id": "wamid.ABC", "status": "delivered" },
  "recipient": { "phone": "+2250700000000" },
  "error": null,
  "timestamp": "1750030073"
}

Le premier corps correspond à whatsapp.inbound_message, le second à whatsapp.message_status; l'en-tête x-external-event porte le nom de l'événement. message.status reprend le vocabulaire du fournisseur : sent, delivered, read ou failed. Sur failed, error porte le code et le libellé transmis par Meta.

idempotency_key et operation_id reprennent la commande d'origine : c'est le moyen de rapprocher un statut de son envoi sans stocker d'identifiant MailPulse. Un statut qui ne correspond à aucune commande de l'application n'est jamais transmis. Les statuts progressent uniquement vers l'avant, et un statut peut réconcilier automatiquement une soumission dont la réponse du fournisseur avait été perdue.

Événements d'accord et de livraison

Ces événements partagent une même charge utile, et un point de terminaison ne reçoit que ceux auxquels il est abonné :

{
  "event": "consent.granted",
  "operationKey": "notification",
  "idempotencyKey": "client_event_987_recipient_123",
  "operationId": "cmd_123",
  "recipient": "+2250700000000",
  "occurredAt": "2026-09-25T16:00:00.000Z"
}
ÉvénementQuand
consent.requestedla demande d'accord est réellement partie
consent.grantedréponse OUI
consent.refusedréponse NON ou STOP ; le contenu retenu est annulé
consent.expiredaucune réponse avant l'échéance ; le contenu retenu est abandonné
message.sentle contenu a été accepté par le fournisseur
message.delivered / message.readaccusé de réception ou de lecture, sur Meta comme sur WhatsApp Web
message.failedle contenu n'a pas pu être livré

message.* porte en plus messageId, et message.failed un failureCode. Un destinataire déjà d'accord ne produit que des message.*. Chaque commande retenue reçoit ses propres événements ; un OUI ou un STOP écrit sans commande en attente produit un seul consent.*, avec operationKey, idempotencyKey et operationId à null. Les événements sont émis une seule fois, même si le fournisseur renvoie son webhook.

Endpoints système existants

GET /api/cron/send-scheduled
GET /api/cron/filon-recoveries
POST /api/cron/process-whatsapp-consents
GET /api/track/open
GET /api/track/click
GET /api/unsubscribe
POST /api/webhooks/email
POST /api/upload

On this page