← Back to blog

BullMQ Repeatable / Cron Jobs Transactional Email with Rovela (2026): upsertJobScheduler + Idempotency-Key per Window

Wire BullMQ Job Schedulers (upsertJobScheduler with cron pattern or every) on Redis to a Worker that sends transactional digests via Rovela POST /emails — sending_access server-only and a window-scoped Idempotency-Key.

Updated November 5, 2026
BullMQ Repeatable / Cron Jobs Transactional Email with Rovela (2026): upsertJobScheduler + Idempotency-Key per Window

BullMQ Job Schedulers own the cron — Rovela delivers the mail

BullMQ is a Redis-backed Node.js job library. Its Job Schedulers (queue.upsertJobScheduler) act as a factory: you declare a scheduler id with repeat settings (pattern cron and/or every milliseconds), and BullMQ enqueues delayed jobs on that cadence. Workers pick them up like any other queue job. Docs still call those produced jobs “repeatable jobs” for historical reasons; the API you want in current BullMQ is Job Schedulers — not the legacy Queue.add(..., { repeat }) path removed in v6 (Job Schedulers, v5→v6 migration).

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 BullMQ Worker calls POST https://api.rovela.dev/emails (Send Email API, Quick Start, Authentication). BullMQ (plus Redis) owns the schedule, delayed-job production, retries, and concurrency. Your worker owns aggregation, secrets for sending_access, and a stable Idempotency-Key per schedule window. Rovela only delivers.

This guide wires BullMQ Job Scheduler → Worker → Rovela that:

  1. Upserts a scheduler with upsertJobScheduler(schedulerId, { pattern | every, tz?, … }, { name, data, opts })
  2. Keeps ROVELA_API_KEY (sending_access), MAIL_FROM, Redis URL, and recipients in server-only env — never in the browser or a public Git tree
  3. Aggregates a digest / report for the current schedule window inside the Worker processor
  4. Sends via Rovela with a window-scoped Idempotency-Key
  5. Survives BullMQ job retries, stalled workers, and duplicate ticks without duplicate mail

Rovela does not create BullMQ queues, Redis keys, or Job Schedulers. BullMQ owns schedule → delayed job → worker. Your worker owns query → map fields → POST /emails. Rovela owns delivery.

Distinct from:

Also distinct from one-off BullMQ delayed jobs (delay) or plain queue.add without a scheduler — this post is the recurring Job Scheduler / cron path only.

What BullMQ schedules vs what Rovela sends

ConcernOwner
Scheduler id, pattern / every, tz, startDate / endDate / limitBullMQ upsertJobScheduler
Redis persistence of scheduler metadata + delayed jobsYour Redis (BullMQ keys under the queue prefix)
Producing the next delayed job when the prior run startsBullMQ Job Scheduler
Worker concurrency, attempts, backoff on the produced jobBullMQ Worker + job template opts
Server secrets for ROVELA_API_KEY (sending_access)Your worker process env / secret store
Query DB / APIs for the windowYour Worker processor
Render HTML + textYour Worker
POST /emails, DKIM, queue, bouncesRovela
Idempotency-Key = bullmq-cron-daily-digest:{yyyy-mm-dd} (or hour / tenant bucket)Your Worker

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

BullMQ Job Scheduler constraints that shape the email worker

From BullMQ docs, treat these as hard requirements for a mail-sending schedule:

  • Prefer Job Schedulers over legacy repeat. Use upsertJobScheduler / getJobSchedulers / removeJobScheduler. Legacy Queue.add(..., { repeat }), getRepeatableJobs, and removeRepeatable* are deprecated from 5.16.0 and removed in v6 (Repeatable (legacy), migration).
  • pattern and every are mutually exclusive. Cron via pattern (cron-parser; optional seconds field) or fixed interval via every (milliseconds) — not both (Repeat strategies).
  • Cron format includes optional seconds. second minute hour day-of-month month day-of-week (second optional). Example weekday 09:00: 0 0 9 * * 1-5 (Repeat strategies).
  • Use tz for wall-clock cron. Evaluate patterns in an IANA zone (America/New_York, Asia/Tokyo). For UTC cron, set tz: 'UTC' — do not rely on the removed legacy utc flag (Repeat options, Job Schedulers).
  • Upsert by scheduler id. Calling upsertJobScheduler again with the same id updates repeat options and/or the job template without duplicating schedulers (Job Schedulers).
  • Production rate follows processing. The scheduler generates the next job when the last one begins processing. A saturated queue or under-provisioned workers can fire less often than the nominal interval (Job Schedulers).
  • One delayed job stays associated while the scheduler is live. Expect a job in Delayed status for the next tick.
  • You cannot set a custom jobId on scheduler-produced jobs. BullMQ assigns special ids so runs cannot be created more often than the repeat settings. Discriminate with the job name / data instead (Job Schedulers).
  • Template opts cover retries. Put attempts, backoff, removeOnFail, etc. on the third-argument template so each produced job inherits them.
  • Optional bounds: startDate, endDate, limit (Repeat options).
  • Keep sending_access server-only. Store ROVELA_API_KEY in the worker environment — never in Redis job payloads that might be inspected broadly, client bundles, or Git (Authentication).
  • Prefer HTTPS POST /emails. Outbound SMTP from a worker is a poor fit (Email API vs SMTP).

