← Back to blog

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.

Updated November 9, 2026
node-cron Transactional Email with Rovela (2026): In-Process Cron + Idempotency-Key per Window

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:

  1. Declares cron.schedule("0 9 * * *", …, { timezone: "UTC" })
  2. Keeps ROVELA_API_KEY (sending_access), MAIL_FROM, and recipients in server-only env — never in browsers, SPA bundles, or a public Git tree
  3. Aggregates a digest / report for the current schedule window inside the callback
  4. Sends via Rovela with a window-scoped Idempotency-Key
  5. 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:

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

ConcernOwner
Cron expression, timezone, scheduled task refnode-cron cron.schedule
Process must stay runningYour Node host / PM2 / systemd
Retries on throwYour code (try/catch + manual retry)
Server secrets for ROVELA_API_KEYProcess env / secret store
Query DB / APIs for the windowYour callback
Render HTML + textYour callback
POST /emails, DKIM, queue, bouncesRovela
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-cron commonly 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_access server-only. Never ship ROVELA_API_KEY to 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

  1. Verified Rovela sending domain (Quick Start).
  2. re_… API key with sending_access.
  3. Node.js + node-cron in a long-lived process (not a short serverless invoke unless the platform keeps the isolate warm — usually prefer platform cron for serverless).
  4. 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 200 can 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

  1. Set env on the single scheduling process.
  2. Confirm the process stays up (pm2 ls, k8s probes).
  3. Log idempotencyKey + Rovela emailId on each fire.
  4. In staging, temporarily use a frequent cron while keeping the window key stable.

Failure modes

SymptomLikely causeWhat to do
Cron never firesProcess exited / serverless freezeLong-lived worker + restart policy
Double digestsTwo replicas both scheduledOne worker, or distributed lock
Wrong wall timeContainer TZ vs options.timezoneSet timezone explicitly
Duplicate after retryUnstable keyWindow-scoped Idempotency-Key
Rovela 401 / 400 / 429Key / domain / rate limitFix env; retry with same key

Production checklist

  • Verified domain; MAIL_FROM matches
  • sending_access only 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.