Skip to content

Powered by Grav CMS + Helios Theme

Resend

This page is for a store sending through Resend 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 Resend account with a verified sending domain.

    Resend verifies domains rather than single addresses. There is no "add one sender address" path, so even a store that only ever mails from one address has to add and verify the domain it belongs to.

  • An API key beginning re_.
  • The grav-plugin-email-resend plugin at 1.0.2 or later, with the Grav Email plugin at 5.2.0 or later. This plugin requires PHP 8.1 or later, because Symfony's Resend bridge does.
  • The Email plugin's Mail Engine set to Resend.

Note

bin/gpm install email-resend currently installs a different, unrelated plugin of the same slug. Team Grav's Resend plugin is not in GPM yet; install it from its GitHub repository or its release zip.

What the API key must be allowed to do

A Resend key has either sending access or full access. There is nothing in between.

Resend's name What it can do Enough for
Sending access Send email, and nothing else. Can optionally be restricted to one domain. Sending, over the API transport or SMTP.
Full access Create, delete, read and update any resource. Sending, plus the Set up in Resend button and the domain lookup the Health tab uses.

Important

A key with Sending access sends mail but cannot create the webhook that reports what happened to it. If the Set up button is refused over a permission, that is why.

The key value is shown once at creation and cannot be viewed afterwards.

One credential, not three. Resend's SMTP user is the literal word resend for every account, the password is the same API key, and the plugin writes the username itself. Fill API Key in on the SMTP transport too: the Set up button and the domain lookup use it.

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 Resend and set Transport to API (faster) or SMTP.

  2. 2

    Paste your API key into API Key.

    It begins re_. A sending-only key sends mail but cannot create the webhook, so the Set up button needs one with full access.

  3. 3

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

Field Default What it does
enabled on The plugin's own switch.
transport api api is what Resend recommend. smtp is for a host that blocks outbound API calls but allows port 465.
api_key empty Sends the mail, creates the webhook, and reads the sending domain's DNS.
signing_secret empty The whsec_… secret that proves events came from Resend. Without it every event is refused, and nothing on any screen says why.

Both transports carry custom headers, so List-Unsubscribe and List-Unsubscribe-Post reach the wire either way.

Set the webhook up in one click

  1. 1

    Go to KahunaCart → Add-ons → Newsletters → Settings → Providers and click Generate a secret on the Resend card.

  2. 2

    Click Set up in Resend.

The button lists your webhooks first, creates one at that address if there is not one already, and writes the signing secret it hands back into email-resend.yaml. If that write fails, the message tells you to open the webhook in Resend and copy the secret from there. The secret is never printed on screen.

It registers seven Resend event names for six contract words: email.delivered, email.bounced, email.complained, email.opened, email.clicked, email.failed and email.suppressed.

Warning

Resend mints a signing secret per webhook. Two webhooks at one address means about half your events arrive signed with a secret the store never saw, and every one of those is refused with nothing saying why. That is why the button checks for duplicates first and a second press adds nothing.

Nothing deletes or disables a webhook. Remove one in Resend's dashboard.

Do it by hand instead

  1. 1

    In Resend, go to the Webhooks page and click Add Webhook.

  2. 2

    Paste the webhook address from the Providers card in.

    It has to be an HTTPS address Resend can reach from the internet.

  3. 3

    Tick email.delivered, email.bounced, email.complained, email.opened, email.clicked, email.failed and email.suppressed.

  4. 4

    Save, then open the webhook's details page and copy its signing secret into Webhook signing secret in the Email Resend plugin's settings.

    It begins whsec_. Without it every event that arrives is refused.

There is no header list to fill in. Resend echoes no headers.

What each event means

Resend event Becomes Notes
email.delivered delivered Resend delivered the message to the recipient's mail server.
email.bounced with bounce.type: Permanent hard bounce The address is suppressed and the subscriber is set to bounced.
email.bounced with Transient or Undetermined soft bounce Three in thirty days are treated as a hard one.
email.complained complaint Delivered, and the recipient marked it as spam. The address is suppressed and the subscriber is unsubscribed.
email.opened open Stamped where the store's own pixel has not already.
email.clicked click The same.
email.suppressed refused address Resend already holds the address on its account-level suppression list. Treated as a hard bounce.
email.failed refused message The send is marked bounced and the subscriber is untouched. Resend's own documented reason is reached_daily_quota.
email.sent, email.scheduled, email.delivery_delayed, email.received, and the domain.*, contact.* and suppression.* families skipped with a note Not an error. Ticking every box in Resend's dashboard is fine.

