← Back to blog

Send Transactional Email with Bun (2026) via HTTP

Send OTP, magic links, and receipts from Bun with native fetch to Rovela POST /emails: ROVELA_API_KEY env, Idempotency-Key, no Bun-specific SDK—distinct from Express and Cloudflare Workers guides.

Updated November 4, 2026
Send Transactional Email with Bun (2026) via HTTP

Bun + Rovela: native fetch, no SDK

Bun already speaks HTTPS. This guide shows how to send transactional mail—OTP codes, magic login links, receipts, password resets—from a Bun process by calling Rovela’s HTTP API with the built-in fetch. You keep codes and tokens in your own store; Rovela queues, DKIM-signs, and delivers.

There is no Bun-specific Rovela SDK to invent or install. Prefer a sending_access key in ROVELA_API_KEY, POST https://api.rovela.dev/emails, and a stable Idempotency-Key per logical send.

This is not the Express OTP walkthrough (Node + Express routes) and not the Cloudflare Workers edge guide. Here the runtime is Bun on a server, CLI, or container—same HTTP contract, different process model and env loading.

What you own vs what Rovela sends

Your Bun app owns:

  • Minting OTP codes / magic-link tokens / receipt payloads
  • Storing hashes, expiry, and redeem logic (SQLite, Postgres, Redis, …)
  • Rate limits and abuse controls
  • Choosing a stable Idempotency-Key per logical send

Rovela owns:

  • POST https://api.rovela.dev/emails with Authorization: Bearer + a sending_access key
  • Queueing, signing, retries, bounce/complaint handling

A 200 means accepted and queued, not “already in the inbox” (Send Email API, Quick Start).

Prerequisites

  1. Verified sending domain in the Rovela dashboard. Sends from an unverified from domain are refused with 400—they are not queued and silently dropped (Quick Start).
  2. An API key that looks like re_…. Prefer sending_access for production: it can queue mail and nothing else (Authentication).
  3. Bun 1.x on the machine that will send (never the browser).

Never put the key in client-side code, a public repo, or a Docker image layer that ships as plaintext.

Env: ROVELA_API_KEY (and a from address)

Bun loads .env automatically in many setups. Keep secrets out of git:

# .env (do not commit)
ROVELA_API_KEY=re_your_sending_access_key
ROVELA_FROM="Acme <security@mail.example.com>"

Or export in the process supervisor / container secrets:

export ROVELA_API_KEY=re_your_sending_access_key
export ROVELA_FROM='Acme <security@mail.example.com>'

Read them in TypeScript:

const apiKey = process.env.ROVELA_API_KEY;
const from = process.env.ROVELA_FROM;
if (!apiKey || !from) {
  throw new Error("missing_rovela_env");
}

Minimal send helper with Bun fetch

// src/rovela.ts
export type SendTransactionalInput = {
  to: string;
  subject: string;
  html: string;
  text: string;
  idempotencyKey: string;
};

