← Back to blog

Agenda.js / node-agenda Scheduled Jobs Transactional Email with Rovela (2026): define + every + Idempotency-Key

Wire Agenda.js (MongoDB-backed Agenda 6.x define + every/schedule) so a job handler sends transactional digests via Rovela POST /emails — sending_access server-only and a window-scoped Idempotency-Key.

Updated November 5, 2026
Agenda.js / node-agenda Scheduled Jobs Transactional Email with Rovela (2026): define + every + Idempotency-Key

Agenda.js owns the MongoDB cron — Rovela delivers the mail

Agenda (often searched as Agenda.js / node-agenda) is a light-weight Node.js job scheduler. In Agenda 6.x you pick a storage backend — commonly @agendajs/mongo-backend for MongoDB — then define handlers and schedule with every, schedule, or now. Recurring work is a first-class job document (type single for every), so rebooting your web process does not create duplicate cron rows when you call every again (Agenda docs, npm agenda).

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 — signup counts, unpaid invoices, failed jobs — rather than content you can freeze hours earlier with Rovela scheduled_at.

Rovela queues, DKIM-signs, and delivers when your Agenda job handler calls POST https://api.rovela.dev/emails (Send Email API, Quick Start, Authentication). Agenda (plus MongoDB, or another Agenda backend) owns the schedule, locking, concurrency, and retries. Your handler owns aggregation, secrets for sending_access, and a stable Idempotency-Key per schedule window. Rovela only delivers.

This guide wires Agenda define + every / schedule → job handler → Rovela that:

  1. Defines a job with agenda.define(name, handler, options?) (Agenda v6 argument order: handler, then options — see the v5→v6 migration guide)
  2. Keeps ROVELA_API_KEY (sending_access), MAIL_FROM, Mongo URL, and recipients in server-only env — never in the browser, public Git, or broadly logged job payloads
  3. Aggregates a digest / report for the current schedule window inside the handler
  4. Sends via Rovela with a window-scoped Idempotency-Key
  5. Survives Agenda lock retries, process restarts, and duplicate ticks without duplicate mail

Rovela does not create Agenda collections, Mongo indexes, or cron rows. Agenda owns schedule → lock → handler. Your handler owns query → map fields → POST /emails. Rovela owns delivery.

Distinct from:

Also distinct from one-off Agenda schedule / now without a recurring every — this post focuses on the recurring Mongo-backed cron / interval path (with a short note on one-shot schedule for delayed transactional mail).

What Agenda schedules vs what Rovela sends

ConcernOwner
Job name, every interval / cron, timezone, skipImmediate, startDate / endDate / skipDaysAgenda every
Job document persistence + lockingYour MongoDB (or Postgres / Redis Agenda backend)
Polling (processEvery), concurrency, lockLifetimeAgenda instance + define options
Server secrets for ROVELA_API_KEY (sending_access)Your Agenda worker process env / secret store
Query DB / APIs for the windowYour define handler
Render HTML + textYour handler
POST /emails, DKIM, queue, bouncesRovela
Idempotency-Key = agenda-cron-daily-digest:{yyyy-mm-dd} (or hour / tenant bucket)Your handler

A 200 from Rovela means accepted and queued, not “already in the inbox” (Quick Start).

Agenda constraints that shape the email worker

From Agenda’s public docs (v6.x), treat these as hard requirements for a mail-sending schedule:

  • Install core + a backend. npm install agenda @agendajs/mongo-backend (Node.js 18+, MongoDB 4+). MongoDB is not bundled in the core package in v6 (migration guide, mongo-backend README).
  • Construct with backend: new MongoBackend(...). Pass address or an existing mongo Db; optional collection (default agendaJobs), ensureIndex, sort (Agenda docs).
  • define(name, fn, options?) — handler before options in v6. Options include concurrency, lockLimit, lockLifetime, priority, backoff, removeOnComplete (define).
  • every(interval, name, data?, options?) creates a single job. Calling the same every line on every boot still yields one recurring row — designed for files that restart often (every).
  • Interval forms. Human-readable ('15 minutes', '1 hour'), cron string ('0 * * * *'), or a Number (ms) (every).
  • Timezone and bounds on every. Pass data when you need options such as timezone, skipImmediate, forkMode, startDate, endDate, skipDays (0=Sunday … 6=Saturday) (every).
  • schedule(when, name, data?) for one-shot. Human-readable ('tomorrow at noon', 'in 5 minutes') or a Date (schedule).
  • start() before jobs run; drain() / stop() on shutdown. Prefer drain(timeoutMs) on platforms with shutdown deadlines so in-flight Rovela calls can finish (Stopping / Draining).
  • Keep sending_access server-only. Store ROVELA_API_KEY in the Agenda process environment — never in Mongo job data that operators browse, client bundles, or Git (Authentication).
  • Prefer HTTPS POST /emails. Outbound SMTP from a job worker is a poor fit (Email API vs SMTP).

