# Guide to MailerSend Webhooks: Features and Best Practices

MailerSend is the transactional email and SMS API from the MailerLite group. Its webhooks report what happened to a message after you sent it: delivered, bounced, opened, marked as spam.

This guide covers how MailerSend webhooks work, the validation ping that stops most integrations before the first real event, how to verify signatures, and the best practices for production.

## What are MailerSend webhooks?

MailerSend posts a JSON event to your URL each time something happens to a message you sent. You configure webhooks per domain, choosing which events you want, and MailerSend generates a signing secret for each one.

Before any of that works, MailerSend validates your URL by calling it, and that validation request behaves differently from every real event it will subsequently send.

## MailerSend webhook features

| Feature | Details |
| --- | --- |
| Configuration | Dashboard (Domains → Manage → Webhooks) or the Webhooks API |
| Signature header | `Signature`, a bare lowercase hex HMAC-SHA256 digest |
| Signing key | A per-webhook Signing Secret. Not your API token |
| Signed content | The raw request body, and nothing else |
| Discriminator | `type`, a top-level body field |
| Events | 23 email events, 3 SMS events, plus the `webhook.test` ping |
| Response deadline | 3 seconds |
| Retries | Exponential backoff for around 3 days |
| Replay protection | None. No timestamp, nonce, or delivery id is sent |

MailerSend documents no source-IP allowlist and no `X-MailerSend-*` headers, so don't build either into your receiver.

## The `webhook.test` ping

When you create or update a webhook, MailerSend immediately calls the URL to validate it. If that request doesn't get a 2xx, the webhook is not saved. This is where most MailerSend integrations fail first, because the ping differs from real events in two ways at once.

```json
{
  "type": "webhook.test",
  "message": "This is a ping test message",
  "created_at": "2026-03-27T07:24:20.577080Z"
}

```

It carries `message` rather than `data`, so a handler that reaches for `payload.data.id` unconditionally throws on it. And it's signed with a fixed, publicly documented secret, `test_Am3L1GuOIc4blLUuHqAPxxwkZaJyEk8G`, rather than your webhook's signing secret, so a handler that only checks the real secret rejects it.

Because that secret is public, anyone can forge a valid `webhook.test`. Accept it, return 200, and make sure it can never gate privileged work.

## Common events

The discriminator is the top-level `type`. Inside `data`, the `type` field repeats the activity name without the `activity.` prefix.

| Event | Fires when |
| --- | --- |
| `activity.sent` | Accepted and dispatched from MailerSend's servers |
| `activity.delivered` | The receiving server accepted the message |
| `activity.soft_bounced` | Temporary failure, such as a full mailbox or greylisting |
| `activity.hard_bounced` | Permanent failure. Suppress the address |
| `activity.opened` / `activity.opened_unique` | Every open, and first open only |
| `activity.clicked` / `activity.clicked_unique` | Every click, and first click only |
| `activity.unsubscribed` | The recipient unsubscribed |
| `activity.spam_complaint` | The recipient marked it as spam. Suppress immediately |
| `activity.deferred` | Temporarily delayed. Paid plans only |
| `sender_identity.verified` | A sender identity finished verification |
| `inbound_forward.failed` | Inbound forwarding to your URL failed |
| `bulk_email.completed` | A bulk send finished processing |
| `recipient.on_hold_added` / `recipient.on_hold_removed` | On-hold list changes |

Survey, maintenance, inbound rejection and email verification events make up the rest of the 23. SMS webhooks are configured separately, under SMS → Webhooks, and add `sms.sent`, `sms.delivered` and `sms.failed`.

The security model is identical across both surfaces - one verifier handles both. The SMS envelope adds `sms_number_id`, `sms_webhook_id` and `url` alongside the usual fields.

```json
{
  "type": "activity.sent",
  "created_at": "2025-08-05T21:23:54.000000Z",
  "data": {
    "id": "6892766a5b66e2daf3dc9155",
    "message_id": "6892766ae78995a317577aa1",
    "type": "sent",
    "email": "test@mailersend.com",
    "tags": ["test", "test2"],
    "meta": []
  }
}

```

Two things in that envelope will bite a typed deserialiser. `data.meta` is an empty array when there's nothing to report and an object when there is. And `created_at` arrives in two documented formats: microsecond ISO-8601 with a `Z` for activity and inbound events, and a space-separated form (`2025-08-05 22:27:14`) for `sender_identity.verified` and the `maintenance.*` events. Normalise both on the way in.

