← Back to blog

Send OTP Emails with Deno (2026) via a Plain HTTP API

Send signup/login OTP from Deno with Deno.env, Web Crypto, and fetch POST /emails plus Idempotency-Key—Rovela delivers only; distinct from Bun transactional and Hono/Express OTP guides.

Updated November 4, 2026
Send OTP Emails with Deno (2026) via a Plain HTTP API

Why Deno for email OTP

Deno already ships TypeScript, a secure-by-default permission model, Web Crypto, and a standards fetch. You do not need a Rovela Deno SDK, an “OTP product” endpoint, or a mail binding that pretends the email API is an identity provider.

What you need is the recipe in Email verification codes: mint a six-digit code with Web Crypto (crypto.getRandomValues), store an HMAC of it (never the digits), set expiry and attempt limits, then fetch POST /emails with both html and text and a stable Idempotency-Key. Rovela queues, DKIM-signs, retries, and records events. It does not generate codes, check them, or create sessions.

Rovela does not provide an OTP feature. Your Deno process owns cryptography, the store entry, resend throttling, redeem logic, and the session cookie / JWT. Rovela only delivers the mail.

This article is Deno-native (Deno.env, Deno.serve, Web Crypto, no Node process.env or Bun-only APIs). It is not Send Transactional Email with Bun (general transactional send on Bun), not Send OTP Emails with Hono (Hono framework + edge bindings), and not Express OTP (node:crypto + Express routes). Sibling OTP posts: Go, Django, Laravel, Spring Boot. Product choice between digits and URLs: Email verification: code vs link. Language-agnostic OTP: Sending OTP codes by email.

What you own vs what Rovela sends

Your Deno app owns:

  • crypto.getRandomValues → six digits, zero-padded (never Math.random())
  • HMAC-SHA256 of email + ":" + code with a server pepper (CODE_PEPPER) via Web Crypto subtle
  • Expiry (docs example: ~10 minutes), attempt counter, and “one send per minute” resend gate
  • Upsert by normalized email so a new send invalidates the previous code (SQLite/Deno.openKv/Postgres/Redis, or a process-local Map for a single Deno process)
  • Constant-time compare on redeem; delete / invalidate on success
  • Identical public responses whether or not you want to reveal account existence (product choice)
  • Rate limits on /auth/otp/send and /auth/otp/verify (your middleware or reverse proxy)

Rovela owns:

  • POST https://api.rovela.dev/emails with Authorization: Bearer + a sending_access key (ROVELA_API_KEY via Deno.env—never commit it, never put it in client bundles)
  • Queueing, signing, retries, bounce/complaint handling

A 200 from the send endpoint means accepted and queued, not “in the inbox.”

Prerequisites

  1. Verified sending domain in the Rovela dashboard. Sends from an unverified from domain are refused with 400 (Quick Start).
  2. An API key that looks like re_…. Prefer sending_access for production OTP senders (Authentication).
  3. Deno 2.x (or recent 1.x) on the machine that will send—never the browser.

Env with Deno.env (permissions matter)

Deno does not silently read every env var. Grant --allow-env (or name the keys) and keep secrets out of git:

# .env (do not commit) — load with --env-file or your orchestrator
ROVELA_API_KEY=re_your_sending_access_key
CODE_PEPPER=long_random_server_secret
OTP_FROM="Acme <security@mail.example.com>"
deno run --allow-net=api.rovela.dev --allow-env=ROVELA_API_KEY,CODE_PEPPER,OTP_FROM \
  --env-file=.env main.ts

Read them in TypeScript:

// env.ts
export function requireEnv(name: string): string {
  const v = Deno.env.get(name);
  if (!v) throw new Error(`missing_env:${name}`);
  return v;
}

export const ROVELA_API_KEY = () => requireEnv("ROVELA_API_KEY");
export const CODE_PEPPER = () => requireEnv("CODE_PEPPER");
export const OTP_FROM = () =>
  Deno.env.get("OTP_FROM") ?? "Acme <security@mail.example.com>";

Unlike Bun’s auto-loaded .env or Node’s process.env, Deno makes the allow-list explicit—that is a feature for OTP services that should not inherit every shell secret.

Tiny Rovela client (fetch + Idempotency-Key)

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

  if (!res.ok) {
    // Do not log the API key.
    const errText = await res.text();
    throw new Error(`Rovela send failed: ${res.status} ${errText}`);
  }
  return (await res.json()) as { object: string; id: string };
}

Auth: Authentication · wire format: API: emails · first send: Quick Start. A sending_access key is enough—and preferred—for production OTP senders.

Web Crypto: getRandomValues + HMAC

