Sidekiq-Cron Transactional Email with Rovela (2026): schedule.yml / Job.create + Idempotency-Key per Window
Wire Sidekiq-Cron recurring jobs (YAML cron or Job.create) to Rovela POST /emails with server-only sending_access and a window-scoped Idempotency-Key for digests and ops reports.
Sidekiq-Cron owns the schedule — Rovela delivers the mail
Sidekiq-Cron is a scheduling add-on for Sidekiq. It runs a thread alongside Sidekiq workers and enqueues jobs at times you declare with cron notation (* * * * *) or Fugit natural language (every day at five). The poller checks for due jobs about every 30 seconds by default and is safe when multiple Sidekiq processes run — it will not enqueue the same cron job twice across nodes.
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 Sidekiq worker calls POST https://api.rovela.dev/emails (Send Email API, Quick Start, Authentication). Sidekiq-Cron owns the cadence and “is due?” logic. Your worker owns aggregation, secrets for sending_access, and a stable Idempotency-Key per schedule window. Rovela only delivers.
This guide wires Sidekiq-Cron → Sidekiq worker → Rovela that:
- Declares a recurring job in
config/schedule.yml(orSidekiq::Cron::Job.create/load_from_hash!) - 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
perform - Sends via Rovela with a window-scoped
Idempotency-Key - Survives Sidekiq retries, process restarts, and multi-process pollers without duplicate mail
Rovela does not create Sidekiq-Cron schedules, Redis, or workers. Sidekiq-Cron owns schedule → enqueue. Your worker owns query → map fields → POST /emails. Rovela owns delivery.
Distinct from:
- Celery Beat Transactional Email — Python Celery
beat_schedule/crontab - Agenda.js Scheduled Jobs Email — Mongo-backed
agendadefine/every(Node) - Temporal Schedule Email — Temporal Schedule Spec + Activity
- BullMQ Repeatable / Cron Jobs Email — Redis BullMQ
upsertJobScheduler - Trigger.dev Cron / Scheduled Tasks Email — Trigger.dev
schedules.task - 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 Sidekiq perform_in / perform_at delay — this post is the Sidekiq-Cron recurring path only (schedule.yml / Sidekiq::Cron::Job). Sidekiq Enterprise’s built-in Periodic Jobs product is a separate commercial feature; this guide uses the open-source sidekiq-cron gem.
What Sidekiq-Cron schedules vs what Rovela sends
| Concern | Owner |
|---|---|
Job name, cron, class, queue, args, namespace, status | Sidekiq-Cron (Job properties) |
Schedule file / hash load (config/schedule.yml, load_from_hash!) | Sidekiq-Cron on Sidekiq server startup |
Poll interval (cron_poll_interval, default 30s) | Sidekiq::Cron.configure |
| Timezone for cron evaluation | Rails timezone, or a zone suffix in the cronline (0 9 * * * America/Chicago) |
| Enqueueing the worker when due | Sidekiq-Cron poller thread (multi-process safe) |
| Job retries, acknowledgements, concurrency | Sidekiq worker + sidekiq_options retry: |
Server secrets for ROVELA_API_KEY (sending_access) | Your Sidekiq process env / secret store |
| Query DB / APIs for the window | Your worker perform |
| Render HTML + text | Your worker perform |
POST /emails, DKIM, queue, bounces | Rovela |
Idempotency-Key = sidekiq-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).
Sidekiq-Cron constraints that shape the email worker
From the Sidekiq-Cron README, treat these as hard requirements for a mail-sending schedule:
- At least one Sidekiq process must be running. Cron jobs are only scheduled while Sidekiq is up — a stopped fleet means a quiet schedule.
- Multi-process is OK. The gem “doesn't schedule the same job multiple times when more than one Sidekiq process is running.” Still keep Rovela
Idempotency-Keyfor retries and crash redelivery. - Default poll is 30 seconds. Sub-minute (6-field) cronlines need a lower
cron_poll_intervalthan the job cadence. - YAML auto-loads from
config/schedule.yml. The gem loads that file automatically (ERB supported). Override withcron_schedule_file, or set it toniland load manually withload_from_hash!(…, source: "schedule")so removed YAML entries are pruned. - Timezone. With Rails, cron is evaluated against the Rails timezone; otherwise default UTC. Prefer an explicit zone in the cronline when wall-clock mail must mean a city:
'0 9 * * * America/New_York'. - Natural language via Fugit. Strings like
"every day at five"work;natural_cron_parsing_mode:strictrejects ambiguous multi-line parses. - Keep
sending_accessserver-only. StoreROVELA_API_KEYin the Sidekiq process environment — never in jobargsthat Redis may retain, 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 Sidekiq-Cron + POST /emails (not scheduled_at)
Use Sidekiq-Cron → live send when:
- The HTML must include fresh metrics at fire time
- You already run Sidekiq (Redis) for background work in a Ruby / Rails app and want schedules next to the same workers
- You need cron-like digests 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 Sidekiq process (Scheduled Email Sending). Sidekiq-Cron schedules and scheduled_at solve different problems; this post is the Sidekiq-Cron path.
Use Celery Beat / BullMQ / Agenda / Temporal / Trigger.dev / Inngest / QStash when you want those runtimes instead of Sidekiq — 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). - Ruby app with Sidekiq + Redis and at least one Sidekiq server process (so the cron poller runs).
- Gem:
sidekiq-cronin the Gemfile (bundle install). Non-Rails apps needrequire 'sidekiq-cron'afterrequire 'sidekiq'. - Worker env:
ROVELA_API_KEY,MAIL_FROM,DIGEST_TO, plus Redis URL and any DB URLs the job needs.
Declare schedule.yml and worker (Ruby)
Daily digest at 09:00 UTC via YAML (auto-loaded from config/schedule.yml):
# config/schedule.yml
daily_digest_email:
cron: "0 9 * * *" # 09:00 every day (Rails TZ, else UTC; or append a zone)
class: "DailyDigestEmailWorker"
queue: mailers
description: "Ops daily digest via Rovela"
Optional initializer overrides:
# config/initializers/sidekiq_cron.rb
Sidekiq::Cron.configure do |config|
# config.cron_schedule_file = "config/schedule.yml" # default
# config.cron_poll_interval = 30 # default seconds
# config.cron_schedule_file = nil # disable auto-load; load manually
end
Worker that aggregates live stats and calls Rovela (stdlib Net::HTTP — swap for Faraday if you prefer):
# app/workers/daily_digest_email_worker.rb
require "json"
require "net/http"
require "uri"
require "time"
class DailyDigestEmailWorker
include Sidekiq::Worker
sidekiq_options queue: :mailers, retry: 5
def perform(*_args)
day = Time.now.utc.strftime("%Y-%m-%d")
idempotency_key = "sidekiq-cron-daily-digest:#{day}"
digest = build_daily_digest(day)
to = ENV.fetch("DIGEST_TO").split(",").map(&:strip).reject(&:empty?)
# never log ROVELA_API_KEY or Redis passwords
Sidekiq.logger.info(
{
day: day,
idempotencyKey: idempotency_key,
jid: jid,
}.to_json
)
result = send_transactional(
to: to,
subject: digest[:subject],
html: digest[:html],
text: digest[:text],
idempotency_key: idempotency_key,
tags: {
kind: "sidekiq-cron-daily-digest",
day: day,
platform: "sidekiq-cron",
worker: self.class.name,
}
)
{ ok: true, emailId: result["id"], day: day }
end
private
def require_env(name)
ENV.fetch(name) { raise "missing env #{name}" }
end
def build_daily_digest(day)
# Replace with your queries against Postgres / Redis / APIs
stats = { signups: 12, paid: 3, incidents: 1, failed_jobs: 0 }
subject = "Acme daily digest — #{day}"
text = [
"Signups: #{stats[:signups]}",
"Paid: #{stats[:paid]}",
"Open incidents: #{stats[:incidents]}",
"Failed jobs: #{stats[:failed_jobs]}",
].join("\n")
html = <<~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[:failed_jobs]}</b></li>
</ul>
<p><a href="https://app.example.com/admin">Open admin</a></p>
HTML
{ subject: subject, html: html, text: text, stats: stats }
end
def send_transactional(to:, subject:, html:, text:, idempotency_key:, tags:)
uri = URI("https://api.rovela.dev/emails")
payload = {
from: require_env("MAIL_FROM"),
to: to,
subject: subject,
html: html,
text: text,
tags: tags,
}
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true
http.open_timeout = 10
http.read_timeout = 30
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer #{require_env('ROVELA_API_KEY')}"
req["Content-Type"] = "application/json"
req["Idempotency-Key"] = idempotency_key
req.body = JSON.generate(payload)
res = http.request(req)
body = res.body.to_s.empty? ? {} : JSON.parse(res.body)
if res.code.to_i >= 400
raise "Rovela #{res.code}: #{body['message'] || 'send failed'}"
end
body
end
end
Programmatic create (dynamic source) instead of YAML:
Sidekiq::Cron::Job.create(
name: "daily-digest-email",
cron: "0 9 * * *",
class: "DailyDigestEmailWorker",
queue: "mailers",
description: "Ops daily digest via Rovela"
)
# => true / false
Bang-load from hash so schedule-source jobs missing from the hash are pruned:
# e.g. Sidekiq.configure_server { |c| c.on(:startup) { ... } }
# after setting Sidekiq::Cron.configure { |c| c.cron_schedule_file = nil }
schedule = YAML.load_file(Rails.root.join("config/schedule.yml"))
Sidekiq::Cron::Job.load_from_hash!(schedule, source: "schedule")
Local-timezone morning digest (zone in the cronline):
# config/schedule.yml
tokyo_morning_ops_email:
cron: "0 5 * * * Asia/Tokyo"
class: "TokyoOpsEmailWorker"
queue: mailers
# In perform — key Idempotency-Key by the schedule timezone day:
require "tzinfo"
day = TZInfo::Timezone.get("Asia/Tokyo").now.strftime("%Y-%m-%d")
idempotency_key = "sidekiq-cron-tokyo-ops:#{day}"
Every-15-minutes ops pulse:
hourly_ops_pulse: # name is free-form; cadence is cron
cron: "*/15 * * * *"
class: "OpsPulseEmailWorker"
queue: mailers
# idempotency_key = "sidekiq-cron-ops-pulse:#{Time.now.utc.strftime('%Y-%m-%dT%H-%M')}"
# For */15, bucket to the quarter-hour window, not wall-clock seconds.
Cron examples that map well to mail cadences (crontab.guru / Fugit):
| Schedule | Meaning |
|---|---|
0 0 * * * | Daily at midnight (evaluated TZ) |
0 9 * * * | Daily at 09:00 |
*/15 * * * * | Every 15 minutes |
30 7 * * 1 | Mondays 07:30 |
0 */3 * * * | Every three hours |
0 9 * * * America/Chicago | 09:00 America/Chicago |
every day at five | Fugit natural language → 0 5 * * * |
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 Sidekiq because:
- Sidekiq retry re-enters
performafter 5xx / timeouts / raised errors - Worker crash after Rovela
200but before ack can redeliver the job - Queue lag can shift wall-clock
Time.now— key off the business schedule window (UTC day / TZ day / hour), not millisecond now - Multi-tenant args need tenant ids in the key
Recommended keys:
| Key | Use when |
|---|---|
sidekiq-cron-daily-digest:{yyyy-mm-dd} | One ops digest per UTC day |
sidekiq-cron-ops-pulse:{yyyy-mm-dd}T{HH}-{MM} | /15 or hourly crontab (bucket to the window) |
sidekiq-cron-tokyo-ops:{yyyy-mm-dd} | Local-TZ morning digest (key by cronline zone day) |
sidekiq-cron-weekly-report:{yyyy}-W{ww} | One founder report per ISO week |
sidekiq-cron-tenant-digest:{tenantId}:{yyyy-mm-dd} | Per-tenant worker args |
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, and Redis URL on the Sidekiq host (web processes do not need the Rovela key if they never send). - Deploy Sidekiq with
sidekiq-cronloaded; confirm Web UI / Redis shows the cron job enabled. - Confirm
config/schedule.yml(orSidekiq::Cron::Job.all) listsdaily_digest_email/DailyDigestEmailWorkerwith the expected cron and timezone. - Wait for the next tick, or temporarily use a short cron in non-prod while keeping the window key stable.
- Check Sidekiq 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 |
|---|---|---|
| Cron never fires | No Sidekiq process / cron disabled | Start Sidekiq; check Sidekiq::Cron.configure enabled / job status |
| Job enqueued but never runs | Wrong queue / no consumers | Point Sidekiq -q at mailers (or your queue) |
| Wrong wall time | Assumed UTC / Rails TZ mismatch | Append an IANA zone to the cronline or align Rails config.time_zone |
| Duplicate digests after retries | Unstable key / keyed by Time.now ms | Use window-scoped keys; Idempotency-Key collapses duplicates within 24h |
| YAML changes ignored | Auto-load overridden / stale Redis job | Use load_from_hash!(…, source: "schedule") bang load; check cron_schedule_file |
| Sub-minute cron misses | Poll interval 30s > cadence | Lower cron_poll_interval below the job interval |
Rovela 401 | Bad / missing ROVELA_API_KEY | Fix Sidekiq env; confirm sending_access |
Rovela 400 naming a domain | MAIL_FROM domain not verified | Verify the domain under Rovela Domains |
Rovela 429 | Rate limit | Raise so Sidekiq retries; keep the same Idempotency-Key |
| Secrets in Git / job args | Key committed or stuffed into args | Keep only in process env / vault |
Production checklist
- Verified Rovela sending domain;
MAIL_FROMmatches it ROVELA_API_KEYissending_access, in Sidekiq env onlysidekiq-crongem loaded; at least one Sidekiq server process always on- Documented cron + timezone (YAML or
Job.create); workerretryconfigured - Sidekiq consuming the job’s queue
- Rovela send uses a window-scoped
Idempotency-Key - Schedule source of truth is versioned YAML (or controlled bang-load)
- Logs show successful
emailIdwithout printing secrets - Alerts on consecutive job failures / Sidekiq down
- Optional: Rovela webhooks for
email.bounced/email.complainedon digest recipients
FAQ
Can Sidekiq-Cron call Rovela without a worker? No usefully — the poller only enqueues Sidekiq jobs. Your worker must aggregate live data and keep sending_access local. Rovela does not host Sidekiq or Redis.
Should I use Rovela scheduled_at from the worker? Usually no. If Sidekiq-Cron 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).
Sidekiq-Cron vs Celery Beat vs BullMQ vs Agenda vs Temporal vs Trigger.dev vs platform cron? Use Sidekiq-Cron when schedules should live next to your Ruby Sidekiq workers. Use Celery Beat for Python Celery (Celery Beat). Use BullMQ / Agenda when schedules live next to Node Redis/Mongo queues (BullMQ, Agenda). Use Temporal for durable Schedule Specs (Temporal). Use Trigger.dev / Inngest / QStash for those SaaS runtimes. Use Vercel/Render/K8s cron when the schedule should live next to that platform’s compute.
YAML or Job.create? Prefer versioned config/schedule.yml (or bang load_from_hash! with source: "schedule") for digests you promote across environments. Use dynamic Job.create for operator-driven one-offs you manage from code/console.
What about Sidekiq Enterprise Periodic Jobs? A separate commercial feature. This guide uses open-source sidekiq-cron (schedule.yml / Sidekiq::Cron::Job). The Rovela send pattern (server-only sending_access + window Idempotency-Key) is the same if you enqueue an equivalent worker from Enterprise Periodic Jobs.
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 Sidekiq retries and Rovela Idempotency-Key? Sidekiq 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 cron schedule; let Rovela deliver
Declare a Sidekiq-Cron job in YAML (or Job.create), put live aggregation and POST /emails in the worker, scope Idempotency-Key to the schedule window (and tenant), and keep sending_access server-only. Sidekiq-Cron wakes on time; Rovela delivers the transactional mail.