Back to Blog

How to Send Emails in Hono (2026 Guide)

17 min read

Hono is the web framework that runs everywhere: Node.js, Bun, Deno, Cloudflare Workers, Vercel Edge Functions, AWS Lambda. Write your email-sending API once, deploy it to any runtime.

Most Hono tutorials show you a basic route handler. This guide goes further: building HTML email templates with React Email, handling Stripe webhooks, common SaaS email patterns, error handling, and deploying your email API to any platform. For runtime-specific guides, see Bun, Deno, or Cloudflare Workers.

All code uses TypeScript and includes provider switchers for Sequenzy, Resend, and SendGrid.

Pick an Email Provider

You have three solid options. Each code example below lets you switch between them.

  • Sequenzy is built for SaaS. Transactional emails, marketing campaigns, automated sequences, subscriber management, all from one SDK. If you're building a SaaS product, this is the simplest path because you won't need to glue together three different tools later. Built-in retries and native Stripe integration.
  • Resend is a developer-friendly transactional email API. Clean DX, good docs, solid deliverability. They have one-off broadcast campaigns but no automations or sequences.
  • SendGrid is the enterprise standard. Feature-rich, sometimes complex. Good if you need high volume and don't mind a bigger API surface.

Install Your SDK

Terminal
npm install hono sequenzy
Terminal
npm install hono resend
Terminal
npm install hono @sendgrid/mail

Add your API key to .env:

.env
SEQUENZY_API_KEY=sq_your_api_key_here
.env
RESEND_API_KEY=re_your_api_key_here
.env
SENDGRID_API_KEY=SG.your_api_key_here

Initialize the Client

src/email.ts
import Sequenzy from "sequenzy";

// Reads SEQUENZY_API_KEY from env automatically
export const sequenzy = new Sequenzy();
src/email.ts
import { Resend } from "resend";

export const resend = new Resend(process.env.RESEND_API_KEY);
src/email.ts
import sgMail from "@sendgrid/mail";

sgMail.setApiKey(process.env.SENDGRID_API_KEY!);

export { sgMail };

Send Your First Email

A basic Hono route handler that sends an email:

src/index.ts
import { Hono } from "hono";
import { sequenzy } from "./email";

const app = new Hono();

app.post("/api/send", async (c) => {
const result = await sequenzy.transactional.send({
  to: "user@example.com",
  subject: "Hello from Hono",
  body: "<p>Your app is sending emails. Nice.</p>",
});

return c.json({ jobId: result.jobId });
});

export default app;
src/index.ts
import { Hono } from "hono";
import { resend } from "./email";

const app = new Hono();

app.post("/api/send", async (c) => {
const { data, error } = await resend.emails.send({
  from: "Your App <noreply@yourdomain.com>",
  to: "user@example.com",
  subject: "Hello from Hono",
  html: "<p>Your app is sending emails. Nice.</p>",
});

if (error) {
  return c.json({ error: error.message }, 500);
}

return c.json({ id: data?.id });
});

export default app;
src/index.ts
import { Hono } from "hono";
import { sgMail } from "./email";

const app = new Hono();

app.post("/api/send", async (c) => {
try {
  await sgMail.send({
    to: "user@example.com",
    from: "noreply@yourdomain.com",
    subject: "Hello from Hono",
    html: "<p>Your app is sending emails. Nice.</p>",
  });

  return c.json({ success: true });
} catch (err) {
  const message = err instanceof Error ? err.message : "Failed to send";
  return c.json({ error: message }, 500);
}
});

export default app;

Run it on any runtime:

# Node.js
npx tsx src/index.ts
 
# Bun
bun run src/index.ts
 
# Test it
curl -X POST http://localhost:3000/api/send

Build Email Templates with React Email

Raw HTML strings get messy fast. React Email lets you build email templates as React components. Hono works with React Email regardless of which runtime you deploy to.

npm install @react-email/components

Here's a welcome email template:

