Skip to content

Webhooks

Webhooks let you receive real-time notifications when events occur in your account: delivered emails, failures, unsubscribes, and campaign and sequence events.

Webhook endpoints belong to your account, not to an API key: they receive the events of all your keys. When an event fires, MailerDash sends a POST to every subscribed endpoint with the payload signed with HMAC-SHA256.

You can have several — say, one for your app and one for your support tool — and choose which events each one gets.


Ventana de terminal
curl -X POST https://api.mailerdash.com/v1/client/webhook-endpoints \
-H "Authorization: Bearer $MAILERDASH_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://my-app.com/webhooks/mailerdash",
"enabled_events": ["*"],
"description": "Main app"
}'

enabled_events takes ["*"] (everything) or the list you care about — see the table below. description is just so you can tell them apart in the panel.

The response includes the secret, and that’s the only time it’s shown in full:

{
"id": "whe_9f3a2b1c8d7e",
"url": "https://my-app.com/webhooks/mailerdash",
"secret": "whsec_...",
"enabled_events": "[\"*\"]",
"status": "active",
"description": "Main app"
}

To list the ones you have (secrets never included) and see one in detail:

Ventana de terminal
GET /v1/client/webhook-endpoints
GET /v1/client/webhook-endpoints/:id

To change the URL, the events, or disable it without deleting it:

Ventana de terminal
PATCH /v1/client/webhook-endpoints/:id
{ "url": "https://my-app.com/webhooks/v2", "status": "active" }

And to delete it:

Ventana de terminal
DELETE /v1/client/webhook-endpoints/:id

Each event arrives as a POST with Content-Type: application/json. The body always contains the fields event, ts, and app, plus additional fields specific to the event type.

{
"event": "email.sent",
"ts": "2026-06-22T15:30:00.000Z",
"app": "mi-app",
"request_id": "req_abc123",
"to": ["usuario@ejemplo.com"],
"subject": "Tu factura de junio",
"message_id": "<abc123@mailerdash.com>"
}
EventDescription
email.sentTransactional email sent successfully
email.failedTransactional email failed to send
email.rejectedWe didn’t send it — it was rejected before going out. The reason field carries the code (domain_unauthorized, quota_exceeded, spam…)
email.suppressedWe didn’t send it because the address is on your suppression list. Remove it from your database, or lift it from the dashboard
email.openedA recipient opened the email (first open, via tracking pixel)
email.clickedA recipient clicked a tracked link (first click)
email.bouncedAn email bounced (hard or soft bounce)
email.complainedA recipient marked the email as spam (FBL)
contact.unsubscribedA contact unsubscribed
contact.confirmedA contact confirmed their subscription (double opt-in)
campaign.email.sentCampaign email sent to a contact
campaign.email.failedCampaign email failed for a contact
campaign.completedCampaign finished sending to all recipients
sequence.subscribedContact subscribed to a sequence
sequence.step.sentSequence step sent
sequence.step.failedSequence step failed
sequence.completedSequence completed for a contact
sequence.cancelledSequence cancelled (field reason with cause)
dmarc.alignment_failureYour domain exceeded the DMARC alignment failure threshold
  • email.sent / email.failed: request_id, to (array), subject, message_id. failed also includes error.
  • email.opened: campaign_id, email.
  • email.clicked: campaign_id, email, url.
  • email.bounced: recipient, status, category, diagnostic, message_id.
  • email.complained: email, isp, subject.
  • contact.unsubscribed: email.
  • contact.confirmed: email, list_id, list_name.
  • campaign.email.sent / campaign.email.failed: email, campaign_id. failed also includes error.
  • campaign.completed: campaign_id, name, sent_count, failed_count, delivery_rate.
  • sequence.*: email, campaign_id (sequence id). sequence.cancelled also includes reason (unsubscribed, manual, bounced, complained, suppressed).
  • dmarc.alignment_failure: domain, fail_rate, volume, policy_p, window_days, top_failing_sources.

Each request includes two security headers:

  • X-Md-Signature: sha256=<hmac-hex>
  • X-Md-Timestamp: ISO timestamp at the time of delivery

The signature is computed over the string <timestamp>.<raw_body> using your webhook_secret.

const crypto = require('crypto');
function verifyWebhook(req, webhookSecret) {
const signature = req.headers['x-md-signature']; // "sha256=abc123..."
const timestamp = req.headers['x-md-timestamp']; // "2026-06-22T15:30:00.000Z"
const rawBody = req.body; // Buffer or string — must be the raw unparsed body
if (!signature || !timestamp) return false;
// Check that the timestamp is not too old (anti-replay)
const diff = Math.abs(Date.now() - new Date(timestamp).getTime());
if (diff > 5 * 60 * 1000) return false; // more than 5 minutes → reject
// Compute HMAC over "<timestamp>.<raw_body>"
const signedPayload = `${timestamp}.${rawBody}`;
const expected = 'sha256=' + crypto
.createHmac('sha256', webhookSecret)
.update(signedPayload)
.digest('hex');
// Compare in constant time to prevent timing attacks
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expected)
);
}
// Example with Express — parse body as Buffer to preserve the raw body
app.post('/webhooks/mailerdash', express.raw({ type: 'application/json' }), (req, res) => {
const valid = verifyWebhook(req, process.env.MAILERDASH_WEBHOOK_SECRET);
if (!valid) return res.status(401).send('Invalid signature');
const event = JSON.parse(req.body);
console.log('Evento recibido:', event.event);
res.sendStatus(200);
});

If your endpoint does not respond with a 2xx, MailerDash retries automatically:

  1. Initial burst (in-process, max 3 attempts): after the first failure, retries at 1s, 5s, and 25s.
  2. Queue with progressive backoff (if the burst fails completely): 5 min → 30 min → 2 h → 6 h → 24 h (×3).
  3. If all attempts are exhausted, the delivery is marked as failed (DLQ) and visible in the delivery history.

The timeout per attempt is 10 seconds. Your endpoint must respond within that time.


Generates a new secret and invalidates the previous one immediately. Use it if you think yours leaked — and have the change ready to deploy, because between the rotation and your deploy your deliveries will fail signature verification.

Ventana de terminal
POST /v1/client/webhook-endpoints/:id/rotate-secret

We also email you when this happens, for the same reason as a URL change.

Fires a webhook.test to the endpoint’s URL so you can verify your server receives it and validates the signature.

Ventana de terminal
POST /v1/client/webhook-endpoints/:id/test

Lists the most recent deliveries with their status, HTTP code, and timestamp.

Ventana de terminal
GET /v1/client/webhook-endpoints/:id/deliveries

Query parameters: limit (default 50, max 200), offset.

Includes the full payload and the response body from your endpoint (up to 2 KB).

Ventana de terminal
GET /v1/client/webhook-endpoints/:id/deliveries/:dId

Re-fires a specific event using the endpoint’s current URL and secret.

Ventana de terminal
POST /v1/client/webhook-endpoints/:id/deliveries/:dId/retry

  • In production, MailerDash only delivers to HTTPS URLs.
  • URLs that resolve to private or reserved IPs (loopback, RFC1918, cloud metadata) are blocked to prevent SSRF.
  • The secret is only shown in full when you create the endpoint and when you rotate it; everywhere else it’s masked.

See the full reference at /reference/platform/.