← Back to blog

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.

Updated November 5, 2026
Hangfire Recurring Jobs Transactional Email with Rovela (2026): RecurringJob.AddOrUpdate + Idempotency-Key per Window

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:

  1. Declares RecurringJob.AddOrUpdate("daily-digest-email", …, Cron.Daily / "0 9 * * *", options)
  2. 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
  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 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:

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

ConcernOwner
Recurring job id, CRON / Cron.*, method expression, queue, timezoneHangfire RecurringJob.AddOrUpdate (docs)
Persistent storage (SQL Server / Redis / …)Your Hangfire storage package
Minute-based recurring check → enqueue fire-and-forget jobHangfire Server
Job retries (AutomaticRetry), workers, DashboardHangfire
Server secrets for ROVELA_API_KEY (sending_access)ASP.NET Core / worker process config / secret store
Query DB / APIs for the windowYour job class method
Render HTML + textYour job class method
POST /emails, DKIM, queue, bouncesRovela
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 RecurringJobOptions for timezone. Optional timeZone: / queue: overloads are deprecated in favor of new 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 / Trigger manage lifecycle; a manual Trigger does not rewrite the next scheduled time.
  • Keep sending_access server-only. Store ROVELA_API_KEY in 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

  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. ASP.NET Core (or other .NET host) with Hangfire packages (Hangfire.AspNetCore, storage such as Hangfire.SqlServer, Hangfire.Core) and services.AddHangfireServer() so a Background Job Server is running.
  4. Config: ROVELA_API_KEY, MAIL_FROM, DIGEST_TO, plus the Hangfire storage connection string.
  5. 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):

ScheduleMeaning
Cron.Daily() / 0 0 * * *Daily at midnight (job timezone)
Cron.Daily(9) / 0 9 * * *Daily at 09:00
*/15 * * * *Every 15 minutes
30 7 * * 1Mondays 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:

  • AutomaticRetry re-enters the method after 5xx / timeouts / raised errors
  • Worker crash after Rovela 200 but 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:

KeyUse 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

  1. Set ROVELA_API_KEY (sending_access), MAIL_FROM, DIGEST_TO, and the Hangfire storage connection on the process that runs AddHangfireServer.
  2. Confirm Dashboard → Recurring Jobs lists daily-digest-email with the expected cron and timezone.
  3. Confirm at least one Hangfire Server is online (logs show “Starting Hangfire Server…”).
  4. Wait for the next tick, or call RecurringJob.TriggerJob("daily-digest-email") in non-prod while keeping the window key stable.
  5. Check app 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 storage credentials per your host’s guidance. Lock down the Hangfire Dashboard for non-local access.

Failure modes and how to handle them

SymptomLikely causeWhat to do
Cron never firesNo Hangfire Server / app recycled awayKeep AddHangfireServer process always on
Job enqueued but never runsWrong queue / no workers listeningAlign queue name with server queues
Wrong wall timeAssumed UTC / wrong TimeZoneSet RecurringJobOptions.TimeZone explicitly
Duplicate digests after retriesUnstable key / keyed by DateTime.Now msUse window-scoped keys; Idempotency-Key collapses duplicates within 24h
Only one of two schedules runsImplicit / colliding recurring idsUse unique explicit ids (AddOrUpdate("…"))
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 so Hangfire retries; keep the same Idempotency-Key
Secrets in Git / job argsKey committed or stuffed into method argsKeep only in env / Key Vault
Dashboard open to the internetDefault local-only auth not extended carefullyRequire authorization filters for remote access

Production checklist

  • Verified Rovela sending domain; MAIL_FROM matches it
  • ROVELA_API_KEY is sending_access, in Hangfire Server env / vault only
  • Hangfire storage + AddHangfireServer always 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 emailId without printing secrets
  • Alerts on consecutive job failures / Hangfire Server down
  • Optional: Rovela webhooks for email.bounced / email.complained on 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.