← Back to blog

Celery Beat Transactional Email with Rovela (2026): crontab / beat_schedule + Idempotency-Key per Window

Wire Celery Beat periodic tasks (crontab or interval) so a worker calls Rovela POST /emails with server-only sending_access and a window-scoped Idempotency-Key for digests and ops reports.

Updated November 5, 2026
Celery Beat Transactional Email with Rovela (2026): crontab / beat_schedule + Idempotency-Key per Window

Celery Beat owns the schedule — Rovela delivers the mail

Celery Beat is Celery’s periodic-task scheduler. It reads entries from beat_schedule (or a custom store such as django-celery-beat), decides when each entry is due (crontab, timedelta / seconds, or a custom schedule), and enqueues the named Celery task for a worker. Beat does not send email itself — a worker runs your task code.

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 Celery task calls POST https://api.rovela.dev/emails (Send Email API, Quick Start, Authentication). Celery Beat owns the cadence and “is due?” logic. Your worker task owns aggregation, secrets for sending_access, and a stable Idempotency-Key per schedule window. Rovela only delivers.

This guide wires Celery Beat → Celery task → Rovela that:

  1. Declares a periodic entry with crontab (or interval) via app.conf.beat_schedule / add_periodic_task
  2. Keeps ROVELA_API_KEY (sending_access), MAIL_FROM, broker/backend URLs, 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 the Celery task
  4. Sends via Rovela with a window-scoped Idempotency-Key
  5. Survives Celery retries, Beat restarts, and duplicate ticks without duplicate mail

Rovela does not create Celery Beat schedules, brokers, or workers. Beat owns schedule → enqueue. Your task owns query → map fields → POST /emails. Rovela owns delivery.

Distinct from:

Also distinct from one-off Celery apply_async(countdown=…) / eta= delay — this post is the Beat periodic path only (beat_schedule / crontab).

What Celery Beat schedules vs what Rovela sends

ConcernOwner
Beat entry name, task, schedule (crontab / seconds / timedelta), args / kwargs / optionsCelery beat_schedule
Timezone for schedule evaluation (default UTC)Celery timezone / app.conf.timezone
Last-run persistence (celerybeat-schedule file or DatabaseScheduler)Beat scheduler process
Enqueueing the task when dueCelery Beat (exactly one Beat per schedule)
Task retries, acknowledgements, concurrencyCelery Worker + task autoretry_for / retry
Server secrets for ROVELA_API_KEY (sending_access)Your worker process env / secret store
Query DB / APIs for the windowYour Celery task
Render HTML + textYour Celery task
POST /emails, DKIM, queue, bouncesRovela
Idempotency-Key = celery-beat-daily-digest:{yyyy-mm-dd} (or hour / tenant bucket)Your Celery task

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

Celery Beat constraints that shape the email task

From Celery’s Periodic Tasks guide, treat these as hard requirements for a mail-sending schedule:

  • Run a single Beat. “You have to ensure only a single scheduler is running for a schedule at a time, otherwise you’d end up with duplicate tasks.” Prefer one dedicated celery -A proj beat process (or a DatabaseScheduler with a single leader) — do not run Beat on every web dyno.
  • Prefer dedicated Beat over worker -B in production. Embedding Beat with celery -A proj worker -B is convenient for a single node and not recommended for production (Starting the Scheduler).
  • crontab for wall-clock mail. Use from celery.schedules import crontab for “Monday 07:30”, “daily at midnight”, “every 15 minutes”, etc. Interval schedules use a float seconds or timedelta relative to Beat start unless relative=True.
  • Timezone defaults to UTC. Set app.conf.timezone = 'Europe/London' (or another IANA zone) when cron should mean local wall time. PersistentScheduler resets when the zone changes; DatabaseScheduler may need a manual last-run reset (Time Zones).
  • Overlapping runs are possible. Like cron, a slow task can overlap the next tick — use a lock if only one digest should run at a time (Periodic Tasks).
  • Beat stores last-run times. Default PersistentScheduler writes celerybeat-schedule (override with -s). Ensure the Beat process can write that path, or use django_celery_beat.schedulers:DatabaseScheduler.
  • Workers must be up. Beat only enqueues; without a worker consuming the queue, no mail leaves.
  • Keep sending_access server-only. Store ROVELA_API_KEY in the worker environment — never in task kwargs that might be logged broadly, 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 Celery Beat + POST /emails (not scheduled_at)

Use Celery Beat → live send when:

  • The HTML must include fresh metrics at fire time
  • You already run Celery workers (Redis/RabbitMQ broker) for background work and want schedules next to the same app
  • 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 Beat/worker pair (Scheduled Email Sending). Beat schedules and scheduled_at solve different problems; this post is the Beat path.

Use BullMQ / Agenda / Temporal / Trigger.dev / Inngest / QStash when you want those runtimes instead of Celery — 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. Celery app with a broker (Redis or RabbitMQ) and at least one worker process.
  4. Worker / Beat env: ROVELA_API_KEY, MAIL_FROM, DIGEST_TO, plus broker URL and any DB URLs the task needs.
  5. Exactly one Beat process for the schedule (plus workers that stay up).

