Skip to content

Powered by Grav CMS + Helios Theme

Mailgun

This page is for a store sending through Mailgun that wants delivered, bounced and complained figures on its campaign screens. Read Delivery reports and providers first for what a provider webhook is and what the store does with each event.

Before you begin

  • A Mailgun account with a verified sending domain.
  • An account API key, and the HTTP webhook signing key.
  • The grav-plugin-email-mailgun plugin 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 Mailgun.
  • The right region. Mailgun's US and EU installations are separate. An account on one does not exist on the other, and webhooks have to be created in each.

What the key must be allowed to do

Mailgun has two kinds of key and one signing key. Which key you paste decides whether the Set up button works at all.

Mailgun's name Where it is found What it is for Endpoints
Mailgun Account API Key Profile menu (top right) → API Keys Sending, and the Set up button. Paste this one into API Key. GET/POST/PUT /v3/domains/{domain}/webhooks, GET /v5/accounts/http_signing_key, GET /v4/domains/{domain}
Domain Sending Key Send → Sending → Domain settings → Sending API keys Sending only. Mailgun scopes it to the /messages, /messages.mime and /events endpoints for one domain. —
HTTP signing key Send → Webhooks → Configuration Verifying every inbound event. Never sent to Mailgun. Paste it into Webhook Signing Key. —

Important

A domain sending key can neither manage webhooks nor read the account's signing key, and Mailgun's onboarding hands most people one. If the Set up button is refused with a 401, that is usually why.

On accounts with role-based keys, Admin and Developer carry Webhooks: Read/Write and Account Management: Read/Write. Analyst and Support keys can read webhooks but not create them.

The signing key is not the API key. Mixing them up is the common mistake, and the help text on the field says so.

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.

  1. 1

    In the Grav admin's plugin list, open Email Mailgun and set Transport.

    HTTPS for almost everything. API is marginally faster, but it sends the parts of a message rather than the message, so every custom header is left behind. That includes List-Unsubscribe. Never use it for newsletters.

  2. 2

    Paste your account API key into API Key, and your sending domain into Domain.

    Both are required. Mailgun is the only provider here whose API address contains the sending domain, /v3/<domain>/messages, so a key on its own cannot send anything.

  3. 3

    Set Region to eu if your Mailgun domain is hosted in the EU.

    A US call against an EU account answers 401, which reads exactly like a wrong key. If you are certain the key is right, check this next.

  4. 4

    Open Email in the same plugin list and set Mail Engine to Mailgun.

Field Default What it does
enabled on The plugin's own switch.
transport api api, https or smtp.
api_key empty Sending on api and https, and every call the Set up button and the domain lookup make.
domain empty The Mailgun sending domain. Required on api and https.
region us us or eu. Picks both the sending host and which API installation every call goes to.
username, password empty SMTP transport only.
signing_key empty Webhook Signing Key. Without it every event is refused.

All three transports carry custom headers, so List-Unsubscribe and List-Unsubscribe-Post survive either way. Over SMTP the headers are the message; over the API the Symfony bridge uses Mailgun's h:<name> form.

Set the webhook up in one click

  1. 1

    Go to KahunaCart → Add-ons → Newsletters → Settings → Providers.

  2. 2

    On the Mailgun card, click Generate a secret.

    The address that appears carries that secret. It is what makes an event arriving at your site provably Mailgun's.

  3. 3

    Click Set up in Mailgun.

    It reads your existing webhooks first, adds this address to each of six event types, then reads the account's signing key back and saves it into the plugin's own config.

The card marks the address once this has been done: a tick where events have actually arrived, and an ellipsis where the webhook is registered and nothing has ever been heard from it.

Mailgun registers one webhook per event type rather than one webhook, so there is no single id to show for it. The card says the date it was registered instead.

Contract event Mailgun webhook type
delivered delivered
bounced permanent_fail and temporary_fail
complained complained
opened opened
clicked clicked

A second press is safe. A type already pointed here is left alone. A type pointed somewhere else gains this URL beside what is there.

Mailgun caps a type at three URLs. A type already holding three is refused by name. Remove one in Mailgun and press the button again.

Reading the signing key needs an account key. A domain sending key can create the webhooks but cannot read the key. The webhooks are still made, and the message says where to find the key by hand.

accepted and unsubscribed are left unregistered. accepted says Mailgun took a message, not that it arrived. unsubscribed is Mailgun's own list rather than the store's.

Nothing deletes a webhook. Remove one in Mailgun's dashboard.

Do it by hand instead

  1. 1

    In Mailgun, go to Send → Webhooks and click Add webhook.

  2. 2

    Choose a domain-level webhook and pick your sending domain.

  3. 3

    Paste the webhook address from the Providers card into the URL field, and add it for Delivered, Permanent Failure, Temporary Failure, Spam Complaints, Opened and Clicked. Save.

  4. 4

    Open the Configuration tab, copy the HTTP signing key, and paste it into Webhook Signing Key in the Email Mailgun plugin's settings.

    It is a different string from the API key, and mixing the two up is the common mistake.

