Gareth Wilson Gareth Wilson

Guide to MailerSend Webhooks: Features and Best Practices

Published


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

FeatureDetails
ConfigurationDashboard (Domains → Manage → Webhooks) or the Webhooks API
Signature headerSignature, a bare lowercase hex HMAC-SHA256 digest
Signing keyA per-webhook Signing Secret. Not your API token
Signed contentThe raw request body, and nothing else
Discriminatortype, a top-level body field
Events23 email events, 3 SMS events, plus the webhook.test ping
Response deadline3 seconds
RetriesExponential backoff for around 3 days
Replay protectionNone. 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.

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

EventFires when
activity.sentAccepted and dispatched from MailerSend's servers
activity.deliveredThe receiving server accepted the message
activity.soft_bouncedTemporary failure, such as a full mailbox or greylisting
activity.hard_bouncedPermanent failure. Suppress the address
activity.opened / activity.opened_uniqueEvery open, and first open only
activity.clicked / activity.clicked_uniqueEvery click, and first click only
activity.unsubscribedThe recipient unsubscribed
activity.spam_complaintThe recipient marked it as spam. Suppress immediately
activity.deferredTemporarily delayed. Paid plans only
sender_identity.verifiedA sender identity finished verification
inbound_forward.failedInbound forwarding to your URL failed
bulk_email.completedA bulk send finished processing
recipient.on_hold_added / recipient.on_hold_removedOn-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.

{
  "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 — 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:

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:

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 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.

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.

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 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 for free and handle MailerSend webhooks reliably in minutes.


Gareth Wilson

Gareth Wilson

Product Marketing

Multi-time founding marketer, Gareth is PMM at Hookdeck and author of the newsletter, Community Inc.