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.
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-Keyper logical send
Rovela owns:
POST https://api.rovela.dev/emailswithAuthorization: Bearer+ asending_accesskey- Queueing, signing, retries, bounce/complaint handling
A 200 means accepted and queued, not “already in the inbox” (Send Email API, Quick Start).
Prerequisites
- Verified sending domain in the Rovela dashboard. Sends from an unverified
fromdomain are refused with400—they are not queued and silently dropped (Quick Start). - An API key that looks like
re_…. Prefersending_accessfor production: it can queue mail and nothing else (Authentication). - 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 OTP | Workers → Rovela | |
|---|---|---|---|
| Runtime | Bun process / Bun.serve | Node + Express | Cloudflare Worker isolate |
| HTTP client | Native fetch | fetch or undici | Worker fetch |
| Secrets | .env / process env | dotenv / platform secrets | wrangler secret |
| Rovela SDK? | No | No | No |
| Same API | POST /emails + Idempotency-Key | Same | Same |
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-Keyif 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/bcccount 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
- Send only from addresses on a domain you verified (Quick Start).
- Use a
sending_accesskey inROVELA_API_KEY; reservefull_accessfor admin tooling that reads events (Authentication). - Optionally restrict a key to one domain if you multi-tenant send.
- Prefer a security subdomain such as
security@mail.example.comfor OTP / magic links.
Plans and quotas: Pricing—check that page rather than inventing limits here.
Checklist before you ship
- Domain verified;
frommatches it. ROVELA_API_KEYissending_accessonly, loaded from env—not the browser.- Bun uses native
fetch+ JSONhtmlandtextfor auth mail. - Stable
Idempotency-Keyper logical send; new code/token → new key. - OTP/token cryptography lives in your store—not in Rovela.
- Handle non-2xx and
429without logging secrets. - Remember: no Bun-specific Rovela SDK—HTTP is the integration.
Related reading
- Docs: Quick Start · Authentication · POST /emails · Email verification codes.
- Same HTTP, other runtimes: Cloudflare Workers transactional email · Express OTP · Hono OTP.
- Auth UX: Email verification: code vs link · OTP / magic-link spam.
- 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.