Mailgun requires an HTTPS endpoint with a certificate signed by a trusted CA. A self-signed certificate will not do.

There is no header to add. See the correlation note below.

What each event means

Mailgun event Becomes Notes
delivered delivered
failed with severity: permanent hard bounce The address is suppressed and the subscriber is set to bounced.
failed with severity: temporary soft bounce Three in thirty days are treated as a hard one.
failed with a reason beginning suppress- refused address Mailgun never handed it to a receiving server. Treated as a hard bounce.
complained complaint The address is suppressed and the subscriber is unsubscribed.
opened open Stamped where the store's own pixel has not already.
clicked click The same.
accepted, unsubscribed, anything else skipped with a note Answered 200 and logged.

Important

permanent_fail and temporary_fail are subscription names that never appear in a payload. Both arrive as event: failed and are told apart by severity.

Mailgun's refusals are always the address and never the message. The three suppress- reasons, suppress-bounce, suppress-complaint and suppress-unsubscribe, are the three lists Mailgun keeps per address. When the address is on one of them, Mailgun drops the message, stops trying, and posts a failure. A message Mailgun will not send for a reason of its own is refused by the API call rather than reported later.

The reason a merchant reads is the receiving server's own line first, then Mailgun's one word, joined with a dash: 550 5.5.0 Requested action not taken: mailbox unavailable — bounce rather than just bounce.

Provider quirks

Mailgun echoes only four headers. Its delivery events carry to, from, subject and message-id and nothing else, so a custom header never comes back. No setting changes it.

Correlation is by Message-ID, and it needs nothing setting up. Mailgun returns it without angle brackets. On an opened or clicked event, message-id is the only header field present.

Mailgun answers a send with the message's own id rather than an id of its own, in its wire form with the angle brackets on. That is not a provider id and is not stored as one. The id Mailgun names in a webhook is a third string again, so keeping the echo would fill the column meant for matching with something no event will ever mention.

The plugin also reads a send id out of Mailgun's user variables where one is there, but it does not set one. Mailgun's documentation warns that user variables are visible to the recipient in the delivered message.

The signature is an HMAC over the timestamp and the token concatenated. The store accepts a timestamp within fifteen minutes either way, because Mailgun asks receivers not to be too strict about it.

A subaccount's events carry a parent-signature as well, computed against the parent account's key, so a store on a subaccount can paste the one key it has. Both comparisons always run.

Mailgun retries a failed post at 10 minutes, 10 minutes, 15 minutes, 30 minutes, 1 hour, 2 hours and 4 hours. It reads 200 as success and 406 as a rejection with no retry. Failed attempts are listed under the Failure logs tab on the Webhooks page.

DNS for the Health tab

Record Value
SPF v=spf1 include:mailgun.org ~all
DKIM a TXT or CNAME record at a selector Mailgun chooses per domain
Tracking / return path a CNAME into mailgun.org

The SPF include and the CNAME target are the same string. That is correct: the same zone carries the sending hosts and the CNAME targets.

There is no DKIM zone to check a name against. Mailgun publishes the public key as a TXT record on your own domain under a per-domain selector. An account on automatic sender security publishes two CNAMEs at hosts like pdk1._domainkey and pdk2._domainkey, rotated every 120 days.

With an account API key configured, the Health tab reads the records Mailgun expects on your domain and takes the selector and the return path from them. Without a key, put the part before ._domainkey into deliverability.dkim_selector on KahunaCart → Add-ons → Newsletters → Settings → Health.

Checking it actually works

Important

The numbers on a campaign screen are not evidence that Mailgun is reporting anything. Opens and clicks are stamped by the store's own tracking pixel and click redirect, which run whether or not a single webhook has ever arrived. A campaign can read delivered, opened and clicked with the provider silent throughout.

The honest signal is the Providers card. "Nothing received yet" means nothing has arrived, whatever the campaign screens say.

Send a one-recipient campaign to an address you can read, open it, press a link, and then check the Mailgun card says events are arriving. That is the only check that distinguishes a working webhook from a well-drawn screen.

Troubleshooting

Symptom Cause
Every event refused Webhook Signing Key is empty, or holds the API key by mistake.
The Set up button answers 401 A domain sending key rather than an account key, or an account key from the other region. The message names both.
"Domain not found" The Domain field does not match a verified domain on this account, in this region.
"Mailgun allows three webhook URLs per event type…" Remove one in Mailgun under Send → Sending → Webhooks, then press the button again.
Webhooks made, key not saved The key was read with a domain sending key, which cannot. Copy it from Send → Webhooks → Configuration by hand.
Setup succeeded, key not written The config file is not writable. Check the Grav log.
Bounces recorded but nobody suppressed Check whether the reason begins suppress-; that path is a refused address and does suppress. A plain temporary failure does not, until the third in thirty days.