10 min read

Your app already sends mail that has nothing to do with signing in: an order shipped, an invoice is ready, a teammate joined, a trial ends on Friday. With Rekey 2.2 you can write those emails once in the panel, publish them, and send each one from your backend by its key with a few values filled in. The subject, the body and the From address are stored in Rekey, so a bug in your backend cannot put the wrong words or the wrong sender on a customer's screen.

This walkthrough covers the whole path: the provider you need first, registering a template, its variables, preview and test send, publishing, the send call, and the errors you are likely to meet. The last section is about the account emails Rekey sends for you, which now carry your app's name and look.

First, your own mail provider

Custom templates go out only through the Application's own Resend API key or SMTP server, set in Panel → Application → Email → Settings. They never use a shared sending pool, and there is no fallback to one. You can write drafts without a provider, but publish, test send and send all answer 403 EMAIL_TRANSPORT_NOT_CUSTOM until you connect one.

The Application also needs a From address. Each published version remembers the domain of the From address it was published for. If you change that domain later, sends of the old version answer 409 EMAIL_SENDER_DOMAIN_MISMATCH until you publish again, so a template approved for one domain never goes out from another.

Register a template

Open Panel → Application → Email → Custom templates, choose a key, a name and a category, and create the draft. The same thing over the operator API is POST /api/v1/tenant/applications/:id/custom-email-templates:

{
  "key": "order_shipped",
  "name": "Order shipped",
  "category": "notification",
  "subject": "Order {{orderNumber}} is on its way",
  "bodyHtml": "<p>Your order {{orderNumber}} has shipped.</p>{{#if trackingUrl}}<p><a href=\"{{trackingUrl}}\">Track it</a></p>{{/if}}",
  "variableSchema": [
    { "name": "orderNumber", "type": "string", "required": true, "sample": "A-1042" },
    { "name": "trackingUrl", "type": "url", "sample": "https://track.example.com/A-1042" }
  ],
  "linkDomains": ["track.example.com"]
}
  • key is permanent: 3 to 64 characters of lowercase letters, digits and underscores, starting with a letter. It cannot be the key of one of Rekey's own emails.
  • category is notification or critical. It decides whether an unsubscribe stops the email, covered below.
  • Leave out bodyText and the plain-text part is derived from the HTML, keeping link addresses.
  • An optional fromName overrides the Application's sender name for this template. The address is always the Application's From address.

An Application holds up to 200 custom templates.

Variables

Each variable you declare has a name, a type, and optionally required, a maxLength (256 by default, 2048 at most) and a sample for the preview. A template can declare up to 50.

TypeA send must pass
stringA string.
numberA finite JSON number.
urlAn absolute https URL whose host is one of the template's link domains, with no username or password in it.
dateAn ISO 8601 date (2026-09-26) or a date-time with an offset. It is inserted as you send it, so send it in the form you want the reader to see.

Values are HTML-escaped in the body, and line breaks in the subject become spaces. The link domains are there for a reason: a url variable can only point at a host you listed, so a value that reached your backend from somewhere untrusted cannot turn your email into a link to someone else's site. Publishing also refuses a template where a plain string variable decides the scheme or host of a link, such as href="{{link}}". Write the fixed part of the address before the variable, or declare it as a url.

Preview and send a test

The template page renders the draft with each variable's sample value. A variable the draft uses without declaring it shows up as its raw {{name}} token, highlighted, with a warning that lists it, so a typo shows before anyone receives it. Over the API the preview is POST …/custom-email-templates/:key/preview, which returns the rendered subject, HTML and text plus an undeclared list, and sends nothing.

Send test to me mails the draft, with sample values, to your own operator address, with [TEST] in front of the subject. It needs your own provider, respects the suppression list, and counts toward the send caps.

Publish

A send always uses a published version, never the draft. Publishing copies the draft into the next version number, so you can keep editing the draft without changing what goes out until you publish again. A send can pin an older version with version.

Publish checks the content and answers 400 EMAIL_TEMPLATE_INVALID, with every problem listed in details.issues, when the subject or body uses a variable the schema does not declare, when a non-url variable decides a link address, or when a url variable exists and the template has no link domains.

Send it from your backend

Sending needs a secret key with the email:send scope. In Panel → Application → API Keys, tick it under Elevated scopes when you mint the key. A full-access * key does not include it and gets 403 API_KEY_SCOPE_INSUFFICIENT, so a key you minted months ago for sign-in cannot start mailing your customers. Keep the email key in its own environment variable.

// lib/emails.ts
import { Rekey, isEmailSendError, emailVariableIssues } from '@rekey.dev/node';

const rekey = new Rekey({
  apiUrl: process.env.REKEY_URL!,
  secretKey: process.env.REKEY_EMAIL_KEY!, // a key with the email:send scope
});

export async function sendOrderShipped(order: {
  id: string;
  email: string;
  number: string;
  trackingUrl: string;
}) {
  try {
    const { status } = await rekey.email.send({
      template: 'order_shipped',
      to: order.email,
      variables: { orderNumber: order.number, trackingUrl: order.trackingUrl },
      idempotencyKey: `order-shipped:${order.id}`,
    });
    return status; // 'sent', or 'suppressed' when nothing went out
  } catch (err) {
    if (isEmailSendError(err) && err.code === 'EMAIL_VARIABLES_INVALID') {
      for (const issue of emailVariableIssues(err)) console.warn(issue.path, issue.message);
    }
    throw err;
  }
}

