MessagePipedocs

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:

FieldAlways presentMeaning
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.
recipientThe recipient address, when known.
template_idThe template, for a template send.
providerThe provider configuration (prv_…) the event came from, e.g. whose feedback loop reported a complaint.
bounce_typeOn 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.
reasonOn bounced and failed: the provider's or dispatcher's explanation.
feedback_typeOn 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):

AttemptAfter the previous one
21m
35m
415m
51h
64h
712h
824h

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:

EventWhen
sentProvider accepted the payload.
deliveredRecipient mailbox / handset / app received it.
bouncedThe receiver refused it. data.bounce_type says hard or soft; only a hard bounce means the address is dead.
complaintThe recipient marked it as spam, reported through the provider's feedback loop (data.provider).
failedTerminal provider error.

The platform does not emit opened or clicked events.

Coverage matrix

Channel / providersentdeliveredbouncedcomplaintfailed
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.