When to use Agenda + POST /emails (not scheduled_at)

Use Agenda every / cron → live send when:

  • The HTML must include fresh metrics at fire time
  • You already run MongoDB (or another Agenda backend) and want recurring jobs next to the same Node app
  • You need self-hosted durable cron without adopting Redis BullMQ, Temporal, or a SaaS scheduler
  • One (or a few) transactional messages go to ops / founder / customer addresses looked up at run time

Use Rovela scheduled_at when the body is already correct and you only need “deliver later” without a running Agenda worker (Scheduled Email Sending). Agenda schedules and scheduled_at solve different problems; this post is the Agenda path.

Use BullMQ / Temporal / Trigger.dev / Inngest / QStash when you want those runtimes instead — see the distinct posts linked above. Use a platform cron when the schedule should live next to that host’s compute.

Prerequisites

  1. A verified sending domain in Rovela. Unverified from domains are refused with 400 (Quick Start).
  2. An API key shaped like re_…. Prefer sending_access for the Agenda worker (Authentication).
  3. MongoDB reachable from the Agenda process, plus agenda and @agendajs/mongo-backend installed.
  4. Process env: ROVELA_API_KEY, MAIL_FROM, DIGEST_TO, MONGODB_URI (and any DB URLs the handler needs).
  5. At least one long-running process that calls agenda.start() — defining jobs without a running Agenda does not send mail.

Define + every + Rovela (TypeScript, Agenda 6.x)

Daily digest at 09:00 UTC with a cron string and timezone: 'UTC':

// src/jobs/digest-agenda.ts
import { Agenda } from "agenda";
import { MongoBackend } from "@agendajs/mongo-backend";

const ROVELA_URL = "https://api.rovela.dev/emails";
const JOB_NAME = "daily-digest-email";

function requireEnv(name: string) {
  const v = process.env[name];
  if (!v) throw new Error(`missing env ${name}`);
  return v;
}

function utcDayKey(d = new Date()) {
  return d.toISOString().slice(0, 10); // YYYY-MM-DD
}

async function buildDailyDigest(day: string) {
  // Replace with your queries against Mongo / Postgres / APIs
  const stats = { signups: 12, paid: 3, incidents: 1, failedJobs: 0 };
  const subject = `Acme daily digest — ${day}`;
  const text = [
    `Signups: ${stats.signups}`,
    `Paid: ${stats.paid}`,
    `Open incidents: ${stats.incidents}`,
    `Failed jobs: ${stats.failedJobs}`,
  ].join("\n");
  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>
      <li>Failed jobs: <b>${stats.failedJobs}</b></li>
    </ul>
    <p><a href="https://app.example.com/admin">Open admin</a></p>
  `;
  return { subject, html, text, stats };
}

async function sendTransactional(opts: {
  to: string[];
  subject: string;
  html: string;
  text: string;
  idempotencyKey: string;
  tags: Record<string, string>;
}) {
  const res = await fetch(ROVELA_URL, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${requireEnv("ROVELA_API_KEY")}`,
      "Content-Type": "application/json",
      "Idempotency-Key": opts.idempotencyKey,
    },
    body: JSON.stringify({
      from: requireEnv("MAIL_FROM"),
      to: opts.to,
      subject: opts.subject,
      html: opts.html,
      text: opts.text,
      tags: opts.tags,
    }),
  });
  const body = await res.json().catch(() => ({}));
  if (!res.ok) {
    throw new Error(`Rovela ${res.status}: ${(body as any).message ?? "send failed"}`);
  }
  return body as { object: string; id: string };
}

