Hangfire Recurring Jobs Transactional Email with Rovela (2026): RecurringJob.AddOrUpdate + Idempotency-Key per Window
Wire Hangfire RecurringJob.AddOrUpdate (cron / Cron.* + RecurringJobOptions) to Rovela POST /emails with server-only sending_access and a window-scoped Idempotency-Key for digests and ops reports.
Hangfire owns the schedule — Rovela delivers the mail
Hangfire is the go-to background job library for .NET and ASP.NET Core. Recurring work lives in persistent storage (SQL Server, Redis, PostgreSQL, and others). A Hangfire Server component checks recurring jobs on a minute-based interval, then enqueues each due entry as a normal fire-and-forget background job you can track in the Dashboard (Performing Recurrent Tasks).
You register or update a schedule with RecurringJob.AddOrUpdate: a unique recurring-job id, a method call expression, and either Cron.* helpers or a CRON string. Hangfire 1.8 prefers an explicit id and RecurringJobOptions for timezone (optional queue name as a separate argument). 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 Hangfire job method calls POST https://api.rovela.dev/emails (Send Email API, Quick Start, Authentication). Hangfire 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 Hangfire recurring job → job method → Rovela that:
- Declares
RecurringJob.AddOrUpdate("daily-digest-email", …, Cron.Daily / "0 9 * * *", options) - Keeps
ROVELA_API_KEY(sending_access),MAIL_FROM, storage connection strings, and recipients in server-only config — never in browsers, SPA bundles, or a public Git tree - Aggregates a digest / report for the current schedule window inside the job method
- Sends via Rovela with a window-scoped
Idempotency-Key - Survives Hangfire AutomaticRetry, worker restarts, and multi-server fleets without duplicate mail
Rovela does not create Hangfire recurring jobs, SQL/Redis storage, or Background Job Servers. Hangfire owns schedule → enqueue → execute. Your job method owns query → map fields → POST /emails. Rovela owns delivery.
Distinct from:
- Sidekiq-Cron Transactional Email — Ruby Sidekiq-Cron
schedule.yml/Job.create - 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 Hangfire BackgroundJob.Enqueue / Schedule delay — this post is the recurring path only (RecurringJob.AddOrUpdate / cron). Continuations (ContinueJobWith) and batches are separate Hangfire features.
What Hangfire schedules vs what Rovela sends
| Concern | Owner |
|---|---|
Recurring job id, CRON / Cron.*, method expression, queue, timezone | Hangfire RecurringJob.AddOrUpdate (docs) |
| Persistent storage (SQL Server / Redis / …) | Your Hangfire storage package |
| Minute-based recurring check → enqueue fire-and-forget job | Hangfire Server |
Job retries (AutomaticRetry), workers, Dashboard | Hangfire |
Server secrets for ROVELA_API_KEY (sending_access) | ASP.NET Core / worker process config / secret store |
| Query DB / APIs for the window | Your job class method |
| Render HTML + text | Your job class method |
POST /emails, DKIM, queue, bounces | Rovela |
Idempotency-Key = hangfire-daily-digest:{yyyy-mm-dd} (or hour / tenant bucket) | Your job method |
A 200 from Rovela means accepted and queued, not “already in the inbox” (Quick Start).
Hangfire constraints that shape the email job
From Hangfire’s recurring-tasks docs and 1.8 guidance, treat these as hard requirements for a mail-sending schedule:
- Hangfire Server must stay up. Recurring scheduling and processing run inside the Background Job Server. If you host processing inside ASP.NET, keep the app always running (ASP.NET Core Applications).
- Use an explicit recurring-job id. Hangfire 1.8 deprecates implicit identifiers — call
AddOrUpdate("daily-digest-email", …)so multiple schedules do not collide on the same method name. - Prefer
RecurringJobOptionsfor timezone. OptionaltimeZone:/queue:overloads are deprecated in favor ofnew RecurringJobOptions { TimeZone = … }and an explicit queue argument when needed. - CRON is five-field (
minute hour day month weekday) unless you intentionally use packages that extend it — Hangfire’s built-in recurring scheduler checks about once a minute, so sub-minute mail is not a good fit. - Identifiers should be unique (and may be case-sensitive depending on storage).
RemoveIfExists/Triggermanage lifecycle; a manualTriggerdoes not rewrite the next scheduled time.- Keep
sending_accessserver-only. StoreROVELA_API_KEYin environment / Key Vault / secrets manager — never in client apps, job argument payloads that land in storage, or Git (Authentication). - Prefer HTTPS
POST /emails. Outbound SMTP from a worker is a poor fit (Email API vs SMTP).
When to use Hangfire + POST /emails (not scheduled_at)
Use Hangfire recurring → live send when:
- The HTML must include fresh metrics at fire time
- You already run Hangfire for .NET background work and want schedules next to the same workers / Dashboard
- 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 Hangfire Server (Scheduled Email Sending). Hangfire schedules and scheduled_at solve different problems; this post is the Hangfire path.
Use Sidekiq-Cron / Celery Beat / BullMQ / Agenda / Temporal / Trigger.dev / Inngest / QStash when you want those runtimes instead of Hangfire — 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). - ASP.NET Core (or other .NET host) with Hangfire packages (
Hangfire.AspNetCore, storage such asHangfire.SqlServer,Hangfire.Core) andservices.AddHangfireServer()so a Background Job Server is running. - Config:
ROVELA_API_KEY,MAIL_FROM,DIGEST_TO, plus the Hangfire storage connection string. - A job class Hangfire can resolve (DI-friendly instance methods or static methods — samples below use a concrete class with
HttpClient).
Register Hangfire + recurring job (ASP.NET Core)
NuGet packages (versions illustrative — pin what your upgrade guide allows):
<ItemGroup>
<PackageReference Include="Hangfire.Core" Version="1.8.*" />
<PackageReference Include="Hangfire.SqlServer" Version="1.8.*" />
<PackageReference Include="Hangfire.AspNetCore" Version="1.8.*" />
<PackageReference Include="Microsoft.Data.SqlClient" Version="*" />
</ItemGroup>
Program.cs / Startup registration pattern from Hangfire’s ASP.NET Core guide:
using Hangfire;
using Hangfire.SqlServer;
builder.Services.AddHangfire(configuration => configuration
.SetDataCompatibilityLevel(CompatibilityLevel.Version_180)
.UseSimpleAssemblyNameTypeSerializer()
.UseRecommendedSerializerSettings()
.UseSqlServerStorage(builder.Configuration.GetConnectionString("HangfireConnection")));
builder.Services.AddHangfireServer();
builder.Services.AddHttpClient(); // for Rovela POST
builder.Services.AddTransient<DailyDigestEmailJob>();
var app = builder.Build();
app.UseHangfireDashboard(); // authorize for non-local access in production
// Explicit id + cron + RecurringJobOptions (Hangfire 1.8 style)
RecurringJob.AddOrUpdate<DailyDigestEmailJob>(
"daily-digest-email",
job => job.ExecuteAsync(),
Cron.Daily(hour: 9, minute: 0), // or "0 9 * * *"
new RecurringJobOptions
{
TimeZone = TimeZoneInfo.Utc
});
app.Run();
Queue-named variant (Hangfire 1.8 explicit queue argument — queue applies when the fire-and-forget job is created):
RecurringJob.AddOrUpdate<DailyDigestEmailJob>(
"daily-digest-email",
"mailers",
job => job.ExecuteAsync(),
"0 9 * * *",
new RecurringJobOptions { TimeZone = TimeZoneInfo.Utc });
Local-timezone morning digest:
RecurringJob.AddOrUpdate<DailyDigestEmailJob>(
"tokyo-morning-ops-email",
job => job.ExecuteAsync(),
"0 5 * * *",
new RecurringJobOptions
{
// Linux IANA id; on Windows you may need "Tokyo Standard Time"
TimeZone = TimeZoneInfo.FindSystemTimeZoneById("Asia/Tokyo")
});
Job method that calls Rovela
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
using Hangfire;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
public sealed class DailyDigestEmailJob
{
private readonly IHttpClientFactory _httpClientFactory;
private readonly IConfiguration _config;
private readonly ILogger<DailyDigestEmailJob> _logger;
public DailyDigestEmailJob(
IHttpClientFactory httpClientFactory,
IConfiguration config,
ILogger<DailyDigestEmailJob> logger)
{
_httpClientFactory = httpClientFactory;
_config = config;
_logger = logger;
}
// AutomaticRetry covers transient HTTP failures; Idempotency-Key covers duplicate accepts
[AutomaticRetry(Attempts = 5)]
public async Task ExecuteAsync()
{
var day = DateTime.UtcNow.ToString("yyyy-MM-dd");
var idempotencyKey = $"hangfire-daily-digest:{day}";
var digest = BuildDailyDigest(day);
var to = (_config["DIGEST_TO"] ?? throw new InvalidOperationException("missing DIGEST_TO"))
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
// never log ROVELA_API_KEY
_logger.LogInformation(
"hangfire digest day={Day} idempotencyKey={Key}",
day,
idempotencyKey);
var result = await SendTransactionalAsync(
to: to,
subject: digest.Subject,
html: digest.Html,
text: digest.Text,
idempotencyKey: idempotencyKey,
tags: new Dictionary<string, string>
{
["kind"] = "hangfire-daily-digest",
["day"] = day,
["platform"] = "hangfire",
["job"] = nameof(DailyDigestEmailJob),
});
_logger.LogInformation("rovela accepted emailId={EmailId} day={Day}", result.Id, day);
}
private static Digest BuildDailyDigest(string day)
{
// Replace with your EF Core / Dapper / API queries
var stats = new { Signups = 12, Paid = 3, Incidents = 1, FailedJobs = 0 };
var subject = $"Acme daily digest — {day}";
var text =
$"Signups: {stats.Signups}\nPaid: {stats.Paid}\nOpen incidents: {stats.Incidents}\nFailed jobs: {stats.FailedJobs}";
var 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 new Digest(subject, html, text);
}
private async Task<RovelaEmailResponse> SendTransactionalAsync(
string[] to,
string subject,
string html,
string text,
string idempotencyKey,
Dictionary<string, string> tags)
{
var apiKey = _config["ROVELA_API_KEY"]
?? throw new InvalidOperationException("missing ROVELA_API_KEY");
var from = _config["MAIL_FROM"]
?? throw new InvalidOperationException("missing MAIL_FROM");
var client = _httpClientFactory.CreateClient();
using var req = new HttpRequestMessage(HttpMethod.Post, "https://api.rovela.dev/emails");
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", apiKey);
req.Headers.TryAddWithoutValidation("Idempotency-Key", idempotencyKey);
req.Content = JsonContent.Create(new
{
from,
to,
subject,
html,
text,
tags,
});
using var res = await client.SendAsync(req);
var body = await res.Content.ReadAsStringAsync();
if (!res.IsSuccessStatusCode)
{
// raise so Hangfire AutomaticRetry re-enters ExecuteAsync
throw new InvalidOperationException($"Rovela {(int)res.StatusCode}: {body}");
}
return JsonSerializer.Deserialize<RovelaEmailResponse>(body)
?? throw new InvalidOperationException("empty Rovela response");
}
private sealed record Digest(string Subject, string Html, string Text);
private sealed class RovelaEmailResponse
{
public string Id { get; set; } = "";
}
}
Every-15-minutes ops pulse (bucket Idempotency-Key to the quarter hour, not wall-clock seconds):
RecurringJob.AddOrUpdate<OpsPulseEmailJob>(
"ops-pulse-email",
"mailers",
job => job.ExecuteAsync(),
"*/15 * * * *",
new RecurringJobOptions { TimeZone = TimeZoneInfo.Utc });
// inside ExecuteAsync:
// var bucket = DateTime.UtcNow;
// bucket = new DateTime(bucket.Year, bucket.Month, bucket.Day, bucket.Hour, bucket.Minute / 15 * 15, 0, DateTimeKind.Utc);
// var idempotencyKey = $"hangfire-ops-pulse:{bucket:yyyy-MM-dd'T'HH-mm}";
Cron examples that map well to mail cadences (Hangfire Cron helpers / crontab.guru):
| Schedule | Meaning |
|---|---|
Cron.Daily() / 0 0 * * * | Daily at midnight (job timezone) |
Cron.Daily(9) / 0 9 * * * | Daily at 09:00 |
*/15 * * * * | Every 15 minutes |
30 7 * * 1 | Mondays 07:30 |
0 */3 * * * | Every three hours |
Cron.Hourly() | Top of each hour |
Cron.Weekly() | Weekly (see Hangfire helper docs for day/hour overloads) |
Manipulate schedules:
RecurringJob.RemoveIfExists("daily-digest-email");
RecurringJob.TriggerJob("daily-digest-email"); // fire now; next schedule unchanged
(Trigger was historically RecurringJob.Trigger; newer APIs also expose TriggerJob — use the overload your Hangfire version documents.)
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 Hangfire because:
AutomaticRetryre-enters the method after 5xx / timeouts / raised errors- Worker crash after Rovela
200but before the job is marked succeeded can redeliver - Queue lag can shift wall-clock
DateTime.UtcNow— key off the business schedule window (UTC day / TZ day / hour), not millisecond now - Multi-tenant jobs need tenant ids in the key
Recommended keys:
| Key | Use when |
|---|---|
hangfire-daily-digest:{yyyy-mm-dd} | One ops digest per UTC day |
hangfire-ops-pulse:{yyyy-mm-dd}T{HH}-{MM} | /15 or hourly crontab (bucket to the window) |
hangfire-tokyo-ops:{yyyy-mm-dd} | Local-TZ morning digest (key by RecurringJobOptions.TimeZone day) |
hangfire-weekly-report:{yyyy}-W{ww} | One founder report per ISO week |
hangfire-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 (Send Email API)).
Attach secrets and verify the schedule
- Set
ROVELA_API_KEY(sending_access),MAIL_FROM,DIGEST_TO, and the Hangfire storage connection on the process that runsAddHangfireServer. - Confirm Dashboard → Recurring Jobs lists
daily-digest-emailwith the expected cron and timezone. - Confirm at least one Hangfire Server is online (logs show “Starting Hangfire Server…”).
- Wait for the next tick, or call
RecurringJob.TriggerJob("daily-digest-email")in non-prod while keeping the window key stable. - Check app 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 storage credentials per your host’s guidance. Lock down the Hangfire Dashboard for non-local access.
Failure modes and how to handle them
| Symptom | Likely cause | What to do |
|---|---|---|
| Cron never fires | No Hangfire Server / app recycled away | Keep AddHangfireServer process always on |
| Job enqueued but never runs | Wrong queue / no workers listening | Align queue name with server queues |
| Wrong wall time | Assumed UTC / wrong TimeZone | Set RecurringJobOptions.TimeZone explicitly |
| Duplicate digests after retries | Unstable key / keyed by DateTime.Now ms | Use window-scoped keys; Idempotency-Key collapses duplicates within 24h |
| Only one of two schedules runs | Implicit / colliding recurring ids | Use unique explicit ids (AddOrUpdate("…")) |
Rovela 401 | Bad / missing ROVELA_API_KEY | Fix server 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 Hangfire retries; keep the same Idempotency-Key |
| Secrets in Git / job args | Key committed or stuffed into method args | Keep only in env / Key Vault |
| Dashboard open to the internet | Default local-only auth not extended carefully | Require authorization filters for remote access |
Production checklist
- Verified Rovela sending domain;
MAIL_FROMmatches it ROVELA_API_KEYissending_access, in Hangfire Server env / vault only- Hangfire storage +
AddHangfireServeralways running - Explicit recurring job id + documented cron +
RecurringJobOptions.TimeZone [AutomaticRetry](or equivalent) configured on the job method- Rovela send uses a window-scoped
Idempotency-Key - Hangfire Dashboard authorized for non-local use
- Logs show successful
emailIdwithout printing secrets - Alerts on consecutive job failures / Hangfire Server down
- Optional: Rovela webhooks for
email.bounced/email.complainedon digest recipients
FAQ
Can Hangfire call Rovela without a job method? No usefully — the recurring scheduler only enqueues background jobs. Your method must aggregate live data and keep sending_access local. Rovela does not host Hangfire or SQL Server.
Should I use Rovela scheduled_at from the job? Usually no. If Hangfire 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).
Hangfire vs Sidekiq-Cron vs Celery Beat vs BullMQ vs Agenda vs Temporal vs Trigger.dev vs platform cron? Use Hangfire when schedules should live next to your .NET workers and Dashboard. Use Sidekiq-Cron for Ruby (Sidekiq-Cron). 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.
Cron.Daily or a CRON string? Both work. Prefer helpers for readable daily/hourly schedules; use a CRON string when you need weekday / step patterns. Always pass an explicit recurring job id.
What about RecurringJobManager? A lower-level API behind the RecurringJob facade — useful when you inject IRecurringJobManager for tests. The Rovela send pattern is unchanged.
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 Hangfire retries and Rovela Idempotency-Key? Hangfire 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 RecurringJob.AddOrUpdate with an explicit id and cron, 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. Hangfire wakes on time; Rovela delivers the transactional mail.