Six digits are only a million possibilities. A leaked table of bare SHA-256 hashes is reversed in well under a second. Follow the docs: HMAC with a pepper that never leaves the server. Deno exposes the same Web Crypto as browsers and Workers—no node:crypto import required.

// otp/crypto.ts
const textEncoder = new TextEncoder();

export function normalizeEmail(raw: string) {
  return raw.trim().toLowerCase();
}

/** Six digits from CSPRNG — never Math.random() */
export function mintCode(): string {
  const buf = new Uint32Array(1);
  crypto.getRandomValues(buf);
  const n = buf[0]! % 1_000_000;
  return n.toString().padStart(6, "0");
}

async function importPepper(pepper: string) {
  return crypto.subtle.importKey(
    "raw",
    textEncoder.encode(pepper),
    { name: "HMAC", hash: "SHA-256" },
    false,
    ["sign"],
  );
}

function toHex(buf: ArrayBuffer) {
  return [...new Uint8Array(buf)]
    .map((b) => b.toString(16).padStart(2, "0"))
    .join("");
}

export async function hashCode(pepper: string, email: string, code: string) {
  const key = await importPepper(pepper);
  const mac = await crypto.subtle.sign(
    "HMAC",
    key,
    textEncoder.encode(`${email}:${code}`),
  );
  return toHex(mac);
}

/** Constant-time hex compare (equal length assumed after hash). */
export function safeEqualHex(a: string, b: string) {
  if (a.length !== b.length) return false;
  let diff = 0;
  for (let i = 0; i < a.length; i++) {
    diff |= a.charCodeAt(i)! ^ b.charCodeAt(i)!;
  }
  return diff === 0;
}

Store: Deno KV or a process-local Map

Suggested JSON value (one active code per email), matching the verification-code example:

// otp/store.ts
export type CodeRecord = {
  codeHash: string;
  expiresAt: number; // epoch ms
  attempts: number;
  sentAt: number;
};

export interface OtpStore {
  get(email: string): Promise<CodeRecord | null>;
  put(email: string, record: CodeRecord, ttlSeconds: number): Promise<void>;
  del(email: string): Promise<void>;
}

/** Single Deno process / local smoke tests */
export class MemoryOtpStore implements OtpStore {
  #map = new Map<string, CodeRecord>();
  async get(email: string) {
    return this.#map.get(email) ?? null;
  }
  async put(email: string, record: CodeRecord, _ttl: number) {
    this.#map.set(email, record);
  }
  async del(email: string) {
    this.#map.delete(email);
  }
}

/** Deno.openKv — key = ["otp", normalizedEmail] */
export class DenoKvOtpStore implements OtpStore {
  constructor(private kv: Deno.Kv) {}
  async get(email: string) {
    const res = await this.kv.get<CodeRecord>(["otp", email]);
    return res.value;
  }
  async put(email: string, record: CodeRecord, ttlSeconds: number) {
    await this.kv.set(["otp", email], record, {
      expireIn: Math.max(60, ttlSeconds) * 1000,
    });
  }
  async del(email: string) {
    await this.kv.delete(["otp", email]);
  }
}

For multi-instance Deploy / containers, prefer Deno KV, Postgres, or Redis. A process-local Map is fine for one deno run process and demos—do not pretend it is shared across replicas.

Service: send + verify

// otp/service.ts
import { sendEmail } from "../rovela.ts";
import {
  hashCode,
  mintCode,
  normalizeEmail,
  safeEqualHex,
} from "./crypto.ts";
import type { OtpStore } from "./store.ts";

const SAFE_SEND_MSG =
  "If that address can receive mail, a code is on its way.";

const TTL_MINUTES = 10;
const RESEND_COOLDOWN_MS = 60_000;
const MAX_ATTEMPTS = 5;

export async function sendOtp(opts: {
  store: OtpStore;
  apiKey: string;
  pepper: string;
  from: string;
  rawEmail: string;
}) {
  const email = normalizeEmail(opts.rawEmail);
  const existing = await opts.store.get(email);
  if (existing && Date.now() - existing.sentAt < RESEND_COOLDOWN_MS) {
    return { ok: false as const, error: "too_soon" };
  }

  const code = mintCode();
  const codeHash = await hashCode(opts.pepper, email, code);
  const now = Date.now();
  const expiresAt = now + TTL_MINUTES * 60_000;

  await opts.store.put(
    email,
    { codeHash, expiresAt, attempts: 0, sentAt: now },
    TTL_MINUTES * 60 + 60,
  );

  // Stable per logical send: hash prefix. New code ⇒ new key.
  const idempotencyKey = `otp-${codeHash.slice(0, 32)}`;

  await sendEmail({
    apiKey: opts.apiKey,
    from: opts.from,
    to: email,
    subject: `Your Acme code: ${code}`,
    html: `<p>Your verification code is</p>
<p style="font-size:28px;font-weight:600;letter-spacing:4px">${code}</p>
<p>It expires in ${TTL_MINUTES} minutes. If you did not ask for it, ignore this email.</p>`,
    text: `Your verification code is ${code}. It expires in ${TTL_MINUTES} minutes. If you did not ask for it, ignore this email.`,
    idempotencyKey,
  });

  return { ok: true as const, message: SAFE_SEND_MSG };
}