// emails/welcome.tsx
import {
  Body,
  Container,
  Head,
  Heading,
  Html,
  Link,
  Preview,
  Text,
} from "@react-email/components";
 
interface WelcomeEmailProps {
  name: string;
  loginUrl: string;
}
 
export default function WelcomeEmail({ name, loginUrl }: WelcomeEmailProps) {
  return (
    <Html>
      <Head />
      <Preview>Welcome to the team, {name}</Preview>
      <Body style={{ backgroundColor: "#f6f9fc", fontFamily: "sans-serif" }}>
        <Container
          style={{
            backgroundColor: "#ffffff",
            padding: "40px",
            borderRadius: "8px",
            margin: "40px auto",
            maxWidth: "480px",
          }}
        >
          <Heading style={{ fontSize: "24px", marginBottom: "16px" }}>
            Welcome, {name}
          </Heading>
          <Text style={{ fontSize: "16px", lineHeight: "1.6", color: "#374151" }}>
            Your account is ready. You can log in and start exploring.
          </Text>
          <Link
            href={loginUrl}
            style={{
              display: "inline-block",
              backgroundColor: "#f97316",
              color: "#ffffff",
              padding: "12px 24px",
              borderRadius: "6px",
              textDecoration: "none",
              fontSize: "14px",
              fontWeight: "600",
              marginTop: "16px",
            }}
          >
            Go to Dashboard
          </Link>
        </Container>
      </Body>
    </Html>
  );
}

Render and send:

src/routes/welcome.ts
import { Hono } from "hono";
import { render } from "@react-email/components";
import { sequenzy } from "../email";
import WelcomeEmail from "../../emails/welcome";

const app = new Hono();

app.post("/api/send-welcome", async (c) => {
const { name, email } = await c.req.json();
const html = await render(WelcomeEmail({ name, loginUrl: "https://app.yoursite.com" }));

const result = await sequenzy.transactional.send({
  to: email,
  subject: `Welcome, ${name}`,
  body: html,
});

return c.json({ jobId: result.jobId });
});

export default app;
src/routes/welcome.ts
import { Hono } from "hono";
import { render } from "@react-email/components";
import { resend } from "../email";
import WelcomeEmail from "../../emails/welcome";

const app = new Hono();

app.post("/api/send-welcome", async (c) => {
const { name, email } = await c.req.json();
const html = await render(WelcomeEmail({ name, loginUrl: "https://app.yoursite.com" }));

const { data, error } = await resend.emails.send({
  from: "Your App <noreply@yourdomain.com>",
  to: email,
  subject: `Welcome, ${name}`,
  html,
});

if (error) return c.json({ error: error.message }, 500);
return c.json({ id: data?.id });
});

export default app;
src/routes/welcome.ts
import { Hono } from "hono";
import { render } from "@react-email/components";
import { sgMail } from "../email";
import WelcomeEmail from "../../emails/welcome";

const app = new Hono();

app.post("/api/send-welcome", async (c) => {
const { name, email } = await c.req.json();
const html = await render(WelcomeEmail({ name, loginUrl: "https://app.yoursite.com" }));

await sgMail.send({
  to: email,
  from: "noreply@yourdomain.com",
  subject: `Welcome, ${name}`,
  html,
});

return c.json({ sent: true });
});

export default app;

More React Email Components

The Tailwind wrapper compiles Tailwind classes to inline styles:

import { Html, Tailwind, Text, Button } from "@react-email/components";
 
export function ReceiptEmail({ amount }: { amount: string }) {
  return (
    <Html>
      <Tailwind>
        <Text className="text-2xl font-bold text-gray-900">Payment received</Text>
        <Text className="text-gray-600">We received your payment of {amount}.</Text>
        <Button
          href="https://app.yoursite.com/billing"
          className="bg-orange-500 text-white px-6 py-3 rounded-md"
        >
          View Invoice
        </Button>
      </Tailwind>
    </Html>
  );
}

Hono Middleware for Validation

Hono has a built-in Zod validator middleware. Use it to validate request bodies before your email logic runs:

