← Back to blog

WorkOS AuthKit Transactional Email with Rovela (2026)

Custom WorkOS AuthKit invite, Magic Auth, verification, and password-reset mail via Rovela POST /emails—WorkOS owns identity/SSO; app sends; Rovela delivers. Not a WorkOS replacement; distinct from Clerk and Cognito guides.

Updated November 4, 2026
WorkOS AuthKit Transactional Email with Rovela (2026)

WorkOS owns identity — Rovela delivers the mail

WorkOS is an enterprise-ready identity platform: AuthKit (hosted or headless auth UI), SSO, Directory Sync, organizations, invitations, Magic Auth, email verification, and password reset. Out of the box AuthKit can send those emails for you. Many B2B teams then need custom transactional copy—branded invite HTML, bilingual Magic Auth codes, a dedicated mail. subdomain with its own reputation, or the same deliverability stack they already use for invoices and product alerts.

This guide keeps WorkOS for identity (AuthKit, SSO, Directory Sync, User Management) and sends app-owned AuthKit emails—invitations, Magic Auth codes, email verification, password reset—by calling Rovela’s HTTP API from a trusted backend that handles WorkOS webhooks or API responses.

Rovela does not replace WorkOS. It does not run SSO, Directory Sync, AuthKit hosted pages, organizations, or session issuance. WorkOS remains the source of truth for users and orgs; your server fetches invitation / Magic Auth / verification objects (or reads them from the create API), then Rovela only queues, DKIM-signs, and delivers via POST https://api.rovela.dev/emails (Send Email API, Quick Start).

This is not Clerk + Rovela (Clerk webhooks / Backend API). It is not AWS Cognito custom email, Auth.js / NextAuth, or a framework OTP walkthrough. The integration surface here is WorkOS AuthKit custom emails (events + Get APIs) plus your Node (or other) server secrets.

When to keep WorkOS email vs route through Rovela

NeedTypical path
Stock AuthKit invitation / Magic Auth / verify / reset mailWorkOS default emails are fine to start
Custom HTML, brand layout, code-in-subject, bilingual copyDisable default emails → app send → Rovela
Separate reputation on mail.yourdomain.comVerified Rovela domain + your from
Product mail that is not an AuthKit template (welcome, invoice)Always app-owned → Rovela
You still want AuthKit, SSO, and Directory SyncKeep WorkOS for identity; swap only the mail transport for AuthKit email flows

Common pattern: turn off default AuthKit emails in the WorkOS Dashboard (Emails → Configuration → Manage), subscribe to the relevant events (or send from the create-API response), retrieve the secret the event deliberately omitted, then deliver with Rovela.

What you own vs what Rovela sends

Your app (plus WorkOS) owns:

  • Users, organizations, SSO, Directory Sync, and AuthKit (WorkOS)
  • Disabling default AuthKit emails when you take over delivery
  • Handling invitation.created / invitation.resent, magic_auth.created, email_verification.created, password_reset.created (webhooks or Events API)
  • Calling Get Invitation / Get Magic Auth / Get Email Verification / Get Password Reset so you have accept_invitation_url, code, or password_reset_url (events omit those secrets on purpose)
  • Verifying WorkOS 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 WorkOS environment with API keys, AuthKit enabled, and (for event-driven mail) a webhook endpoint. Disable the AuthKit email types you will send yourself under Emails → Configuration.
  4. Confirm redirect URLs for invitations and password reset under Applications → your application → Redirects when you use AuthKit hosted flows or your own acceptance path.
  5. Never put the Rovela key or WorkOS secret in the browser or a public repo. Only a trusted server may call Rovela or the WorkOS API.

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 WORKOS_API_KEY=sk_...                 # WorkOS API key
export WORKOS_WEBHOOK_SECRET=whsec_...       # from WorkOS Dashboard → Webhooks

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

Shared Rovela sender helper

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

export 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,
    }),
  });

  const data = await res.json().catch(() => ({}));
  if (!res.ok) {
    // Do not log API keys. Surface status + provider message only.
    const err = new Error(
      `rovela_send_failed ${res.status} ${data.message || ""}`,
    );
    (err as Error & { status?: number }).status = res.status;
    throw err;
  }
  return data as { object: string; id: string };
}

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

Pattern A: invitation email (invitation.created / invitation.resent)

WorkOS emits invitation.created when an invite is created and invitation.resent when it is resent. Event payloads omit token and accept_invitation_url on purpose. After you verify the webhook signature, call Get Invitation with the invitation id, then email the recipient.

Subscribe to both invitation.created and invitation.resent. If you only handle created, Dashboard resends will quietly deliver nothing.

import { WorkOS } from "@workos-inc/node";

const workos = new WorkOS(process.env.WORKOS_API_KEY!);

