Send Transactional Email from Cloudflare Workers (2026) via HTTP
Call Rovela POST /emails from a Cloudflare Worker with fetch: wrangler secrets for a sending_access ROVELA_API_KEY, Idempotency-Key, verified domain—OTP, magic links, receipts. Not Cloudflare Email Workers.
Cloudflare Workers + Rovela (not Cloudflare Email Workers)
This guide is for sending transactional mail—OTP codes, magic login links, receipts, password resets—from a Cloudflare Worker by calling Rovela’s HTTP API with fetch.
It is not about Cloudflare Email Workers, Email Routing, or any Cloudflare-native “send mail” product. Those are separate Cloudflare features for receiving or routing mail on your zone. Here the Worker is just another HTTPS client: it holds a sending_access key in Worker secrets, posts JSON to https://api.rovela.dev/emails, and lets Rovela queue, DKIM-sign, and deliver.
If you want agent-oriented scaffolding (Cursor / Claude writing the same curl), see AI agents send email over HTTP. Framework OTP / magic-link walkthroughs live elsewhere (Next.js, Express OTP, Go OTP, …). This post stays on the Worker edge: secrets, fetch, idempotency, and verified domains.
What you own vs what Rovela sends
Your Worker (or the origin it fronts) owns:
- Minting OTP codes / magic-link tokens / receipt payloads
- Storing hashes, expiry, and redeem logic (Workers KV, D1, Durable Objects, or an origin DB)
- Rate limits and abuse controls at the edge
- 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 Workers: it can queue mail and nothing else (Authentication). - A Worker project (
wrangler) where secrets never land in source or public bindings.
Never put the key in client-side code, a public repo, or Worker vars that ship in the script. Use encrypted secrets.
Store the key with Wrangler secrets
# In your Worker project directory
npx wrangler secret put ROVELA_API_KEY
# paste the re_… sending_access key when prompted
# Optional: from address as a plain var (not secret) if it is not sensitive
npx wrangler vars put ROVELA_FROM
# e.g. Acme <security@mail.example.com>
In wrangler.toml / wrangler.jsonc, declare the binding name only—not the value:
name = "acme-mail"
main = "src/index.ts"
compatibility_date = "2026-11-01"
[vars]
ROVELA_FROM = "Acme <security@mail.example.com>"
# ROVELA_API_KEY comes from `wrangler secret put` — do not commit it
Type the env in TypeScript:
export interface Env {
ROVELA_API_KEY: string;
ROVELA_FROM: string;
}
Minimal Worker: fetch → POST /emails
// src/index.ts
export interface Env {
ROVELA_API_KEY: string;
ROVELA_FROM: string;
}
type SendBody = {
to: string;
subject: string;
html: string;
text: string;
idempotencyKey: string;
};
async function sendTransactional(env: Env, msg: SendBody) {
const res = await fetch("https://api.rovela.dev/emails", {
method: "POST",
headers: {
Authorization: `Bearer ${env.ROVELA_API_KEY}`,
"Content-Type": "application/json",
Accept: "application/json",
"Idempotency-Key": msg.idempotencyKey.slice(0, 256),
},
body: JSON.stringify({
from: env.ROVELA_FROM,
to: msg.to,
subject: msg.subject,
html: msg.html,
text: msg.text,
}),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
// Do not log the API key. Logging status + message id fields is fine.
throw new Error(`rovela_send_failed:${res.status}`);
}
return data as { object: string; id: string };
}
export default {
async fetch(request: Request, env: Env): Promise<Response> {
if (request.method !== "POST") {
return new Response("method_not_allowed", { status: 405 });
}
// Example: edge endpoint that your app calls after minting an OTP
const body = (await request.json()) as {
to?: string;
code?: string;
sendId?: string;
};
const to = (body.to || "").trim().toLowerCase();
const code = (body.code || "").trim();
const sendId = (body.sendId || "").trim();
if (!to || !/^\d{6}$/.test(code) || !sendId) {
return Response.json({ error: "invalid_request" }, { status: 400 });
}
// Stable per logical send — reuse on Worker/platform retries
const idempotencyKey = `otp-${sendId}`;
try {
const queued = await sendTransactional(env, {
to,
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.`,
idempotencyKey,
});
return Response.json({ ok: true, emailId: queued.id });
} catch {
return Response.json({ error: "send_failed" }, { status: 502 });
}
},
};
Plain JavaScript is the same shape—fetch, bearer header, JSON body. There is no Rovela Cloudflare SDK to install; Workers already speak HTTPS.
Smoke-test with curl against your Worker URL only after secrets are set. Prefer hitting Rovela directly once for DNS/key checks:
curl -X POST https://api.rovela.dev/emails \
-H "Authorization: Bearer $ROVELA_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: worker-smoke-2026-11-05" \
-d '{
"from": "Acme <security@mail.example.com>",
"to": "you@example.com",
"subject": "Worker smoke test",
"html": "<p>It worked.</p>",
"text": "It worked."
}'
Idempotency-Key: edge retries without double mail
Cloudflare, your client, and your own waitUntil / queue consumers all retry. Without Idempotency-Key, a timeout can deliver two OTP codes that disagree—or two magic links.
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-{hashPrefix},magic-{tokenId},receipt-{orderId}) - A deliberate resend that mints a new code or token must use a new key
headers: {
Authorization: `Bearer ${env.ROVELA_API_KEY}`,
"Content-Type": "application/json",
"Idempotency-Key": `otp-${codeHash.slice(0, 32)}`,
}
OTP and magic-link notes at the edge
Rovela does not generate OTP codes or login tokens. Follow Email verification codes: mint with a CSPRNG, store an HMAC (never raw digits), expiry, attempt limits, then send both html and text.
| Flow | Edge concern | Body |
|---|---|---|
| OTP | Short secret → HMAC + pepper in KV/D1/origin | Digits in subject + html/text |
| Magic link | Opaque token; prefer click-tracking off on the security domain | Absolute HTTPS URL in html and text |
| Receipt | Idempotency on orderId / invoice id | Order summary; optional attachment later |
Product choice between digits and URLs: Email verification: code vs link. Spam checklist for auth mail: Why OTP and magic-link emails go to spam.
Keep redeem logic off the public internet when you can: Worker validates a signed internal request from your origin, or the Worker only sends after the origin already wrote the hash.
Rate limits and 429
Rovela may return 429 when send rate limits are exceeded (API errors). On the Worker:
- Back off and retry with the same
Idempotency-Keyif you are unsure the first attempt was accepted - Apply your own per-IP / per-email gates before calling Rovela so abuse burns your quota, not just edge CPU
- Each recipient counts toward quota;
cc/bcccount too—transactional auth mail should usually be a singleto
Do not busy-loop on 429 from a Cron Trigger or queue consumer.
Verified domain, least privilege, logging
- 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.
- Never
console.logthe bearer token. Loggingemail id, status codes, and idempotency key prefixes is enough. - 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.
Workers vs Email Routing (clarity check)
| This article (Worker → Rovela HTTP) | Cloudflare Email Routing / Email Workers | |
|---|---|---|
| Direction | Outbound transactional send | Mostly inbound routing / processing |
| Transport | fetch to api.rovela.dev | Cloudflare mail pipeline on your zone |
| Auth | Rovela sending_access bearer | Cloudflare zone / Email Workers APIs |
| DKIM for your product mail | Rovela on your verified domain | Not a substitute for Rovela’s send path |
You can use Email Routing for inbound and Rovela for outbound in the same product. Do not confuse the two when searching docs or Stack Overflow.
Checklist before you ship
- Domain verified;
frommatches it. ROVELA_API_KEYviawrangler secret put(sending_accessonly).- Worker uses
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: this is HTTP to Rovela, not Cloudflare Email Workers “sending.”
Related reading
- Docs: Quick Start · Authentication · POST /emails · Email verification codes.
- Agents (same HTTP, different audience): AI agents send email over HTTP.
- Auth UX: Email verification: code vs link · OTP / magic-link spam.
- Framework siblings: Next.js magic link · Express OTP · Spring Boot OTP · Go OTP.
Bottom line: put a sending_access key in Worker secrets, fetch POST /emails with an Idempotency-Key, and keep OTP/magic-link state in your own store—Cloudflare Workers are the runtime; Rovela is the mail pipe. This is not Cloudflare Email Workers.