npm install zod @hono/zod-validator
src/routes/contact.ts
import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator";
import { z } from "zod";
import { sequenzy } from "../email";

const contactSchema = z.object({
email: z.string().email(),
message: z.string().min(1).max(5000),
});

const app = new Hono();

app.post(
"/api/contact",
zValidator("json", contactSchema),
async (c) => {
  const { email, message } = c.req.valid("json");

  await sequenzy.transactional.send({
    to: "you@yourcompany.com",
    subject: `Contact from ${email}`,
    body: `<p><strong>From:</strong> ${email}</p><p>${message}</p>`,
  });

  return c.json({ sent: true });
}
);

export default app;
src/routes/contact.ts
import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator";
import { z } from "zod";
import { resend } from "../email";

const contactSchema = z.object({
email: z.string().email(),
message: z.string().min(1).max(5000),
});

const app = new Hono();

app.post(
"/api/contact",
zValidator("json", contactSchema),
async (c) => {
  const { email, message } = c.req.valid("json");

  const { error } = await resend.emails.send({
    from: "Contact Form <noreply@yourdomain.com>",
    to: "you@yourcompany.com",
    subject: `Contact from ${email}`,
    html: `<p><strong>From:</strong> ${email}</p><p>${message}</p>`,
  });

  if (error) return c.json({ error: error.message }, 500);
  return c.json({ sent: true });
}
);

export default app;
src/routes/contact.ts
import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator";
import { z } from "zod";
import { sgMail } from "../email";

const contactSchema = z.object({
email: z.string().email(),
message: z.string().min(1).max(5000),
});

const app = new Hono();

app.post(
"/api/contact",
zValidator("json", contactSchema),
async (c) => {
  const { email, message } = c.req.valid("json");

  await sgMail.send({
    to: "you@yourcompany.com",
    from: "noreply@yourdomain.com",
    subject: `Contact from ${email}`,
    html: `<p><strong>From:</strong> ${email}</p><p>${message}</p>`,
  });

  return c.json({ sent: true });
}
);

export default app;

Zod validation gives you type-safe request bodies and automatic 400 errors with descriptive messages when validation fails.

Common Email Patterns for SaaS

Password Reset

src/emails/password-reset.ts
import { sequenzy } from "../email";

export async function sendPasswordResetEmail(email: string, resetToken: string) {
const resetUrl = `${process.env.APP_URL}/reset-password?token=${resetToken}`;

await sequenzy.transactional.send({
  to: email,
  subject: "Reset your password",
  body: `
    <h2>Password Reset</h2>
    <p>Click the link below to reset your password. This link expires in 1 hour.</p>
    <a href="${resetUrl}"
       style="display:inline-block;background:#f97316;color:#fff;padding:12px 24px;border-radius:6px;text-decoration:none;">
      Reset Password
    </a>
    <p style="color:#6b7280;font-size:14px;margin-top:24px;">
      If you didn't request this, ignore this email.
    </p>
  `,
});
}
src/emails/password-reset.ts
import { resend } from "../email";

export async function sendPasswordResetEmail(email: string, resetToken: string) {
const resetUrl = `${process.env.APP_URL}/reset-password?token=${resetToken}`;

await resend.emails.send({
  from: "Your App <noreply@yourdomain.com>",
  to: email,
  subject: "Reset your password",
  html: `
    <h2>Password Reset</h2>
    <p>Click the link below to reset your password. This link expires in 1 hour.</p>
    <a href="${resetUrl}"
       style="display:inline-block;background:#f97316;color:#fff;padding:12px 24px;border-radius:6px;text-decoration:none;">
      Reset Password
    </a>
    <p style="color:#6b7280;font-size:14px;margin-top:24px;">
      If you didn't request this, ignore this email.
    </p>
  `,
});
}
src/emails/password-reset.ts
import { sgMail } from "../email";

