MailerSend
This page is for a store sending through MailerSend that wants bounces and complaints acted on. Read Delivery reports and providers first for what a provider webhook is and what the store does with each event.
Important
MailerSend's webhooks carry no headers and do not return the Message-ID the store minted, so a delivery event cannot be tied to a send row. Hard bounces and spam complaints still suppress the address, because the store falls back to matching the recipient. Per-campaign delivered, opened and clicked figures do not fill in. This is a permanent limit of MailerSend's webhooks.
Before you begin
- A MailerSend account with a verified sending domain. Webhooks attach to a domain, not to an account.
- An API token.
- The
grav-plugin-email-mailersendplugin at 1.1.2 or later, with the Grav Email plugin at 5.2.0 or later on PHP 8.1 or later. - The Email plugin's Mail Engine set to MailerSend.
What the API token must be allowed to do
Create the token in MailerSend under Integrations → MailerSend API → Manage → Create new token. Choose Custom access and set these.
| Permission | Level | What it is for |
|---|---|---|
| Webhooks | Full access | Listing the webhooks already on the domain, creating one, and updating it. MailerSend offers no read-only level for this group. |
| Domains | Read only | Finding the sending domain's id, and reading the account's real DKIM selectors and return-path host for the Health tab. |
| Full access | Sending, on the api transport only. Not needed for delivery reports. |
Warning
A token created without an expiration date expires in 24 hours. MailerSend shows the token value once, so copy it when it is made.
The token is scoped to a domain when it is created. On a multi-domain account, make the token for the domain this store sends from.
Configure the transport
The transport is a Grav plugin, so its own settings live in the Grav admin's plugin list. The webhook that reports deliveries back is set up from the Providers card in the KahunaCart admin, in the next section.
Warning
On anything below a Professional plan, use the SMTP transport.
MailerSend treats custom headers as a paid feature on its Email API. A newsletter sets two of them, the send id and the one-click unsubscribe pair, so on a smaller plan every campaign message is refused outright with This feature requires a Professional plan or higher (#MS42233). Nothing is sent and the campaign records a failure.
The same message over SMTP carries the same headers and is accepted, on any plan. It is the transport to pick unless you are paying for Professional or above.
-
1
In the Grav admin's plugin list, open Email MailerSend and set Transport to API or SMTP.
Read the warning above first. On a free or Starter plan this needs to be SMTP.
-
2
Paste your token into API Key.
Needed for the API transport and for the Set up button, whichever transport you send on.
-
3
On the SMTP transport, fill in Username and Password.
These are the SMTP credentials generated on the domain's own page in MailerSend, not the API token.
-
4
Fill in Sending domain if the account has more than one, then open Email in the same plugin list and set Mail Engine to MailerSend.
Note
A trial account can only mail the address that owns it. Verify a domain to send anywhere else. A trial also sends from MailerSend's own test-….mlsender.net domain, which is shared and unauthenticated, so those messages usually land in spam. That says nothing about how your own verified domain will do.
| Field | Default | What it does |
|---|---|---|
enabled |
on | The plugin's own switch. |
transport |
api |
api posts to https://api.mailersend.com/v1/email; smtp connects to smtp.mailersend.net on 587 with STARTTLS. |
api_key |
empty | The bearer token, used for sending on the API transport and for the delivery-reports button on either. |
username, password |
empty | SMTP transport only. |
signing_secret |
empty | The HMAC key MailerSend signs each webhook with. Without it every event is refused. |
domain |
empty | The sending domain, e.g. example.com. Only needed when the account has more than one. |
domain_id |
empty | MailerSend's own sixteen-character domain id. Written by the Set up button; read-only on the form. |
Important
MailerSend's API gates custom headers and one-click unsubscribe by plan. headers and list_unsubscribe are Professional and Enterprise only. Below that, a message sent over the API is refused with a 422, and the same message sent over SMTP is accepted with its headers stripped. A store below Professional should send over SMTP.
Set the webhook up in one click
-
1
Go to KahunaCart → Add-ons → Newsletters → Settings → Providers and click Generate a secret on the MailerSend card.
-
2
Click Set up in MailerSend.
The button finds the domain id first: from the saved id, then by name against your domains, then from the only domain there is on a single-domain account. It then lists the webhooks on that domain and either creates one or updates the one already at that address. Every refusal names the domains it found.
It registers six events: activity.delivered, activity.hard_bounced, activity.soft_bounced, activity.spam_complaint, activity.opened and activity.clicked. New webhooks are made on payload version 2.
MailerSend will not save a webhook until the address answers a ping. It posts a webhook.test message and requires a 2xx. The store answers it.
Important
MailerSend returns the signing secret only when a webhook is created. An update can succeed and leave verification broken. If reports are being refused after a second press, delete the webhook in MailerSend and press Set up again.
Pressing the button twice updates the webhook already at that address rather than making a second one.
The address must be https://. MailerSend will not post to anything else.
Do it by hand instead
-
1
In MailerSend, go to Email → Domains and click Manage on the domain this store sends from.
-
2
Scroll down to Webhooks, click Add webhook, and paste the webhook address from the Providers card in.
-
3
Leave the version on 2.0.
Version 1.0 is deprecated and is removed on 1 December 2026.
-
4
Tick Delivery, Soft bounce, Hard bounce, Spam complaint, Open and Click.
Leave Unsubscribe unticked. That is MailerSend's own unsubscribe rather than the store's.
-
5
Save, then copy the signing secret from the webhook's management page into Signing secret in the Email MailerSend plugin's settings.
There is no header list to fill in. MailerSend echoes no headers on any webhook.
What each event means
| MailerSend event | Becomes | Notes |
|---|---|---|
activity.delivered |
delivered | |
activity.hard_bounced |
hard bounce | The address is suppressed and the subscriber is set to bounced. |
activity.soft_bounced |
soft bounce | Three in thirty days are treated as a hard one. |
activity.spam_complaint |
complaint | The address is suppressed and the subscriber is unsubscribed. |
activity.opened |
open | Stamped where the store's own pixel has not already. |
activity.clicked |
click | The same. |
activity.sent |
skipped | Not a delivery. The message left MailerSend's servers and may still hard bounce. |
activity.deferred |
skipped | The receiving server asked MailerSend to try later. |
activity.opened_unique, activity.clicked_unique |
skipped | A first-time open or click that already arrived as an open or a click. |
activity.unsubscribed |
skipped | An unsubscribe made through MailerSend rather than through this store. |
recipient.on_hold_added, recipient.on_hold_removed |
skipped | See below. |
| the survey pair, and anything else | skipped with a note | Logged, not acted on. |
MailerSend never produces a refused message or a refused address. The closest thing it has is the On Hold list: a 72-hour hold after five soft bounces in thirty days, with its own on_hold_until, removed automatically. A three-day hold is not a permanent suppression, so recipient.on_hold_added is skipped.
A send to an address MailerSend has already suppressed is refused by the API call, so it reaches the store as a failed send on that person's row rather than as a delivery report.
Bounce reasons are often absent. MailerSend documents the field they would arrive in only as null.
Provider quirks
Two payload versions, told apart by a field that changes type. In version 1 data.email is an object; in version 2 it is the recipient's address as a string. MailerSend's own two pages disagree about the version 2 spelling, calling that field email in one place and recipient in another. Both are read.
No correlation handle beyond MailerSend's own id. No header list, no metadata object, no user variables, and the sender's Message-ID is not returned. What does come back is MailerSend's own message id, the same one their SMTP relay answers with and their API returns in the x-message-id response header.
The signature has no timestamp. It is an HMAC-SHA256 of the raw body and nothing else, so a captured request stays replayable. The secret in the webhook address is what stands between a replay and the store.
The endpoint check is signed with a published secret. When MailerSend pings a new webhook, the real secret does not exist yet, so the ping is signed with a secret printed in MailerSend's own documentation. The store accepts it, fenced: the real key is checked over the raw bytes first, the published key is only tried after that fails, and a body that verifies under the published key is refused unless it is the ping.
Tags, and other API limits. At most five tags of 191 characters each. reply_to takes one address, not a list. A message dated more than 72 hours ahead goes now rather than being refused. A send where every recipient is suppressed answers 202 with a warning rather than an error, and carries no message id.
DNS for the Health tab
| Record | Host | Value |
|---|---|---|
| SPF | the domain itself | v=spf1 include:_spf.mailersend.net … |
| DKIM | ms1._domainkey.yourdomain.com |
CNAME to ms1._domainkey.mailersend.net |
| DKIM | ms2._domainkey.yourdomain.com |
CNAME to ms2._domainkey.mailersend.net |
| Return path | mta.yourdomain.com by default |
CNAME to mailersend.net |
There must be exactly one SPF record in the zone. MailerSend's dashboard hands you a value already merged with any existing one, and a second SPF record blocks verification.
MailerSend moved to two DKIM keys. It now signs with 2048-bit keys published as two CNAMEs, ms1 and ms2, and both have to be in your zone. A domain verified on the older single record keeps working until 1 February 2027. You do not need to remove the legacy record in the meantime.
With Domains: Read only on the token, the Health tab reads whichever selectors your account has, the two new ones or a legacy single record, and the real return-path host. Without a token, put the selectors into deliverability.dkim_selector on KahunaCart → Add-ons → Newsletters → Settings → Health, separated by a comma.
Checking it actually works
Important
The numbers on a campaign screen are not evidence that MailerSend is reporting anything. Opens and clicks are counted by the store's own tracking pixel and click redirect, and those run whether or not a webhook has ever arrived. A campaign can show delivered, opened and clicked while MailerSend has said nothing at all.
The Providers card is the honest answer. If it says nothing has been received, nothing has.
This matters more here than elsewhere. Testing a free MailerSend account with a registered, enabled webhook and all six events ticked, a delivered message produced no webhook of any kind within fifteen minutes, while the campaign screen showed delivered, opened and clicked from the store's own tracking throughout. Whether their smaller plans deliver webhooks at all is not something MailerSend documents either way.
Send a one-recipient campaign, open it, press a link, then check the MailerSend card before believing any number on a campaign screen.
Troubleshooting
| Symptom | Cause |
|---|---|
| Every event refused | Signing secret is empty, or the webhook was updated rather than created and never handed one back. Delete the webhook in MailerSend and press Set up again. |
| The Set up button answers 401 | The token has expired. One created with no expiry date is good for 24 hours. |
| The Set up button answers 403 | The token needs Webhooks: Full access. |
| "This MailerSend account has no verified sending domain yet" | Verify a domain first; a webhook attaches to one. |
| "This MailerSend account has more than one sending domain" | Put the one this site sends from into Sending domain, then press the button again. |
422 on headers or list_unsubscribe when sending |
Those API fields are Professional and Enterprise only. Switch to the SMTP transport. |
| Delivered and opened figures never fill in on a campaign | MailerSend echoes nothing that ties an event to a send. Suppressions still work. |
| Bounces with no reason on them | Normal. MailerSend does not document what a bounce puts in the reason field, and often sends none. |