// Inside your verified webhook handler, after signature check + dedupe:
async function handleInvitationEvent(invitationId: string, eventId: string) {
  const invitation = await workos.userManagement.getInvitation(invitationId);
  // invitation.email, invitation.accept_invitation_url, invitation.expires_at,
  // invitation.organization_id, invitation.inviter_user_id, …

  const url = invitation.acceptInvitationUrl; // or build from invitation.token
  const email = invitation.email;

  await sendRovelaEmail({
    to: email,
    subject: "You're invited to Acme",
    html:
      `<p>You have been invited to join Acme.</p>` +
      `<p><a href="${escapeHtml(url)}">Accept invitation</a></p>` +
      `<p>If the button does not work, paste this URL into your browser:</p>` +
      `<p>${escapeHtml(url)}</p>`,
    text:
      `You have been invited to join Acme.\n\n` +
      `Accept invitation: ${url}\n`,
    // Stable across WorkOS retries for the same event; new key on deliberate resend path if needed
    idempotencyKey: `workos-invite-${invitationId}-${eventId}`,
  });
}

If your backend created the invite with the Send Invitation API, the response already includes the secrets—you can send immediately from that object and still keep the webhook handler for invites created in the Dashboard.

For most AuthKit setups, accept_invitation_url is the right link. If you host your own acceptance UI, build a URL from token / invitation_token and pass it into the authenticate methods WorkOS documents. Product invite UX that is not AuthKit at all still belongs in your app + Rovela (same POST /emails shape).

Pattern B: Magic Auth code (magic_auth.created)

Magic Auth is WorkOS’s email code login: create or receive a Magic Auth object, email the six-digit code (valid on the order of minutes per WorkOS docs), and let AuthKit or your UI collect it. Events omit the code; Get Magic Auth returns it.

async function handleMagicAuthCreated(magicAuthId: string, eventId: string) {
  const magic = await workos.userManagement.getMagicAuth(magicAuthId);
  const email = magic.email;
  const code = magic.code; // six digits — never log in production

  await sendRovelaEmail({
    to: email,
    subject: `Your Acme code: ${code}`,
    html:
      `<p>Your sign-in code is</p>` +
      `<p style="font-size:28px;font-weight:600;letter-spacing:4px"><b>${escapeHtml(code)}</b></p>` +
      `<p>It expires soon. If you did not ask for it, ignore this email.</p>`,
    text:
      `Your sign-in code is ${code}. It expires soon. ` +
      `If you did not ask for it, ignore this email.`,
    idempotencyKey: `workos-magic-${magicAuthId}-${eventId}`,
  });
}

If you call Create Magic Auth yourself, send from the API response and skip the event path for that request. Prefer putting the code in the subject as well as the body so mobile notification previews stay useful (Email verification codes).

Pattern C: email verification + password reset

Same shared shape for the other two AuthKit email types after you disable defaults:

  1. Email verification — handle email_verification.created (or the email_verification_required API error’s id), Get Email Verification, email the code to email.
  2. Password reset — handle password_reset.created, Get Password Reset, email password_reset_url (or a URL you build from password_reset_token). Confirm the password-reset redirect under your application’s Redirects settings.
async function handlePasswordResetCreated(resetId: string, eventId: string) {
  const reset = await workos.userManagement.getPasswordReset(resetId);
  const url = reset.passwordResetUrl;
  const email = reset.email;

  await sendRovelaEmail({
    to: email,
    subject: "Reset your Acme password",
    html:
      `<p>Reset your password:</p>` +
      `<p><a href="${escapeHtml(url)}">Choose a new password</a></p>` +
      `<p>If you did not request a reset, you can ignore this email.</p>`,
    text:
      `Reset your password: ${url}\n` +
      `If you did not request a reset, ignore this email.`,
    idempotencyKey: `workos-reset-${resetId}-${eventId}`,
  });
}

WorkOS documents the four AuthKit custom-email flows together (invitations, Magic Auth, verification, password reset). Rovela is only the transport once you have the link or code—do not invent SSO or Directory Sync email features that WorkOS does not expose this way.

Webhooks, Events API, and idempotency

WorkOS can deliver via webhooks or the Events API. Either way:

  • Verify signatures before trusting the payload
  • Treat delivery as at-least-once: dedupe on event id
  • Remember events omit secrets—always Get* before sending
  • Pass a stable Rovela Idempotency-Key (≤ 256 chars, remembered 24 hours) so a timeout does not mint two codes or two invites that disagree (emails API)
  • A deliberate resend that should produce a new message needs a new key (WorkOS’s invitation.resent already gives you a fresh event id to fold in)

Return 2xx from the webhook only after the send is accepted or safely enqueued in your own job system, so WorkOS retries stay predictable.

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 webhook handlers, decide deliberately whether a Rovela failure should return non-2xx (WorkOS 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 WORKOS_API_KEY
  3. In WorkOS: disable the AuthKit email types you will send; register webhook (or poll Events); verify signatures; subscribe to created and invitation.resent
  4. Get Invitation / Magic Auth / Email Verification / Password Reset before sending—never trust the sparse event alone for secrets
  5. fetch with both html and text + Idempotency-Key
  6. Keep AuthKit / SSO / Directory Sync on WorkOS; use Rovela only as the mail transport
  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 · WorkOS Custom Emails.