← Back to blog

delayed_job Recurring Transactional Email with Rovela (2026): Recurring Pattern + Idempotency-Key per Window

Wire Ruby delayed_job recurring patterns to Rovela POST /emails with server-only sending_access and a window-scoped Idempotency-Key for digests and ops reports.

Updated November 9, 2026
delayed_job Recurring Transactional Email with Rovela (2026): Recurring Pattern + Idempotency-Key per Window

delayed_job recurring owns the schedule — Rovela delivers the mail

delayed_job is the classic Active Record–backed background job library for Rails. It does not ship a first-class cron DSL like Sidekiq-Cron; recurring mail usually means either (a) a gem such as delayed_job_recurring / custom after re-enqueue, or (b) a tiny external ticker (Clockwork / OS cron) that enqueues a delayed_job each window. This guide focuses on a durable recurring pattern on top of delayed_job whose job method calls Rovela with live data.

Distinct from Sidekiq-Cron, Resque-scheduler, and Solid Queue Recurring.

Rovela queues, DKIM-signs, and delivers when your job method calls POST https://api.rovela.dev/emails (Send Email API, Quick Start, Authentication). delayed_job recurring owns the cadence and “is due?” logic. Your job method owns aggregation, secrets for sending_access, and a stable Idempotency-Key per schedule window. Rovela only delivers.

This guide wires delayed_job recurring → job method → Rovela that:

  1. Declares a recurring delayed_job (gem or re-enqueue pattern) that runs DailyDigestEmailJob once per window and POSTs to Rovela
  2. Keeps ROVELA_API_KEY (sending_access), MAIL_FROM, and recipients in server-only config — never in browsers, SPA bundles, or a public Git tree
  3. Aggregates a digest / report for the current schedule window inside the job method
  4. Sends via Rovela with a window-scoped Idempotency-Key
  5. Survives retries, restarts, and multi-worker fleets without duplicate mail

Rovela does not create delayed_job recurring schedules, workers, or storage. delayed_job recurring owns schedule → enqueue/fire → execute. Your job method owns query → map fields → POST /emails. Rovela owns delivery.

Distinct from:

If the recurring layer feels too thin for your stack, prefer Solid Queue Recurring Email on Rails 8+ or Sidekiq-Cron.

What delayed_job recurring schedules vs what Rovela sends

ConcernOwner
Recurring registration / next-run enqueuedelayed_job_recurring gem, custom after, or Clockwork/cron enqueue
Job storage (DB table)delayed_job
Workers (rake jobs:work)delayed_job workers
Server secrets for ROVELA_API_KEY (sending_access)Process / worker env / secret store
Query DB / APIs for the windowYour job method
Render HTML + textYour job method
POST /emails, DKIM, queue, bouncesRovela
Idempotency-Key = delayed-job-daily-digest:{yyyy-mm-dd}Your job method

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

delayed_job recurring constraints that shape the email job

  • delayed_job alone is a queue, not a cron service — you must add a recurring strategy.

  • Workers must stay up to execute due jobs.

  • Avoid duplicate recurring registrations on deploy (idempotent schedule bootstrap).

  • Prefer Solid Queue recurring.yml on modern Rails if you are migrating off delayed_job.

  • Keep secrets in ENV, not in YAML args stored in the jobs table.

  • Keep sending_access server-only. Store ROVELA_API_KEY in environment / vault — never in client apps or public Git (Authentication).

  • Prefer HTTPS POST /emails. Outbound SMTP from a worker is a poor fit (Email API vs SMTP).

When to use delayed_job recurring + POST /emails (not scheduled_at)

Use delayed_job recurring → live send when:

  • The HTML must include fresh metrics at fire time
  • You already run delayed_job recurring 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 scheduler (Scheduled Email Sending). Prefer Solid Queue or Sidekiq-Cron for new Rails apps; use this pattern when you must stay on delayed_job.

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. Rails app with delayed_job (Active Record backend) and workers; plus a recurring strategy (gem or ticker).
  4. Config: ROVELA_API_KEY, MAIL_FROM, DIGEST_TO.
  5. A job class responding to perform (Delayed::Worker compatible).

Register the schedule

Option A — delayed_job_recurring-style (illustrative API; pin your gem’s docs):

class DailyDigestEmailJob
  include Delayed::RecurringJob  # if using delayed_job_recurring
  run_every 1.day
  run_at Time.utc(2000,1,1,9,0,0) # time-of-day anchor; confirm gem API
  timezone "UTC"

  def perform
    send_rovela_digest!
  end
end

# boot / initializer (once):
DailyDigestEmailJob.schedule! if defined?(DailyDigestEmailJob)

Option B — OS cron / Clockwork enqueues a one-shot delayed_job each window:

# lib/tasks / clockwork
DailyDigestEmailJob.delay(queue: "mailers").perform
0 9 * * * cd /var/www/app && bin/rails runner 'DailyDigestEmailJob.delay.perform'

Workers:

bundle exec rake jobs:work QUEUE=mailers