export async function sendTransactional(msg: SendTransactionalInput) {
  const apiKey = process.env.ROVELA_API_KEY;
  const from = process.env.ROVELA_FROM;
  if (!apiKey || !from) throw new Error("missing_rovela_env");

  const res = await fetch("https://api.rovela.dev/emails", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${apiKey}`,
      "Content-Type": "application/json",
      Accept: "application/json",
      "Idempotency-Key": msg.idempotencyKey.slice(0, 256),
    },
    body: JSON.stringify({
      from,
      to: msg.to,
      subject: msg.subject,
      html: msg.html,
      text: msg.text,
    }),
  });

  const data = (await res.json().catch(() => ({}))) as {
    object?: string;
    id?: string;
    message?: string;
  };

  if (!res.ok) {
    // Do not log the API key. Status + optional message id fields are enough.
    throw new Error(`rovela_send_failed:${res.status}`);
  }

  return data as { object: string; id: string };
}

Smoke-test once against Rovela (after DNS + key are ready):

curl -X POST https://api.rovela.dev/emails \
  -H "Authorization: Bearer $ROVELA_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: bun-smoke-2026-11-05" \
  -d '{
    "from": "Acme <security@mail.example.com>",
    "to": "you@example.com",
    "subject": "Bun smoke test",
    "html": "<p>It worked.</p>",
    "text": "It worked."
  }'

Success looks like {"object":"email","id":"…"}.

Example A — six-digit OTP

Rovela does not generate the code. Follow Email verification codes: CSPRNG digits, store an HMAC (never raw), expiry, attempt limits, then send both html and text.

// src/otp.ts
import { createHmac, randomInt, timingSafeEqual } from "crypto";
import { sendTransactional } from "./rovela";

const PEPPER = process.env.OTP_PEPPER!;
const TTL_MS = 10 * 60 * 1000;

function hashCode(email: string, code: string) {
  return createHmac("sha256", PEPPER).update(`${email}:${code}`).digest("hex");
}

export async function issueOtp(email: string, sendId: string) {
  const normalized = email.trim().toLowerCase();
  const code = String(randomInt(0, 1_000_000)).padStart(6, "0");
  const codeHash = hashCode(normalized, code);
  const expiresAt = Date.now() + TTL_MS;

  // Persist { email, codeHash, expiresAt, attempts: 0 } — never the raw code
  await db.upsertVerification({ email: normalized, codeHash, expiresAt });

  await sendTransactional({
    to: normalized,
    subject: `Your Acme code: ${code}`,
    html: `<p>Your verification code is</p><p style="font-size:28px;font-weight:600;letter-spacing:4px"><b>${code}</b></p><p>It expires in 10 minutes. If you did not ask for it, ignore this email.</p>`,
    text: `Your verification code is ${code}. It expires in 10 minutes. If you did not ask for it, ignore this email.`,
    // Stable for this logical send — retries reuse it
    idempotencyKey: `otp-${sendId}`,
  });
}

Product choice between digits and URLs: Email verification: code vs link. Auth-mail spam checklist: Why OTP and magic-link emails go to spam.

Example B — magic login link

Same HTTP helper; different body. Prefer an absolute HTTPS URL in both html and text, opaque tokens hashed at rest, and a short TTL.

export async function sendMagicLink(email: string, tokenId: string, absoluteUrl: string) {
  const to = email.trim().toLowerCase();
  await sendTransactional({
    to,
    subject: "Sign in to Acme",
    html: `<p><a href="${absoluteUrl}">Sign in to Acme</a></p><p>This link expires in 15 minutes. If you did not ask for it, ignore this email.</p>`,
    text: `Sign in to Acme: ${absoluteUrl}\n\nThis link expires in 15 minutes. If you did not ask for it, ignore this email.`,
    idempotencyKey: `magic-${tokenId}`,
  });
}

For a framework-shaped redeem flow (cookies, SSR), see Next.js magic link or Remix magic link—the Bun piece is still just fetch + env.

Example C — receipt / order confirmation

Use the order id (or invoice id) as the idempotency seed so a flaky payment webhook cannot spam duplicates:

export async function sendReceipt(opts: {
  to: string;
  orderId: string;
  totalLabel: string;
}) {
  await sendTransactional({
    to: opts.to,
    subject: `Your Acme receipt ${opts.orderId}`,
    html: `<p>Thanks for your order <b>${opts.orderId}</b>.</p><p>Total: ${opts.totalLabel}</p>`,
    text: `Thanks for your order ${opts.orderId}. Total: ${opts.totalLabel}`,
    idempotencyKey: `receipt-${opts.orderId}`,
  });
}

Idempotency-Key: retries without double mail

Bun servers, reverse proxies, and your own job runners all retry. Without Idempotency-Key, a timeout can deliver two OTP codes that disagree—or two receipts.

Per the emails API:

  • Header ≤ 256 characters
  • Rovela remembers it for 24 hours and returns the original response on replay
  • Derive the key from something stable for that logical send (otp-{sendId}, magic-{tokenId}, receipt-{orderId})
  • A deliberate resend that mints a new code or token must use a new key

Tiny Bun.serve endpoint (optional)

If you expose an internal send route (behind your own auth), Bun’s server is enough:

// src/server.ts
import { issueOtp } from "./otp";

Bun.serve({
  port: Number(process.env.PORT || 3000),
  async fetch(req) {
    const url = new URL(req.url);
    if (req.method === "POST" && url.pathname === "/internal/otp") {
      // Verify a shared secret / mTLS / private network before trusting the body
      const body = (await req.json()) as { to?: string; sendId?: string };
      const to = (body.to || "").trim().toLowerCase();
      const sendId = (body.sendId || "").trim();
      if (!to || !sendId) {
        return Response.json({ error: "invalid_request" }, { status: 400 });
      }
      try {
        await issueOtp(to, sendId);
        return Response.json({ ok: true });
      } catch {
        return Response.json({ error: "send_failed" }, { status: 502 });
      }
    }
    return new Response("not_found", { status: 404 });
  },
});

Keep this off the public internet when you can. The browser must never hold ROVELA_API_KEY.

Bun vs Node (Express) vs Cloudflare Workers

This article (Bun)Express OTPWorkers → Rovela
RuntimeBun process / Bun.serveNode + ExpressCloudflare Worker isolate
HTTP clientNative fetchfetch or undiciWorker fetch
Secrets.env / process envdotenv / platform secretswrangler secret
Rovela SDK?NoNoNo
Same APIPOST /emails + Idempotency-KeySameSame

Pick the runtime your app already runs on. Do not wrap Rovela in a fake “Bun SDK” when fetch is enough.

Rate limits, 429, and logging

Rovela may return 429 when send rate limits are exceeded (API errors). On Bun:

  • Back off and retry with the same Idempotency-Key if you are unsure the first attempt was accepted
  • Gate per-IP / per-email before calling Rovela so abuse burns your quota, not just CPU
  • Auth mail should usually be a single to (cc / bcc count toward recipient caps)

Never console.log the bearer token. Logging email id, status codes, and idempotency key prefixes is enough.

Verified domain and least privilege

  1. Send only from addresses on a domain you verified (Quick Start).
  2. Use a sending_access key in ROVELA_API_KEY; reserve full_access for admin tooling that reads events (Authentication).
  3. Optionally restrict a key to one domain if you multi-tenant send.
  4. Prefer a security subdomain such as security@mail.example.com for OTP / magic links.

Plans and quotas: Pricing—check that page rather than inventing limits here.

Checklist before you ship

  1. Domain verified; from matches it.
  2. ROVELA_API_KEY is sending_access only, loaded from env—not the browser.
  3. Bun uses native fetch + JSON html and text for auth mail.
  4. Stable Idempotency-Key per logical send; new code/token → new key.
  5. OTP/token cryptography lives in your store—not in Rovela.
  6. Handle non-2xx and 429 without logging secrets.
  7. Remember: no Bun-specific Rovela SDK—HTTP is the integration.

Related reading

  1. Docs: Quick Start · Authentication · POST /emails · Email verification codes.
  2. Same HTTP, other runtimes: Cloudflare Workers transactional email · Express OTP · Hono OTP.
  3. Auth UX: Email verification: code vs link · OTP / magic-link spam.
  4. Agents (same HTTP, different audience): AI agents send email over HTTP.

Bottom line: put a sending_access key in ROVELA_API_KEY, call POST /emails with Bun’s native fetch and an Idempotency-Key, and keep OTP / magic-link / receipt state in your own store—Bun is the runtime; Rovela is the mail pipe. No Bun-specific SDK required.