Send your own transactional emails through Rekey
Blog GuideYour 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"]
}keyis 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.categoryisnotificationorcritical. It decides whether an unsubscribe stops the email, covered below.- Leave out
bodyTextand the plain-text part is derived from the HTML, keeping link addresses. - An optional
fromNameoverrides 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.
| Type | A send must pass |
|---|---|
string | A string. |
number | A finite JSON number. |
url | An absolute https URL whose host is one of the template's link domains, with no username or password in it. |
date | An 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 forRetry-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.
| Code | What to do |
|---|---|
EMAIL_TRANSPORT_NOT_CUSTOM | Connect your own Resend key or SMTP server in Email → Settings. |
EMAIL_TEMPLATE_NOT_FOUND | Check the key. If you pinned a version that does not exist, drop version to send the latest. |
EMAIL_TEMPLATE_NOT_PUBLISHED | The template is still a draft. Publish it, then send again. |
EMAIL_SENDER_DOMAIN_MISMATCH | Set a From address, or review the template and publish it again for the new domain. |
EMAIL_VARIABLES_INVALID | Read details.issues (or emailVariableIssues(err)). It lists every unknown, missing, too long or wrong-typed value at once. |
EMAIL_RECIPIENT_NOT_END_USER | Send to one of your users, or turn off Only send to end users. |
EMAIL_RATE_LIMITED | Wait for Retry-After. The same idempotency key still works. |
EMAIL_DELIVERY_FAILED | Your provider refused the message or timed out. Fix its credentials or the From address, then send with a new idempotency key. |
API_KEY_SCOPE_INSUFFICIENT | The 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, areplyToso answers reach your inbox, and asupportEmailshown in the “Need help?” footer line. On a shared sending pool the name gets a suffix, such asAcme 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 UTCrather than an ISO timestamp. The ISO values are still there as…AtIsovariables 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
Where to go next
- Pair a template with a lifecycle webhook, like the trial reminder in sending new users to onboarding.
- Read what else changed in Rekey 2.2 since the first release candidate.
- Every field and code is in docs/email-templates.md.
