← website
Packages · Email

@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
keep .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

OptionRequiredNotes
apiKeyyesYour Resend API key.
fromyesDefault sender. A template may override it — see below.
webhookSecretnoThe svix signing secret. Without it every webhook is rejected — see below.
loggernoA NotifKit logger. Send failures are logged at warn.
limitsnoProvider rate limit. Defaults to { limit: 1000, windowSeconds: 10 }.

Which template keys it reads

Template keyLands as
subjectThe subject line. Defaults to "Notification" if absent.
text or bodyThe plain-text part.
html or htmlBodyThe HTML part. Falls back to the text wrapped in <p>.
fromOverrides the constructor's sender, for this template only.
replyToSent 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.

two addresses, one reputation

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 eventBecomes
email.openedopened
email.clickedclicked, with the URL kept on the log row
email.bouncedbounced — suppresses only when Resend calls it permanent
email.complainedcomplained, which suppresses
email.delivered · email.delivery_delayedignored
no secret means no webhooks at all

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.

bounces are soft unless proven hard

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.

verify every address you send from

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.