> See MailerSend webhook payloads in action. Inspect and replay sample payloads in the [Hookdeck Console](https://console.hookdeck.com) — no account or setup required.

## Setting up MailerSend webhooks

Create the webhook in the dashboard under Domains → Manage → Webhooks, or through the Webhooks API, selecting the events you want. MailerSend generates a Signing Secret for that webhook, which is what you verify against. It is not your API token.

Your endpoint has to be reachable and returning 2xx before you save, because of the validation ping. Locally, that means a tunnel:

```bash
npx hookdeck-cli listen 3000 mailersend --path /webhooks/mailersend

```

Paste the printed URL into the webhook's URL field. The ping fires the moment you save, so you'll know immediately whether your ping handling works.

## Securing MailerSend webhooks

The `Signature` header is a bare lowercase hex HMAC-SHA256 digest of the raw body. No timestamp, no nonce, no version prefix, no concatenated fields:

```javascript
const crypto = require('crypto');

// MailerSend signs its URL-validation ping with this fixed, publicly
// documented secret rather than yours.
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, so guard the length first.
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

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.

```

`rawBody` has to be the exact bytes received. Re-serialising parsed JSON changes whitespace and key order and breaks the digest.

Note that the official Node SDK ships a `verifyWebHook()` helper, but it isn't exported from the package entry point, it calls `timingSafeEqual` without a length guard, and its README reads an `x-mailersend-signature` header that MailerSend doesn't send. Verifying manually, as above, matches the documented behaviour.

> Make MailerSend webhooks production-ready. [Hookdeck Event Gateway](/event-gateway) verifies the signature, acknowledges inside the deadline, deduplicates, and durably queues every event.

## MailerSend webhook limitations and pain points

### The webhook won't save, and the error doesn't say why

The Problem: You paste your URL, hit save, and MailerSend refuses it. The endpoint is up and your signature verification is correct for real events, but the webhook still won't persist.

Why It Happens: The validation ping is rejected by your own handler, because it's signed with the public test secret rather than yours, or because your code reads `data` from a payload that only has `message`.

Workarounds:

* Accept both secrets, and once parsed, require that anything signed with the test secret has `type === 'webhook.test'`.
* Handle the ping's envelope explicitly rather than assuming `data` exists.

How Hookdeck Can Help: Point MailerSend at a Hookdeck source. It returns 2xx immediately, so the webhook saves whatever your handler is doing.

### A rejected signature is never retried

The Problem: MailerSend never retries 4xx responses other than 429, so a signature rejection gets exactly one attempt. A misconfigured secret doesn't produce a backlog you can replay once fixed. Those events are gone.

Why It Happens: This is intended behaviour. A 401 means the request was rejected, not that it failed.

Workarounds:

* Get verification right before pointing production traffic at the endpoint.
* Log rejected requests with enough detail to reconstruct what was lost.

How Hookdeck Can Help: Hookdeck persists every request on arrival, so a verification mistake is a replay rather than data loss.

### Three seconds, and then the webhook gets paused

The Problem: MailerSend logs an attempt as failed if you don't respond within 3 seconds. Failures retry for around three days, but a webhook whose endpoint stays down too long is paused automatically and has to be re-enabled by hand in the dashboard.

Why It Happens: The deadline is tight, and the pause threshold isn't documented as the same as the retry window, so you can't assume you have the full three days before intervention is required.

Workarounds:

* Return 2xx first and process in a background job.
* Alert on a paused webhook, because a paused webhook is silent rather than noisy.

How Hookdeck Can Help: Hookdeck answers in milliseconds regardless of your service's state, so your endpoint's downtime never reaches MailerSend's pause logic.

### `data.meta` changes type

The Problem: `meta` is an empty array when there's nothing to report and an object when there is. Typed deserialisation fails on one shape or the other, usually in production and usually on the events you care about most.

Why It Happens: It's a PHP-style empty-array-as-empty-map serialisation leaking into the JSON.

Workarounds:

* Normalise `meta` to an object, treating `[]` as `{}`, before it reaches typed code.

How Hookdeck Can Help: A Hookdeck transformation normalises the field before your service sees it.

### A captured request stays valid forever

The Problem: MailerSend sends no timestamp, no nonce and no delivery id header, so a request someone captured last month still verifies today and the usual timestamp tolerance check can't be implemented at all.

Why It Happens: The signature covers the body alone.

Workarounds:

* Key idempotency on `data.id`.

How Hookdeck Can Help: Hookdeck deduplicates before delivery.

## Best practices

### Handle the ping before you handle anything else

Accept both the real secret and the public test secret, then require `webhook.test` for anything the test secret authenticated. Nothing else works until this does.

### Acknowledge within three seconds

Return 2xx and queue the work. The deadline is tight and sustained failures get the webhook paused. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

### Treat verification failures as data loss

Because 4xx is never retried, a wrong secret silently discards events. Validate against a known payload before you switch production traffic on.

### Normalise the envelope on the way in

Convert `meta: []` to an object and parse both `created_at` formats before the payload reaches typed code.

### Dedupe on `data.id`

There's no replay-protection material to check, so idempotency is your application's job. See our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

### Act on suppression events immediately

`activity.hard_bounced` and `activity.spam_complaint` should suppress the address on receipt. Deferring that work is what damages sender reputation.

## Conclusion

MailerSend webhooks are straightforward once the validation ping is out of the way. Accept both the per-webhook signing secret and the public `webhook.test` secret, and never let the public one authorise a real event. Verify the bare hex `Signature` against the raw body, acknowledge within three seconds so sustained failures don't pause the webhook, normalise `meta` and `created_at`, and dedupe on `data.id`. Remember that a rejected signature is never retried, so verification mistakes cost you events rather than delaying them.

[Hookdeck Event Gateway](https://hookdeck.com) verifies the signature, answers MailerSend inside the deadline, deduplicates, and durably queues every event, so a verification mistake becomes a replay instead of data loss.

[Get started with Hookdeck](https://dashboard.hookdeck.com/signup) for free and handle MailerSend webhooks reliably in minutes.