API and webhooks
Public V1 API, idempotency, signed webhooks, and integration compatibility.
Authentication
The public V1 API uses generic Bearer keys: mp_live_* or mp_test_*.
Keys are isolated per MailPulse organization. Legacy mp_filon_* keys remain
accepted for compatibility, but new clients should use MailPulse keys.
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}/preferencesContacts stay generic. Client business identifiers belong in metadata, such as
external_user_id or external_tenant_id.
Messages
POST /api/v1/messages
GET /api/v1/messages/{message_id}Statuses: queued, retrying, sent, delivered, read, failed,
cancelled, template_required.
An email refused by the provider moves to failed and error_code gives the
cause: email_bounced, email_complained, email_suppressed (address on the
provider's suppression list) or email_failed (sending failure, reason in
error_message). A late event never moves a delivered, read or failed message
backwards. A complaint received after delivery unsubscribes the contact but
leaves the message delivered or read.
GET /api/v1/messages/{message_id} also returns delivery_delays, the delivery
delays reported by the provider (at most 20, newest first). A delay does not
change the status: the message stays sent while the provider retries.
"delivery_delays": [
{ "occurred_at": "2026-09-23T10:45:00.000Z" }
]WhatsApp sending number
A WhatsApp message leaves from a number of the application the key is attached to. An application can hold several numbers (a school, its departments); one of them is the default. A key without an application, or attached to an application that never had a number, sends from the organization's number. The numbers belong to the application, not to the key: a new key created under the same name as an attached key joins the same application, so a rotation keeps the numbers.
To choose the number, pass sender_id (WhatsApp only):
{
"channel": "whatsapp",
"recipient": { "type": "phone", "value": "+2250700000000" },
"content": { "type": "text", "text": "Hello" },
"sender_id": "cmabc123number"
}Without sender_id, the default number speaks. GET /api/v1/whatsapp/numbers
lists the numbers the key may name:
{
"numbers": [
{ "id": "cmabc123number", "label": "Registrar", "transport": "whatsapp_web", "active": true, "default": true }
],
"default_sender_id": "cmabc123number"
}The message becomes failed with error_code sender_unavailable if the
named number is disabled, or if, with no
number named, all of the application's numbers are disabled. MailPulse never sends from another application's number. A sender_id
that is unknown, another application's, or given by a key with no
application answers 422 sender_id_inconnu; on another channel, 400. A recipient who answered NO or STOP to one of
the application's numbers receives nothing more from that number, whether the
message comes from the API or a signed command: error_code consent_refused.
Verification codes (/api/v1/verifications) follow the same rule and accept
sender_id too. Only a WhatsApp Web number can send a code: without
sender_id, if the default number is on Meta, the code leaves from another
WhatsApp Web number of the same application; a named number that cannot send a
code, or no capable number at all, answers whatsapp_indisponible. For an
application number, the WhatsApp Web session is not checked beforehand: a
disconnected session shows up at send time, as a send failure.
Numbers are managed in Messaging, Numbers tab: QR pairing, default number,
name, deactivation, moving to another application, and the identifier to pass
as sender_id. A moved number keeps its identifier: the new application uses
it with the same sender_id, the old one can no longer name it. Its replies
and the refusals already received (STOP) follow it; the history of its sends
stays with the old application. If sends from the number have not reached an
outcome, the move dialog lists them and offers to stop them: a command that
never left is rejected with sender_moved (the application receives
message.failed), an unanswered consent request expires, and a send whose
outcome is unknown is closed without being sent again. A key is
attached to an application in Platform, API keys and integrations tab. Only the
organization's owners and admins can change a key, a sender or a number.
The number used is recorded on the message when it is created: a replay leaves from that number, and the history does not change if the default number changes later.
Replaying a request
Replaying POST /api/v1/messages with the same Idempotency-Key and body never
creates a second message. The response describes the message as it is now, not
the first response: a queued message resumes its dispatch, a message already
submitted or settled is only read back, with no new provider call and no new
webhook event.
How long a WhatsApp Web send takes
On WhatsApp Web, every message is preceded by 2 to 10 seconds of simulated typing, proportional to the text length, and MailPulse answers once the message is sent. Allow a client timeout of at least 20 seconds. Links get no preview.
External applications
POST /api/v1/external-applications/{application_key}/commands
POST /api/webhooks/whatsapp/meta/{application_key}Command requests include x-external-organization-id, x-external-timestamp,
and x-external-signature. The HMAC signature covers timestamp + "." + rawBody
and carries a key identifier:
x-external-signature: v1:key_id=<signature>Applications send an operation_key and their own idempotency key:
{
"operation_key": "notification",
"channel": "whatsapp",
"recipient": { "type": "phone", "value": "+2250700000000" },
"content": { "type": "text", "text": "Your notification is ready." },
"metadata": { "idempotency_key": "client_event_987_recipient_123" }
}For WhatsApp text commands, MailPulse permits delivery only while the recipient
has an active 24-hour external conversation window. A signed inbound Meta text
message opens or extends that window. Outside it, the command is durably rejected
with HTTP 422 and rejection_code: "whatsapp_service_window_closed"; use an
approved template command instead. Template commands remain permitted outside the
window.
Document
A command can send a file:
{
"type": "document",
"url": "https://files.example.com/statement.pdf",
"filename": "statement.pdf",
"mimeType": "application/pdf",
"caption": "Optional text"
}url must be HTTPS. filename is at most 120 characters, with no / or \.
mimeType (or mime_type) is application/pdf, image/jpeg or image/png.
caption is optional, at most 1024 characters. The recipient sees the real file
name. On Meta, a document is free-form content and follows the 24-hour window
rule, like text.
Recipient consent
A command can require the recipient's explicit agreement before writing to them. The request text is written by the client application:
{
"operation_key": "notification",
"channel": "whatsapp",
"recipient": { "type": "phone", "value": "+2250700000000" },
"content": { "type": "text", "text": "Your notification is ready." },
"metadata": { "idempotency_key": "client_event_987_recipient_123" },
"consent": {
"request": { "text": "Hello, do you agree to receive our messages? Reply YES or NO." },
"expiresInSeconds": 172800
}
}request.text is 1 to 1024 characters. expiresInSeconds (or
expires_in_seconds) defaults to 172800 (48 h) and is brought within 3600 to
604800. The expiry starts when the request is actually sent. Consent is held per
application, per sending account and per recipient.
| Consent state | What MailPulse does | Response |
|---|---|---|
| already granted | sends the content at once | as without consent (202 accepted) |
| refused (NO or STOP) | sends nothing | 409 { "code": "consent_refused" } |
| none, or expired | queues the request and keeps the content encrypted | 202 { "status": "consent_pending", "operationId": "…" } |
| a request already pending | adds the content to the same wait, with no second request | 202 { "status": "consent_pending", "operationId": "…" } |
A recipient who replied NO or STOP receives no further command from the
application through this account, with or without consent. A new YES from
them reactivates consent. A command whose consent expired is rejected with 422
and rejection_code: "consent_expired"; send it again with a new idempotency
key to ask again.
Replies are read regardless of case, accents, punctuation and spaces. OUI,
YES, OK, D'ACCORD and J'ACCEPTE grant; NON, NO, STOP, ARRÊT,
STOPPER and DÉSABONNER refuse. A reply that settles a sent request is not
forwarded as an inbound message.
On Meta, the request is free-form text: it needs an open conversation window,
otherwise the command is rejected with whatsapp_service_window_closed. On
WhatsApp Web, consent-gated sends follow the pacing described on the WhatsApp
page.
Callbacks to the application
MailPulse pushes provider events to the endpoints registered for the
application. Every call carries x-external-event, x-external-timestamp and
x-external-signature, signed with the endpoint secret in the same
v1:key_id=... format. An endpoint only receives the events it subscribes to.
{
"event_id": "wamid.ABC",
"sender": { "phone": "+2250700000000" },
"message": { "text": "NOTES" },
"timestamp": "1750030073"
}{
"event_id": "wamid.ABC:delivered",
"operation_id": "cmd_123",
"idempotency_key": "your_idempotency_key",
"message": { "provider_message_id": "wamid.ABC", "status": "delivered" },
"recipient": { "phone": "+2250700000000" },
"error": null,
"timestamp": "1750030073"
}The first body is whatsapp.inbound_message, the second is
whatsapp.message_status. message.status carries the provider vocabulary:
sent, delivered, read or failed. On failed, error holds the code and
title reported by Meta.
idempotency_key and operation_id echo the original command, so a status can
be matched to its send without storing a MailPulse identifier. A status that
matches no command of the application is never forwarded. Statuses only move
forward, and a status can automatically reconcile a submission whose provider
response was lost.
Consent and delivery events
These events share one payload, and an endpoint only receives those it subscribes to:
{
"event": "consent.granted",
"operationKey": "notification",
"idempotencyKey": "client_event_987_recipient_123",
"operationId": "cmd_123",
"recipient": "+2250700000000",
"occurredAt": "2026-09-25T16:00:00.000Z"
}| Event | When |
|---|---|
consent.requested | the consent request actually left |
consent.granted | YES reply |
consent.refused | NO or STOP reply; the held content is cancelled |
consent.expired | no reply before the deadline; the held content is abandoned |
message.sent | the content was accepted by the provider |
message.delivered / message.read | delivery or read receipt, on Meta and on WhatsApp Web |
message.failed | the content could not be delivered |
message.* also carries messageId, and message.failed a failureCode. A
recipient who already agreed only produces message.*. Each held command gets
its own events; a YES or STOP written with no command waiting produces a single
consent.*, with operationKey, idempotencyKey and operationId set to
null. Events are emitted once, even when the provider replays its webhook.
Templates
POST /api/v1/templates
GET /api/v1/templates
PATCH /api/v1/templates/{template_key}Templates may contain client business language, but MailPulse does not model the
client business domain. For WhatsApp, free-form messages outside the 24h service
window return template_required; WhatsApp templates must be approved.
Conversations
GET /api/v1/conversations
GET /api/v1/conversations/{conversation_id}/messagesMailPulse groups messages by conversation and stores the WhatsApp service window when available.
Outgoing webhooks
POST /api/v1/webhooks
GET /api/v1/webhooks
DELETE /api/v1/webhooks/{webhook_id}
POST /api/v1/webhooks/{webhook_id}/rotate-secretEvents contain event_id, type, tenant_id, created_at, and data.
Requests are signed with HMAC SHA-256:
mailpulse-event-id: evt_xxx
mailpulse-timestamp: 1782996000
mailpulse-signature: v1=<signature>The signature is computed over timestamp + "." + rawBody with the webhook
secret shown once at creation time.
Secret rotation
POST /api/v1/webhooks/{webhook_id}/rotate-secretThe response contains the new secret (signing_secret), shown once. For 24
hours, every request carries two signatures, the new secret's first:
mailpulse-signature: v1=<new_signature>,v1=<old_signature>Split the header on commas and accept the request if any signature matches your secret: you switch secrets without losing events.
Before your first rotation, make sure your application accepts a header holding several signatures. A check that compares the whole header with
v1=<signature>would reject every delivery during the 24-hour overlap. A second rotation within those 24 hours replaces the old secret with the previous new one.
Delivery and retries
- The URL must be public HTTPS: local, internal and private addresses are refused, and redirects are not followed.
- Your server must answer 2xx within 10 seconds.
- A timeout, a network error, or a 408, 429 or 5xx response is retried after 1 min, 5 min, 30 min, 2 h then 6 h, six attempts in total. Any other response stops the delivery.
- A retry resends the same
event_id: use it to ignore a duplicate. - Deliveries can be reviewed, and resent by an admin, in Platform › Webhooks.
Client examples
Client applications generate idempotency keys, build business content, and pass
external identifiers through metadata.
MailPulse owns only technical communication concerns: channels, templates,
statuses, webhooks, retries, tenant isolation, and the WhatsApp window.