export async function sendPasswordResetEmail(email: string, resetToken: string) {
const resetUrl = `${process.env.APP_URL}/reset-password?token=${resetToken}`;

await sgMail.send({
  to: email,
  from: "noreply@yourdomain.com",
  subject: "Reset your password",
  html: `
    <h2>Password Reset</h2>
    <p>Click the link below to reset your password. This link expires in 1 hour.</p>
    <a href="${resetUrl}"
       style="display:inline-block;background:#f97316;color:#fff;padding:12px 24px;border-radius:6px;text-decoration:none;">
      Reset Password
    </a>
    <p style="color:#6b7280;font-size:14px;margin-top:24px;">
      If you didn't request this, ignore this email.
    </p>
  `,
});
}

Payment Receipt

src/emails/receipt.ts
import { sequenzy } from "../email";

interface ReceiptParams {
email: string;
amount: number;
plan: string;
invoiceUrl: string;
}

export async function sendReceiptEmail({ email, amount, plan, invoiceUrl }: ReceiptParams) {
const formatted = `$${(amount / 100).toFixed(2)}`;

await sequenzy.transactional.send({
  to: email,
  subject: `Payment receipt - ${formatted}`,
  body: `
    <h2>Payment Received</h2>
    <p>Thanks for your payment. Here's your receipt:</p>
    <table style="width:100%;border-collapse:collapse;margin:16px 0;">
      <tr>
        <td style="padding:8px;border-bottom:1px solid #e5e7eb;">Plan</td>
        <td style="padding:8px;border-bottom:1px solid #e5e7eb;text-align:right;">${plan}</td>
      </tr>
      <tr>
        <td style="padding:8px;font-weight:600;">Total</td>
        <td style="padding:8px;text-align:right;font-weight:600;">${formatted}</td>
      </tr>
    </table>
    <a href="${invoiceUrl}" style="color:#f97316;">View full invoice</a>
  `,
});
}
src/emails/receipt.ts
import { resend } from "../email";

interface ReceiptParams {
email: string;
amount: number;
plan: string;
invoiceUrl: string;
}

export async function sendReceiptEmail({ email, amount, plan, invoiceUrl }: ReceiptParams) {
const formatted = `$${(amount / 100).toFixed(2)}`;

await resend.emails.send({
  from: "Your App <billing@yourdomain.com>",
  to: email,
  subject: `Payment receipt - ${formatted}`,
  html: `
    <h2>Payment Received</h2>
    <p>Thanks for your payment. Here's your receipt:</p>
    <table style="width:100%;border-collapse:collapse;margin:16px 0;">
      <tr>
        <td style="padding:8px;border-bottom:1px solid #e5e7eb;">Plan</td>
        <td style="padding:8px;border-bottom:1px solid #e5e7eb;text-align:right;">${plan}</td>
      </tr>
      <tr>
        <td style="padding:8px;font-weight:600;">Total</td>
        <td style="padding:8px;text-align:right;font-weight:600;">${formatted}</td>
      </tr>
    </table>
    <a href="${invoiceUrl}" style="color:#f97316;">View full invoice</a>
  `,
});
}
src/emails/receipt.ts
import { sgMail } from "../email";

interface ReceiptParams {
email: string;
amount: number;
plan: string;
invoiceUrl: string;
}

export async function sendReceiptEmail({ email, amount, plan, invoiceUrl }: ReceiptParams) {
const formatted = `$${(amount / 100).toFixed(2)}`;

await sgMail.send({
  to: email,
  from: "billing@yourdomain.com",
  subject: `Payment receipt - ${formatted}`,
  html: `
    <h2>Payment Received</h2>
    <p>Thanks for your payment. Here's your receipt:</p>
    <table style="width:100%;border-collapse:collapse;margin:16px 0;">
      <tr>
        <td style="padding:8px;border-bottom:1px solid #e5e7eb;">Plan</td>
        <td style="padding:8px;border-bottom:1px solid #e5e7eb;text-align:right;">${plan}</td>
      </tr>
      <tr>
        <td style="padding:8px;font-weight:600;">Total</td>
        <td style="padding:8px;text-align:right;font-weight:600;">${formatted}</td>
      </tr>
    </table>
    <a href="${invoiceUrl}" style="color:#f97316;">View full invoice</a>
  `,
});
}