Resend is the only provider where the address-or-message split needs no string matching. The event name is the whole answer.

email.delivery_delayed is not acted on. It is a temporary problem, such as a full mailbox or a busy server, and is followed by either a delivery or a bounce.

Resend's bounce vocabulary is Amazon's, because Resend sends through Amazon SES.

Provider quirks

Resend echoes no headers at all. Nothing comes back on any outbound event, and there is no setting that changes it.

Correlation is by Message-ID first, and it needs nothing setting up. Resend returns the store's own Message-ID on every documented email.* payload, with angle brackets on.

Resend runs on Amazon SES underneath, which is worth knowing when an id does not match. The Message-ID a recipient sees on the delivered message is one SES minted on the way out, not the one the store sent, and Resend's own id for the message is a third string again. None of that gets in the way, because the store matches on what the webhook reports rather than on what arrives in the mailbox. It does explain why the id in someone's mail client will not be one you can search for.

The fallback is a tag, not a header. Resend echoes data.tags, so the send header's name is folded into a tag key: the leading X- is dropped, everything is lower-cased, and anything outside letters, digits, underscores and hyphens becomes an underscore. X-Grav-Send-Id becomes grav-send-id. Rename the header and the tag renames with it.

Signing is Svix. Three headers, svix-id, svix-timestamp and svix-signature, over the id, the timestamp and the raw body joined with full stops, HMAC-SHA256, keyed with the part of the secret after whsec_. The white-labelled spelling webhook-id / webhook-timestamp / webhook-signature is accepted too. Every v1 entry in the header is compared in constant time, so a secret can be rotated without a gap, and a secret pasted with or without the whsec_ prefix works.

Delivery is at-least-once and unordered. Resend may deliver an event more than once and does not guarantee order, so an email.opened can arrive before email.delivered. The store rebuilds counters from the send rows rather than incrementing per event, and never moves a send's status backwards. Retries are at 5 seconds, 5 minutes, 30 minutes, 2 hours, 5 hours and 10 hours.

No region setting. Resend has one API base. A region is a property of a domain, not of an account.

DNS for the Health tab

Record Host Value
SPF send.yourdomain.com v=spf1 include:amazonses.com ~all
DKIM resend._domainkey.yourdomain.com a TXT record holding the key itself
Return path send.yourdomain.com an MX record at feedback-smtp.<region>.amazonses.com, priority 10

Resend sends through Amazon SES, so seeing amazonses.com beside the word Resend is correct.

The DKIM record is a TXT, not a CNAME into a Resend zone. There is no zone to check a name against, so this plugin reads the selector off the account rather than assuming one. Every Resend domain uses resend as its selector today, but the plugin still reads it in case that changes.

With a full-access key configured, the Health tab makes two calls and reads the real records. Without one, put the selector into deliverability.dkim_selector on KahunaCart → Add-ons → Newsletters → Settings → Health.

Note

Resend has said that domains created after August 2026 may be given CNAME records instead of the TXT and MX above. Add whatever your domain's own Records tab shows.

Checking it actually works

Important

The numbers on a campaign screen are not evidence that Resend 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 Resend has said nothing.

The Providers card is the honest answer. If it says nothing has been received, nothing has.

Send a one-recipient campaign to an address you can read, open it, press a link, then check the Resend card.

Troubleshooting

Symptom Cause
Every event refused Webhook signing secret is empty. This is the commonest one, and the webhook looks healthy in Resend's dashboard while it happens.
About half the events refused Two webhooks at the same address, each with its own secret. Delete one in Resend.
"the Svix timestamp was outside the tolerance" The store's clock is wrong, or the event is a replay. The window is five minutes either way.
"the Resend signing secret is not a whsec_ value" Something other than the secret is in the field.
The Set up button is refused over a permission The key has Sending access. Make one with Full access.
"The webhook was created in Resend, but its answer carried no signing secret" Copy it off the webhook's page in Resend by hand.
"…its signing secret could not be saved here" The config file is not writable. Paste the secret in by hand.
A quota failure suppressed nobody It is not meant to. email.failed is the message, not the address.