Skip to content

Powered by Grav CMS + Helios Theme

Delivery reports and providers

Your mail transport reports whether a message reached the receiving server, bounced or received a spam complaint. Configure a webhook to send these events to the store. A campaign screen reading "Sent to 4,812" confirms sending, but does not establish delivery.

This page is for the store owner setting a provider webhook up for the first time, and for anybody reading a campaign's numbers and wondering where each of them comes from.

Before you begin

Three plugins have to be current for delivery reports to work.

  • The Grav Email plugin, 5.1.0 or later, on PHP 8.1 or later. That is where the provider contract lives. Below either, nothing on the site can read any provider's webhooks.
  • The transport plugin for the service you send through, at a release that implements the contract.
  • This add-on.

If any of them is behind, the card on the Providers tab says which one. Nothing else stops working: sending, campaigns and automations do not go through any of this.

Your transport plugin does this

Everything a mail service knows about itself lives in that service's own Grav plugin: how its webhooks are verified and read, how one is created from a pasted API key, what its DNS looks like, and whether its transport carries a custom header.

Service Plugin
SMTP2GO grav-plugin-email-smtp2go
Amazon SES grav-plugin-email-amazon
SendGrid grav-plugin-email-sendgrid
Postmark grav-plugin-email-postmark
Mailgun grav-plugin-email-mailgun
MailerSend grav-plugin-email-mailersend
Resend grav-plugin-email-resend

Adding a provider is installing a plugin, not waiting for a release of the newsletter. Each service has its own page in this chapter with what its API key must be allowed to do, what its events mean, and its own quirks.

Set a webhook up

  1. Go to Newsletters → Settings and open the Providers tab.
  2. Find the card for the service you send through. One card is drawn per transport plugin on the site that can report deliveries.
  3. Click Generate a secret. The webhook URL appears beside it with the secret in it.
  4. Click Set up in \<provider> where the card offers it, and you are done.
  5. Where it does not, copy the URL and paste it into that provider's dashboard, following the card's own instructions.

The card then says what it did, such as Registered in SMTP2GO on 5 September 2026, listening for delivered, bounce, spam complaint, open, click and reject, and folds the by-hand instructions away behind Do it by hand instead.

A card with a secret and no registration says so too.

The URL, and the secret in it

TXT
https://example.com/shop/newsletter/webhook/mailgun/gK7x…

The last segment is a path secret: thirty-six random bytes, generated for you, one per provider. On a provider that signs nothing it is the only thing standing between a genuine event and a stranger who guessed the address.

Important

Press Generate a secret rather than inventing one.

A wrong secret answers 404, not 401, so a scanner walking …/newsletter/webhook/… cannot tell a wrong secret from a provider you do not use. A provider nobody has registered answers 404 for the same reason. A signature that does not check out is a 401, because a provider needs to see a failing webhook in its own dashboard when you have pasted the wrong key.

Generating a new secret stops the old URL working. Paste the new one into the provider or its events start being dropped. Where you had used Set up in …, the card says that the webhook registered in that provider is now posting at an address that answers 404, until Set up is pressed again.

The store's own alert strip leaves a freshly registered webhook alone for a day, because nothing can arrive until the store sends something.

Where each credential lives

What Where
The secret in the webhook URL Newsletters → Settings → Providers. This store's own, generated here, never anywhere else.
Whatever a provider verifies its webhooks with That provider's transport plugin's settings: Mailgun's signing key in email-mailgun, SendGrid's verification key in email-sendgrid, Postmark's basic auth pair in email-postmark, Resend's signing secret in email-resend, MailerSend's signing secret in email-mailersend.
The API key the Set up button uses The same transport plugin, which is where it already was for sending.
An Authorization header you set on an SMTP2GO webhook by hand providers.smtp2go.auth_header here, because it is a header you invented rather than a credential SMTP2GO issued.

The card says when a verification key is missing, because a provider that signs and has no key on file refuses every event while looking healthy.

Warning

Earlier releases kept those verification fields on this add-on's own settings screen. They are not read from there any more. Move yours across if you have not, or the provider's events will be refused.

What the store does with an event

Six kinds of event, whichever provider they came from.

Event What happens
Delivered The send row is stamped delivered_at, the first report wins, and the campaign's delivered figure starts meaning what it says.
Hard bounce The send is stamped and marked bounced, the address goes on the suppression list with the provider's own reason, and the subscriber is set to bounced and taken off every list.
Soft bounce The send is stamped bounced_at and nothing else. Three soft bounces for one address in thirty days are treated as a hard one.
Refused message The provider would not send the message at all. The send is marked bounced, and the subscriber is left exactly as they were.
Complaint The send is marked complained, the address is suppressed with the reason complaint, and the subscriber is unsubscribed from every list.
Open, click The send is stamped where this store's own pixel and redirect have not already. Never counted twice.

Campaign counters are rebuilt from the send rows after each batch of events, so a provider delivering the same event twice changes nothing.

An event that names an address the store has on file still suppresses that address even when it cannot be tied to a campaign. An event with neither a send nor a known address is written to the log and dropped.

Provider events are also written to their own log, with the provider's own words in them, so when somebody asks why an address stopped receiving mail the answer has the receiving server's reply in it. That log is swept by tracking.retain_days like the click rows are.

A refused message is not a refused address

Every provider has a word for a message it would not send, and two different things arrive under it.

