@notifkit/provider-resend
The email transport. Sends through Resend, and mounts a webhook route so opens, clicks, bounces and complaints come back into your delivery log and the suppression list.
npm install @notifkit/provider-resend
.npmrc in the repo
Resend pulls in @react-email/render, which declares react and
react-dom as peer dependencies. If your npm runs with
legacy-peer-deps=true — easy to inherit from a user-level
~/.npmrc you set once and forgot — npm install resolves the tree
without those peers and writes a package-lock.json that omits them.
Nothing looks wrong locally. It breaks in Docker or CI, where npm ci installs
strictly from that lockfile with npm's default peer resolution and finds the tree it is
asked to reproduce does not satisfy the peers. Commit an .npmrc containing
legacy-peer-deps=false at the repository root: a project-level config beats
the user-level one, so every machine and every build agent resolves the same way.
import { NotifkitServer } from "notifkit";
import { ResendTransport } from "@notifkit/provider-resend";
const server = new NotifkitServer({
services: ["all"],
providers: [
new ResendTransport({
apiKey: process.env.RESEND_KEY!,
from: "no-reply@corp.com",
webhookSecret: process.env.RESEND_WEBHOOK_SECRET,
}),
],
});
Options
| Option | Required | Notes |
|---|---|---|
apiKey | yes | Your Resend API key. |
from | yes | Default sender. A template may override it — see below. |
webhookSecret | no | The svix signing secret. Without it every webhook is rejected — see below. |
logger | no | A NotifKit logger. Send failures are logged at warn. |
limits | no | Provider rate limit. Defaults to { limit: 1000, windowSeconds: 10 }. |
Which template keys it reads
| Template key | Lands as |
|---|---|
subject | The subject line. Defaults to "Notification" if absent. |
text or body | The plain-text part. |
html or htmlBody | The HTML part. Falls back to the text wrapped in <p>. |
from | Overrides the constructor's sender, for this template only. |
replyTo | Sent only when present. |
Sending from more than one address
The constructor's from is a default, not a fixed value. A template naming its
own from wins for that template, which is how one transport serves
no-reply@ for receipts and marketing@ for campaigns:
await notifkit.syncTemplates({
templates: [
{
id: "receipt",
channel: "email",
content: { from: "no-reply@corp.com", subject: "Your receipt", text: "…" },
},
{
id: "spring-sale",
channel: "email",
topic: "promotions",
content: {
from: "Acme Offers <marketing@corp.com>",
replyTo: "hello@corp.com",
subject: "{{headline}}",
html: "<h1>{{headline}}</h1>",
},
},
],
});
Both fields accept the display-name form, and both are
header fields to the renderer — an interpolated value
has CR/LF stripped, so it cannot inject a second header. A value that is not a non-empty
string falls back to the default rather than being coerced: a from: 123 reaching
Resend as "123" would fail the send with an error naming neither the template nor
the field.
no-reply@corp.com and marketing@corp.com are different senders to
a reader and the same sender to a mailbox provider: reputation attaches to the
domain, not the local part. If your goal is to stop complaints about a campaign from
depressing delivery of your password resets, the split has to be at the domain — bulk mail
from a subdomain like news.corp.com with its own DKIM record.
Webhooks
The transport declares webhookPath = "/webhooks/resend" and the API mounts it
automatically. Point Resend at https://your-public-url/webhooks/resend.
| Resend event | Becomes |
|---|---|
email.opened | opened |
email.clicked | clicked, with the URL kept on the log row |
email.bounced | bounced — suppresses only when Resend calls it permanent |
email.complained | complained, which suppresses |
email.delivered · email.delivery_delayed | ignored |
Signature verification fails closed. With webhookSecret unset the transport
logs an error and rejects every delivery, so you get no opens, no clicks, and — the part
that costs you — no bounce or complaint suppression. Sends still succeed, so nothing looks
broken until your bounce rate does.
Verification runs a second time inside parseWebhook rather than trusting the
body the route already parsed, because an unverified webhook is an anonymous, forgeable
write into your delivery history.
Only a bounce Resend describes as permanent or hard suppresses the address. Anything else — including a severity string the transport does not recognise — is read as soft, on the principle that wrongly suppressing a working address silently stops mail the person still wants. A full mailbox should not cost you a customer.
Resend rejects a from it has no authorisation for. Each address or domain has
to be verified with Resend first, or the send fails at the API call — visible as a failed
row in the delivery log, not at template sync, since nothing validates the address when the
template is stored.