# 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](https://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.

```javascript
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/](https://github.com/hookdeck/webhook-skills/tree/main/skills/mailersend-webhooks/examples/express/), [examples/nextjs/](https://github.com/hookdeck/webhook-skills/tree/main/skills/mailersend-webhooks/examples/nextjs/), [examples/fastapi/](https://github.com/hookdeck/webhook-skills/tree/main/skills/mailersend-webhooks/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](https://github.com/hookdeck/webhook-skills/blob/main/skills/mailersend-webhooks/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.

```json
{
  "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:

```json
{
  "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.

| 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](https://github.com/hookdeck/webhook-skills/blob/main/skills/mailersend-webhooks/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

```bash
# 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

```bash
# 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](https://github.com/hookdeck/webhook-skills/blob/main/skills/mailersend-webhooks/references/overview.md) - What MailerSend webhooks are, all events, payload shapes
* [references/setup.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/mailersend-webhooks/references/setup.md) - Dashboard and API configuration, getting the signing secret
* [references/verification.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/mailersend-webhooks/references/verification.md) - Signature verification details, gotchas, debugging