Back to blog
HonoTutorialIntegration

How to Send Transactional Emails from Hono

Send emails from Hono on Node.js, Cloudflare Workers, Bun, or Deno. Covers setup, validation, error handling, and async patterns.

Johan SteniusDecember 29, 20253 min read

Hono is what we use for SendPigeon's API. We picked it because it's built on standard Web APIs instead of Node-specific stuff. Your application logic stays portable across Node.js, Cloudflare Workers, Bun, and Deno — only the server setup differs.

Here's how to send transactional emails from Hono.

TL;DR

Quick setup:

  1. npm install sendpigeon
  2. Add SENDPIGEON_API_KEY to your environment
  3. Access via process.env, c.env, Bun.env, or Deno.env

Basic setup

npm install sendpigeon hono
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);
  }

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

export default app;

Environment by runtime

Node.js

import { serve } from "@hono/node-server";

const client = new SendPigeon(process.env.SENDPIGEON_API_KEY!);

serve({ fetch: app.fetch, port: 3000 });

Cloudflare Workers

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);
  // ...
});

Bun

const client = new SendPigeon(Bun.env.SENDPIGEON_API_KEY!);

Deno

const client = new SendPigeon(Deno.env.get("SENDPIGEON_API_KEY")!);

Validation with Zod

Use @hono/zod-openapi for validated, typed requests:

import { OpenAPIHono, createRoute, z } from "@hono/zod-openapi";

const sendEmailSchema = z.object({
  to: z.string().email(),
  subject: z.string().min(1).max(200),
  html: z.string(),
});

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"); // Fully typed
  // ...
});

Error handling

const { data, error } = await client.send({ from, to, subject, html });

if (error) {
  if (error.status === 429) {
    return c.json({ error: "Rate limited" }, 429);
  }
  return c.json({ error: error.message }, error.status || 500);
}

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

Non-blocking emails

Cloudflare Workers:

app.post("/signup", async (c) => {
  const user = await createUser(data);
  c.executionCtx.waitUntil(sendWelcomeEmail(user.email));
  return c.json({ userId: user.id });
});

Node.js:

app.post("/signup", async (c) => {
  const user = await createUser(data);
  sendWelcomeEmail(user.email).catch(console.error);
  return c.json({ userId: user.id });
});

Testing

import { describe, it, expect } from "vitest";
import app from "./app";

describe("POST /send", () => {
  it("sends email", async () => {
    const res = await app.request("/send", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        to: "user@example.com",
        subject: "Test",
        html: "<p>Hello</p>",
      }),
    });

    expect(res.status).toBe(200);
  });
});

Next steps