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.
Create an endpoint
Section titled “Create an endpoint”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:
GET /v1/client/webhook-endpointsGET /v1/client/webhook-endpoints/:idTo change the URL, the events, or disable it without deleting it:
PATCH /v1/client/webhook-endpoints/:id{ "url": "https://my-app.com/webhooks/v2", "status": "active" }And to delete it:
DELETE /v1/client/webhook-endpoints/:idWebhookEvent payload
Section titled “WebhookEvent payload”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>"}Available events
Section titled “Available events”| Event | Description |
|---|---|
email.sent | Transactional email sent successfully |
email.failed | Transactional email failed to send |
email.rejected | We didn’t send it — it was rejected before going out. The reason field carries the code (domain_unauthorized, quota_exceeded, spam…) |
email.suppressed | We didn’t send it because the address is on your suppression list. Remove it from your database, or lift it from the dashboard |
email.opened | A recipient opened the email (first open, via tracking pixel) |
email.clicked | A recipient clicked a tracked link (first click) |
email.bounced | An email bounced (hard or soft bounce) |
email.complained | A recipient marked the email as spam (FBL) |
contact.unsubscribed | A contact unsubscribed |
contact.confirmed | A contact confirmed their subscription (double opt-in) |
campaign.email.sent | Campaign email sent to a contact |
campaign.email.failed | Campaign email failed for a contact |
campaign.completed | Campaign finished sending to all recipients |
sequence.subscribed | Contact subscribed to a sequence |
sequence.step.sent | Sequence step sent |
sequence.step.failed | Sequence step failed |
sequence.completed | Sequence completed for a contact |
sequence.cancelled | Sequence cancelled (field reason with cause) |
dmarc.alignment_failure | Your domain exceeded the DMARC alignment failure threshold |
Fields per event
Section titled “Fields per event”email.sent/email.failed:request_id,to(array),subject,message_id.failedalso includeserror.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.failedalso includeserror.campaign.completed:campaign_id,name,sent_count,failed_count,delivery_rate.sequence.*:email,campaign_id(sequence id).sequence.cancelledalso includesreason(unsubscribed,manual,bounced,complained,suppressed).dmarc.alignment_failure:domain,fail_rate,volume,policy_p,window_days,top_failing_sources.
Verifying the HMAC-SHA256 signature
Section titled “Verifying the HMAC-SHA256 signature”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 bodyapp.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);});import hmacimport hashlibimport timefrom datetime import datetime, timezone
def verify_webhook(signature: str, timestamp: str, raw_body: bytes, secret: str) -> bool: # Check that the timestamp is not too old (anti-replay) ts = datetime.fromisoformat(timestamp.replace('Z', '+00:00')) diff = abs((datetime.now(timezone.utc) - ts).total_seconds()) if diff > 300: # more than 5 minutes → reject return False
signed_payload = f"{timestamp}.{raw_body.decode('utf-8')}".encode('utf-8') expected = 'sha256=' + hmac.new( secret.encode('utf-8'), signed_payload, hashlib.sha256 ).hexdigest()
return hmac.compare_digest(signature, expected)Retries with backoff
Section titled “Retries with backoff”If your endpoint does not respond with a 2xx, MailerDash retries automatically:
- Initial burst (in-process, max 3 attempts): after the first failure, retries at 1s, 5s, and 25s.
- Queue with progressive backoff (if the burst fails completely): 5 min → 30 min → 2 h → 6 h → 24 h (×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.
Management endpoints
Section titled “Management endpoints”Rotate the secret
Section titled “Rotate the secret”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.
POST /v1/client/webhook-endpoints/:id/rotate-secretWe also email you when this happens, for the same reason as a URL change.
Send a test event
Section titled “Send a test event”Fires a webhook.test to the endpoint’s URL so you can verify your server receives it and validates the signature.
POST /v1/client/webhook-endpoints/:id/testDelivery history
Section titled “Delivery history”Lists the most recent deliveries with their status, HTTP code, and timestamp.
GET /v1/client/webhook-endpoints/:id/deliveriesQuery parameters: limit (default 50, max 200), offset.
Delivery detail
Section titled “Delivery detail”Includes the full payload and the response body from your endpoint (up to 2 KB).
GET /v1/client/webhook-endpoints/:id/deliveries/:dIdManually retry a delivery
Section titled “Manually retry a delivery”Re-fires a specific event using the endpoint’s current URL and secret.
POST /v1/client/webhook-endpoints/:id/deliveries/:dId/retryAdditional security
Section titled “Additional security”- 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/.