Back to blog
HonoTutorialIntegrationEmail APICloudflare Workers

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.

Johan SteniusFebruary 3, 202611 min read

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.

TL;DR

Quick setup:

  1. npm install sendpigeon hono
  2. Add SENDPIGEON_API_KEY to your environment
  3. 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