Stripe Webhook Handler

Hono gives you raw body access through c.req.text(), which Stripe needs for signature verification:

src/routes/stripe-webhook.ts
import { Hono } from "hono";
import Stripe from "stripe";
import { sequenzy } from "../email";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const app = new Hono();

app.post("/api/webhooks/stripe", async (c) => {
const body = await c.req.text();
const signature = c.req.header("stripe-signature")!;

const event = stripe.webhooks.constructEvent(
  body,
  signature,
  process.env.STRIPE_WEBHOOK_SECRET!
);

switch (event.type) {
  case "checkout.session.completed": {
    const session = event.data.object as Stripe.Checkout.Session;

    await sequenzy.transactional.send({
      to: session.customer_email!,
      subject: "Payment confirmed",
      body: "<h1>Thanks for your purchase!</h1><p>Your subscription is now active.</p>",
    });

    await sequenzy.subscribers.create({
      email: session.customer_email!,
      tags: ["customer", "stripe"],
    });
    break;
  }

  case "invoice.payment_failed": {
    const invoice = event.data.object as Stripe.Invoice;

    await sequenzy.transactional.send({
      to: invoice.customer_email!,
      subject: "Payment failed - action required",
      body: `
        <h2>Payment Failed</h2>
        <p>We couldn't process your payment. Update your payment method to keep your account active.</p>
        <a href="${process.env.APP_URL}/billing"
           style="display:inline-block;background:#f97316;color:#fff;padding:12px 24px;border-radius:6px;text-decoration:none;">
          Update Payment Method
        </a>
      `,
    });
    break;
  }
}

return c.json({ received: true });
});

export default app;
src/routes/stripe-webhook.ts
import { Hono } from "hono";
import Stripe from "stripe";
import { resend } from "../email";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const app = new Hono();

app.post("/api/webhooks/stripe", async (c) => {
const body = await c.req.text();
const signature = c.req.header("stripe-signature")!;

const event = stripe.webhooks.constructEvent(
  body,
  signature,
  process.env.STRIPE_WEBHOOK_SECRET!
);

switch (event.type) {
  case "checkout.session.completed": {
    const session = event.data.object as Stripe.Checkout.Session;

    await resend.emails.send({
      from: "Your App <noreply@yourdomain.com>",
      to: session.customer_email!,
      subject: "Payment confirmed",
      html: "<h1>Thanks for your purchase!</h1><p>Your subscription is now active.</p>",
    });
    break;
  }

  case "invoice.payment_failed": {
    const invoice = event.data.object as Stripe.Invoice;

    await resend.emails.send({
      from: "Your App <noreply@yourdomain.com>",
      to: invoice.customer_email!,
      subject: "Payment failed - action required",
      html: `
        <h2>Payment Failed</h2>
        <p>We couldn't process your payment. Update your payment method.</p>
        <a href="${process.env.APP_URL}/billing"
           style="display:inline-block;background:#f97316;color:#fff;padding:12px 24px;border-radius:6px;text-decoration:none;">
          Update Payment Method
        </a>
      `,
    });
    break;
  }
}

return c.json({ received: true });
});

export default app;
src/routes/stripe-webhook.ts
import { Hono } from "hono";
import Stripe from "stripe";
import { sgMail } from "../email";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const app = new Hono();

