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.
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:
- Upserts a scheduler with
upsertJobScheduler(schedulerId, { pattern | every, tz?, … }, { name, data, opts }) - 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 - Aggregates a digest / report for the current schedule window inside the Worker processor
- Sends via Rovela with a window-scoped
Idempotency-Key - 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:
- Trigger.dev Cron / Scheduled Tasks Email — Trigger.dev
schedules.task(not Redis BullMQ) - Inngest Cron Transactional Email — Inngest
crontrigger +step.run - Upstash QStash Schedules Email — QStash HTTPS schedule +
Upstash-Signature - Supabase pg_cron Email — Postgres
pg_cron+pg_net - Kubernetes CronJob Email —
batch/v1CronJob → Job Pod - DigitalOcean App Platform Jobs Email — App Platform
kind: SCHEDULED - Heroku Scheduler Email — coarse Scheduler dynos
- Cloudflare Workers Cron Triggers Email — Workers
triggers.crons - Azure Functions Timer Email — Azure NCRONTAB
- GCP Cloud Scheduler Email — Cloud Scheduler → Cloud Functions / Pub/Sub
- AWS EventBridge Scheduler Email — EventBridge Scheduler → Lambda
- Netlify Scheduled Functions Email — Netlify UTC cron
- Fly Scheduled Machines Email — Fly Machine schedules
- Render Cron Transactional Email — Render Cron Job process
- Railway Cron Transactional Email — Railway cron service
- Vercel Cron Digest Email — platform cron
GETto a production route - GitHub Actions Transactional Email — workflow / CI notify
- Scheduled Email Sending — Rovela
scheduled_atwhen the body is already known
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
| Concern | Owner |
|---|---|
Scheduler id, pattern / every, tz, startDate / endDate / limit | BullMQ upsertJobScheduler |
| Redis persistence of scheduler metadata + delayed jobs | Your Redis (BullMQ keys under the queue prefix) |
| Producing the next delayed job when the prior run starts | BullMQ Job Scheduler |
| Worker concurrency, attempts, backoff on the produced job | BullMQ Worker + job template opts |
Server secrets for ROVELA_API_KEY (sending_access) | Your worker process env / secret store |
| Query DB / APIs for the window | Your Worker processor |
| Render HTML + text | Your Worker |
POST /emails, DKIM, queue, bounces | Rovela |
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. UseupsertJobScheduler/getJobSchedulers/removeJobScheduler. LegacyQueue.add(..., { repeat }),getRepeatableJobs, andremoveRepeatable*are deprecated from 5.16.0 and removed in v6 (Repeatable (legacy), migration). patternandeveryare mutually exclusive. Cron viapattern(cron-parser; optional seconds field) or fixed interval viaevery(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
tzfor wall-clock cron. Evaluate patterns in an IANA zone (America/New_York,Asia/Tokyo). For UTC cron, settz: 'UTC'— do not rely on the removed legacyutcflag (Repeat options, Job Schedulers). - Upsert by scheduler id. Calling
upsertJobScheduleragain 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
jobIdon scheduler-produced jobs. BullMQ assigns special ids so runs cannot be created more often than the repeat settings. Discriminate with the job name /datainstead (Job Schedulers). - Template
optscover retries. Putattempts,backoff,removeOnFail, etc. on the third-argument template so each produced job inherits them. - Optional bounds:
startDate,endDate,limit(Repeat options). - Keep
sending_accessserver-only. StoreROVELA_API_KEYin 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
- A verified sending domain in Rovela. Unverified
fromdomains are refused with400(Quick Start). - An API key shaped like
re_…. Prefersending_accessfor the worker (Authentication). - Redis reachable from producers and workers, plus
bullmqinstalled in your Node app. - Worker process env:
ROVELA_API_KEY,MAIL_FROM,DIGEST_TO,REDIS_URL(and any DB URLs the processor needs). - 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
200after 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
datais multi-tenant
Recommended keys:
| Key | Use 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
- Set
ROVELA_API_KEY(sending_access),MAIL_FROM,DIGEST_TO, andREDIS_URLon the worker (and any producer that upserts schedulers). - Deploy / restart workers; run
ensureDailyDigestScheduler()once per environment. - Confirm with
queue.getJobSchedulers()thatdaily-digest-emailshows the expectedpattern/tz(orevery). - Wait for the next tick, or temporarily use a short
everyin a non-prod Redis DB while keeping the window key stable. - Check worker logs for
idempotencyKey+ RovelaemailId. 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
| Symptom | Likely cause | What to do |
|---|---|---|
| Scheduler exists but nothing runs | No Worker / wrong queue name | Start a Worker on the same queue name + Redis |
| Fires less often than cron | Queue backlog / low concurrency | Scale workers; remember next job waits until prior starts processing |
| Legacy repeat errors on v6 | Still using Queue.add + repeat | Migrate to upsertJobScheduler on v5 first (migration) |
| Wrong wall time | Missing tz / assumed local | Set tz: 'UTC' or the correct IANA zone |
Rovela 401 | Bad / missing ROVELA_API_KEY | Fix worker env; confirm sending_access |
Rovela 400 naming a domain | MAIL_FROM domain not verified | Verify the domain under Rovela Domains |
Rovela 429 | Rate limit | Throw so BullMQ retries; keep the same Idempotency-Key |
| Duplicate digests | Retries / multi-worker without a stable key | Use window-scoped keys; Idempotency-Key collapses duplicates within 24h |
| Secrets in Git / Redis payloads | Key committed or stuffed into job.data | Keep only in process env / vault |
Custom jobId rejected / ignored | Scheduler-produced jobs own their ids | Use job name + data; put uniqueness in Idempotency-Key |
Production checklist
- Verified Rovela sending domain;
MAIL_FROMmatches it ROVELA_API_KEYissending_access, in worker env only- Job Scheduler via
upsertJobScheduler(not legacyrepeat) - Documented
pattern+tzorevery; templateattempts/backoffset - 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
emailIdwithout printing secrets - Alerts on consecutive worker failures / stalled jobs
- Optional: Rovela webhooks for
email.bounced/email.complainedon 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.