← Back to blog

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.

Updated November 5, 2026
Sidekiq-Cron Transactional Email with Rovela (2026): schedule.yml / Job.create + Idempotency-Key per Window

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:

  1. Declares a recurring job in config/schedule.yml (or Sidekiq::Cron::Job.create / load_from_hash!)
  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 perform
  4. Sends via Rovela with a window-scoped Idempotency-Key
  5. 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:

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

ConcernOwner
Job name, cron, class, queue, args, namespace, statusSidekiq-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 evaluationRails timezone, or a zone suffix in the cronline (0 9 * * * America/Chicago)
Enqueueing the worker when dueSidekiq-Cron poller thread (multi-process safe)
Job retries, acknowledgements, concurrencySidekiq worker + sidekiq_options retry:
Server secrets for ROVELA_API_KEY (sending_access)Your Sidekiq process env / secret store
Query DB / APIs for the windowYour worker perform
Render HTML + textYour worker perform
POST /emails, DKIM, queue, bouncesRovela
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-Key for retries and crash redelivery.
  • Default poll is 30 seconds. Sub-minute (6-field) cronlines need a lower cron_poll_interval than the job cadence.
  • YAML auto-loads from config/schedule.yml. The gem loads that file automatically (ERB supported). Override with cron_schedule_file, or set it to nil and load manually with load_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 :strict rejects ambiguous multi-line parses.
  • Keep sending_access server-only. Store ROVELA_API_KEY in the Sidekiq process environment — never in job args that 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

  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. Ruby app with Sidekiq + Redis and at least one Sidekiq server process (so the cron poller runs).
  4. Gem: sidekiq-cron in the Gemfile (bundle install). Non-Rails apps need require 'sidekiq-cron' after require 'sidekiq'.
  5. 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):

ScheduleMeaning
0 0 * * *Daily at midnight (evaluated TZ)
0 9 * * *Daily at 09:00
*/15 * * * *Every 15 minutes
30 7 * * 1Mondays 07:30
0 */3 * * *Every three hours
0 9 * * * America/Chicago09:00 America/Chicago
every day at fiveFugit 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 perform after 5xx / timeouts / raised errors
  • Worker crash after Rovela 200 but 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:

KeyUse 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

  1. 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).
  2. Deploy Sidekiq with sidekiq-cron loaded; confirm Web UI / Redis shows the cron job enabled.
  3. Confirm config/schedule.yml (or Sidekiq::Cron::Job.all) lists daily_digest_email / DailyDigestEmailWorker with the expected cron and timezone.
  4. Wait for the next tick, or temporarily use a short cron in non-prod while keeping the window key stable.
  5. Check Sidekiq 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
Cron never firesNo Sidekiq process / cron disabledStart Sidekiq; check Sidekiq::Cron.configure enabled / job status
Job enqueued but never runsWrong queue / no consumersPoint Sidekiq -q at mailers (or your queue)
Wrong wall timeAssumed UTC / Rails TZ mismatchAppend an IANA zone to the cronline or align Rails config.time_zone
Duplicate digests after retriesUnstable key / keyed by Time.now msUse window-scoped keys; Idempotency-Key collapses duplicates within 24h
YAML changes ignoredAuto-load overridden / stale Redis jobUse load_from_hash!(…, source: "schedule") bang load; check cron_schedule_file
Sub-minute cron missesPoll interval 30s > cadenceLower cron_poll_interval below the job interval
Rovela 401Bad / missing ROVELA_API_KEYFix Sidekiq env; confirm sending_access
Rovela 400 naming a domainMAIL_FROM domain not verifiedVerify the domain under Rovela Domains
Rovela 429Rate limitRaise so Sidekiq retries; keep the same Idempotency-Key
Secrets in Git / job argsKey committed or stuffed into argsKeep only in process env / vault

Production checklist

  • Verified Rovela sending domain; MAIL_FROM matches it
  • ROVELA_API_KEY is sending_access, in Sidekiq env only
  • sidekiq-cron gem loaded; at least one Sidekiq server process always on
  • Documented cron + timezone (YAML or Job.create); worker retry configured
  • 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 emailId without printing secrets
  • Alerts on consecutive job failures / Sidekiq down
  • Optional: Rovela webhooks for email.bounced / email.complained on 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.