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.
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
| Need | Typical path |
|---|---|
| Stock Email OTP / Email Magic Links with Stytch delivery | Stytch built-in messaging (optional custom domain + HTML templates) |
| Full HTML + sender branding while Stytch still sends | Stytch custom email domain + custom templates add-on |
Same ESP / mail. reputation as invoices and product mail | App-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 template | Always 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-Keyper logical send
Rovela owns:
POST /emailswithAuthorization: Bearer+ asending_accesskey- Queueing, signing, retries, bounce/complaint handling
A 200 means accepted and queued, not “already in the inbox” (Quick Start).
Prerequisites
- Verified sending domain in the Rovela dashboard. Unverified
fromdomains are refused with400(Quick Start). Prefer a subdomain such asmail.example.comfor auth mail (Why OTP and magic-link emails go to spam). - An API key that looks like
re_…. Prefersending_accessfor production senders (Authentication). - A Stytch project with server credentials (
STYTCH_PROJECT_ID,STYTCH_SECRET). Never expose the secret to the browser. - 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.
- 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, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """);
}
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:
- Resolve or create the Stytch user (Search / Create User) so you have a
user_id. POST /v1/magic_links(or the SDKmagicLinks.create) with thatuser_id(and optionalexpiration_minutes).- Build
https://yourapp.com/authenticate?token=…&stytch_token_type=magic_links(match your redirect and Stytch docs). - Send that URL with Rovela.
- 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):
400naming your domain —fromnot verified in this Rovela org400recipient is suppressed: …— prior bounce/complaint; ask for another address401— bad or revoked key429— honourRetry-After; keep the sameIdempotency-Keyon 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
- Verify
mail.example.com(or your chosen domain) in Rovela; SPF/DKIM/DMARC pass - Create a
sending_accesskey; store only in server env next to Stytch secrets - Decide per flow: Stytch built-in Email OTP / Email Magic Links vs Embeddable create + Rovela vs app-minted OTP + Rovela
- If using Embeddable: enable with Stytch, handle bot pre-clicks, accept unverified-email semantics
fetchwith bothhtmlandtext+Idempotency-Key- Keep sessions / orgs / passwordless authenticate on Stytch; use Rovela only as the mail transport for the flows you send
- Watch delivery via dashboard message page or Rovela webhooks—not by assuming
200means inbox
Soft docs: Send Email · Authentication · Email verification codes · Quick Start · Stytch Embeddable Magic Links · Custom email templates.