When to use BullMQ Job Schedulers + POST /emails (not scheduled_at)

Use BullMQ cron / every → live send when:

  • The HTML must include fresh metrics at fire time
  • You already run Redis + BullMQ workers for background work and want schedules next to the same queues
  • You need Redis-durable recurring jobs without adopting a separate 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 worker (Scheduled Email Sending). BullMQ schedules and scheduled_at solve different problems; this post is the BullMQ path.

Use Trigger.dev / Inngest / QStash when you want those platforms’ schedule runtimes instead of self-hosted Redis BullMQ — 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 worker (Authentication).
  3. Redis reachable from producers and workers, plus bullmq installed in your Node app.
  4. Worker process env: ROVELA_API_KEY, MAIL_FROM, DIGEST_TO, REDIS_URL (and any DB URLs the processor needs).
  5. At least one long-running Worker for the queue — schedulers alone do not send mail.

Upsert a Job Scheduler and Worker (TypeScript)

Daily digest at 09:00 UTC with a cron pattern and tz: 'UTC':

// src/queues/digest.ts
import { Queue, Worker, type ConnectionOptions } from "bullmq";

const ROVELA_URL = "https://api.rovela.dev/emails";
const QUEUE_NAME = "transactional-email";

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

function redisConnection(): ConnectionOptions {
  // ioredis-compatible URL or host/port object — match your ops setup
  return { url: requireEnv("REDIS_URL") } as ConnectionOptions;
}

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

function utcHourKey(d = new Date()) {
  return d.toISOString().slice(0, 13); // YYYY-MM-DDTHH
}

async function buildDailyDigest(day: string) {
  // Replace with your queries against Postgres / Redis / 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 ensureDailyDigestScheduler() {
  const queue = new Queue(QUEUE_NAME, { connection: redisConnection() });

  // Job Scheduler — preferred API (not legacy Queue.add + repeat)
  await queue.upsertJobScheduler(
    "daily-digest-email", // stable scheduler id (upsert key)
    {
      // second minute hour dom month dow — 09:00:00 UTC every day
      pattern: "0 0 9 * * *",
      tz: "UTC",
    },
    {
      name: "daily-digest-email",
      data: { kind: "daily-digest" },
      opts: {
        attempts: 5,
        backoff: { type: "exponential", delay: 3000 },
        removeOnComplete: 1000,
        removeOnFail: 5000,
      },
    }
  );

  await queue.close();
}

