Samva is in early access — self-serve signup is limited. Have a team invite? Sign up with that email. Contact us for access.

Hono on Cloudflare Workers

Send email with Hono on Cloudflare Workers using Samva, Worker secrets, and an edge-native fetch send path.

Hono on Cloudflare Workers

Send email from a Hono app running on Cloudflare Workers with the samva SDK. The send path runs on Workers with fetch, so it does not need SMTP, Node APIs, or nodejs_compat.

Use Worker bindings for secrets. Read c.env.SAMVA_API_KEY inside the route handler instead of process.env or a module-top client.

Install

bun add hono samva
bun add -d wrangler @cloudflare/workers-types typescript

Store the Samva API key as a Worker secret:

wrangler secret put SAMVA_API_KEY

For local development, put the same binding in .dev.vars:

SAMVA_API_KEY="sk_sm_..."

Route handler

import { Hono } from "hono";
import { createClient } from "samva";

type Bindings = {
  SAMVA_API_KEY: string;
};

type SendRequestBody = {
  to?: unknown;
  subject?: unknown;
  html?: unknown;
  text?: unknown;
};

const app = new Hono<{ Bindings: Bindings }>();

const isRecord = (value: unknown): value is Record<string, unknown> =>
  typeof value === "object" && value !== null && !Array.isArray(value);

const readString = (value: unknown): string => (typeof value === "string" ? value.trim() : "");

app.post("/send", async (c) => {
  const body: unknown = await c.req.json().catch(() => null);
  if (!isRecord(body)) {
    return c.json({ error: "Expected a JSON object." }, 400);
  }

  const { to, subject, html, text } = body as SendRequestBody;
  const recipientEmail = readString(to);
  const emailSubject = readString(subject);

  if (!recipientEmail || !emailSubject) {
    return c.json({ error: "to and subject are required" }, 400);
  }

  const samva = createClient({ apiKey: c.env.SAMVA_API_KEY });
  const { data, error } = await samva.messages.send({
    to: [{ email: recipientEmail }],
    channel: "email",
    email: {
      subject: emailSubject,
      html: readString(html) || "<p>Hello from Hono on Cloudflare Workers.</p>",
      text: readString(text) || undefined,
    },
  });

  if (error) {
    return c.json({ ok: false, error }, 502);
  }

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

export default app;

Samva sends from the verified sender configured on your account, so the payload has no from field.

Worker config

Use a module Worker entrypoint and omit Node compatibility flags:

{
  "$schema": "node_modules/wrangler/config-schema.json",
  "name": "hono-cloudflare-workers-samva",
  "main": "src/index.ts",
  "compatibility_date": "2026-06-29"
}

Hono's export default app maps to the Worker fetch(request, env, ctx) entry, and Hono exposes Worker bindings through c.env.

waitUntil

Await samva.messages.send(...) for user-facing sends where the caller should see API errors. Use c.executionCtx.waitUntil(...) only for background work where returning immediately matters more than surfacing the send result.

c.executionCtx.waitUntil(
  samva.messages.send({
    to: [{ email: "ada@example.com" }],
    channel: "email",
    email: {
      subject: "Background notification",
      html: "<p>This send runs after the response.</p>",
    },
  }),
);

React Email

React Email's render package has a Workers-compatible edge build. Render to HTML, derive text, then pass both strings to Samva:

bun add @react-email/render react react-dom
import { render, toPlainText } from "@react-email/render";

const html = await render(<WelcomeEmail name="Ada" />);

await samva.messages.send({
  to: [{ email: "ada@example.com" }],
  channel: "email",
  email: {
    subject: "Welcome",
    html,
    text: toPlainText(html),
  },
});

For deeper templating, see the React Email integration.

Webhook receiver shape

If the Worker receives Samva webhooks, read the raw body before parsing JSON. Signature verification belongs in the samva/webhooks SDK subpath.

Do not deploy this receiver until it verifies the Samva webhook signature. The stub below only shows the raw-body shape that verification needs.

app.post("/webhooks/samva", async (c) => {
  const payload = await c.req.text();
  const signature = c.req.header("x-webhook-signature");

  // TODO(wave 3): verify payload and signature with samva/webhooks.
  void payload;
  void signature;

  return c.body(null, 204);
});

Cookbook and example

The cookbook and example links resolve after the companion samva-integrations PR lands.

On this page