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_idContacts
POST /api/v1/contacts
GET /api/v1/contacts/{contact_id}
PATCH /api/v1/contacts/{contact_id}/preferencesLes 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}/messagesMailPulse 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-secretChaque 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-secretLa 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'accord | Ce que fait MailPulse | Réponse |
|---|---|---|
| déjà accordé | envoie le contenu tout de suite | comme sans accord (202 accepté) |
| refusé (NON ou STOP) | n'envoie rien | 409 { "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 attente | ajoute le contenu à la même attente, sans seconde demande | 202 { "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énement | Quand |
|---|---|
consent.requested | la demande d'accord est réellement partie |
consent.granted | réponse OUI |
consent.refused | réponse NON ou STOP ; le contenu retenu est annulé |
consent.expired | aucune réponse avant l'échéance ; le contenu retenu est abandonné |
message.sent | le contenu a été accepté par le fournisseur |
message.delivered / message.read | accusé de réception ou de lecture, sur Meta comme sur WhatsApp Web |
message.failed | le 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