export function startDigestWorker() {
  const worker = new Worker(
    QUEUE_NAME,
    async (job) => {
      if (job.name !== "daily-digest-email") return;

      // Prefer the job's scheduled intent over wall-clock after lag
      const when = job.timestamp ? new Date(job.timestamp) : new Date();
      const day = utcDayKey(when);
      const idempotencyKey = `bullmq-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 Redis passwords
      console.log(
        JSON.stringify({
          day,
          idempotencyKey,
          jobId: job.id,
          schedulerKind: job.data?.kind,
        })
      );

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

      return { ok: true, emailId: result.id, day };
    },
    { connection: redisConnection() }
  );

  worker.on("failed", (job, err) => {
    console.error(
      JSON.stringify({
        event: "digest_job_failed",
        jobId: job?.id,
        attemptsMade: job?.attemptsMade,
        message: err.message,
      })
    );
  });

  return worker;
}

Call ensureDailyDigestScheduler() once from a boot/migrate path (safe to re-run — upsert). Run startDigestWorker() in a dedicated process that stays up.

Local timezone morning digest:

await queue.upsertJobScheduler(
  "tokyo-morning-ops-email",
  {
    pattern: "0 0 5 * * *", // 05:00:00 in tz
    tz: "Asia/Tokyo",
  },
  {
    name: "tokyo-morning-ops-email",
    data: { kind: "tokyo-ops" },
    opts: { attempts: 5, backoff: { type: "exponential", delay: 3000 } },
  }
);

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

Fixed-interval ops pulse with every (mutually exclusive with pattern):

await queue.upsertJobScheduler(
  "hourly-ops-email",
  {
    every: 60 * 60 * 1000, // 1 hour
  },
  {
    name: "hourly-ops-email",
    data: { kind: "hourly-ops" },
    opts: { attempts: 5, backoff: { type: "exponential", delay: 2000 } },
  }
);

// idempotencyKey = `bullmq-cron-hourly-ops:${utcHourKey(new Date(job.timestamp))}`

List or remove schedulers when rotating:

const schedulers = await queue.getJobSchedulers();
await queue.removeJobScheduler("daily-digest-email");

Window-scoped Idempotency-Key

Rovela’s Idempotency-Key (up to 256 characters) makes a retry of the same send within 24 hours return the original response instead of mailing again (Send Email API). That matters with BullMQ because:

  • Worker attempts / backoff re-enter the processor after 5xx / timeouts
  • Stalled-job recovery can re-run a processor that already got a Rovela 200 after a crash mid-ack
  • Queue lag can shift wall-clock Date.now() — key off the job’s schedule window (job.timestamp / business day), not millisecond now
  • Multiple worker processes share one Redis scheduler — include tenant ids in the key when job data is multi-tenant

Recommended keys:

KeyUse when
bullmq-cron-daily-digest:{yyyy-mm-dd}One ops digest per UTC day
bullmq-cron-hourly-ops:{yyyy-mm-dd}T{HH}Hourly every / hourly cron
bullmq-cron-tokyo-ops:{yyyy-mm-dd}Local-TZ morning digest (key by schedule tz day)
bullmq-cron-weekly-report:{yyyy}-W{ww}One founder report per ISO week
bullmq-cron-tenant-digest:{tenantId}:{yyyy-mm-dd}Per-tenant job data

If you mail many personalized digests in one job, prefer per-recipient keys (or POST /emails/batch for up to 100 non-template messages — batch rejects template / topic_id by design (Send Email API)).

Attach secrets and verify the schedule

  1. Set ROVELA_API_KEY (sending_access), MAIL_FROM, DIGEST_TO, and REDIS_URL on the worker (and any producer that upserts schedulers).
  2. Deploy / restart workers; run ensureDailyDigestScheduler() once per environment.
  3. Confirm with queue.getJobSchedulers() that daily-digest-email shows the expected pattern / tz (or every).
  4. Wait for the next tick, or temporarily use a short every in a non-prod Redis DB while keeping the window key stable.
  5. Check worker logs for idempotencyKey + Rovela emailId. Optionally correlate later with Rovela webhooks.

Prefer sending_access on production senders — never full_access (Authentication). Never log API keys. Rotate Rovela by create-then-revoke; rotate Redis credentials per your host’s guidance.

Failure modes and how to handle them

SymptomLikely causeWhat to do
Scheduler exists but nothing runsNo Worker / wrong queue nameStart a Worker on the same queue name + Redis
Fires less often than cronQueue backlog / low concurrencyScale workers; remember next job waits until prior starts processing
Legacy repeat errors on v6Still using Queue.add + repeatMigrate to upsertJobScheduler on v5 first (migration)
Wrong wall timeMissing tz / assumed localSet tz: 'UTC' or the correct IANA zone
Rovela 401Bad / missing ROVELA_API_KEYFix worker env; confirm sending_access
Rovela 400 naming a domainMAIL_FROM domain not verifiedVerify the domain under Rovela Domains
Rovela 429Rate limitThrow so BullMQ retries; keep the same Idempotency-Key
Duplicate digestsRetries / multi-worker without a stable keyUse window-scoped keys; Idempotency-Key collapses duplicates within 24h
Secrets in Git / Redis payloadsKey committed or stuffed into job.dataKeep only in process env / vault
Custom jobId rejected / ignoredScheduler-produced jobs own their idsUse job name + data; put uniqueness in Idempotency-Key

Production checklist

  • Verified Rovela sending domain; MAIL_FROM matches it
  • ROVELA_API_KEY is sending_access, in worker env only
  • Job Scheduler via upsertJobScheduler (not legacy repeat)
  • Documented pattern + tz or every; template attempts / backoff set
  • At least one Worker process always on for that queue
  • Rovela send uses a window-scoped Idempotency-Key
  • Redis HA / persistence appropriate for your RPO
  • Logs show successful emailId without printing secrets
  • Alerts on consecutive worker failures / stalled jobs
  • Optional: Rovela webhooks for email.bounced / email.complained on digest recipients

FAQ

Can BullMQ call Rovela without my Worker? No usefully — the scheduler only enqueues jobs. Your processor must aggregate live data and keep sending_access local. Rovela does not host BullMQ or Redis.

Should I use Rovela scheduled_at from the worker? Usually no. If BullMQ 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).

BullMQ vs Trigger.dev vs Inngest vs QStash vs platform cron? Use BullMQ when schedules should live next to your own Redis queues. Use Trigger.dev / Inngest when you want those SaaS task runtimes (Trigger.dev, Inngest). Use QStash for HTTP-native signed schedules (QStash). Use Vercel/Render/K8s cron when the schedule should live next to that platform’s compute.

pattern or every? Prefer pattern + tz for wall-clock digests (09:00 local / UTC). Prefer every for fixed-interval pulses (every N ms) that do not need calendar alignment.

What about legacy repeat on Queue.add? Migrate to Job Schedulers before BullMQ v6 — the legacy APIs are gone there (migration).

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 BullMQ attempts and Rovela Idempotency-Key? BullMQ 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 scheduler; let Rovela deliver

Upsert a BullMQ Job Scheduler with cron pattern (or every), put live aggregation and POST /emails in the Worker, scope Idempotency-Key to the schedule window (and tenant), and keep sending_access server-only. Redis + BullMQ wake on time; Rovela delivers the transactional mail.