export async function verifyOtp(opts: {
  store: OtpStore;
  pepper: string;
  rawEmail: string;
  rawCode: string;
}) {
  const email = normalizeEmail(opts.rawEmail);
  const code = opts.rawCode.trim();
  if (!/^\d{6}$/.test(code)) {
    return { ok: false as const, error: "invalid" };
  }

  const row = await opts.store.get(email);
  if (!row) return { ok: false as const, error: "invalid" };

  if (row.expiresAt < Date.now()) {
    await opts.store.del(email);
    return { ok: false as const, error: "expired" };
  }

  if (row.attempts >= MAX_ATTEMPTS) {
    return { ok: false as const, error: "locked" };
  }

  const candidate = await hashCode(opts.pepper, email, code);
  const match = safeEqualHex(candidate, row.codeHash);

  if (!match) {
    await opts.store.put(
      email,
      { ...row, attempts: row.attempts + 1 },
      Math.max(60, Math.ceil((row.expiresAt - Date.now()) / 1000)),
    );
    return { ok: false as const, error: "invalid" };
  }

  await opts.store.del(email);
  // issue session / JWT here
  return { ok: true as const };
}

Putting the code in the subject helps lock-screen previews—fine for many products, skip it on highly shared screens (verification-code docs). Always send both html and text with the digits in the text part (POST /emails).

Deno.serve routes

// main.ts
import { CODE_PEPPER, OTP_FROM, ROVELA_API_KEY } from "./env.ts";
import { sendOtp, verifyOtp } from "./otp/service.ts";
import { DenoKvOtpStore, MemoryOtpStore } from "./otp/store.ts";

const useKv = Deno.env.get("OTP_USE_KV") === "1";
const store = useKv
  ? new DenoKvOtpStore(await Deno.openKv())
  : new MemoryOtpStore();

Deno.serve({ port: Number(Deno.env.get("PORT") ?? 8000) }, async (req) => {
  const url = new URL(req.url);

  if (req.method === "POST" && url.pathname === "/auth/otp/send") {
    const body = await req.json().catch(() => ({})) as { email?: string };
    const email = String(body.email ?? "");
    if (!email.includes("@")) {
      return Response.json({ error: "invalid_email" }, { status: 400 });
    }

    try {
      const result = await sendOtp({
        store,
        apiKey: ROVELA_API_KEY(),
        pepper: CODE_PEPPER(),
        from: OTP_FROM(),
        rawEmail: email,
      });
      if (!result.ok && result.error === "too_soon") {
        return Response.json({ error: "too_soon" }, { status: 429 });
      }
      return Response.json({ message: result.message ?? "ok" });
    } catch {
      return Response.json({ error: "send_failed" }, { status: 502 });
    }
  }

  if (req.method === "POST" && url.pathname === "/auth/otp/verify") {
    const body = await req.json().catch(() => ({})) as {
      email?: string;
      code?: string;
    };
    const result = await verifyOtp({
      store,
      pepper: CODE_PEPPER(),
      rawEmail: String(body.email ?? ""),
      rawCode: String(body.code ?? ""),
    });
    if (!result.ok) {
      const status =
        result.error === "locked" || result.error === "too_soon" ? 429 : 400;
      return Response.json({ error: result.error }, { status });
    }
    return Response.json({ ok: true });
  }

  return new Response("not_found", { status: 404 });
});

Run with network + env (and --unstable-kv / Deploy KV when you use Deno.openKv):

deno run --allow-net=api.rovela.dev,0.0.0.0:8000 \
  --allow-env=ROVELA_API_KEY,CODE_PEPPER,OTP_FROM,OTP_USE_KV,PORT \
  --env-file=.env main.ts

The Rovela key never leaves the Deno process. Prefer a verified auth subdomain for from (security@mail.…).

Idempotency-Key, html + text, deliverability

  1. Derive Idempotency-Key from the code hash (otp- + prefix) so a timeout retry does not enqueue a second message with a different body. A deliberate resend mints a new code and therefore a new key. Rovela remembers keys for 24 hours (emails API).
  2. Always send both html and text; put the six digits in the text part.
  3. Use a subdomain with reputation separated from marketing mail (verification-code docs).
  4. If codes land in spam, follow Why OTP and Magic-Link Emails Go to Spam.

