← Back to blog

Send Transactional Email with Clerk + Rovela (2026)

Custom OTP, welcome, and invite mail from your Clerk backend or webhook via Rovela POST /emails—app owns tokens; Rovela delivers. Not a Clerk replacement; distinct from Supabase and framework OTP guides.

Updated November 4, 2026
Send Transactional Email with Clerk + Rovela (2026)

Clerk handles identity — Rovela delivers the mail

Clerk is an authentication and user-management platform: hosted sign-in UI, sessions, organizations, and a Backend API for users and invitations. Many teams ship with Clerk’s built-in email for verification and password resets, then need custom transactional copy—branded OTP HTML, bilingual welcome notes, invite emails with your product’s deep links, or a dedicated mail. subdomain with its own reputation.

This guide keeps Clerk for identity and sends app-owned transactional mail—OTP codes you mint, welcome messages on user.created, workspace invites—by calling Rovela’s HTTP API from a trusted backend or Clerk webhook handler.

Rovela does not replace Clerk. It does not create users, issue sessions, verify OTP codes, or manage invitations. Your server owns tokens, hashes, rate limits, and redeem logic; Rovela only queues, DKIM-signs, and delivers via POST https://api.rovela.dev/emails (Send Email API, Quick Start).

This is not Supabase + Rovela transactional email (Edge Functions + Postgres). It is not a framework OTP walkthrough (Express, Next.js magic link). The integration surface here is Clerk webhooks / Backend API + your Node (or other) server secrets.

When to keep Clerk email vs route through Rovela

NeedTypical path
Stock verification / reset templates from ClerkClerk’s built-in email is fine to start
Custom HTML, brand layout, code-in-subject, bilingual copyApp-owned send → Rovela
Separate reputation on mail.yourdomain.comVerified Rovela domain + your from
Welcome / product invite that is not a Clerk templateAlways app-owned → Rovela
You still want Clerk sessions and componentsKeep Clerk for auth; swap only the mail transport for custom flows

Common pattern: leave Clerk Auth as the source of truth for users and sessions; on events such as user.created (or from your own API after an invite action), mint any codes/tokens yourself, send with Rovela, and verify in your backend—not in the browser.

What you own vs what Rovela sends

Your app (plus Clerk) owns:

  • Users, sessions, and organizations (Clerk)
  • Minting OTP digits or opaque invite / magic-link tokens with a CSPRNG
  • Storing HMAC hashes, expiry, attempt counters (your DB—never the raw code)
  • Rate limits (“one send per minute per address”)
  • Redeem / verify logic (and any post-verify Clerk Backend API calls you need)
  • Verifying Clerk webhook signatures and deduplicating deliveries
  • A stable Idempotency-Key per logical send

Rovela owns:

  • POST /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” (Quick Start).

Prerequisites

  1. Verified sending domain in the Rovela dashboard. Unverified from domains are refused with 400 (Quick Start). Prefer a subdomain such as mail.example.com for auth mail (Why OTP and magic-link emails go to spam).
  2. An API key that looks like re_…. Prefer sending_access for production senders (Authentication).
  3. A Clerk application with a Backend API key (CLERK_SECRET_KEY) and, for event-driven mail, a webhook endpoint + signing secret (CLERK_WEBHOOK_SIGNING_SECRET).
  4. Never put the Rovela key or Clerk secret in the browser, a React Native client, or a public repo. Only a trusted server (or serverless function) may call Rovela or the Clerk Backend API.

Store secrets in the environment

# Example for a Next.js / Node server — names are conventional
export ROVELA_API_KEY=re_your_sending_access_key
export ROVELA_FROM='Acme <security@mail.example.com>'
export CODE_PEPPER="$(openssl rand -hex 32)"
export CLERK_SECRET_KEY=sk_live_...          # Clerk Backend API
export CLERK_WEBHOOK_SIGNING_SECRET=whsec_... # from Clerk Dashboard → Webhooks

Rotate by updating the env and redeploying; revoke the old Rovela key in the dashboard when done (Authentication). Keep Clerk and Rovela credentials in the same secret store your host already uses (Vercel/Fly/Railway env, etc.)—do not hard-code them.