Job method that calls Rovela

require "net/http"
require "json"
require "uri"

class DailyDigestEmailJob
  def perform
    send_rovela_digest!
  end

  def send_rovela_digest!
    day = Time.now.utc.strftime("%Y-%m-%d")
    idempotency_key = "delayed-job-daily-digest:#{day}"
    stats = { signups: 12, paid: 3, incidents: 1 }
    to = ENV.fetch("DIGEST_TO").split(",").map(&:strip).reject(&:empty?)
    subject = "Acme daily digest — #{day}"
    text = "Signups: #{stats[:signups]}\nPaid: #{stats[:paid]}"
    html = "<h1>Daily digest — #{day}</h1><ul><li>Signups: <b>#{stats[:signups]}</b></li><li>Paid: <b>#{stats[:paid]}</b></li></ul>"

    Rails.logger.info({ msg: "delayed_job digest", day: day, idempotency_key: idempotency_key }.to_json)

    uri = URI("https://api.rovela.dev/emails")
    req = Net::HTTP::Post.new(uri)
    req["Authorization"] = "Bearer #{ENV.fetch('ROVELA_API_KEY')}"
    req["Content-Type"] = "application/json"
    req["Idempotency-Key"] = idempotency_key
    req.body = JSON.generate(
      from: ENV.fetch("MAIL_FROM"), to: to, subject: subject, html: html, text: text,
      tags: { kind: "delayed-job-daily-digest", day: day, platform: "delayed-job" }
    )
    res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true, open_timeout: 10, read_timeout: 30) { |h| h.request(req) }
    raise "Rovela #{res.code}: #{res.body}" unless res.is_a?(Net::HTTPSuccess)
    Rails.logger.info({ msg: "rovela accepted", emailId: JSON.parse(res.body)["id"] }.to_json)
  end
end

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 because:

  • Retries re-enter the job after 5xx / timeouts
  • Crash after Rovela 200 but before the job is marked succeeded can redeliver
  • Queue lag can shift wall-clock — key off the business schedule window, not millisecond now
  • Multi-tenant jobs need tenant ids in the key

Recommended keys:

KeyUse when
delayed-job-daily-digest:{yyyy-mm-dd}One ops digest per UTC day
delayed-job-ops-pulse:{yyyy-mm-dd}T{HH}-{MM}/15 or hourly crontab (bucket to the window)
delayed-job-tokyo-ops:{yyyy-mm-dd}Local-TZ morning digest
delayed-job-weekly-report:{yyyy}-W{ww}One founder report per ISO week
delayed-job-tenant-digest:{tenantId}:{yyyy-mm-dd}Per-tenant job 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).

Attach secrets and verify the schedule

  1. Set ROVELA_API_KEY (sending_access), MAIL_FROM, DIGEST_TO on the process that runs the scheduler/worker.
  2. Confirm the schedule entry is registered with the expected cron and timezone.
  3. Confirm workers are online.
  4. Wait for the next tick, or trigger once in non-prod while keeping the window key stable.
  5. Check logs for idempotencyKey + Rovela emailId. Optionally correlate later with Rovela webhooks.

Prefer sending_access on production senders — never full_access. Never log API keys. Rotate Rovela by create-then-revoke.

Failure modes and how to handle them

SymptomLikely causeWhat to do
Cron never firesScheduler/worker downKeep the scheduling process always on
Double digestsMultiple schedulers / replicasSingle leader or distributed lock / persistent store
Wrong wall timeAssumed UTC / wrong timezoneSet timezone explicitly
Duplicate digests after retriesUnstable key / keyed by ms nowUse window-scoped keys; Idempotency-Key collapses duplicates within 24h
Rovela 401Bad / missing ROVELA_API_KEYFix server env; confirm sending_access
Rovela 400 naming a domainMAIL_FROM domain not verifiedVerify the domain under Rovela Domains
Rovela 429Rate limitRaise/retry; keep the same Idempotency-Key
Secrets in GitKey committedKeep only in env / vault

Production checklist

  • Verified Rovela sending domain; MAIL_FROM matches it
  • ROVELA_API_KEY is sending_access, in worker env / vault only
  • Scheduler + workers always running as documented for delayed_job recurring
  • Documented cron + timezone
  • Rovela send uses a window-scoped Idempotency-Key
  • Logs show successful emailId without printing secrets
  • Alerts on consecutive job failures / scheduler down
  • Optional: Rovela webhooks for email.bounced / email.complained on digest recipients

FAQ

Can the scheduler call Rovela without a job method? No usefully — the scheduler only wakes your code. Your method must aggregate live data and keep sending_access local. Rovela does not host your scheduler or workers.

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

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 scheduler retries and Rovela Idempotency-Key? The scheduler 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 recurring job; let Rovela deliver

Register the delayed_job recurring schedule with an explicit cron and timezone, put live aggregation and POST /emails in the job method, scope Idempotency-Key to the schedule window (and tenant), and keep sending_access server-only. delayed_job recurring wakes on time; Rovela delivers the transactional mail.