Rate limit / single-use / expiry checklist

  1. Issue throttle — one new code per email per minute; also cap by IP at the proxy.
  2. Short TTL — ~10 minutes (docs example); shorter is fine for step-up.
  3. Hash at rest — HMAC-SHA256 of email:code + pepper; never store digits.
  4. Upsert — new send invalidates the previous unused hash for that email.
  5. Attempt cap — lock after ~5 failures; delete on success.
  6. Enumeration-safe send — same status/body whether or not you reveal accounts.
  7. Idempotency-Key — stable per logical send; new code → new key.
  8. Shared store — Deno KV / Redis / SQL for multi-instance; memory map only for one process.

curl equivalent (agents / smoke tests)

Same wire format your Deno handler posts—useful for local smoke tests and AI agents that call HTTP directly (AI agent send email):

curl -X POST https://api.rovela.dev/emails \
  -H "Authorization: Bearer re_your_sending_key" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: otp-smoke-deno-2026-11-05" \
  -d '{
    "from": "Acme <security@mail.acme.com>",
    "to": "alice@example.com",
    "subject": "Your Acme code: 482913",
    "html": "<p>Your verification code is</p><p style=\"font-size:28px;font-weight:600;letter-spacing:4px\">482913</p><p>It expires in 10 minutes.</p>",
    "text": "Your verification code is 482913. It expires in 10 minutes."
  }'

Production keys need sending_access; from must be on a verified domain (Quick Start).

Deno vs Bun vs Hono vs Express (clarity check)

This article (Deno OTP)Bun transactionalHono OTPExpress OTP
RuntimeDeno process / Deno.serveBun / Bun.serveHono on edge or Node/BunNode + Express
SecretsDeno.env + --allow-envprocess.env / .envc.env / wrangler secretsdotenv / platform secrets
CryptoWeb CryptoOften node:cryptoWeb Cryptonode:crypto
Store exampleDeno KV / memory MapYour DB of choiceWorkers KV / MapSQL / ORM
FocusSignup/login OTP on DenoAny transactional send on BunOTP routes in HonoOTP routes in Express

All four call the same POST /emails. Do not treat the Bun transactional post as a substitute for hashed OTP storage, and do not wrap Rovela in a fake “Deno SDK” when fetch is enough.

OTP vs magic link in the same Deno app

OTP (six digits)Magic link
Docs shapeVerification codesSame as password reset
Deno placement/auth/otp/send + /auth/otp/verifySeparate magic routes
UXType digitsOne click (+ Continue)
StorageHMAC of email:code + pepperSHA-256 of high-entropy token
Typical TTL~10 minutes~30 minutes
Good forShared screens, step-up, mobileDesktop/mobile browser sign-in

Both call the same POST /emails. Keep separate stores—do not reuse an OTP record as a magic-link token. Product choice: code vs link. For framework-shaped magic links see Next.js or Remix—the Deno piece is still just fetch + Deno.env.

Deploy checklist

  1. Domain verified; SPF/DKIM/DMARC green for the auth subdomain.
  2. ROVELA_API_KEY with sending_access only via Deno.env—never in public client config.
  3. CODE_PEPPER long and random; never committed.
  4. OTP_FROM uses the verified subdomain; html + text both present; digits in text (and optionally subject).
  5. Idempotency-Key on every logical send; store upsert + attempt cap + delete on success.
  6. Multi-instance: Deno KV / Redis / SQL—not a naive global Map.
  7. Grant least --allow-net / --allow-env (and KV permissions when used).
  8. Treat send 200 as queued; use webhooks for bounces/complaints if you need delivery truth (Webhooks).

What this post is not

  • Not a full production auth framework (OAuth2, MFA, passkeys).
  • Not a duplicate of the Bun transactional, Hono OTP, or Express OTP posts.
  • Not a claim that Rovela stores, validates, or expires your codes.
  • Not China-domestic ESP comparison or invented product features.

Next steps

  1. Implement mint + HMAC + store from the verification-code example.
  2. Send with POST /emails · Quick Start · Authentication.
  3. Harden deliverability: OTP / magic-link spam.
  4. Compare UX primitives: code vs link.
  5. Sibling stacks: Bun transactional · Hono OTP · Express OTP · Cloudflare Workers transactional.
  6. Limits: Pricing.

Bottom line: put OTP in Deno with Deno.env, Web Crypto, and Deno.serve, HMAC codes into Deno KV or your DB, and fetch one POST /emails to Rovela with an Idempotency-Key—keep secrets on the server, and never pretend the email API is an identity provider.