export async function startDigestAgenda() {
  const agenda = new Agenda({
    backend: new MongoBackend({
      address: requireEnv("MONGODB_URI"),
      collection: "agendaJobs",
    }),
    processEvery: "30 seconds",
    maxConcurrency: 20,
  });

  // v6: define(name, handler, options?) — options last
  agenda.define(
    JOB_NAME,
    async (job) => {
      // Prefer scheduled intent when present; fall back to now
      const when =
        (job.attrs.lastRunAt as Date | undefined) ??
        (job.attrs.nextRunAt as Date | undefined) ??
        new Date();
      const day = utcDayKey(when instanceof Date ? when : new Date(when));
      const idempotencyKey = `agenda-cron-daily-digest:${day}`;

      const digest = await buildDailyDigest(day);
      const to = requireEnv("DIGEST_TO")
        .split(",")
        .map((s) => s.trim())
        .filter(Boolean);

      // never log ROVELA_API_KEY or Mongo passwords
      console.log(
        JSON.stringify({
          day,
          idempotencyKey,
          jobName: job.attrs.name,
          jobId: String(job.attrs._id ?? ""),
          kind: job.attrs.data?.kind,
        })
      );

      const result = await sendTransactional({
        to,
        subject: digest.subject,
        html: digest.html,
        text: digest.text,
        idempotencyKey,
        tags: {
          kind: "agenda-cron-daily-digest",
          day,
          platform: "agenda",
          jobName: JOB_NAME,
        },
      });

      return { ok: true, emailId: result.id, day };
    },
    {
      concurrency: 1, // one digest send at a time per instance
      lockLifetime: 10 * 60 * 1000,
      priority: "normal",
    }
  );

  await agenda.start();

  // every creates a single recurring job — safe to call on every boot
  await agenda.every(
    "0 9 * * *", // 09:00 UTC (cron)
    JOB_NAME,
    { kind: "daily-digest" },
    { timezone: "UTC", skipImmediate: true }
  );

  agenda.on("fail", (err, job) => {
    console.error(
      JSON.stringify({
        event: "agenda_job_failed",
        jobName: job?.attrs?.name,
        message: err.message,
      })
    );
  });

  const shutdown = async () => {
    // wait for in-flight Rovela POSTs (cloud platforms often ~30s)
    await agenda.drain(30_000);
    process.exit(0);
  };
  process.on("SIGTERM", shutdown);
  process.on("SIGINT", shutdown);

  return agenda;
}

Call startDigestAgenda() from a dedicated worker process that stays up (or from your API process if you intentionally colocate Agenda there).

Local timezone morning digest:

await agenda.every(
  "0 5 * * *", // 05:00 in tz
  "tokyo-morning-ops-email",
  { kind: "tokyo-ops" },
  { timezone: "Asia/Tokyo", skipImmediate: true }
);

// In the handler, key Idempotency-Key by the schedule timezone day:
// const day = new Date().toLocaleDateString("en-CA", { timeZone: "Asia/Tokyo" });
// const idempotencyKey = `agenda-cron-tokyo-ops:${day}`;

Human-readable interval (also valid for every):

await agenda.every("1 hour", "hourly-ops-email", { kind: "hourly-ops" }, {
  skipImmediate: true,
});

// Idempotency-Key example for hourly:
// const hour = new Date().toISOString().slice(0, 13); // YYYY-MM-DDTHH
// const idempotencyKey = `agenda-cron-hourly-ops:${hour}`;

One-shot delayed transactional send with schedule (not a recurring cron):

await agenda.schedule("in 15 minutes", "trial-expiry-nudge", {
  userId: "u_123",
  kind: "trial-nudge",
});
// Handler should use Idempotency-Key like `agenda-schedule-trial-nudge:u_123:2026-11-05`
// so Agenda retries / lock re-runs do not double-send.

Window-scoped Idempotency-Key (required)

Agenda may re-run a job after a crash, lock expiry, or handler throw. Rovela’s Idempotency-Key (up to 256 characters, 24-hour replay window) returns the original response instead of sending again (Send Email API).

CadenceExample key
Daily digestagenda-cron-daily-digest:2026-11-05
Hourly opsagenda-cron-hourly-ops:2026-11-05T09
Per-tenant dailyagenda-cron-tenant-digest:tenant_42:2026-11-05
One-shot scheduleagenda-schedule-trial-nudge:u_123:2026-11-05

Do not use a random UUID per attempt — that defeats idempotency. Do not put the API key in the key string or in logs.

Secrets and ops checklist

  • ROVELA_API_KEY with sending_access only in the Agenda process env / secret manager (Authentication)
  • MAIL_FROM on a verified domain
  • MONGODB_URI with least privilege for the Agenda collection
  • Recipients from env or a server-side query — not hard-coded in a public repo
  • Graceful drain() on SIGTERM so in-flight POST /emails can complete
  • Monitor Agenda fail events and Rovela webhooks / GET /emails/:id for delivery (Quick Start)

Troubleshooting

SymptomLikely cause
Jobs never runForgot await agenda.start(), or no process keeps Agenda alive
Duplicate cron rows after upgradeMixing v5 db / mongo constructor with v6 backend — follow the migration guide
every options ignoreddata must be passed when using the options argument (every)
Rovela 401Missing/invalid Bearer key, or wrong key type for the endpoint
Rovela 400 on fromDomain not verified (Quick Start)
Duplicate mail on retryMissing or non-stable Idempotency-Key per window
Handler times out under loadRaise lockLifetime, lower concurrency, or move heavy aggregation out of the critical path

Summary

Agenda.js schedules recurring (and one-shot) work on MongoDB — or Postgres / Redis backends in v6 — with define, every, and schedule. Your job handler aggregates live state and calls Rovela POST /emails with a sending_access key kept server-only and a window-scoped Idempotency-Key. Rovela delivers; Agenda does not become an ESP, and Rovela does not become your cron.

Next: verify a domain, create a sending_access key, run the worker above against staging, then confirm acceptance with the returned email id and delivery via events or webhooks.