Pattern A: welcome email on user.created (webhook)

Clerk can POST signed webhook events to your endpoint when a user is created (dashboard, hosted UI, or Backend API). Treat the payload as untrusted until you verify the signature (Clerk documents Svix-signed headers such as svix-id, svix-timestamp, and svix-signature). Delivery is at-least-once: return 2xx after you have accepted the work, and dedupe on svix-id (or a stable welcome-{userId} idempotency key) so retries do not spam the inbox.

Minimal Next.js App Router sketch (app/api/webhooks/clerk/route.ts)—verify with Clerk’s helper or the Svix library as in Clerk’s webhook docs, then send:

import { verifyWebhook } from "@clerk/nextjs/webhooks";
import type { NextRequest } from "next/server";

const ROVELA_URL = "https://api.rovela.dev/emails";

export async function POST(req: NextRequest) {
  let evt;
  try {
    evt = await verifyWebhook(req); // reads CLERK_WEBHOOK_SIGNING_SECRET
  } catch {
    return new Response("bad_signature", { status: 400 });
  }

  if (evt.type === "user.created") {
    const primaryId = evt.data.primary_email_address_id;
    const email = evt.data.email_addresses.find(
      (a) => a.id === primaryId,
    )?.email_address;
    const name = evt.data.first_name || "there";
    const userId = evt.data.id;

    if (email) {
      const apiKey = process.env.ROVELA_API_KEY!;
      const from = process.env.ROVELA_FROM!;
      const res = await fetch(ROVELA_URL, {
        method: "POST",
        headers: {
          Authorization: `Bearer ${apiKey}`,
          "Content-Type": "application/json",
          Accept: "application/json",
          // Stable across Clerk retries for the same user
          "Idempotency-Key": `clerk-welcome-${userId}`.slice(0, 256),
        },
        body: JSON.stringify({
          from,
          to: email,
          subject: "Welcome to Acme",
          html:
            `<p>Hi ${escapeHtml(name)}, your Acme account is ready.</p>` +
            `<p><a href="https://acme.example/dashboard">Open your dashboard</a></p>`,
          text:
            `Hi ${name}, your Acme account is ready. ` +
            `Open https://acme.example/dashboard`,
        }),
      });
      if (!res.ok) {
        const data = await res.json().catch(() => ({}));
        // Do not log API keys. Surface status + provider message only.
        console.error("rovela_send_failed", res.status, data.message);
        // Non-2xx makes Clerk retry — only fail if you want a redelivery
        return new Response("send_failed", { status: 502 });
      }
    }
  }

  return new Response("ok", { status: 200 });
}

function escapeHtml(s: string) {
  return s
    .replace(/&/g, "&amp;")
    .replace(/</g, "&lt;")
    .replace(/>/g, "&gt;")
    .replace(/"/g, "&quot;");
}

Register the URL in Clerk Dashboard → Webhooks, subscribe to user.created, and store the signing secret in env. Prefer returning 200 once the send is accepted (or safely queued in your own job system) so Clerk’s retries stay predictable. For template-driven welcome copy, see Welcome email from a template.

Pattern B: custom OTP from your backend (Clerk session, app-owned code)

When you want a product OTP (step-up confirm, email change, sensitive action) with your own HTML—while the user is already a Clerk user—mint the code on the server, store only a hash, and deliver with Rovela. Clerk still owns the session; your route owns the challenge.

import { auth, clerkClient } from "@clerk/nextjs/server";
import { createHash, randomInt, timingSafeEqual } from "node:crypto";

const ROVELA_URL = "https://api.rovela.dev/emails";

export async function POST(req: Request) {
  const { userId } = await auth();
  if (!userId) return Response.json({ error: "unauthorized" }, { status: 401 });

  const apiKey = process.env.ROVELA_API_KEY!;
  const from = process.env.ROVELA_FROM!;
  const pepper = process.env.CODE_PEPPER!;

  // Resolve primary email via Clerk Backend API — do not trust a client-supplied address
  const user = await (await clerkClient()).users.getUser(userId);
  const primaryId = user.primaryEmailAddressId;
  const email = user.emailAddresses.find((e) => e.id === primaryId)?.emailAddress;
  if (!email) return Response.json({ error: "no_email" }, { status: 400 });

  const body = (await req.json().catch(() => ({}))) as { sendId?: string };
  const sendId = (body.sendId || crypto.randomUUID()).trim();

  // CSPRNG six digits — never Math.random()
  const code = String(randomInt(0, 1_000_000)).padStart(6, "0");
  const codeHash = createHash("sha256")
    .update(`${pepper}:${email}:${code}`)
    .digest("hex");

  // Upsert hash + expiry in YOUR database (omitted). Never store the raw code.

  const res = await fetch(ROVELA_URL, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${apiKey}`,
      "Content-Type": "application/json",
      Accept: "application/json",
      "Idempotency-Key": `clerk-otp-${userId}-${sendId}`.slice(0, 256),
    },
    body: JSON.stringify({
      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"><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.`,
    }),
  });

  const data = await res.json().catch(() => ({}));
  if (!res.ok) {
    return Response.json(
      { error: "send_failed", status: res.status, message: data.message },
      { status: 502 },
    );
  }

  // Identical public shape whether or not you later hide account existence
  return Response.json({ ok: true, emailId: data.id });
}