The provider refused the address. It is on that provider's own suppression list, or it bounced, complained or unsubscribed there. Nothing sent to that address through this transport will ever arrive, so it is recorded as a hard bounce: the address is suppressed and the subscriber is set to bounced.

The provider refused the message. You reached your daily quota, a virus scanner read an attachment, the provider did not like a header, it called the content bad. The person it was addressed to did nothing. The send is marked bounced and nothing else happens: no suppression, no change to the subscriber, and the event is filed under its own subtype so it never counts towards the three-soft-bounces rule.

The transport plugin decides which, from the provider's own reason. Where a reason cannot be placed, it reads as the message.

Provider The address The message
SMTP2GO reject a reason naming the suppression list, a previous bounce, a complaint or an unsubscribe an unverified sender, and anything else
Amazon SES Reject never always
SendGrid dropped Unsubscribed Address, Bounced Address, Spam Reporting Address, Invalid Invalid SMTPAPI header, Spam Content, Recipient List over Package Quota
Mailgun failed with a suppress- reason always never
Resend email.suppressed email.failed
Postmark reports no refusal at all
MailerSend reports no refusal at all

Postmark and MailerSend both refuse a send to an address they have already suppressed at the API call itself, so it reaches the store as a failed send rather than as a delivery report.

How an event finds its send

Two paths, tried in this order.

By Message-ID. This store mints the Message-ID before a campaign message leaves and stores it on the send row, so a provider that echoes the header back gives a direct join with nothing to configure. Most do.

By X-Grav-Send-Id. Every campaign message carries a header naming its send row. Providers that hand custom headers, metadata, tags or variables back return it. This is what makes correlation work where a provider mints its own message id and never repeats ours.

The header name belongs to the Grav Email plugin rather than to this add-on, so a transport plugin can register the same header with a provider. A site that wants a different one sets providers.send_header in the Email plugin's own configuration (Plugins → Email → Delivery Reports → Send id header), and both ends follow. Leave it empty and X-Grav-Send-Id is used.

Important

Some providers only echo a header you have registered with them. If you were receiving delivery reports before the header was renamed, press Set up again, or add the new name to the webhook's header list by hand.

Messages also carry X-PM-Metadata-Grav-Send-Id, which is the same value under the name Postmark wants. Postmark returns no headers on any webhook, but it returns metadata on all of them, and a header with that prefix is how metadata is attached to a message sent over SMTP. Postmark strips it before the message reaches the recipient; every other provider passes an unrecognised X- header straight through.

Where the send header is renamed, the derived names follow. The Postmark metadata key drops the leading X- and is capped at the twenty characters Postmark allows, and Resend's tag key is that name lower-cased with anything outside letters, digits, underscores and hyphens turned into an underscore.

The one request this store makes itself

Amazon's webhook is an SNS topic subscription, and the first thing SNS sends is a SubscriptionConfirmation asking the endpoint to fetch a URL to prove it meant to subscribe. The transport plugin names the URL and this store makes the request, over HTTPS only, only to a host matching sns.<region>.amazonaws.com, and never to a URL carrying credentials. You do not have to click anything. See Amazon SES.

What a card is telling you

A card has one sentence per state, and the ordinary state is silence.

The card says Means
Nothing extra The transport plugin answered and everything is normal.
The Email plugin is too old to ask Nothing on the site can read any provider's webhooks. Update the Grav Email plugin to 5.1.0 or later.
Nothing answers for this provider Its transport plugin is a release behind, or it is a transport with no delivery API at all. The card names the plugin to update.
This transport cannot report deliveries Sending still works; the delivered, bounced and complained figures stay empty.
A verification key is missing The provider signs its webhooks and there is no key on file, so every event is refused. Paste the key into that transport plugin's settings.

When something is wrong

Three things appear on the admin's Needs attention strip, each opening the Settings screen on its Providers tab.

  • The bounce rate over the last thirty days is above alerts.bounce_rate_percent, five per cent by default. This is the number the big mailbox providers watch.
  • A campaign has gone out, a secret is configured, and no event has arrived in thirty days. Either the webhook is not really pointed here or the events are not ticked.
  • Something is posting to a provider's webhook address and no secret is configured for it. Almost always somebody halfway through setting one up with the wrong URL, in which case those events are being thrown away.

Look in the Grav log first. A refused signature is logged with the reason and the provider, at most one line a minute so a wrong key cannot fill a disk overnight, and an unreadable payload is logged with its first 500 bytes.

An unreadable payload still receives a 200 response. Every one of these providers treats a 4xx as a reason to retry for days, and Postmark treats most of them as a reason to drop the event outright.

Test a payload without waiting for a campaign

BASH
bin/plugin kahunacart-newsletters webhook:replay mailgun /path/to/payload.json
bin/plugin kahunacart-newsletters webhook:replay mailgun /path/to/payload.json --apply

Runs a saved payload through the real provider and the real handler and prints what it would do, without writing anything. Add --apply to do it.

Use this when a bounce did not suppress an address: save the payload out of the provider's own webhook log, replay it, and the output says which send row it found, whether it read the bounce as hard or soft, what it took the address to be, and what it would write.

The provider has to be one a transport plugin on this site registered. Run it with a name nobody registered and it lists the ones that are there. Sample payloads for each provider live in that provider's own plugin, under tests/fixtures/webhooks/.

The signature is not checked. A payload saved out of a dashboard has lost its headers, so there is nothing to check it against. --apply writes whatever the file says, so read the dry run first.