Resend Integration Guide
██████╗ ███████╗███████╗███████╗███╗ ██╗██████╗
██╔══██╗██╔════╝██╔════╝██╔════╝████╗ ██║██╔══██╗
██████╔╝█████╗ ███████╗█████╗ ██╔██╗ ██║██║ ██║
██╔══██╗██╔══╝ ╚════██║██╔══╝ ██║╚██╗██║██║ ██║
██║ ██║███████╗███████║███████╗██║ ╚████║██████╔╝
╚═╝ ╚═╝╚══════╝╚══════╝╚══════╝╚═╝ ╚═══╝╚═════╝Resend Integration Guide
Focus: Transactional email delivery for codeAmani products — send emails programmatically using React Email templates and the Resend API.
Overview
Resend is a developer-first transactional email platform. Built by the team behind React Email, it provides a clean SDK and native support for rendering React components as email HTML. Used in codeAmani products for auth notifications, payment receipts, onboarding flows, and system alerts.
Here is the core flow at a glance — your app hands off to Resend, and delivery events flow back to you via webhooks:
Official Documentation
| Resource | URL |
|---|---|
| Resend Docs | https://resend.com/docs |
| Node.js SDK | https://github.com/resend/resend-node |
| React Email | https://react.email/docs |
| API Reference | https://resend.com/docs/api-reference |
| Webhooks | https://resend.com/docs/dashboard/webhooks/introduction |
SDK Setup
npm install resendCurrent major: Resend Node SDK v6 (
resend@6.x). A first-party Python SDK (resendon PyPI, v2.x) exists too, but codeAmani products are Node/Next.js.
Initialize Client
import { Resend } from "resend";
const resend = new Resend(process.env.RESEND_API_KEY);Core Patterns
Send a Simple Email
// app/api/email/send/route.ts
import { Resend } from "resend";
import { NextRequest } from "next/server";
const resend = new Resend(process.env.RESEND_API_KEY);
export async function POST(req: NextRequest) {
const { to, subject, html } = await req.json();
const { data, error } = await resend.emails.send({
from: "codeAmani Labs <no-reply@codeamanilabs.com>",
to,
subject,
html,
});
if (error) {
return Response.json({ error }, { status: 500 });
}
return Response.json({ id: data?.id });
}React Email Templates
npm install react-email react react-dom
# Optional dev-only preview UI (current manual setup):
npm install -D @react-email/uiReact Email 6.x unified the packages. Components and the
render/prettyutilities now import from the singlereact-emailpackage. The old@react-email/componentsand@react-email/renderpackages are deprecated — if you seeimport ... from "@react-email/components", migrate it toreact-email.
// emails/WelcomeEmail.tsx
import {
Body, Button, Container, Head, Heading,
Html, Preview, Section, Text,
} from "react-email";
interface WelcomeEmailProps {
userName: string;
dashboardUrl: string;
}
export function WelcomeEmail({ userName, dashboardUrl }: WelcomeEmailProps) {
return (
<Html>
<Head />
<Preview>Welcome to codeAmani Labs</Preview>
<Body style={{ backgroundColor: "#f6f9fc", fontFamily: "sans-serif" }}>
<Container style={{ margin: "0 auto", padding: "20px", maxWidth: "580px" }}>
<Heading>Welcome, {userName}!</Heading>
<Text>Your account is ready. Click below to get started.</Text>
<Section style={{ textAlign: "center", margin: "32px 0" }}>
<Button
href={dashboardUrl}
style={{ background: "#16a34a", color: "#fff", padding: "12px 24px", borderRadius: "6px" }}
>
Open Dashboard
</Button>
</Section>
<Text style={{ color: "#6b7280", fontSize: "12px" }}>
Powered by codeAmani Labs
</Text>
</Container>
</Body>
</Html>
);
}Send Using React Template
import { render } from "react-email";
import { WelcomeEmail } from "@/emails/WelcomeEmail";
// render() is async in React Email 6.x — always await it.
const html = await render(
<WelcomeEmail
userName={user.firstName}
dashboardUrl={`${process.env.APP_URL}/dashboard`}
/>
);
await resend.emails.send({
from: "codeAmani Labs <no-reply@codeamanilabs.com>",
to: user.email,
subject: "Welcome to codeAmani Labs",
html,
});Batch Send
await resend.batch.send([
{
from: "alerts@codeamanilabs.com",
to: "user1@example.com",
subject: "Payment Received",
html: receipt1Html,
},
{
from: "alerts@codeamanilabs.com",
to: "user2@example.com",
subject: "Payment Received",
html: receipt2Html,
},
]);Clerk Webhook → Welcome Email Pattern
Trigger welcome emails automatically when Clerk creates a user:
// app/api/webhooks/clerk/route.ts
import { Webhook } from "svix";
import { Resend } from "resend";
import { render } from "react-email";
import { WelcomeEmail } from "@/emails/WelcomeEmail";
const resend = new Resend(process.env.RESEND_API_KEY);
export async function POST(req: Request) {
const body = await req.text();
const svix_id = req.headers.get("svix-id")!;
const svix_timestamp = req.headers.get("svix-timestamp")!;
const svix_signature = req.headers.get("svix-signature")!;
const wh = new Webhook(process.env.CLERK_WEBHOOK_SECRET!);
const event = wh.verify(body, { "svix-id": svix_id, "svix-timestamp": svix_timestamp, "svix-signature": svix_signature }) as { type: string; data: { email_addresses: { email_address: string }[]; first_name: string } };
if (event.type === "user.created") {
const email = event.data.email_addresses[0].email_address;
const html = await render(<WelcomeEmail userName={event.data.first_name} dashboardUrl={`${process.env.APP_URL}/dashboard`} />);
await resend.emails.send({
from: "codeAmani Labs <welcome@codeamanilabs.com>",
to: email,
subject: "Welcome to codeAmani Labs",
html,
});
}
return new Response("OK");
}M-Pesa Receipt Emails
When a Daraja STK Push succeeds, the M-Pesa callback delivers the MpesaReceiptNumber, amount, and transaction date. Email a receipt as a secondary confirmation — the SMS from Safaricom is the user's primary proof, so never block the callback on the email send. Email here is a nicety (a paper trail), not the source of truth.
Here is the flow from a successful payment to a sent receipt:
PaymentReceipt Template
Amount is rendered with the KES prefix (Daraja amounts are integer KES — no decimals). The MpesaReceiptNumber is the canonical reference users quote in support.
// emails/PaymentReceipt.tsx
import {
Body, Container, Head, Heading, Hr,
Html, Preview, Row, Column, Section, Text,
} from "react-email";
interface PaymentReceiptProps {
customerName: string;
amount: number; // integer KES
mpesaReceiptNumber: string;
transactionDate: string; // already formatted for display (EAT)
description: string;
}
export function PaymentReceipt({
customerName,
amount,
mpesaReceiptNumber,
transactionDate,
description,
}: PaymentReceiptProps) {
return (
<Html>
<Head />
<Preview>Payment received — KES {amount.toLocaleString("en-KE")}</Preview>
<Body style={{ backgroundColor: "#f6f9fc", fontFamily: "sans-serif" }}>
<Container style={{ margin: "0 auto", padding: "20px", maxWidth: "580px" }}>
<Heading>Payment received</Heading>
<Text>Hi {customerName}, your M-Pesa payment was successful.</Text>
<Section style={{ background: "#fff", borderRadius: "8px", padding: "20px", marginTop: "16px" }}>
<Row>
<Column style={{ color: "#6b7280" }}>Amount</Column>
<Column style={{ textAlign: "right", fontWeight: 700 }}>
KES {amount.toLocaleString("en-KE")}
</Column>
</Row>
<Hr style={{ borderColor: "#e5e7eb", margin: "12px 0" }} />
<Row>
<Column style={{ color: "#6b7280" }}>M-Pesa receipt</Column>
<Column style={{ textAlign: "right" }}>{mpesaReceiptNumber}</Column>
</Row>
<Hr style={{ borderColor: "#e5e7eb", margin: "12px 0" }} />
<Row>
<Column style={{ color: "#6b7280" }}>Date</Column>
<Column style={{ textAlign: "right" }}>{transactionDate}</Column>
</Row>
<Hr style={{ borderColor: "#e5e7eb", margin: "12px 0" }} />
<Row>
<Column style={{ color: "#6b7280" }}>For</Column>
<Column style={{ textAlign: "right" }}>{description}</Column>
</Row>
</Section>
<Text style={{ color: "#6b7280", fontSize: "12px", marginTop: "16px" }}>
Keep this receipt for your records. Powered by codeAmani Labs.
</Text>
</Container>
</Body>
</Html>
);
}Send on the Daraja Callback
Pass the component directly via the react property — the Resend SDK renders it to HTML for you, so no manual render() call is needed. Pass it as a function call (PaymentReceipt({ ... })), not as JSX, in a .ts route handler.
// app/api/mpesa/callback/route.ts
import { Resend } from "resend";
import { PaymentReceipt } from "@/emails/PaymentReceipt";
import { NextRequest } from "next/server";
const resend = new Resend(process.env.RESEND_API_KEY);
export async function POST(req: NextRequest) {
const body = await req.json();
const cb = body.Body.stkCallback;
// Always ACK Daraja fast — do not block on DB or email work
if (cb.ResultCode !== 0) {
// Payment failed / cancelled — record and return
return Response.json({ ResultCode: 0, ResultDesc: "Accepted" });
}
// Pull metadata items by Name (order is not guaranteed)
const items: { Name: string; Value: string | number }[] =
cb.CallbackMetadata.Item;
const get = (name: string) => items.find((i) => i.Name === name)?.Value;
const amount = Number(get("Amount"));
const mpesaReceiptNumber = String(get("MpesaReceiptNumber"));
const checkoutRequestId = cb.CheckoutRequestID;
// Idempotency: only the FIRST processing of this CheckoutRequestID
// should send the receipt. Daraja can retry the callback.
const txn = await markPaidIfNew(checkoutRequestId, {
amount,
mpesaReceiptNumber,
});
if (txn.firstTime && txn.customerEmail) {
// Fire-and-forget: never let a Resend error fail the callback ACK
sendReceipt(txn).catch((err) => logError("receipt-email", err));
}
return Response.json({ ResultCode: 0, ResultDesc: "Accepted" });
}
async function sendReceipt(txn: {
customerName: string;
customerEmail: string;
amount: number;
mpesaReceiptNumber: string;
transactionDate: string;
description: string;
}) {
const { error } = await resend.emails.send({
from: "codeAmani Labs <receipts@codeamanilabs.com>",
to: txn.customerEmail,
subject: `Payment received — KES ${txn.amount.toLocaleString("en-KE")}`,
react: PaymentReceipt({
customerName: txn.customerName,
amount: txn.amount,
mpesaReceiptNumber: txn.mpesaReceiptNumber,
transactionDate: txn.transactionDate,
description: txn.description,
}),
});
if (error) throw error;
}Gotcha — idempotent send, non-blocking ACK. Daraja may deliver the same callback more than once. Gate the email behind a "first time we marked this
CheckoutRequestIDpaid" check (markPaidIfNewreturnsfirstTime) so a retry never double-sends a receipt. And send fire-and-forget (.catch(...)) — the route must returnResultCode 0to Daraja promptly regardless of whether Resend is slow or down. A failed receipt email must never turn a successful payment into a failed-looking callback.Belt-and-suspenders. Resend also supports a native idempotency key — pass
{ idempotencyKey: 'receipt/' + checkoutRequestId }as the second argument toemails.send. Resend dedupes identical requests for 24 hours, so even if your app-level gate has a race, Resend will not send the same receipt twice within the window. Keys can be up to 256 chars; the recommended format is<event-type>/<entity-id>.
Webhooks (Delivery Events)
Resend sends delivery status events over Svix-signed webhooks. Verify with the SDK's
built-in resend.webhooks.verify() — no separate svix npm package is needed
(the Resend SDK wraps it). Pass the raw request body (do not parse JSON first) and
the three svix-* headers as { id, timestamp, signature }:
// app/api/webhooks/resend/route.ts
import { Resend } from "resend";
import { NextRequest, NextResponse } from "next/server";
const resend = new Resend(process.env.RESEND_API_KEY);
export async function POST(req: NextRequest) {
const payload = await req.text(); // raw body — required
const id = req.headers.get("svix-id");
const timestamp = req.headers.get("svix-timestamp");
const signature = req.headers.get("svix-signature");
if (!id || !timestamp || !signature) {
return new NextResponse("Missing headers", { status: 400 });
}
let event: ReturnType<typeof resend.webhooks.verify>;
try {
event = resend.webhooks.verify({
payload,
headers: { id, timestamp, signature },
webhookSecret: process.env.RESEND_WEBHOOK_SECRET!,
});
} catch {
return new NextResponse("Invalid webhook", { status: 400 });
}
switch (event.type) {
case "email.delivered":
// Mark as delivered in DB
break;
case "email.bounced":
// Handle bounce — suppress the address
break;
case "email.complained":
// Handle spam complaint — unsubscribe
break;
}
return new NextResponse("OK");
}Prefer the built-in verifier above. Verifying with the raw
svixWebhookclass (new Webhook(secret).verify(body, { "svix-id": ... })) still works and is the documented fallback if you already depend onsvix— but the built-in method keeps your dependency surface smaller. Event types:email.sent,email.delivered,email.delivery_delayed,email.opened,email.clicked,email.bounced,email.complained.
Domain Setup
A quick map of getting your own domain verified — once these DNS records propagate, your custom from address works and you stay out of spam:
To send from your own domain, add DNS records via Porkbun:
# Records to add in Porkbun dashboard or via API:
# Type: TXT Name: resend._domainkey Value: (from Resend dashboard)
# Type: TXT Name: @ Value: v=spf1 include:amazonses.com ~all
# Type: MX (if not already configured)Preview Emails Locally
# Launch React Email preview server
npx react-email dev
# Open http://localhost:3000 to preview templatesEnvironment Variables
# Required
RESEND_API_KEY=re_...
# Optional
RESEND_WEBHOOK_SECRET=whsec_...Common Use Cases
| Use Case | Template |
|---|---|
| Welcome / onboarding | WelcomeEmail.tsx triggered by user.created |
| M-Pesa payment receipt | PaymentReceipt.tsx triggered by M-Pesa callback |
| Subscription confirmation | SubscriptionEmail.tsx triggered by Stripe webhook |
| Password reset | Use Clerk's built-in email — only override for custom branding |
| System alerts | Plain HTML — no React template needed |
Troubleshooting
| Issue | Fix |
|---|---|
Invalid API key | Verify RESEND_API_KEY starts with re_ |
| Emails going to spam | Verify domain DNS records in Resend dashboard |
from domain not verified | Add and verify domain before using custom from |
| React Email not rendering | Run npx react-email dev to preview template locally |
| Webhook 400 | Verify with the SDK's resend.webhooks.verify(); pass the raw req.text() body (never parsed JSON) and the svix-* headers |