app.post("/api/webhooks/stripe", async (c) => {
const body = await c.req.text();
const signature = c.req.header("stripe-signature")!;

const event = stripe.webhooks.constructEvent(
  body,
  signature,
  process.env.STRIPE_WEBHOOK_SECRET!
);

switch (event.type) {
  case "checkout.session.completed": {
    const session = event.data.object as Stripe.Checkout.Session;

    await sgMail.send({
      to: session.customer_email!,
      from: "noreply@yourdomain.com",
      subject: "Payment confirmed",
      html: "<h1>Thanks for your purchase!</h1><p>Your subscription is now active.</p>",
    });
    break;
  }

  case "invoice.payment_failed": {
    const invoice = event.data.object as Stripe.Invoice;

    await sgMail.send({
      to: invoice.customer_email!,
      from: "noreply@yourdomain.com",
      subject: "Payment failed - action required",
      html: `
        <h2>Payment Failed</h2>
        <p>We couldn't process your payment. Update your payment method.</p>
        <a href="${process.env.APP_URL}/billing"
           style="display:inline-block;background:#f97316;color:#fff;padding:12px 24px;border-radius:6px;text-decoration:none;">
          Update Payment Method
        </a>
      `,
    });
    break;
  }
}

return c.json({ received: true });
});

export default app;

Error Handling

Hono has a built-in onError handler that catches unhandled errors across all routes:

src/app.ts
import { Hono } from "hono";
import Sequenzy from "sequenzy";
import { sequenzy } from "./email";

const app = new Hono();

// Global error handler
app.onError((err, c) => {
if (err instanceof Sequenzy.RateLimitError) {
  return c.json({ error: "Rate limited, try again later" }, 429);
}
if (err instanceof Sequenzy.AuthenticationError) {
  return c.json({ error: "Email service configuration error" }, 500);
}
console.error("Unhandled error:", err);
return c.json({ error: "Internal server error" }, 500);
});

app.post("/api/send", async (c) => {
const result = await sequenzy.transactional.send({
  to: "user@example.com",
  subject: "Hello",
  body: "<p>Hello!</p>",
});
return c.json({ jobId: result.jobId });
});

export default app;
src/app.ts
import { Hono } from "hono";
import { resend } from "./email";

const app = new Hono();

app.onError((err, c) => {
console.error("Unhandled error:", err);
return c.json({ error: "Internal server error" }, 500);
});

app.post("/api/send", async (c) => {
const { data, error } = await resend.emails.send({
  from: "Your App <noreply@yourdomain.com>",
  to: "user@example.com",
  subject: "Hello",
  html: "<p>Hello!</p>",
});

if (error) return c.json({ error: error.message }, 500);
return c.json({ id: data?.id });
});

export default app;
src/app.ts
import { Hono } from "hono";
import { sgMail } from "./email";

const app = new Hono();

app.onError((err, c) => {
console.error("Unhandled error:", err);
return c.json({ error: "Internal server error" }, 500);
});

app.post("/api/send", async (c) => {
await sgMail.send({
  to: "user@example.com",
  from: "noreply@yourdomain.com",
  subject: "Hello",
  html: "<p>Hello!</p>",
});
return c.json({ success: true });
});

export default app;

For critical emails, add a retry wrapper:

export async function withRetry<T>(fn: () => Promise<T>, maxRetries = 3): Promise<T> {
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
    try {
      return await fn();
    } catch (error) {
      if (attempt === maxRetries) throw error;
      await new Promise((r) => setTimeout(r, Math.pow(2, attempt) * 1000));
    }
  }
  throw new Error("Unreachable");
}

Deploy Anywhere

Hono's superpower is multi-runtime support. Here's how to serve the same email API on different platforms:

// Node.js
import { serve } from "@hono/node-server";
import app from "./app";
serve({ fetch: app.fetch, port: 3000 });
 
// Bun - just export
export default app;
 
// Cloudflare Workers - just export
export default app;
 
// Deno
Deno.serve(app.fetch);

On Cloudflare Workers, use environment bindings instead of process.env:

type Bindings = {
  SEQUENZY_API_KEY: string;
};
 
const app = new Hono<{ Bindings: Bindings }>();
 