Declare Beat schedule and task (Python)

Daily digest at 09:00 UTC with crontab:

# proj/celery.py
import os
from datetime import datetime, timezone

import httpx
from celery import Celery
from celery.schedules import crontab

app = Celery("proj")
app.config_from_object("django.conf:settings", namespace="CELERY")  # or your own config module
# If not using Django:
# app.conf.broker_url = os.environ["CELERY_BROKER_URL"]
# app.conf.result_backend = os.environ.get("CELERY_RESULT_BACKEND")

app.conf.timezone = "UTC"

app.conf.beat_schedule = {
    "daily-digest-email": {
        "task": "proj.tasks.send_daily_digest_email",
        "schedule": crontab(minute=0, hour=9),  # 09:00 every day in app.conf.timezone
        # "args": (),
        # "kwargs": {},
        # "options": {"expires": 3600},
    },
}


def require_env(name: str) -> str:
    v = os.environ.get(name)
    if not v:
        raise ValueError(f"missing env {name}")
    return v


def utc_day_key(d: datetime | None = None) -> str:
    d = d or datetime.now(timezone.utc)
    return d.astimezone(timezone.utc).strftime("%Y-%m-%d")


def build_daily_digest(day: str) -> dict:
    # Replace with your queries against Postgres / Redis / APIs
    stats = {"signups": 12, "paid": 3, "incidents": 1, "failed_jobs": 0}
    subject = f"Acme daily digest — {day}"
    text = "\n".join(
        [
            f"Signups: {stats['signups']}",
            f"Paid: {stats['paid']}",
            f"Open incidents: {stats['incidents']}",
            f"Failed jobs: {stats['failed_jobs']}",
        ]
    )
    html = f"""
    <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>
    """
    return {"subject": subject, "html": html, "text": text, "stats": stats}


def send_transactional(
    *,
    to: list[str],
    subject: str,
    html: str,
    text: str,
    idempotency_key: str,
    tags: dict[str, str],
) -> dict:
    url = "https://api.rovela.dev/emails"
    headers = {
        "Authorization": f"Bearer {require_env('ROVELA_API_KEY')}",
        "Content-Type": "application/json",
        "Idempotency-Key": idempotency_key,
    }
    payload = {
        "from": require_env("MAIL_FROM"),
        "to": to,
        "subject": subject,
        "html": html,
        "text": text,
        "tags": tags,
    }
    with httpx.Client(timeout=30.0) as client:
        res = client.post(url, headers=headers, json=payload)
        body = res.json() if res.content else {}
        if res.status_code >= 400:
            raise RuntimeError(f"Rovela {res.status_code}: {body.get('message', 'send failed')}")
        return body


@app.task(
    name="proj.tasks.send_daily_digest_email",
    bind=True,
    autoretry_for=(httpx.TransportError, RuntimeError),
    retry_backoff=True,
    retry_kwargs={"max_retries": 5},
)
def send_daily_digest_email(self):
    # Prefer the schedule window over wall-clock after lag
    day = utc_day_key()
    idempotency_key = f"celery-beat-daily-digest:{day}"

    digest = build_daily_digest(day)
    to = [s.strip() for s in require_env("DIGEST_TO").split(",") if s.strip()]

    # never log ROVELA_API_KEY or broker passwords
    print(
        {
            "day": day,
            "idempotencyKey": idempotency_key,
            "taskId": self.request.id,
            "retries": self.request.retries,
        }
    )

    result = send_transactional(
        to=to,
        subject=digest["subject"],
        html=digest["html"],
        text=digest["text"],
        idempotency_key=idempotency_key,
        tags={
            "kind": "celery-beat-daily-digest",
            "day": day,
            "platform": "celery-beat",
            "task": self.name,
        },
    )
    return {"ok": True, "emailId": result.get("id"), "day": day}

Equivalent with add_periodic_task after configure (useful when avoiding module-level evaluation):

@app.on_after_configure.connect
def setup_periodic_tasks(sender, **kwargs):
    sender.add_periodic_task(
        crontab(minute=0, hour=9),
        send_daily_digest_email.s(),
        name="daily-digest-email",
    )

Run processes (separate containers/processes in production):

celery -A proj worker -l INFO
celery -A proj beat -l INFO -s /var/run/celerybeat-schedule
# optional Django DB scheduler:
# celery -A proj beat -l INFO --scheduler django_celery_beat.schedulers:DatabaseScheduler

Local timezone morning digest (set Beat timezone, then crontab in that zone):

app.conf.timezone = "Asia/Tokyo"

app.conf.beat_schedule = {
    "tokyo-morning-ops-email": {
        "task": "proj.tasks.send_tokyo_ops_email",
        "schedule": crontab(minute=0, hour=5),  # 05:00 Asia/Tokyo
    },
}

# In the task, key Idempotency-Key by the schedule timezone day, e.g. with zoneinfo:
# from zoneinfo import ZoneInfo
# day = datetime.now(ZoneInfo("Asia/Tokyo")).strftime("%Y-%m-%d")
# idempotency_key = f"celery-beat-tokyo-ops:{day}"

