Resend Integration Guide

Technology: resend · Category: comms · Last reviewed: 2026-08-23

Source: https://tech-stack.codeamanilabs.org/guide/resend

Insight:

Resend is transactional email with React Email templates — receipts, resets, notifications. Email is a weak primary channel for many African users (unreliable inboxes), so treat it as secondary to SMS (Africa's Talking) and WhatsApp. Verify your sending domain (SPF/DKIM) to stay out of spam.

██████╗ ███████╗███████╗███████╗███╗   ██╗██████╗
██╔══██╗██╔════╝██╔════╝██╔════╝████╗  ██║██╔══██╗
██████╔╝█████╗  ███████╗█████╗  ██╔██╗ ██║██║  ██║
██╔══██╗██╔══╝  ╚════██║██╔══╝  ██║╚██╗██║██║  ██║
██║  ██║███████╗███████║███████╗██║ ╚████║██████╔╝
╚═╝  ╚═╝╚══════╝╚══════╝╚══════╝╚═╝  ╚═══╝╚═════╝

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:

flowchart LR
  A["Your App"] -->|"render React Email"| B["HTML"]
  B --> C["resend.emails.send"]
  C --> D["Resend API"]
  D --> E["Recipient inbox"]
  D -->|"delivery events"| F["Resend Webhook"]
  F -->|"email.delivered / bounced / complained"| G["Your webhook route"]
  G --> H["Update DB"]

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 resend

Current major: Resend Node SDK v6 (resend@6.x). A first-party Python SDK (resend on 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/ui

React Email 6.x unified the packages. Components and the render / pretty utilities now import from the single react-email package. The old @react-email/components and @react-email/render packages are deprecated — if you see import ... from "@react-email/components", migrate it to react-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:

sequenceDiagram
  participant D as "Daraja"
  participant CB as "Callback route"
  participant DB as "Database"
  participant R as "Resend"
  D->>CB: "STK callback · ResultCode 0"
  CB->>DB: "upsert by CheckoutRequestID"
  DB-->>CB: "inserted · receiptEmailSent false"
  CB-->>D: "200 OK immediately"
  CB->>R: "send PaymentReceipt · async"
  R-->>CB: "email id"
  CB->>DB: "set receiptEmailSent true"

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 CheckoutRequestID paid" check (markPaidIfNew returns firstTime) so a retry never double-sends a receipt. And send fire-and-forget (.catch(...)) — the route must return ResultCode 0 to 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 to emails.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 svix Webhook class (new Webhook(secret).verify(body, { "svix-id": ... })) still works and is the documented fallback if you already depend on svix — 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:

flowchart TD
  A["Add domain in Resend dashboard"] --> B["Resend provides DNS records"]
  B --> C["Add TXT DKIM record in Porkbun"]
  B --> D["Add TXT SPF record in Porkbun"]
  C --> E["Resend verifies DNS"]
  D --> E
  E --> F{"Verified?"}
  F -->|"yes"| G["Send from custom domain"]
  F -->|"no"| H["Wait for propagation, recheck"]
  H --> E

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 templates

Environment 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

Official docs: