← Back to blog

Stytch Transactional Email with Rovela (2026)

Custom Stytch Embeddable Magic Links, app-minted OTP, and product invites via Rovela POST /emails—Stytch owns passwordless/B2B auth; Rovela delivers. Not a Stytch replacement; distinct from WorkOS, Clerk, Cognito, Auth.js.

Updated November 4, 2026
Stytch Transactional Email with Rovela (2026)

Stytch owns passwordless auth — Rovela delivers the mail you send

Stytch is a passwordless and B2B authentication platform: Consumer and B2B APIs, Email OTP, Email Magic Links, sessions, organizations, and related products. Out of the box Stytch can send login mail for you—from login@stytch.com (or a custom email domain) with optional custom HTML templates (otp_code, magic_link_url, and similar variables). Many teams still need app-owned delivery: the same mail. subdomain and reputation as invoices and product alerts, bilingual OTP HTML you control end-to-end, or magic-link copy that lives next to the rest of your transactional stack.

This guide keeps Stytch for auth (users, sessions, Consumer/B2B passwordless flows) and sends selected transactional mail—Embeddable Magic Links you create in Stytch, app-minted OTP codes for product step-up, welcome and invite notes—by calling Rovela’s HTTP API from a trusted backend.

Rovela does not replace Stytch. It does not run Email OTP / Email Magic Links as Stytch products, issue Stytch sessions, manage B2B organizations, or host Stytch’s built-in messaging. Stytch remains the source of truth for identity; Rovela only queues, DKIM-signs, and delivers via POST https://api.rovela.dev/emails (Send Email API, Quick Start).

This is not WorkOS AuthKit + Rovela (AuthKit custom emails + Get APIs). It is not Clerk + Rovela, AWS Cognito custom email, or Auth.js / NextAuth + Rovela. The integration surface here is Stytch’s Embeddable Magic Links create API (Consumer), app-owned OTP you mint yourself, and product mail after Stytch auth—plus your server secrets.

When to keep Stytch email vs route through Rovela

NeedTypical path
Stock Email OTP / Email Magic Links with Stytch deliveryStytch built-in messaging (optional custom domain + HTML templates)
Full HTML + sender branding while Stytch still sendsStytch custom email domain + custom templates add-on
Same ESP / mail. reputation as invoices and product mailApp-owned send → Rovela
Magic link you embed in your email (or other channel)Stytch Embeddable Magic Links create → Rovela
Product OTP / step-up code you mint (not Stytch’s Email OTP send)App mint + hash + Rovela; Stytch still owns sessions after login
Welcome / product invite that is not a Stytch templateAlways app-owned → Rovela

Important honesty check: Stytch’s otps/email/send and Email Magic Links send endpoints deliver through Stytch’s messaging. There is no public “hand me the OTP so I can email it myself” hook for those products. Do not invent a webhook that returns Stytch’s OTP. For BYO magic-link delivery, use Embeddable Magic Links (Consumer). For BYO OTP digits, mint them in your app (or keep using Stytch’s Email OTP with Stytch delivery).

What you own vs what Rovela sends

Your app (plus Stytch) owns:

  • Users, sessions, organizations, and passwordless verify/authenticate calls (Stytch)
  • Creating Embeddable Magic Link tokens via Stytch’s create API (when enabled for your workspace)
  • Minting app-owned OTP digits with a CSPRNG; storing HMAC hashes, expiry, attempt counters (your DB—never the raw code)
  • Rate limits (“one send per minute per address”)
  • Bot / scanner protection on your magic-link landing page when you send Embeddable links (Stytch’s hosted Email Magic Links include intermediary protections; Embeddable does not)
  • 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 Stytch project with server credentials (STYTCH_PROJECT_ID, STYTCH_SECRET). Never expose the secret to the browser.
  4. For Embeddable Magic Links: the product is disabled by default on many workspaces—enable it with Stytch support per their docs/forum guidance. Embeddable links are Consumer API (not B2B), and authenticating them does not mark email factors verified the way Email Magic Links do. Read Stytch’s caveats before choosing this path over built-in Email Magic Links.
  5. Never put the Rovela key or Stytch secret in a client app or public repo. Only a trusted server may call Rovela or Stytch’s secret APIs.

Store secrets in the environment

# Example for a Node / Next.js 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 STYTCH_PROJECT_ID=project-live-...
export STYTCH_SECRET=secret-live-...
export MAGIC_LINK_REDIRECT_URL=https://acme.example/authenticate

Rotate by updating the env and redeploying; revoke the old Rovela key in the dashboard when done (Authentication). Keep Stytch and Rovela credentials in the same secret store your host already uses.

Shared Rovela helper

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

async function sendRovelaEmail(opts: {
  to: string;
  subject: string;
  html: string;
  text: string;
  idempotencyKey: string;
}) {
  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",
      "Idempotency-Key": opts.idempotencyKey.slice(0, 256),
    },
    body: JSON.stringify({
      from,
      to: opts.to,
      subject: opts.subject,
      html: opts.html,
      text: opts.text,
    }),
  });
  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);
    throw new Error(`rovela_${res.status}`);
  }
  return res.json(); // { object: "email", id: "..." }
}

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

Use both html and text. Put short codes in the subject when useful so mobile notification previews stay readable (Email verification codes). Pass an Idempotency-Key (≤ 256 chars, remembered 24 hours) so a timeout does not send two disagreeing messages (emails API).

Pattern A: Embeddable Magic Link → Rovela (Consumer)

When you want Stytch to create the token but your stack to deliver the email:

  1. Resolve or create the Stytch user (Search / Create User) so you have a user_id.
  2. POST /v1/magic_links (or the SDK magicLinks.create) with that user_id (and optional expiration_minutes).
  3. Build https://yourapp.com/authenticate?token=…&stytch_token_type=magic_links (match your redirect and Stytch docs).
  4. Send that URL with Rovela.
  5. On click, call Stytch’s Magic Link authenticate endpoint and set a session.
import * as stytch from "stytch";

const client = new stytch.Client({
  project_id: process.env.STYTCH_PROJECT_ID!,
  secret: process.env.STYTCH_SECRET!,
});

async function sendEmbeddableMagicLinkEmail(userId: string, email: string) {
  const created = await client.magicLinks.create({
    user_id: userId,
    // expiration_minutes: 60, // optional; Stytch enforces min/max
  });

  const redirect = process.env.MAGIC_LINK_REDIRECT_URL!;
  const url =
    `${redirect}?token=${encodeURIComponent(created.token)}` +
    `&stytch_token_type=magic_links`;

  await sendRovelaEmail({
    to: email,
    subject: "Sign in to Acme",
    html:
      `<p>Click to sign in to Acme:</p>` +
      `<p><a href="${escapeHtml(url)}">Sign in</a></p>` +
      `<p>If the button does not work, paste this URL into your browser:</p>` +
      `<p>${escapeHtml(url)}</p>` +
      `<p>If you did not request this, you can ignore the email.</p>`,
    text:
      `Sign in to Acme:\n${url}\n\n` +
      `If you did not request this, ignore this email.`,
    idempotencyKey: `stytch-eml-${userId}-${created.request_id || created.token.slice(0, 24)}`,
  });
}

Treat the token as a secret: never log it, never put it in client-side analytics. Add CAPTCHA or similar on the authenticate page—email security scanners may pre-click links and consume one-time tokens. Prefer Stytch’s built-in Email Magic Links when you want Stytch’s intermediary bot protection and automatic email verification behavior.

Pattern B: app-minted OTP + Rovela (Stytch still owns the session)

Use this when the digits are yours (product step-up, sensitive action confirm, or a login path you designed)—not when you called Stytch otps/email/send and expected Stytch to email the code. After the user proves the code, continue with Stytch sessions (or a Stytch-authenticated session you already hold).

import { createHmac, randomInt, timingSafeEqual } from "node:crypto";

function mintSixDigitCode() {
  return String(randomInt(0, 1_000_000)).padStart(6, "0");
}

function hashCode(code: string, email: string) {
  const pepper = process.env.CODE_PEPPER!;
  return createHmac("sha256", pepper).update(`${email}:${code}`).digest("hex");
}

async function sendAppOtp(email: string, purpose: string) {
  const code = mintSixDigitCode();
  const digest = hashCode(code, email.toLowerCase());
  // Persist digest + expiry + attempts in YOUR database — never the raw code.
  // await db.otp.upsert({ email, purpose, digest, expiresAt: Date.now() + 10 * 60_000 })

  await sendRovelaEmail({
    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>${escapeHtml(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: `stytch-app-otp-${purpose}-${email.toLowerCase()}-${digest.slice(0, 16)}`,
  });
}

function verifyAppOtp(email: string, code: string, storedDigest: string) {
  const a = Buffer.from(hashCode(code, email.toLowerCase()));
  const b = Buffer.from(storedDigest);
  return a.length === b.length && timingSafeEqual(a, b);
}

Rate-limit sends and attempts. On success, proceed with your Stytch-authenticated session or a Stytch API that fits your product—Rovela is not involved in redeem.

Pattern C: welcome / product invite after Stytch auth

Stytch webhooks and your own API after login/signup are good triggers for product mail (welcome, “finish setting up your org”) that is not a Stytch login template. Verify webhook signatures, treat delivery as at-least-once, and dedupe with a stable Rovela Idempotency-Key such as stytch-welcome-{user_id}.

For B2B product invites you compose yourself: create or update the org/member in Stytch as their B2B docs describe, then email your deep link with Rovela. That is different from relying on Stytch’s own invite/login messaging. Embeddable Magic Links are Consumer-only—do not assume the same create endpoint for B2B.

Errors you will actually hit

Same Rovela 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 Stytch’s side, surface create/authenticate errors separately from Rovela transport failures.

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 Stytch secrets
  3. Decide per flow: Stytch built-in Email OTP / Email Magic Links vs Embeddable create + Rovela vs app-minted OTP + Rovela
  4. If using Embeddable: enable with Stytch, handle bot pre-clicks, accept unverified-email semantics
  5. fetch with both html and text + Idempotency-Key
  6. Keep sessions / orgs / passwordless authenticate on Stytch; use Rovela only as the mail transport for the flows you send
  7. 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 · Stytch Embeddable Magic Links · Custom email templates.