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 Signature header verification failing?
  • Why won't my MailerSend webhook save / why does the URL validation fail?
  • What is webhook.test and the test_Am3L1GuOIc4blLUuHqAPxxwkZaJyEk8G secret?
  • How do I handle activity.hard_bounced / activity.spam_complaint events?
  • 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:

  1. Different envelope. It carries message, not data. Code that does payload.data.id unconditionally will 500 on the ping.
  2. 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.type is the bare activity name (sent), without the activity. prefix.
  • data.meta is an empty ARRAY [] when there is nothing to report, and an object otherwise. This breaks naive typed deserialisation — normalise it.
  • created_at comes in two documented formats: microsecond ISO-8601 with Z (2025-08-05T21:23:54.000000Z) for activity and inbound events, and space-separated (2025-08-05 22:27:14) for sender_identity.verified and the maintenance.* events. Parse defensively.

Event Types

23 documented events, plus the webhook.test ping.

EventFires when
activity.sentEmail accepted and dispatched from MailerSend's servers
activity.deliveredReceiving server accepted the email
activity.soft_bouncedTemporary delivery failure (mailbox full, greylisting)
activity.hard_bouncedPermanent failure — suppress the address
activity.openedRecipient opened the email (every open)
activity.opened_uniqueFirst open only
activity.clickedRecipient clicked a link (every click)
activity.clicked_uniqueFirst click only
activity.unsubscribedRecipient unsubscribed
activity.spam_complaintRecipient marked the email as spam — suppress immediately
activity.deferredTemporarily delayed (paid plans only)
activity.survey_openedSurvey email opened for the first time
activity.survey_submittedSurvey submitted, or 30-minute idle timeout
sender_identity.verifiedA sender identity finished verification
maintenance.startScheduled maintenance began
maintenance.endScheduled maintenance ended
inbound_forward.failedInbound forwarding to your URL failed
inbound_message.rejectedInbound message rejected (unsupported_attachment_type or attachment_size_exceeded)
email_single.verifiedSingle email address verification finished
email_list.verifiedEmail list verification finished
bulk_email.completedBulk send finished processing
recipient.on_hold_addedRecipient placed on the on-hold list
recipient.on_hold_removedRecipient removed from the on-hold list
webhook.testURL 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.id instead.
  • 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


Repository

hookdeck/webhook-skills

v0.1.0 · MIT · Updated Aug 28, 2026

View on GitHub →