Over HTTP it is POST /api/v1/email/send with the same body, answering 202. The body takes only template, to, variables, version and idempotencyKey. A subject, an HTML body or a From address in the call is refused.

If your app only ever mails its own users, turn on Only send to end users on the Custom templates page. A send to any other address then fails with 403 EMAIL_RECIPIENT_NOT_END_USER.

Idempotency

Pass idempotencyKey (or an Idempotency-Key header), up to 200 characters, and build it from the thing the email is about, like the order id above. A repeat with the same key and the same template, recipient, version and variables returns the first result and sends nothing. That includes a failed first attempt: its 502 EMAIL_DELIVERY_FAILED comes back again, so retry a failed send with a new key once you have fixed the cause.

  • Same key, different email: 409 EMAIL_IDEMPOTENCY_KEY_REUSED.
  • The first send with that key is still running: 409 EMAIL_SEND_IN_FLIGHT. Wait for Retry-After.
  • The first send started more than five minutes ago and never recorded an outcome: 409 EMAIL_SEND_OUTCOME_UNKNOWN. Check whether it arrived before you send with a new key.

Caps

Sends are counted per workspace across all its Applications: 1000 per UTC day and 10 per recipient per hour by default. A self-hosted deployment sets them with EMAIL_SEND_DAILY_CAP and EMAIL_SEND_RECIPIENT_HOURLY_CAP. Over a cap the send answers 429 EMAIL_RATE_LIMITED with Retry-After, and since nothing was sent the same idempotency key still works afterwards.

Unsubscribe and suppressions

A notification template carries one-click unsubscribe headers (RFC 8058), which mail clients show as an unsubscribe button. Using it puts the address on the Application's suppression list for notification mail only. Your critical templates and Rekey's own account emails, such as password resets and sign-in links, still reach that person. Pick critical for mail the customer needs whatever their preferences, like a receipt, and notification for the rest.

A send to a suppressed address is not an error. It answers 202 with status: "suppressed", nothing goes out, and the attempt is logged. Addresses land on the list after a bounce, a complaint, an unsubscribe, or an entry you add by hand under Email → Suppressions, which also shows what each entry stops. Turning email off for the whole Application has the same effect on every send.

Errors you are likely to meet

Every error also carries a fix field that says what to do. isEmailSendError(err) tells you whether a thrown error is one of these.

CodeWhat to do
EMAIL_TRANSPORT_NOT_CUSTOMConnect your own Resend key or SMTP server in Email → Settings.
EMAIL_TEMPLATE_NOT_FOUNDCheck the key. If you pinned a version that does not exist, drop version to send the latest.
EMAIL_TEMPLATE_NOT_PUBLISHEDThe template is still a draft. Publish it, then send again.
EMAIL_SENDER_DOMAIN_MISMATCHSet a From address, or review the template and publish it again for the new domain.
EMAIL_VARIABLES_INVALIDRead details.issues (or emailVariableIssues(err)). It lists every unknown, missing, too long or wrong-typed value at once.
EMAIL_RECIPIENT_NOT_END_USERSend to one of your users, or turn off Only send to end users.
EMAIL_RATE_LIMITEDWait for Retry-After. The same idempotency key still works.
EMAIL_DELIVERY_FAILEDYour provider refused the message or timed out. Fix its credentials or the From address, then send with a new idempotency key.
API_KEY_SCOPE_INSUFFICIENTThe key lacks email:send. Mint one with it ticked under Elevated scopes.

The emails Rekey sends for you

The built-in emails (password reset, email verification, magic link, welcome, two-factor enabled, password changed and the failed payment reminder) were redesigned in 2.2. They now look like they came from your product:

  • They read the branding in Panel → Application → Portal: your display name in the header, subjects and footer (“Reset your Acme password”), your logo when it is an https URL, and your primary colour on the button and links. Without branding they use the Application's name.
  • The sender comes from Email → Sender: a fromName, a replyTo so answers reach your inbox, and a supportEmail shown in the “Need help?” footer line. On a shared sending pool the name gets a suffix, such as Acme Support (via Rekey), because the address belongs to the deployment. With your own provider it is used as written.
  • Dates read like dates: 27 Sep 2026, 14:50 UTC rather than an ISO timestamp. The ISO values are still there as …AtIso variables if you customise a template.
  • The failed payment reminder has an Update payment method button when your hosted portal is on.
  • Each one has a dark-mode friendly HTML part and a plain-text part.

To change one, open it under Email → Templates. The editor starts from the default with your branding applied, and saving keeps your own copy. A saved copy is sent exactly as saved, so later changes to the defaults or your branding do not reach it; Revert to default brings them back.

The footer line

On Rekey Cloud's Free plan the built-in emails end with a small “Secured by Rekey” line. Paid workspaces and self-hosted installs never show it, and it is never added to a template you saved or to your custom templates.

Where to go next

  1. Pair a template with a lifecycle webhook, like the trial reminder in sending new users to onboarding.
  2. Read what else changed in Rekey 2.2 since the first release candidate.
  3. Every field and code is in docs/email-templates.md.
Send your own transactional emails through Rekey | Rekey