Webhooks
Receive delivery events from MessagePipe (payload shape, signing, retries).
When something happens to one of your sends (sent, delivered, bounced, a spam complaint, or failed) MessagePipe POSTs a JSON event to every webhook endpoint you have registered. This page is the integrator's contract.
Registering an endpoint
curl https://app.dev.messagepipe.dev/api/webhooks \
-H "Cookie: mp_session=..." \
-H "Content-Type: application/json" \
-d '{
"workspace_id": "wks_…",
"project_id": "prj_…",
"url": "https://api.acme.dev/webhooks/messagepipe",
"events": ["delivered", "bounced", "complaint", "failed"]
}'project_id is optional; without it the endpoint receives events for every
project in the workspace. You can also add an endpoint in the dashboard under
Webhooks.
The response includes a signing secret (shown once). Store it; you'll use it to verify every incoming request.
Verification
Before an endpoint receives events it must answer a challenge. On create,
MessagePipe POSTs a signed {"type":"webhook.verification","challenge":"…"}
to the URL, and the endpoint must reply 2xx with the challenge value in the
response body within 10 seconds. The response's status is then
active. Anything else leaves it failed_verification, and it gets no events
until you fix the endpoint and call POST /api/webhooks/{id}/verify.
POST /api/webhooks/{id}/test sends a signed sample, {"type":"webhook.test","event":{…}}.
It checks reachability and your signature code, but it isn't shaped like a
delivery event (it has no data), so don't parse it as one.
Payload shape
POST /webhooks/messagepipe HTTP/1.1
Content-Type: application/json
User-Agent: MessagePipe-Webhooks/1.0
X-MP-Signature: t=1735776123,v1=4f3a2b…
X-MP-Timestamp: 1735776123
X-MP-Event-Type: delivery.event.delivered
X-MP-Event-Id: evt_06F4Y9CP1GQB39AA
{
"id": "evt_06F4Y9CP1GQB39AA",
"type": "delivery.event.delivered",
"occurred_at": "2026-05-23T12:34:56.789Z",
"data": {
"message_id": "msg_06F4Y9CP1GQB39AA",
"channel": "email",
"recipient": "maya@example.com",
"template_id": null,
"provider": "prv_8c4e2a1f",
"idempotency_key": "order-1234-confirmation",
"metadata": { "order_id": "1234" }
}
}The top level is always id, type, occurred_at, and data. The type
field and the X-MP-Event-Type header are namespaced as
delivery.event.<event> (for example delivery.event.bounced), even though
you subscribe with the bare event names. Every delivery also carries
X-MP-Timestamp (the unix timestamp) and X-MP-Event-Id (the event id).
Inside data:
| Field | Always present | Meaning |
|---|---|---|
message_id | ✓ | The send's id, as returned in the 202 from the send call. |
channel | ✓ | email, sms, chat or push. |
idempotency_key | ✓* | The Idempotency-Key (header or body field) the send was made with, or null. A send made with POST /v1/sends/{id}/retry has none of its own; match it through metadata.retried_from. |
metadata | ✓* | The metadata object you sent, unchanged, or {}. A send made with POST /v1/sends/{id}/retry also carries retried_from. |
recipient | The recipient address, when known. | |
template_id | The template, for a template send. | |
provider | The provider configuration (prv_…) the event came from, e.g. whose feedback loop reported a complaint. | |
bounce_type | On bounced: hard (permanent; stop sending), soft (the receiver refused it for now: SendGrid blocked, SES Transient), or undetermined (SES couldn't tell). A failure the provider is still retrying (Mailgun's temporary failed) is a deferred event, not a bounce. | |
reason | On bounced and failed: the provider's or dispatcher's explanation. | |
feedback_type | On complaint from SES: the feedback-loop type, e.g. abuse. |
* Present on every event unless MessagePipe couldn't read the send record
when it built the event. Then idempotency_key, metadata and recipient
are left out, rather than sent as null / {}, and you can match the event by
message_id.
Other provider details may appear alongside these. A provider field never
overwrites message_id, idempotency_key or metadata.
Verifying the signature
Compute an HMAC-SHA256 over the exact string ${timestamp}.${rawBody} keyed
by your signing secret, hex-encode it, and compare it to the v1= value in
the X-MP-Signature header with a constant-time comparison.
import { createHmac, timingSafeEqual } from "node:crypto";
function verify(rawBody: Buffer, header: string, secret: string): boolean {
const parts = Object.fromEntries(
header.split(",").map((kv) => kv.split("=") as [string, string]),
);
const ts = parts.t;
const sig = parts.v1;
if (!ts || !sig) return false;
// Reject anything more than 5 minutes old to neutralise replays.
if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
const expected = createHmac("sha256", secret)
.update(`${ts}.${rawBody.toString("utf8")}`)
.digest("hex");
return sig.length === expected.length &&
timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
}The Node, Python, and Go SDKs all expose a verifyWebhook helper that does
this for you.
Idempotency on your side
Network retries can cause us to deliver the same event twice. Dedupe on
event.id before processing. The id is a stable, monotonically-increasing
ULID-style identifier.
Retries
If your endpoint doesn't answer 2xx within 10 seconds, MessagePipe
retries with exponential backoff (each delay ±15% jitter):
| Attempt | After the previous one |
|---|---|
| 2 | 1m |
| 3 | 5m |
| 4 | 15m |
| 5 | 1h |
| 6 | 4h |
| 7 | 12h |
| 8 | 24h |
After the 8th attempt fails the delivery is given up, and every attempt is
listed under GET /api/webhooks/{id}/deliveries. Each retry is signed afresh
with a new timestamp, and keeps the same X-MP-Event-Id. After 24 consecutive
failures the endpoint is marked failing and stops receiving events.
Event types
The send status lifecycle is queued -> sent -> delivered | bounced |
failed. Subscribe to these events:
| Event | When |
|---|---|
sent | Provider accepted the payload. |
delivered | Recipient mailbox / handset / app received it. |
bounced | The receiver refused it. data.bounce_type says hard or soft; only a hard bounce means the address is dead. |
complaint | The recipient marked it as spam, reported through the provider's feedback loop (data.provider). |
failed | Terminal provider error. |
The platform does not emit opened or clicked events.
Coverage matrix
| Channel / provider | sent | delivered | bounced | complaint | failed |
|---|---|---|---|---|---|
| email (SendGrid, Mailgun, SES, Resend, Postmark, MailerSend, Bird, Elastic Email) | ✓ | ✓ | ✓ | ✓ | ✓ |
| email (ZeptoMail, Mailtrap) | ✓ | . | . | . | ✓ |
| email (SMTP) | ✓ | . | . | . | ✓ |
| sms (Twilio, Telnyx, Plivo, Bandwidth, Sinch, Vonage, Bird SMS) | ✓ | ✓ | ✓ | . | ✓ |
| sms (Termii) | ✓ | ✓ | . | . | ✓ |
| sms (Infobip, Amazon SNS, MessagePipe managed) | ✓ | . | . | . | ✓ |
| push (FCM, APNs, OneSignal) | ✓ | . | . | . | ✓ |
| chat (WhatsApp Cloud API, WhatsApp via Twilio) | ✓ | ✓ | ✓ | . | ✓ |
| chat (Telegram, Signal) | ✓ | . | . | . | ✓ |
A . means the channel / provider does not emit that event. We never
synthesise. Provider events reach MessagePipe only once the provider's
event webhook points at your provider configuration's inbound URL
(/v1/inbound/{provider}/{provider_config_id}), with the provider's
webhook signing key saved on that configuration. The provider's page in
the dashboard shows the URL. For SendGrid, Mailgun, SES, Resend, Postmark, MailerSend and Bird email, enable at least
the delivered, bounce and spam-report (complaint) events there. For Elastic Email, create the webhook with
Sign requests turned on (it cannot be changed later) and notify on Sent, Bounce / Error and Complaints; Elastic Email
reports a message the recipient server accepted as Sent, which MessagePipe records as delivered.