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_id

Contacts

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

Contacts 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.

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 stateWhat MailPulse doesResponse
already grantedsends the content at onceas without consent (202 accepted)
refused (NO or STOP)sends nothing409 { "code": "consent_refused" }
none, or expiredqueues the request and keeps the content encrypted202 { "status": "consent_pending", "operationId": "…" }
a request already pendingadds the content to the same wait, with no second request202 { "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.

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"
}
EventWhen
consent.requestedthe consent request actually left
consent.grantedYES reply
consent.refusedNO or STOP reply; the held content is cancelled
consent.expiredno reply before the deadline; the held content is abandoned
message.sentthe content was accepted by the provider
message.delivered / message.readdelivery or read receipt, on Meta and on WhatsApp Web
message.failedthe 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}/messages

MailPulse 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-secret

Events 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-secret

The 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.

On this page