Verify in a second route with constant-time compare against the stored hash, then delete the row. Postgres shape matches the verification-code example. Product choice between codes and links: Email verification: code vs link.

Pattern C: invite email you compose yourself

Clerk can create organization invitations through its Backend API and may send its own invite mail depending on your Clerk configuration and plan. When you need fully custom invite HTML (deep link into your app, bilingual body, attachment-free transactional layout), create the invitation (or your own invite token) on the server, then send the message with Rovela using an Idempotency-Key such as clerk-invite-{invitationId}. Do not send invites from the client with a Rovela key.

await fetch("https://api.rovela.dev/emails", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.ROVELA_API_KEY}`,
    "Content-Type": "application/json",
    "Idempotency-Key": `clerk-invite-${invitationId}`,
  },
  body: JSON.stringify({
    from: process.env.ROVELA_FROM,
    to: inviteeEmail,
    subject: "You're invited to Acme",
    html: `<p>Join the workspace: <a href="${inviteUrl}">Accept invite</a></p>`,
    text: `Join the workspace: ${inviteUrl}`,
  }),
});

Idempotency on webhook and API retries

Clerk webhooks, your queue, and platform retries can all replay a POST. Without Idempotency-Key, a timeout can deliver two welcome emails or two OTP codes that disagree.

Per the emails API:

  • Header ≤ 256 characters
  • Remembered for 24 hours; replay returns the original response
  • Derive from something stable (clerk-welcome-{userId}, clerk-otp-{userId}-{sendId})
  • A deliberate resend that mints a new code must use a new key
  • Also store processed svix-id values so side effects besides Rovela stay once-only

Errors you will actually hit

Same shape everywhere (Quick Start):

  • 400 naming your domain — from not verified in this Rovela org
  • 400 recipient is suppressed: … — prior bounce/complaint; ask for another address
  • 401 — bad or revoked key
  • 429 — honour Retry-After; keep the same Idempotency-Key on retry

Do not tell the user “check your inbox” when the API refused the send. On webhook handlers, decide deliberately whether a Rovela failure should return non-2xx (Clerk retry) or 200 after you enqueue a local job.

Checklist

  1. Verify mail.example.com (or your chosen domain) in Rovela; SPF/DKIM/DMARC pass
  2. Create a sending_access key; store only in server env next to CLERK_SECRET_KEY
  3. For event mail: webhook endpoint + CLERK_WEBHOOK_SIGNING_SECRET; verify signatures; dedupe svix-id
  4. For OTP: table for HMAC hashes + expiry + attempts; resolve email via Clerk Backend API, never from the client alone
  5. fetch with both html and text + Idempotency-Key
  6. Watch delivery via dashboard message page or Rovela webhooks—not by assuming 200 means inbox

Soft docs: Send Email · Authentication · Email verification codes · Quick Start.