app.post("/api/send", async (c) => {
  // Access secrets through c.env
  const response = await fetch("https://api.sequenzy.com/api/v1/transactional/send", {
    method: "POST",
    headers: {
      "Authorization": `Bearer ${c.env.SEQUENZY_API_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      to: "user@example.com",
      subject: "Hello from Workers",
      body: "<p>Sent from the edge.</p>",
    }),
  });
 
  return c.json(await response.json());
});

Going to Production

1. Verify Your Domain

Every email provider requires domain verification via DNS records (SPF, DKIM, DMARC). Without this, your emails go to spam. See our email authentication guide for full instructions.

2. Use a Dedicated Sending Domain

Send from mail.yourapp.com instead of your root domain. Protects your main domain's reputation.

3. Set a Consistent "From" Address

const FROM = "YourApp <notifications@mail.yourapp.com>";

4. Rate Limit Email Endpoints

const rateLimits = new Map<string, number[]>();
 
function rateLimit(max: number, windowMs: number) {
  return async (c: any, next: any) => {
    const ip = c.req.header("x-forwarded-for") ?? "unknown";
    const now = Date.now();
    const timestamps = (rateLimits.get(ip) ?? []).filter((t) => t > now - windowMs);
 
    if (timestamps.length >= max) {
      return c.json({ error: "Too many requests" }, 429);
    }
 
    timestamps.push(now);
    rateLimits.set(ip, timestamps);
    await next();
  };
}
 
app.post("/api/contact", rateLimit(10, 15 * 60 * 1000), async (c) => {
  // ...
});

Beyond Transactional: Marketing Emails and Sequences

At some point, you'll want more than one-off transactional emails. You'll want onboarding sequences, marketing campaigns, lifecycle automation, and engagement tracking.

Most teams stitch together Resend/SendGrid for transactional with Mailchimp/ConvertKit for marketing. That means two dashboards and keeping subscriber lists in sync.

Sequenzy handles both from one platform. Same SDK, same dashboard. Transactional sends, marketing campaigns, automated sequences, subscriber segments, and native Stripe integration.

import { sequenzy } from "./email";
 
// Add a subscriber when they sign up
await sequenzy.subscribers.create({
  email: "user@example.com",
  firstName: "Jane",
  tags: ["signed-up"],
  customAttributes: { plan: "free", source: "organic" },
});
 
// Tag them when they upgrade
await sequenzy.subscribers.tags.add({
  email: "user@example.com",
  tag: "customer",
});
 
// Track events to trigger automated sequences
await sequenzy.subscribers.events.trigger({
  email: "user@example.com",
  event: "onboarding.completed",
  properties: { completedSteps: 5 },
});

FAQ

Can I use Hono on Cloudflare Workers for sending emails?

Yes. Hono was originally built for Cloudflare Workers. The only difference is how you access environment variables: Workers use c.env.SEQUENZY_API_KEY instead of process.env. You'll need to pass the API key explicitly when initializing SDK clients, or use the raw fetch API to call the provider directly.

Is Hono fast enough for a high-volume email API?

Hono adds near-zero overhead. It's one of the fastest web frameworks available, especially on Bun and Workers. The bottleneck for email sending is always the provider API call, not your framework.

How do I handle authentication in Hono before sending emails?

Use Hono middleware. Check for a valid API key, JWT, or session before the route handler runs:

app.use("/api/*", async (c, next) => {
  const token = c.req.header("authorization")?.replace("Bearer ", "");
  if (!token || !isValidToken(token)) {
    return c.json({ error: "Unauthorized" }, 401);
  }
  await next();
});

How do I test Hono email routes?

Hono has a built-in test helper. Mock the email client and test your routes directly:

import app from "./app";
 
const res = await app.request("/api/send", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ email: "test@example.com", name: "Test" }),
});
 
expect(res.status).toBe(200);

Should I use Hono or Express for an email API?

Use Hono if you want multi-runtime support (Workers, Bun, Deno, Node), a lighter bundle, or built-in Zod validation. Use Express if you need the massive middleware ecosystem or your team already knows Express.

How do I send emails in the background with Hono on Workers?

On Cloudflare Workers, use c.executionCtx.waitUntil() to keep the worker alive while the email sends asynchronously:

app.post("/api/send", (c) => {
  c.executionCtx.waitUntil(
    sequenzy.transactional.send({
      to: "user@example.com",
      subject: "Hello",
      body: "<p>Sent in the background.</p>",
    })
  );
  return c.json({ queued: true });
});

On Node.js or Bun, simply don't await the send call.

Can I organize Hono routes into separate files?

Yes, use app.route() to mount sub-apps:

import { Hono } from "hono";
import emailRoutes from "./routes/email";
import webhookRoutes from "./routes/stripe-webhook";
 
const app = new Hono();
app.route("/api/email", emailRoutes);
app.route("/api/webhooks", webhookRoutes);
 
export default app;

Wrapping Up

Here's what we covered:

  1. Set up an email client with Sequenzy, Resend, or SendGrid
  2. Send from Hono routes with typed request handling
  3. Build templates with React Email for maintainable HTML emails
  4. Validate requests with Zod middleware before sending
  5. Handle Stripe webhooks with raw body access for signature verification
  6. Common SaaS patterns: password reset, payment receipts, failed payment alerts
  7. Error handling with Hono's global onError handler and retry logic
  8. Deploy anywhere: Node.js, Bun, Cloudflare Workers, Deno
  9. Production checklist: domain verification, dedicated sending domain, rate limiting

The code in this guide is production-ready. Copy the patterns that fit your app, swap in your provider of choice, and start shipping emails.

Frequently Asked Questions

What is Hono and why use it for email sending?

Hono is a lightweight web framework that runs on any JavaScript runtime (Cloudflare Workers, Deno, Bun, Node.js). It's ideal for email API endpoints because it's fast, has zero dependencies, and deploys anywhere. Its fetch-based design works perfectly with email provider APIs.

Can I use Hono on Cloudflare Workers for email sending?

Yes, and it's one of the best combinations. Hono was designed for edge runtimes. Your email-sending endpoints deploy globally with near-zero cold starts. Use Workers secrets for API keys and c.env to access them in handlers.

How do I validate request bodies in Hono email endpoints?

Use Hono's built-in validator middleware with Zod. Define a schema for your email request (to, subject, body) and validate incoming requests before processing. Hono returns a 400 error automatically for invalid payloads.

How do I handle errors in Hono email routes?

Use Hono's app.onError() global error handler for consistent error responses. In individual routes, wrap email sends in try/catch and return appropriate status codes. Log errors using your runtime's logging API.

Can I use Hono middleware for email-related cross-cutting concerns?

Yes. Create middleware for rate limiting, authentication, and request logging. Apply it to your email routes with app.use('/api/email/*', middleware). Hono's middleware system is composable and lightweight.

How do I send emails asynchronously in Hono?

On Cloudflare Workers, use c.executionCtx.waitUntil() to send emails after the response. On Node.js or Bun, use a job queue. On Deno, use Deno.cron for scheduled sends. The approach depends on your runtime.

How do I test Hono email endpoints?

Use Hono's built-in app.request() method for testing. Create a test instance of your app, mock the email SDK, and make requests against it. Assert on response status codes and that the email function was called correctly.

How do I store email API keys across different Hono runtimes?

Each runtime handles secrets differently. Cloudflare Workers: use wrangler secret. Deno: use Deno.env. Bun/Node: use .env files. Access them via c.env in Hono handlers. Use Hono's adapter-specific env helpers for consistent access.

Can I deploy the same Hono email code to multiple platforms?

Yes, that's Hono's strength. Write your email logic once and deploy to Workers, Deno Deploy, or any Node.js host by changing only the adapter. Keep platform-specific code (secrets, queues) behind interfaces for portability.

How do I add CORS to Hono email endpoints?

Use Hono's built-in CORS middleware: app.use('/api/*', cors({ origin: 'https://yourapp.com' })). Configure allowed origins, methods, and headers. This is essential if your frontend calls the email API from a different domain.