Resend
Send email and newsletters from your handlers, and act on what Resend reports back.
@kizunajs/resend connects your API to Resend. Handlers send email and manage newsletters through plugins.resend, and Resend's webhook events reach functions you write.
Install the plugin
A missing API key, or a from Resend wouldn't accept, stops the app at startup, naming the field. from is an address, or a name and an address like Kizuna <hello@example.com>.
Send an email
Every route and job handler reaches the plugin at plugins.resend:
from comes from the plugin's options unless the email names its own.
React Email
Pass a component as react instead of html, and Resend renders it. Install @react-email/render too, which Resend's SDK loads when an email has react:
sendBroadcast takes react the same way.
Newsletters
A list is a Resend segment, plus the topic its contacts opt in and out of. Name each one once, and handlers use the name:
subscribe adds a contact to a list, creating the contact when it's new, and returns Resend's id for it:
Contact events carry it back as event.data.id, which the webhook example uses to find the user.
unsubscribe takes a contact off one list, or off everything when you leave list out:
When a user changes their email, changeEmail moves their contact to the new address, keeping its name, lists and topics:
Resend can't change a contact's email, so this creates a new contact and removes the old one, which is why the id changes too.
sendBroadcast sends to everyone on a list, now or at scheduledAt:
Resend fills placeholders in a broadcast for each contact. Every newsletter needs the unsubscribe link:
The text after | is used when a contact has no first name. See Resend's broadcast docs for every placeholder.
To check a newsletter before it goes out, send it to yourself first with sendEmail. Resend only fills placeholders in broadcasts, so a test through sendEmail shows {{{FIRST_NAME}}} as it's written. Render the test with your own name instead:
A list without a topicId works too. Unsubscribing from it removes the contact from its segment.
Webhooks
Create a webhook in Resend pointing at /resend/webhooks, and pass its signing secret. The plugin checks each delivery's signature against the body as it was sent, then runs your function for the event's type:
Write the functions in a file of their own with defineResendEvents. Here, someone who unsubscribes through Resend's link loses their consent in your own database too. Give it your Config, the way you give it to new Kizuna<Config>(), and jobs and plugins are typed from your app:
Each function receives:
| Argument | What it is |
|---|---|
event | The event, typed from Resend's own types |
deliveryId | The delivery's id, the same on every retry of it |
jobs | Your app's jobs, to queue work |
plugins | Your installed plugins, plugins.resend included |
Anything else it needs, like your database, it imports. An event with no function is acknowledged and dropped. A delivery whose signature doesn't match is answered with 400, and nothing runs.
The route is hidden, so it stays out of your clients and the OpenAPI document.
Keep the functions quick
Resend waits a few seconds for an answer, then sends the event again. For anything slow, queue a job from the function and return. On a serverless host like Vercel, work started after the response is sent can be cut off, so there it's the only reliable way.
Handle an event twice safely
Resend can deliver the same event more than once. Skip a deliveryId you've already handled.
Tag emails with your own ids
Tags you set when sending come back on every event for that email, in event.data.tags:
Contact events carry no tags.
Moving the webhook route
The plugin serves its routes under /resend. When that clashes with your own routes, pass another base path:
In development
intercept catches every email and forwards it to your own inbox instead of its real recipients:
enabled says whether it's on. When it is, forwardTo has to be set, so a missing EMAIL_FORWARD_TO stops the app at startup instead of emailing real people.
| Option | Default | Description |
|---|---|---|
enabled | required | Whether emails are intercepted |
forwardTo | required when enabled is true | Where every email is forwarded, one address or several |
deliverTo | none | Addresses, or whole domains like @example.com, whose emails are delivered as normal, with forwardTo in bcc |
subjectPrefix | none | Put in front of every intercepted email's subject |
Each intercepted email carries who it was for in X-Intercepted-To, X-Intercepted-Cc and X-Intercepted-Bcc headers. Use delivered@resend.dev as forwardTo when nobody should get anything.
Broadcasts go to a segment, so intercept doesn't reach them. Point your lists at a test segment in development instead.
Errors
Resend's SDK answers with { data, error } instead of throwing. The plugin throws a ResendRequestError instead, so a failed call fails the handler that made it, and kizuna answers 500 and logs it.
A call Resend rate-limits is tried again after the wait its retry-after header asks for, up to three times, before it fails.
Anything else
plugins.resend.client is the Resend client itself, for anything the plugin doesn't cover:
Options
| Option | Default | Description |
|---|---|---|
apiKey | required | The Resend API key |
from | required | The sender every email and broadcast uses unless it names its own |
lists | none | Lists by name, each a segmentId and an optional topicId |
webhookSecret | none | The webhook's signing secret. Set it to serve the webhook route |
on | none | A function per webhook event type. Needs webhookSecret |
intercept | none | Catch every email in development, see In development |
resend | none | Resend's own ResendOptions, passed to its client |
basePath | /resend | Where the webhook route is served |