← Back to blog

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.

Updated November 4, 2026
Send Transactional Email from Cloudflare Workers (2026) via HTTP

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-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 Workers: it can queue mail and nothing else (Authentication).
  3. 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.

FlowEdge concernBody
OTPShort secret → HMAC + pepper in KV/D1/originDigits in subject + html/text
Magic linkOpaque token; prefer click-tracking off on the security domainAbsolute HTTPS URL in html and text
ReceiptIdempotency on orderId / invoice idOrder 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-Key if 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 / bcc count too—transactional auth mail should usually be a single to

Do not busy-loop on 429 from a Cron Trigger or queue consumer.

Verified domain, least privilege, logging

  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. Never console.log the bearer token. Logging email id, status codes, and idempotency key prefixes is enough.
  5. 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.

Workers vs Email Routing (clarity check)

This article (Worker → Rovela HTTP)Cloudflare Email Routing / Email Workers
DirectionOutbound transactional sendMostly inbound routing / processing
Transportfetch to api.rovela.devCloudflare mail pipeline on your zone
AuthRovela sending_access bearerCloudflare zone / Email Workers APIs
DKIM for your product mailRovela on your verified domainNot 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

  1. Domain verified; from matches it.
  2. ROVELA_API_KEY via wrangler secret put (sending_access only).
  3. Worker uses 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: this is HTTP to Rovela, not Cloudflare Email Workers “sending.”

Related reading

  1. Docs: Quick Start · Authentication · POST /emails · Email verification codes.
  2. Agents (same HTTP, different audience): AI agents send email over HTTP.
  3. Auth UX: Email verification: code vs link · OTP / magic-link spam.
  4. 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.