Resend is an email API with first-class TypeScript types, a fetch-based SDK, native idempotency keys, and tight integration with React Email templates. It's a good default for new DaloyJS projects. Read the email overview first: it defines the shared EmailSender, emailPlugin(), headerSafe, escapeHtml(), and the email.send / email.suppress jobs used below.
Send through the resend SDK
Route handlerResend SDKResend APIWebhooks
01requestRoute handlerResend SDKresend.emails.send({ from, to, subject }, { idempotencyKey })uses the standard fetch API (edge-friendly)
04asyncResend APIWebhooksemail.bounced, email.complained eventssvix-signed; verify before trusting payloads
resend.emails.send() runs on the standard fetch API, so it works on edge runtimes too. The SDK returns { data, error } rather than throwing, so handle the error branch and return data.id on success.
1. Provision
Sign up at resend.com and add a sending domain (a subdomain such as mail.acme.example.com keeps transactional reputation separate). Add the DKIM, SPF, and return-path records Resend lists, publish DMARC, then click Verify.
Create an API key under API Keys with Sending access scoped to that domain. Use a different key per environment.
Add RESEND_API_KEY: z.string().startsWith("re_") and RESEND_WEBHOOK_SECRET: z.string().startsWith("whsec_") to the overview's defineConfig() schema.
4. Sender
src/email/resend.tsts
import { Resend } from "resend";import type { EmailSender } from "../plugins/email.ts";import { config } from "../config.ts";export const resend = new Resend(config.RESEND_API_KEY);export const sender: EmailSender = { async send(msg) { const body = msg.html !== undefined ? { html: msg.html, text: msg.text } : { text: msg.text ?? "" }; const { data, error } = await resend.emails.send( { from: config.EMAIL_FROM, to: msg.to, replyTo: msg.replyTo, subject: msg.subject, headers: msg.headers, ...body, }, // Resend dedupes on Idempotency-Key, so a retried job never sends twice. { idempotencyKey: msg.idempotencyKey, signal: msg.signal }, ); if (error) throw new Error(`Resend send failed: ${error.name}`); // log detail, never echo it return { id: data?.id ?? "" }; },};
Resend SDK methods return { data, error } rather than throwing. Throw on the error branch with a short message, log the details, and return a generic problem+json to the client (never leak provider errors). The overview's email.send job passes job.id as idempotencyKey, which is stable across retries.
src/app.tsts
import { App, secureHeaders } from "@daloyjs/core";import { emailPlugin } from "./plugins/email.ts";import { emailJobs } from "./email/jobs.ts";import { sender } from "./email/resend.ts";export const app = new App();app.use(secureHeaders());emailPlugin(app, sender);emailJobs(app, sender);
5. Use it in a route
A passwordless sign-in link has to go to the address the visitor typed, so the content is fixed server-side, the response is identical whether or not the account exists (no account enumeration), and a per-recipient limiter stops one inbox from being flooded:
src/routes/magic-link.tsts
import { z } from "zod";import { App, every, rateLimit } from "@daloyjs/core";import { escapeHtml } from "../plugins/email.ts";export function magicLinkRoutes(app: App) { app.post( "/auth/magic-link", { operationId: "sendMagicLink", hooks: every( rateLimit({ windowMs: 10 * 60_000, max: 10 }), // per client IP rateLimit({ windowMs: 60 * 60_000, max: 3, // per recipient, however many IPs ask keyGenerator: (ctx) => `magic-to:${String((ctx as { body?: { email?: string } }).body?.email ?? "").toLowerCase()}`, }), ), request: { body: z.object({ email: z.email() }).strict() }, responses: { 202: { description: "Accepted", body: z.object({ ok: z.literal(true) }) }, }, }, async ({ body, state }) => { const link = await issueMagicLink(body.email); // null when there is no account if (link) { await state.email.send({ to: body.email, subject: "Your sign-in link", text: `Sign in: ${link}`, html: `<p><a href="${escapeHtml(link)}">Sign in to Acme</a></p><p>This link expires in 15 minutes.</p>`, }); } return { status: 202, body: { ok: true as const } }; // same answer either way }, );}declare function issueMagicLink(email: string): Promise<string | null>; // your auth provider
Who issues and verifies the token is an identity concern; see auth before building your own.
React Email templates
Resend reads the react field and renders it to HTML for you. React escapes text children by default, which takes care of HTML injection for interpolated values. The SDK imports @react-email/render as an optional peer, and pnpm's strict layout does not expose the copy nested under @react-email/components, so install both:
bun add @react-email/components @react-email/render react react-dom
emails/welcome.tsxtsx
import { Html, Button, Heading, Text } from "@react-email/components";export default function Welcome({ name }: { name: string }) { return ( <Html> <Heading>Welcome, {name}!</Heading> <Text>Thanks for joining Acme.</Text> <Button href="https://acme.example.com/start">Get started</Button> </Html> );}
src/email/send-welcome.tsxtsx
import Welcome from "../../emails/welcome.tsx";import { resend } from "./resend.ts";import { config } from "../config.ts";export async function sendWelcome(user: { id: string; email: string; name: string }) { const { error } = await resend.emails.send( { from: config.EMAIL_FROM, to: user.email, subject: "Welcome to Acme", react: <Welcome name={user.name} /> }, { idempotencyKey: `welcome-${user.id}` }, ); if (error) throw new Error(`Resend send failed: ${error.name}`);}
Batch sending
Use resend.batch.send([...]) to send up to 100 messages in one API call, handy for fan-out notifications. Each message is still built server-side; do not batch client-supplied recipients.
Webhooks: bounces and complaints
Create a webhook for email.bounced, email.complained, and email.suppressed in the Resend dashboard and copy its signing secret. Resend signs with Svix headers (svix-id, svix-timestamp, svix-signature), and resend.webhooks.verify() checks the signature and the timestamp tolerance for you. It throws on failure and returns the parsed event on success:
src/routes/resend-webhook.tsts
import { z } from "zod";import { App, UnauthorizedError, jobIdempotencyKey, readBodyLimited } from "@daloyjs/core";import { resend } from "../email/resend.ts";import { config } from "../config.ts";export function resendWebhookRoutes(app: App) { app.post( "/webhooks/resend", { operationId: "resendEvents", responses: { 200: { description: "Accepted", body: z.object({ ok: z.literal(true) }) }, 401: { description: "Rejected" }, }, }, async ({ request }) => { // No body schema: verify the exact bytes Resend signed. const raw = new TextDecoder().decode(await readBodyLimited(request, 1_048_576)); const id = request.headers.get("svix-id") ?? ""; let event: ReturnType<typeof resend.webhooks.verify>; try { event = resend.webhooks.verify({ payload: raw, headers: { id, timestamp: request.headers.get("svix-timestamp") ?? "", signature: request.headers.get("svix-signature") ?? "", }, webhookSecret: config.RESEND_WEBHOOK_SECRET, }); } catch { throw new UnauthorizedError("invalid signature"); } if (event.type === "email.bounced" || event.type === "email.complained") { // Resend reports hard bounces with bounce.type "Permanent"; check its event docs for subtypes. const hard = event.type === "email.complained" || event.data.bounce.type === "Permanent"; for (const [i, email] of (hard ? event.data.to : []).entries()) { await app.jobs!.enqueue({ name: "email.suppress", payload: { email, reason: event.type }, // svix-id is stable across Resend's retries of the same event. idempotencyKey: jobIdempotencyKey({ name: "email.suppress", key: `resend-${id}-${i}` }), }); } } return { status: 200, body: { ok: true as const } }; }, );}
Without the SDK (another runtime, or to avoid the dependency), the scheme is HMAC-SHA256 over the svix-id value, a dot, the svix-timestamp value, a dot, and the raw body, keyed with the base64-decoded part of the secret after whsec_. The svix-signature header holds a space-separated list of v1,<base64> entries; accept if any matches, and reject timestamps more than five minutes from now:
Resend adds an address to your team-wide suppression list automatically after a hard bounce or a spam complaint, and skips later sends to it (you get an email.suppressed event). Some mailbox providers, Gmail among them, do not send complaint feedback, so you will not see every complaint. Manage the list in the dashboard or with resend.suppressions.add(), list(), get(), and remove(); only remove an address after the cause is fixed, or it is suppressed again. Keep your own undeliverable flag through the email.suppress job too.
Runtimes
The resend SDK uses the standard fetch API, so it runs on Node, Bun, Deno, AWS Lambda, Vercel, and Cloudflare Workers without adapters. On Workers, read the key from cloudflare:workersenv (or process.env with nodejs_compat; see secrets) and pair it with the Workers adapter.