node-cron Transactional Email with Rovela (2026): In-Process Cron + Idempotency-Key per Window
Wire Node.js node-cron schedules to Rovela POST /emails with server-only sending_access and a window-scoped Idempotency-Key for digests and ops reports.
node-cron owns the schedule — Rovela delivers the mail
node-cron is a lightweight in-process cron scheduler for Node.js. You call cron.schedule(expression, callback, options) and keep the Node process running; when the expression matches, your callback runs on that same event loop (or a scheduled worker thread depending on options). There is no separate Beat process and no Redis job store unless you add one yourself.
That shape fits digests, ops reports, trial-expiry reminders, and other transactional messages whose body depends on live app or database state at fire time — rather than content you can freeze earlier with Rovela scheduled_at.
Rovela queues, DKIM-signs, and delivers when your callback calls POST https://api.rovela.dev/emails (Send Email API, Quick Start, Authentication). node-cron owns the cadence. Your callback owns aggregation, secrets for sending_access, and a stable Idempotency-Key per schedule window. Rovela only delivers.
This guide wires node-cron → callback → Rovela that:
- Declares
cron.schedule("0 9 * * *", …, { timezone: "UTC" }) - Keeps
ROVELA_API_KEY(sending_access),MAIL_FROM, and recipients in server-only env — never in browsers, SPA bundles, or a public Git tree - Aggregates a digest / report for the current schedule window inside the callback
- Sends via Rovela with a window-scoped
Idempotency-Key - Survives callback retries and accidental double-scheduling without duplicate mail
Rovela does not create node-cron schedules or keep your Node process alive. node-cron owns schedule → fire. Your callback owns query → map fields → POST /emails. Rovela owns delivery.
Distinct from:
- Quartz Scheduler Transactional Email — Java Quartz JobDetail / CronTrigger
- NestJS Schedule Cron Email — NestJS
@nestjs/schedule@Cron - Laravel Task Scheduler Email — Laravel
Schedule:: - APScheduler Cron Email — Python APScheduler CronTrigger
- Spring @Scheduled Email — Spring
@Scheduledcron - Resque-scheduler Email — Ruby Resque-scheduler
- RQ Scheduler Email — Python RQ Scheduler
- delayed_job Recurring Email — Ruby delayed_job recurring
- Solid Queue Recurring Email — Rails Solid Queue recurring.yml
- Hangfire Recurring Jobs Email — .NET Hangfire
- Sidekiq-Cron Transactional Email — Ruby Sidekiq-Cron
- Celery Beat Transactional Email — Python Celery Beat
- Agenda.js Scheduled Jobs Email — Mongo agenda
- BullMQ Repeatable Email — Redis BullMQ
- Temporal Schedule Email — Temporal Schedule Spec
- Trigger.dev Cron Email — Trigger.dev schedules.task
- Inngest Cron Transactional Email — Inngest cron
- Kubernetes CronJob Email — K8s CronJob
- Vercel Cron Digest Email — Vercel cron GET
- Scheduled Email Sending — Rovela scheduled_at when body is known
Also distinct from BullMQ repeatable jobs / Agenda.js (persistent queues) and NestJS @Cron (decorator schedule inside Nest). This post is plain Node + node-cron only.
What node-cron schedules vs what Rovela sends
| Concern | Owner |
|---|---|
| Cron expression, timezone, scheduled task ref | node-cron cron.schedule |
| Process must stay running | Your Node host / PM2 / systemd |
| Retries on throw | Your code (try/catch + manual retry) |
Server secrets for ROVELA_API_KEY | Process env / secret store |
| Query DB / APIs for the window | Your callback |
| Render HTML + text | Your callback |
POST /emails, DKIM, queue, bounces | Rovela |
Idempotency-Key = node-cron-daily-digest:{yyyy-mm-dd} | Your callback |
A 200 from Rovela means accepted and queued, not “already in the inbox”.
node-cron constraints that shape the email job
- The Node process must stay up. If the process exits, schedules die. Use a process manager (PM2, systemd, Docker restart policy).
- In-process only. Multiple replicas each run their own
cron.schedule— two web dynos can double-send unless you elect a single worker or add a distributed lock. - Five or six fields.
node-croncommonly uses five-field Unix cron (minute hour day month weekday); some versions accept seconds — pin your package docs. - Timezone via options. Prefer
timezone: "UTC"or an IANA id (Asia/Tokyo) rather than relying on container local time. - No built-in durable misfire history. If the process was down at 09:00, the tick is missed unless you add catch-up logic.
- Keep
sending_accessserver-only. Never shipROVELA_API_KEYto the browser (Authentication). - Prefer HTTPS
POST /emails. (Email API vs SMTP)
When to use node-cron + POST /emails (not scheduled_at)
Use node-cron → live send when you want a tiny dependency inside one long-lived Node worker, need fresh metrics at fire time, and can guarantee a single scheduling process. Use Rovela scheduled_at when the body is already known. Prefer BullMQ / Agenda when you need persistent queues and multi-worker coordination. Prefer Nest @Cron when schedules live inside a Nest app (NestJS Schedule Cron Email).
Prerequisites
- Verified Rovela sending domain (Quick Start).
re_…API key withsending_access.- Node.js +
node-cronin a long-lived process (not a short serverless invoke unless the platform keeps the isolate warm — usually prefer platform cron for serverless). - Env:
ROVELA_API_KEY,MAIL_FROM,DIGEST_TO.
Register the schedule
npm install node-cron
import cron from "node-cron";
import { sendDigest } from "./digest.js";
// Five-field: minute hour day-of-month month day-of-week
const task = cron.schedule(
"0 9 * * *",
async () => {
try {
await sendDigest();
} catch (err) {
console.error("digest failed", err);
// optional: retry once; keep the same Idempotency-Key
throw err;
}
},
{ timezone: "UTC", scheduled: true, name: "daily-digest-email" }
);
// Keep the process alive (example)
process.on("SIGTERM", () => {
task.stop();
process.exit(0);
});
Every 15 minutes:
cron.schedule("*/15 * * * *", () => sendOpsPulse(), { timezone: "UTC", name: "ops-pulse-email" });
Tokyo morning:
cron.schedule("0 5 * * *", () => sendDigest(), { timezone: "Asia/Tokyo", name: "tokyo-morning-ops-email" });
Callback that calls Rovela
// digest.js
export async function sendDigest() {
const day = new Date().toISOString().slice(0, 10); // UTC date
const idempotencyKey = `node-cron-daily-digest:${day}`;
const stats = await loadStats(day); // your DB queries
const to = (process.env.DIGEST_TO || "").split(",").map((s) => s.trim()).filter(Boolean);
const subject = `Acme daily digest — ${day}`;
const text = `Signups: ${stats.signups}\nPaid: ${stats.paid}\nIncidents: ${stats.incidents}`;
const html = `<h1>Daily digest — ${day}</h1><ul>
<li>Signups: <b>${stats.signups}</b></li>
<li>Paid: <b>${stats.paid}</b></li>
<li>Open incidents: <b>${stats.incidents}</b></li>
</ul>`;
// never log ROVELA_API_KEY
console.info("node-cron digest", { day, idempotencyKey });
const result = await sendTransactional({
to,
subject,
html,
text,
idempotencyKey,
tags: { kind: "node-cron-daily-digest", day, platform: "node-cron" },
});
console.info("rovela accepted", { emailId: result.id, day });
}
async function sendTransactional({ to, subject, html, text, idempotencyKey, tags }) {
const apiKey = process.env.ROVELA_API_KEY;
const from = process.env.MAIL_FROM;
if (!apiKey) throw new Error("missing ROVELA_API_KEY");
if (!from) throw new Error("missing MAIL_FROM");
const res = await fetch("https://api.rovela.dev/emails", {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
"Idempotency-Key": idempotencyKey,
},
body: JSON.stringify({ from, to, subject, html, text, tags }),
});
const body = await res.text();
if (!res.ok) throw new Error(`Rovela ${res.status}: ${body}`);
return JSON.parse(body);
}
async function loadStats(day) {
return { signups: 12, paid: 3, incidents: 1 };
}
Window-scoped Idempotency-Key
Rovela’s Idempotency-Key (up to 256 characters) collapses duplicate accepts within 24 hours (Send Email API). Critical for node-cron because:
- Manual retries after 5xx can re-enter the callback
- Process crash after Rovela
200can redeliver on restart if you add catch-up - Multiple replicas each firing the same cron double-send without a lock — the key still collapses duplicates within 24h, but prefer a single worker
Recommended keys: node-cron-daily-digest:{yyyy-mm-dd}, node-cron-ops-pulse:{yyyy-mm-dd}T{HH}-{MM} (bucket /15), node-cron-tokyo-ops:{yyyy-mm-dd}, node-cron-tenant-digest:{tenantId}:{yyyy-mm-dd}.
Attach secrets and verify
- Set env on the single scheduling process.
- Confirm the process stays up (
pm2 ls, k8s probes). - Log
idempotencyKey+ RovelaemailIdon each fire. - In staging, temporarily use a frequent cron while keeping the window key stable.
Failure modes
| Symptom | Likely cause | What to do |
|---|---|---|
| Cron never fires | Process exited / serverless freeze | Long-lived worker + restart policy |
| Double digests | Two replicas both scheduled | One worker, or distributed lock |
| Wrong wall time | Container TZ vs options.timezone | Set timezone explicitly |
| Duplicate after retry | Unstable key | Window-scoped Idempotency-Key |
Rovela 401 / 400 / 429 | Key / domain / rate limit | Fix env; retry with same key |
Production checklist
- Verified domain;
MAIL_FROMmatches sending_accessonly on the scheduling worker- Exactly one process owns each mail cron (or locked)
- Documented expression + timezone
- Window-scoped
Idempotency-Key - Logs without secrets; alerts on consecutive failures
FAQ
Can the scheduler call Rovela without a job method? No usefully — the scheduler only wakes your code. Your method must aggregate live data and keep sending_access local. Rovela does not host your scheduler or workers.
Should I use Rovela scheduled_at from the job? Usually no. If the scheduler already fires at send time with a live body, call POST /emails immediately. Use scheduled_at when you accept the message now and want Rovela to deliver later without another wake (Scheduled Email Sending).
What key scope does sending_access cover? Enough to queue mail via POST /emails / batch. Reading messages, rescheduling, and cancel need full_access or a dashboard session (Send Email API, Authentication). Keep the worker on sending_access.
Does a 200 mean the founder already got the digest? No — it means Rovela accepted and queued the message. Use GET /emails/:id, events, or webhooks for delivery state (Quick Start).
Why both scheduler retries and Rovela Idempotency-Key? The scheduler retries the job when your code fails. Rovela’s key collapses duplicate sends if the HTTP call succeeds twice in the same window (or times out after Rovela accepted). You want both layers.
Ship the node-cron job; let Rovela deliver
Register cron.schedule with an explicit timezone, put live aggregation and POST /emails in the callback, scope Idempotency-Key to the schedule window, and keep sending_access server-only. node-cron wakes on time; Rovela delivers.