Lifecycle emails without your own queue
Blog GuideYour SaaS has an onboarding sequence: a day after sign-up, anyone who has not finished setting up gets a short nudge. Usually that means a job queue, a worker, a table of who was sent what, and an unsubscribe link you wire up yourself. With a custom email template in Rekey, the schedule, the cancel, the opt-out and the send log are already there, and your code is a handful of calls.
This recipe walks through one nudge from start to finish. Each step has the code for it, using @rekey.dev/node. It assumes you already have a published notification template; if not, the custom email templates walkthrough covers the provider, variables and publishing.
The key your backend uses
Sending, scheduling, canceling and reading sends all need a secret key with the elevated email:send scope. A full-access * key does not include it. The preferences page further down also reads with auth:read, so mint one key with both, for example ["auth:read", "email:send"], and keep it in its own environment variable.
// lib/rekey.ts
import { Rekey } from '@rekey.dev/node';
export const rekey = new Rekey({
apiUrl: process.env.REKEY_URL!,
secretKey: process.env.REKEY_EMAIL_KEY!, // auth:read + email:send
});Give nudges their own topic
Do this before the first nudge goes out. A notification template has a topic: what a recipient leaves when they press unsubscribe. It defaults to the template's key, and templates that share a topic are left together. Set topic to onboarding on every nudge template, on the template page in the panel or in the create call:
POST /api/v1/tenant/applications/:id/custom-email-templates
{
"key": "finish_setup",
"name": "Finish setting up",
"category": "notification",
"topic": "onboarding",
"subject": "{{name}}, one step left",
"bodyHtml": "<p>Hi {{name}}, your workspace is almost ready.</p>",
"variableSchema": [{ "name": "name", "type": "string", "required": true, "sample": "Sam" }]
}Now someone who is tired of nudges can leave onboarding and still get your other notification topics. Your critical templates and Rekey's account emails, like password resets, reach them either way. The topic is copied onto each version when you publish, so publish again after changing it.
Schedule the nudge
Pass sendAt and the send waits instead of going now. Send to { endUserId } rather than an address: Rekey looks the address up when the mail goes, so your worker never holds it, and a user who changes their address in the meantime gets it at the new one.
// After the user has confirmed their email address.
import { rekey } from './rekey.js';
const DAY_MS = 24 * 60 * 60 * 1000;
export async function scheduleSetupNudge(user: { id: string; name: string; createdAt: string }) {
await rekey.email.send({
template: 'finish_setup',
to: { endUserId: user.id },
variables: { name: user.name },
sendAt: new Date(Date.parse(user.createdAt) + DAY_MS).toISOString(),
idempotencyKey: `finish-setup:${user.id}`,
});
}The answer is 202 with status: "scheduled" and the send's id. The idempotency key is built from the user, so if your handler runs twice, the second call replays scheduled and nothing is queued twice. That only works when the second call is identical, and sendAt is part of the comparison. Work it out from something fixed, like the user's createdAt, not from Date.now(), or the retry answers 409 EMAIL_IDEMPOTENCY_KEY_REUSED. The limits, exactly:
sendAtis ISO 8601 with an offset, in the future, and at most 30 days ahead. A time in the past or further out is refused with400 EMAIL_SEND_AT_INVALID.- Every check runs when you schedule, so a wrong template key, a missing variable or a bad recipient fails now, while your code is still in the request that caused it.
- A user who has not confirmed their address is refused with
409 EMAIL_RECIPIENT_UNVERIFIED, a banned one with409 EMAIL_RECIPIENT_BANNED, and an unknown or erased one with404 EMAIL_RECIPIENT_NOT_FOUND. Schedule after the address is confirmed. - The send fires shortly after
sendAt.
What happens when it fires
The send runs as if you had made it at that moment. If, since you scheduled it, the user left the onboarding topic, left every notification email, landed on the suppression list, or was banned, it is logged suppressed and nothing goes out. Erasing a user deletes their scheduled sends.
The send caps (1000 per UTC day per workspace and 10 per recipient per hour by default) are counted when it fires, not when you schedule. A send that hits a cap is not dropped: it stays scheduled and is tried again once the window has room.
Cancel it when the user acts
If the user finishes setting up first, the nudge should not go. You do not need to have stored the send's id: read it back by the same idempotency key, then cancel it. Rekey's user.onboarding_completed webhook, with data.userId, is one place to call this from, or call it from your own “setup done” handler.
import { RekeyError } from '@rekey.dev/node';
import { rekey } from './rekey.js';
export async function cancelSetupNudge(userId: string) {
try {
const { id } = await rekey.email.getSend({ idempotencyKey: `finish-setup:${userId}` });
await rekey.email.cancelSend(id);
} catch (err) {
// Never scheduled, or it already fired: nothing left to cancel.
if (err instanceof RekeyError && (err.code === 'EMAIL_SEND_NOT_FOUND' || err.code === 'EMAIL_SEND_NOT_SCHEDULED')) return;
throw err;
}
}cancelSend works until the send fires. After that it throws 409 EMAIL_SEND_NOT_SCHEDULED. Once canceled, the same idempotency key replays canceled, so a later retry of the schedule call does not bring the nudge back. Use a new key if you do want to schedule it again.
An email preferences page in your app
The unsubscribe link in the email and a settings page in your app write the same records, so the two always agree. Reading needs auth:read; changing needs email:send, so a default key cannot sign anyone back up.
import { rekey } from './rekey.js';
// { allNotifications: 'subscribed', topics: { onboarding: 'subscribed', ... } }
export const loadPreferences = (userId: string) => rekey.users.getEmailPreferences(userId);
export const setOnboardingEmails = (userId: string, on: boolean) =>
rekey.users.updateEmailPreferences(userId, { onboarding: on ? 'subscribed' : 'unsubscribed' });topicslists every topic ever published on one of the Application's notification templates. A topic that was never published is refused withEMAIL_TOPIC_UNKNOWN.- Setting a topic back to
subscribednever lifts “leave every notification email” or a suppression an operator added. WhenallNotificationsisunsubscribed, every topic reads unsubscribed. - Opt-outs are kept by address. After a user changes their email, the new address starts subscribed.
A dry run in CI
Someone edits the template in the panel, renames a variable and publishes. Your code still sends the old name, and the nudge starts failing with 400 EMAIL_VARIABLES_INVALID. A dry run catches that before deploy: it runs every check a send runs, including the recipient and the suppression list, and returns the rendered email. It sends nothing, writes no log row, counts against no cap and takes no idempotency key.
// test/emails.test.ts
import { expect, test } from 'vitest';
import { rekey } from '../lib/rekey.js';
test('finish_setup still accepts the variables the app sends', async () => {
const result = await rekey.email.dryRun({
template: 'finish_setup',
to: { endUserId: process.env.CI_EMAIL_TEST_USER_ID! }, // a confirmed test user
variables: { name: 'Sam' },
});
expect(result.status).toBe('would_send');
});The answer is 200 with status set to would_send or suppressed, plus subject, html and text if you want to assert on the copy too.
Read what happened
When a user asks why they never got the nudge, read the send by its key:
const send = await rekey.email.getSend({ idempotencyKey: `finish-setup:${userId}` });
console.log(send.status, send.sendAt, send.error);| status | What it means |
|---|---|
scheduled | Waiting for sendAt, or waiting out a send cap. |
canceled | You canceled it before it fired. |
sent | Your provider accepted it. |
suppressed | Nothing went out: the user had opted out, was suppressed or banned, or email was off for the Application. |
error | The send failed; error says why. |
pending | It is being sent right now. |
unknown | A send started and never recorded an outcome. Check your provider before sending again. |
no_transport | No mail provider was connected when it fired. Connect one and schedule it again. |
getSend only sees the sends of the Application the key belongs to.
Hear about unsubscribes
Subscribe a webhook endpoint to email.unsubscribed if your own systems need to know, for example to stop a sales follow-up too. It is sent once per change, whether the user pressed the link in an email or your preferences page set a topic to unsubscribed.
import { verifyWebhookSignature, type WebhookEventEnvelope } from '@rekey.dev/node';
app.post('/webhooks/rekey', { config: { rawBody: true } }, async (req, reply) => {
const ok = verifyWebhookSignature({
header: req.headers['x-rekey-signature'] as string,
payload: req.rawBody!,
secret: process.env.REKEY_WEBHOOK_SECRET!,
});
if (!ok) return reply.status(401).send();
const event = req.body as WebhookEventEnvelope<{ address: string; endUserId?: string; topic?: string }>;
if (event.type === 'email.unsubscribed') {
// No topic means they left every notification email.
await markOptedOut(event.data.endUserId ?? event.data.address, event.data.topic ?? 'all');
}
return reply.send();
});Bounces are yours to record
email.unsubscribed. Add them under Email → Suppressions, and the next scheduled send to that address is logged suppressed.Where to go next
- The same steps as a short checklist are in docs/lifecycle-email.md, with every field and code in docs/email-templates.md.
- Set up the template itself with custom email templates.
- Decide when a user counts as set up with sending new users to onboarding.
Was this page helpful?
