How to Send Email with Hono (Node.js, Cloudflare Workers, Bun, Deno)
Complete guide to sending transactional emails from Hono. Covers every runtime — Node.js, Cloudflare Workers, Bun, and Deno — with code examples, validation, templates, webhooks, and testing.
We built SendPigeon's API on Hono. It runs on standard Web APIs, which means your email sending code is portable across Node.js, Cloudflare Workers, Bun, and Deno — only the server bootstrap changes.
This guide covers everything you need to send transactional emails from Hono, from basic setup to production patterns.
Quick setup:
npm install sendpigeon hono- Add
SENDPIGEON_API_KEYto your environment - Call
client.send()from any route
Works on every runtime Hono supports. Full examples below.
Prerequisites
- A Hono project (or we'll create one)
- A SendPigeon account (free tier: 1,000 emails/month)
- An API key from your dashboard
- A verified sending domain
Project setup
If you're adding to an existing Hono project, skip to Basic email sending.
New project
# Create a new Hono project
npm create hono@latest my-email-app
cd my-email-app
# Install SendPigeon SDK
npm install sendpigeon
The create hono CLI will ask you to pick a runtime template. Choose whichever fits your deployment target — the email sending code is the same.
Basic email sending
import { Hono } from "hono";
import { SendPigeon } from "sendpigeon";
const app = new Hono();
app.post("/send", async (c) => {
const client = new SendPigeon(process.env.SENDPIGEON_API_KEY!);
const { to, subject, html } = await c.req.json();
const { data, error } = await client.send({
from: "hello@yourdomain.com",
to,
subject,
html,
});
if (error) {
return c.json({ error: error.message }, error.status ?? 500);
}
return c.json({ id: data.id });
});
export default app;
That's the core pattern. The rest of this guide covers runtime-specific setup, real-world use cases, and production best practices.
Runtime-specific setup
Hono runs on every major JavaScript runtime. The email sending code stays the same — only the server bootstrap and environment variable access differ.
Node.js
import { serve } from "@hono/node-server";
import { Hono } from "hono";
import { SendPigeon } from "sendpigeon";
const app = new Hono();
const client = new SendPigeon(process.env.SENDPIGEON_API_KEY!);
app.post("/send", async (c) => {
const { to, subject, html } = await c.req.json();
const { data, error } = await client.send({
from: "hello@yourdomain.com",
to,
subject,
html,
});
if (error) return c.json({ error: error.message }, error.status ?? 500);
return c.json({ id: data.id });
});
serve({ fetch: app.fetch, port: 3000 });
# .env
SENDPIGEON_API_KEY=sp_live_your_key_here
Cloudflare Workers
On Workers, environment variables come from c.env bindings:
import { Hono } from "hono";
import { SendPigeon } from "sendpigeon";
type Bindings = {
SENDPIGEON_API_KEY: string;
};
const app = new Hono<{ Bindings: Bindings }>();
app.post("/send", async (c) => {
const client = new SendPigeon(c.env.SENDPIGEON_API_KEY);
const { to, subject, html } = await c.req.json();
const { data, error } = await client.send({
from: "hello@yourdomain.com",
to,
subject,
html,
});
if (error) return c.json({ error: error.message }, error.status ?? 500);
return c.json({ id: data.id });
});
export default app;
# wrangler.toml
[vars]
# Don't put secrets here — use `wrangler secret put SENDPIGEON_API_KEY`
# Set the secret
wrangler secret put SENDPIGEON_API_KEY
On Workers, create the SendPigeon client inside the request handler (not at module level) since c.env is only available during request handling.
Bun
import { Hono } from "hono";
import { SendPigeon } from "sendpigeon";
const app = new Hono();
const client = new SendPigeon(Bun.env.SENDPIGEON_API_KEY!);
app.post("/send", async (c) => {
const { to, subject, html } = await c.req.json();
const { data, error } = await client.send({
from: "hello@yourdomain.com",
to,
subject,
html,
});
if (error) return c.json({ error: error.message }, error.status ?? 500);
return c.json({ id: data.id });
});
export default {
port: 3000,
fetch: app.fetch,
};
Deno
import { Hono } from "hono";
import { SendPigeon } from "sendpigeon";
const app = new Hono();
const client = new SendPigeon(Deno.env.get("SENDPIGEON_API_KEY")!);
app.post("/send", async (c) => {
const { to, subject, html } = await c.req.json();
const { data, error } = await client.send({
from: "hello@yourdomain.com",
to,
subject,
html,
});
if (error) return c.json({ error: error.message }, error.status ?? 500);
return c.json({ id: data.id });
});
Deno.serve({ port: 3000 }, app.fetch);
Real-world examples
Contact form
A common pattern: a public-facing contact form that sends you an email.
import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator";
import { z } from "zod";
import { SendPigeon } from "sendpigeon";
const contactSchema = z.object({
name: z.string().min(1).max(100),
email: z.string().email(),
message: z.string().min(10).max(5000),
});
const app = new Hono();
const client = new SendPigeon(process.env.SENDPIGEON_API_KEY!);
app.post("/contact", zValidator("json", contactSchema), async (c) => {
const { name, email, message } = c.req.valid("json");
const { error } = await client.send({
from: "noreply@yourdomain.com",
to: "support@yourdomain.com",
replyTo: email,
subject: `Contact form: ${name}`,
html: `
<h2>New contact form submission</h2>
<p><strong>From:</strong> ${name} (${email})</p>
<p><strong>Message:</strong></p>
<p>${message}</p>
`,
});
if (error) return c.json({ error: "Failed to send" }, 500);
return c.json({ success: true });
});
Welcome email on signup
app.post("/signup", async (c) => {
const { email, name } = await c.req.json();
// Create user in your database
const user = await db.user.create({ email, name });
// Send welcome email
const { error } = await client.send({
from: "welcome@yourdomain.com",
to: email,
subject: `Welcome to MyApp, ${name}!`,
html: `
<h1>Welcome, ${name}!</h1>
<p>Thanks for signing up. Here's how to get started:</p>
<ol>
<li>Complete your profile</li>
<li>Explore the dashboard</li>
<li>Invite your team</li>
</ol>
<a href="https://yourdomain.com/dashboard">Go to Dashboard</a>
`,
});
if (error) {
console.error("Failed to send welcome email:", error.message);
// Don't fail signup if email fails
}
return c.json({ userId: user.id });
});
Password reset
app.post("/forgot-password", async (c) => {
const { email } = await c.req.json();
const user = await db.user.findByEmail(email);
if (!user) {
// Don't reveal if user exists
return c.json({ message: "If an account exists, we sent a reset link." });
}
const token = crypto.randomUUID();
await db.passwordReset.create({ userId: user.id, token, expiresAt: new Date(Date.now() + 3600000) });
await client.send({
from: "security@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="https://yourdomain.com/reset-password?token=${token}">Reset Password</a>
<p>If you didn't request this, you can ignore this email.</p>
`,
});
return c.json({ message: "If an account exists, we sent a reset link." });
});
Input validation with Zod
Use @hono/zod-validator or @hono/zod-openapi for validated, typed requests:
npm install @hono/zod-validator zod
import { zValidator } from "@hono/zod-validator";
import { z } from "zod";
const sendEmailSchema = z.object({
to: z.string().email(),
subject: z.string().min(1).max(200),
html: z.string(),
});
app.post("/send", zValidator("json", sendEmailSchema), async (c) => {
const { to, subject, html } = c.req.valid("json"); // Fully typed, validated
const { data, error } = await client.send({ from: "hello@yourdomain.com", to, subject, html });
if (error) return c.json({ error: error.message }, error.status ?? 500);
return c.json({ id: data.id });
});
For auto-generated API docs, use @hono/zod-openapi:
import { OpenAPIHono, createRoute, z } from "@hono/zod-openapi";
const sendRoute = createRoute({
method: "post",
path: "/send",
request: {
body: { content: { "application/json": { schema: sendEmailSchema } } },
},
responses: {
200: {
description: "Email sent",
content: { "application/json": { schema: z.object({ id: z.string() }) } },
},
},
});
const app = new OpenAPIHono();
app.openapi(sendRoute, async (c) => {
const body = c.req.valid("json");
// ...
});
Error handling
The SendPigeon SDK returns { data, error } instead of throwing. Handle errors explicitly:
app.post("/send", async (c) => {
const { to, subject, html } = await c.req.json();
const { data, error } = await client.send({ from: "hello@yourdomain.com", to, subject, html });
if (error) {
switch (error.status) {
case 400:
return c.json({ error: "Invalid request", details: error.message }, 400);
case 401:
return c.json({ error: "Invalid API key" }, 401);
case 429:
return c.json({ error: "Rate limited, try again later" }, 429);
default:
return c.json({ error: "Failed to send email" }, 500);
}
}
return c.json({ id: data.id });
});
For a global error handler:
app.onError((err, c) => {
console.error("Unhandled error:", err);
return c.json({ error: "Internal server error" }, 500);
});
Non-blocking emails
Don't make users wait for email delivery. Send emails in the background.
Cloudflare Workers
Use waitUntil to keep the worker alive after responding:
app.post("/signup", async (c) => {
const user = await createUser(data);
// Send response immediately, email sends in background
c.executionCtx.waitUntil(
client.send({
from: "welcome@yourdomain.com",
to: user.email,
subject: "Welcome!",
html: "<h1>Welcome!</h1>",
})
);
return c.json({ userId: user.id });
});
Node.js / Bun / Deno
Fire and forget with error logging:
app.post("/signup", async (c) => {
const user = await createUser(data);
// Don't await — fire and forget
client.send({
from: "welcome@yourdomain.com",
to: user.email,
subject: "Welcome!",
html: "<h1>Welcome!</h1>",
}).catch((err) => console.error("Email send failed:", err));
return c.json({ userId: user.id });
});
Using templates
Instead of inline HTML, use SendPigeon's template system. Create a template in the dashboard, then reference it by ID:
app.post("/send-welcome", async (c) => {
const { email, name } = await c.req.json();
const { data, error } = await client.send({
from: "welcome@yourdomain.com",
to: email,
templateId: "tmpl_welcome_email",
variables: {
name,
dashboardUrl: "https://yourdomain.com/dashboard",
},
});
if (error) return c.json({ error: error.message }, error.status ?? 500);
return c.json({ id: data.id });
});
Or build your own email templates with our free visual email builder.
Receiving webhooks
SendPigeon sends webhooks for email events (delivered, bounced, opened, clicked). Here's how to handle them in Hono:
app.post("/webhooks/email", async (c) => {
const payload = await c.req.json();
switch (payload.event) {
case "email.delivered":
console.log(`Email ${payload.data.emailId} delivered to ${payload.data.toAddress}`);
break;
case "email.bounced":
console.log(`Email ${payload.data.emailId} bounced: ${payload.data.bounceType}`);
// Remove bad address from your database
await db.user.markEmailInvalid(payload.data.toAddress);
break;
case "email.opened":
console.log(`Email ${payload.data.emailId} opened`);
break;
case "email.clicked":
console.log(`Link clicked: ${payload.data.linkUrl}`);
break;
}
return c.json({ received: true });
});
Configure your webhook URL in the SendPigeon dashboard.
Testing locally
Instead of sending real emails during development, use SendPigeon's local dev server:
npx @sendpigeon-sdk/cli dev
This starts a local SMTP server and web UI. Emails are captured and displayed at localhost:4100 instead of being sent to real inboxes.
Your code stays the same — set the environment variable to enable dev mode:
# .env.development
SENDPIGEON_DEV=true
SENDPIGEON_API_KEY=sp_test_your_test_key
The SDK automatically routes to localhost:4125 when SENDPIGEON_DEV=true.
Testing with Vitest
import { describe, it, expect } from "vitest";
import app from "./app";
describe("POST /send", () => {
it("sends email successfully", async () => {
const res = await app.request("/send", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
to: "user@example.com",
subject: "Test email",
html: "<p>Hello</p>",
}),
});
expect(res.status).toBe(200);
const body = await res.json();
expect(body.id).toBeDefined();
});
it("validates input", async () => {
const res = await app.request("/send", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
to: "not-an-email",
subject: "",
html: "",
}),
});
expect(res.status).toBe(400);
});
});
Use a test API key (starting with sp_test_) in your test environment. Test keys capture emails in your SendPigeon dashboard without sending them to real addresses.
Sending from multiple domains
If you run multiple projects, you can send from different domains in the same Hono app. Add each domain in the SendPigeon dashboard, then use the appropriate from address:
// Project 1
await client.send({
from: "noreply@myapp.com",
to: "user@example.com",
subject: "Welcome to MyApp",
html: "...",
});
// Project 2
await client.send({
from: "noreply@myotherapp.com",
to: "user@example.com",
subject: "Welcome to MyOtherApp",
html: "...",
});
All plans include multiple domains. No extra cost per domain.
Middleware pattern
If you're sending emails from many routes, create a middleware to inject the client:
import { Hono } from "hono";
import { SendPigeon } from "sendpigeon";
import { createMiddleware } from "hono/factory";
type Env = {
Variables: {
email: SendPigeon;
};
};
const emailMiddleware = createMiddleware<Env>(async (c, next) => {
c.set("email", new SendPigeon(process.env.SENDPIGEON_API_KEY!));
await next();
});
const app = new Hono<Env>();
app.use(emailMiddleware);
app.post("/send", async (c) => {
const email = c.get("email");
const { data, error } = await email.send({ /* ... */ });
// ...
});
For Cloudflare Workers, use the bindings pattern instead:
type Env = {
Bindings: { SENDPIGEON_API_KEY: string };
Variables: { email: SendPigeon };
};
const emailMiddleware = createMiddleware<Env>(async (c, next) => {
c.set("email", new SendPigeon(c.env.SENDPIGEON_API_KEY));
await next();
});
Deployment checklist
Before going to production:
Verify your domain
Add your sending domain in the SendPigeon dashboard and configure SPF, DKIM, and DMARC records.
See our email authentication guide for step-by-step instructions.
Use a production API key
Replace your test key (sp_test_...) with a live key (sp_live_...). Store it as an environment variable, never in code.
Set up webhooks
Configure webhook endpoints in the dashboard to handle bounces and complaints. This protects your sender reputation.
Add error handling
Make sure email failures don't crash your app. Log errors and handle them gracefully.
Next steps
- API Docs — Full API reference
- Email Authentication Guide — SPF, DKIM, DMARC setup
- Email Deliverability Checklist — Reach the inbox
- Visual Email Builder — Build templates without code
- Framework Guides — More integrations (Next.js, Remix, Astro, etc.)