Agent skill
MailerSend Webhooks Skill
Receive and verify MailerSend webhooks. Use when setting up MailerSend webhook handlers, debugging MailerSend signature verification with the `Signature` header (HMAC-SHA256 hex over the raw body), handling the `webhook.test` URL validation ping, or handling MailerSend activity events like activity.sent, activity.delivered, activity.hard_bounced, activity.opened, activity.clicked and activity.spam_complaint. Also covers MailerSend SMS webhooks (sms.sent, sms.delivered, sms.failed). MailerSend is the transactional email/SMS API — not MailerLite, Mailgun, Mailchimp or Resend.
Install this skill
npx skills add hookdeck/webhook-skills --skill mailersend-webhooks
When to Use This Skill
- How do I receive MailerSend webhooks?
- How do I verify a MailerSend webhook signature?
- Why is my MailerSend
Signatureheader verification failing? - Why won't my MailerSend webhook save / why does the URL validation fail?
- What is
webhook.testand thetest_Am3L1GuOIc4blLUuHqAPxxwkZaJyEk8Gsecret? - How do I handle
activity.hard_bounced/activity.spam_complaintevents? - How do I handle MailerSend SMS webhooks (
sms.sent,sms.delivered,sms.failed)?
MailerSend, not MailerLite. MailerSend is the transactional email and SMS API from the MailerLite group (developers.mailersend.com). MailerLite (marketing email) is a separate product with a separate webhook scheme. This skill is not for Mailgun, Mailchimp or Resend either.
Verification (core)
Signature: <lowercase hex HMAC-SHA256 of the RAW request body>, keyed with the per-webhook Signing Secret. No timestamp, no nonce, no version prefix, no field concatenation — the header value is the bare digest.
const crypto = require('crypto');
// MailerSend signs its URL-validation ping with this FIXED, PUBLICLY DOCUMENTED
// secret — not your signing secret. Accept it, but only for `webhook.test`.
const MAILERSEND_TEST_SECRET = 'test_Am3L1GuOIc4blLUuHqAPxxwkZaJyEk8G';
function verifySignature(rawBody, signature, secret) {
if (!signature || !secret) return false;
const expected = crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
const a = Buffer.from(String(signature).trim().toLowerCase(), 'utf8');
const b = Buffer.from(expected, 'utf8');
// timingSafeEqual THROWS on a length mismatch — guard the length first
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
// rawBody MUST be the exact bytes received. Re-serialising parsed JSON breaks it.
const signature = req.header('Signature');
const signedByYou = verifySignature(rawBody, signature, process.env.MAILERSEND_WEBHOOK_SECRET);
const signedByPing = !signedByYou && verifySignature(rawBody, signature, MAILERSEND_TEST_SECRET);
if (!signedByYou && !signedByPing) return res.status(401).send('Invalid signature');
// After parsing: if signedByPing, require type === 'webhook.test' — the test
// secret is public, so it must never authorise a real event.
For complete handlers with tests, see examples/express/, examples/nextjs/, examples/fastapi/.
The official Node SDK (mailersend) ships MailerSendUtils.verifyWebHook(), but it is not exported from the package entry point, it calls timingSafeEqual without a length guard (throws RangeError on a malformed header), and its README snippet reads a x-mailersend-signature header that MailerSend does not send. Verify manually as above — it matches the docs' own Node/Go/PHP samples. See references/verification.md.
The webhook.test Ping (read this before your first webhook fails to save)
When you create or update a webhook, MailerSend immediately calls the URL to validate it. If that request does not get a 2xx, the webhook is not saved.
{
"type": "webhook.test",
"message": "This is a ping test message",
"created_at": "2026-03-27T07:24:20.577080Z"
}
Two traps:
- Different envelope. It carries
message, notdata. Code that doespayload.data.idunconditionally will 500 on the ping. - Different secret. It is signed with the fixed, publicly documented
test_Am3L1GuOIc4blLUuHqAPxxwkZaJyEk8G, not your webhook's signing secret. A handler that only checks the real secret rejects the ping and the webhook never saves.
Because that secret is public, anyone can forge a valid webhook.test. Accept it, return 200, and never let it gate privileged work.
Payload Envelope
Real events:
{
"type": "activity.sent",
"created_at": "2025-08-05T21:23:54.000000Z",
"data": {
"id": "6892766a5b66e2daf3dc9155",
"domain_id": "yv69oxl5kl785kw2",
"message_id": "6892766ae78995a317577aa1",
"email_id": "6892766a8d52ba62543d5e71",
"type": "sent",
"subject": "Test email",
"email": "test@mailersend.com",
"tags": ["test", "test2"],
"meta": []
}
}
data.typeis the bare activity name (sent), without theactivity.prefix.data.metais an empty ARRAY[]when there is nothing to report, and an object otherwise. This breaks naive typed deserialisation — normalise it.created_atcomes in two documented formats: microsecond ISO-8601 withZ(2025-08-05T21:23:54.000000Z) for activity and inbound events, and space-separated (2025-08-05 22:27:14) forsender_identity.verifiedand themaintenance.*events. Parse defensively.
Event Types
23 documented events, plus the webhook.test ping.
| Event | Fires when |
|---|---|
activity.sent | Email accepted and dispatched from MailerSend's servers |
activity.delivered | Receiving server accepted the email |
activity.soft_bounced | Temporary delivery failure (mailbox full, greylisting) |
activity.hard_bounced | Permanent failure — suppress the address |
activity.opened | Recipient opened the email (every open) |
activity.opened_unique | First open only |
activity.clicked | Recipient clicked a link (every click) |
activity.clicked_unique | First click only |
activity.unsubscribed | Recipient unsubscribed |
activity.spam_complaint | Recipient marked the email as spam — suppress immediately |
activity.deferred | Temporarily delayed (paid plans only) |
activity.survey_opened | Survey email opened for the first time |
activity.survey_submitted | Survey submitted, or 30-minute idle timeout |
sender_identity.verified | A sender identity finished verification |
maintenance.start | Scheduled maintenance began |
maintenance.end | Scheduled maintenance ended |
inbound_forward.failed | Inbound forwarding to your URL failed |
inbound_message.rejected | Inbound message rejected (unsupported_attachment_type or attachment_size_exceeded) |
email_single.verified | Single email address verification finished |
email_list.verified | Email list verification finished |
bulk_email.completed | Bulk send finished processing |
recipient.on_hold_added | Recipient placed on the on-hold list |
recipient.on_hold_removed | Recipient removed from the on-hold list |
webhook.test | URL validation ping — see above |
SMS webhooks are configured separately (SMS → Webhooks) with an identical security model — same Signature header, same HMAC-SHA256 hex over the raw body, same per-webhook signing secret, same fixed test secret. One verifier handles both surfaces. They add three event names: sms.sent, sms.delivered, sms.failed.
Full list: references/overview.md.
Delivery Semantics
- Respond within 3 seconds or the attempt is logged as failed. Acknowledge with 2xx immediately and do the work in a background job.
- Failed calls retry with exponential backoff for ~3 days. Separately, a webhook whose endpoint "stays down too long" is automatically paused and must be re-enabled in the dashboard — the docs don't pin that threshold to the retry window, so don't assume they're the same deadline.
- 4xx other than 429, and DNS failures, are never retried. A signature rejection therefore gets exactly one attempt — that is intended.
- No replay-protection material is sent (no timestamp, no nonce, no delivery id header), so a timestamp tolerance check is impossible. Use application-level idempotency keyed on
data.idinstead. - MailerSend documents no source-IP allowlist and no
X-MailerSend-*headers. Don't build either into your receiver.
Environment Variables
# The per-webhook Signing Secret MailerSend generates when the webhook is
# created (Dashboard -> Domains -> Manage -> Webhooks, or the Webhooks API).
# This is NOT your MailerSend API token.
MAILERSEND_WEBHOOK_SECRET=your_webhook_signing_secret
# Port the example server listens on
PORT=3000
Local Development
# No install, no account required — creates a guest account on first run
npx hookdeck-cli listen 3000 mailersend --path /webhooks/mailersend
Paste the printed URL into the webhook's URL field. MailerSend fires the webhook.test ping the moment you save, so you'll see the first request immediately — a good check that your ping handling works before any real email.
Use 8000 instead of 3000 for the FastAPI example.
Reference Materials
- references/overview.md - What MailerSend webhooks are, all events, payload shapes
- references/setup.md - Dashboard and API configuration, getting the signing secret
- references/verification.md - Signature verification details, gotchas, debugging