Fixed-interval ops pulse (seconds / timedelta):

from datetime import timedelta

app.conf.beat_schedule = {
    "hourly-ops-email": {
        "task": "proj.tasks.send_hourly_ops_email",
        "schedule": 3600.0,  # every 3600 seconds from Beat's clock
        # or: "schedule": timedelta(hours=1),
    },
}

# idempotency_key = f"celery-beat-hourly-ops:{utc_hour_key()}"
# utc_hour_key → YYYY-MM-DDTHH

Crontab examples that map well to mail cadences (docs table):

ScheduleMeaning
crontab(minute=0, hour=0)Daily at midnight (in timezone)
crontab(minute=0, hour=9)Daily at 09:00
crontab(minute='*/15')Every 15 minutes
crontab(hour=7, minute=30, day_of_week=1)Mondays 07:30
crontab(minute=0, hour='*/3')Every three hours

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

  • Task autoretry / retry re-enters after 5xx / timeouts
  • Worker crash after Rovela 200 but before ack can redeliver the message
  • Accidental two Beats (or Beat + worker -B elsewhere) enqueue duplicates
  • Queue lag can shift wall-clock datetime.now() — key off the business schedule window (UTC day / TZ day / hour), not millisecond now
  • Multi-tenant kwargs need tenant ids in the key

Recommended keys:

KeyUse when
celery-beat-daily-digest:{yyyy-mm-dd}One ops digest per UTC day
celery-beat-hourly-ops:{yyyy-mm-dd}T{HH}Hourly interval / hourly crontab
celery-beat-tokyo-ops:{yyyy-mm-dd}Local-TZ morning digest (key by Beat timezone day)
celery-beat-weekly-report:{yyyy}-W{ww}One founder report per ISO week
celery-beat-tenant-digest:{tenantId}:{yyyy-mm-dd}Per-tenant task kwargs

If you mail many personalized digests in one task, 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 broker URL on worker and Beat hosts (Beat does not need the Rovela key if it only enqueues; the worker must have it).
  2. Deploy one Beat + N workers; confirm Beat can write -s / DB schedule store.
  3. Confirm beat_schedule (or django-celery-beat admin) shows daily-digest-email with the expected crontab / timezone.
  4. Wait for the next tick, or temporarily use a short interval in non-prod while keeping the window key stable.
  5. Check worker 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 broker credentials per your host’s guidance.

Failure modes and how to handle them

SymptomLikely causeWhat to do
Beat runs but nothing executesNo worker / wrong queue / routingStart workers on the task’s queue; check task name matches
Duplicate digestsTwo Beat processes / worker -B + BeatRun one Beat; drop embedded -B in prod
Wrong wall timetimezone still UTC / assumed localSet app.conf.timezone to the IANA zone you mean
Overlapping digestsLong task + short crontabAdd a lock / singleton pattern; shorten work
Schedule file errorsBeat cannot write celerybeat-scheduleFix -s path permissions or use DatabaseScheduler
Rovela 401Bad / missing ROVELA_API_KEYFix worker env; confirm sending_access
Rovela 400 naming a domainMAIL_FROM domain not verifiedVerify the domain under Rovela Domains
Rovela 429Rate limitRaise so Celery retries; keep the same Idempotency-Key
Duplicate mail after retriesUnstable key / keyed by now()Use window-scoped keys; Idempotency-Key collapses duplicates within 24h
Secrets in Git / task kwargsKey committed or stuffed into kwargsKeep only in process env / vault

Production checklist

  • Verified Rovela sending domain; MAIL_FROM matches it
  • ROVELA_API_KEY is sending_access, in worker env only
  • Exactly one Celery Beat process for the schedule
  • Documented crontab + timezone (or interval); task retries configured
  • At least one Worker process always on for that queue
  • Rovela send uses a window-scoped Idempotency-Key
  • Beat schedule store durable (-s volume or DatabaseScheduler)
  • Logs show successful emailId without printing secrets
  • Alerts on consecutive task failures / Beat down
  • Optional: Rovela webhooks for email.bounced / email.complained on digest recipients

FAQ

Can Celery Beat call Rovela without a worker task? No usefully — Beat only enqueues Celery tasks. Your task must aggregate live data and keep sending_access local. Rovela does not host Celery, Redis, or RabbitMQ.

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

Celery Beat vs BullMQ vs Agenda vs Temporal vs Trigger.dev vs platform cron? Use Celery Beat when schedules should live next to your Python Celery workers. 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.

crontab or interval seconds? Prefer crontab + timezone for wall-clock digests (09:00 local / UTC). Prefer seconds / timedelta for fixed-interval pulses that do not need calendar alignment.

What about celery worker -B? Fine for a laptop or a single-node demo. In production, run Beat separately so scaling workers does not multiply schedulers (docs).

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 Celery retries and Rovela Idempotency-Key? Celery retries the task 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 Beat schedule; let Rovela deliver

Declare a Celery Beat entry with crontab (or interval), put live aggregation and POST /emails in the worker task, scope Idempotency-Key to the schedule window (and tenant), and keep sending_access server-only. Beat wakes on time; Rovela delivers the transactional mail.