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.
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:
- Declares a periodic entry with
crontab(or interval) viaapp.conf.beat_schedule/add_periodic_task - 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 - Aggregates a digest / report for the current schedule window inside the Celery task
- Sends via Rovela with a window-scoped
Idempotency-Key - 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:
- 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 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
| Concern | Owner |
|---|---|
Beat entry name, task, schedule (crontab / seconds / timedelta), args / kwargs / options | Celery 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 due | Celery Beat (exactly one Beat per schedule) |
| Task retries, acknowledgements, concurrency | Celery Worker + task autoretry_for / retry |
Server secrets for ROVELA_API_KEY (sending_access) | Your worker process env / secret store |
| Query DB / APIs for the window | Your Celery task |
| Render HTML + text | Your Celery task |
POST /emails, DKIM, queue, bounces | Rovela |
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 beatprocess (or a DatabaseScheduler with a single leader) — do not run Beat on every web dyno. - Prefer dedicated Beat over
worker -Bin production. Embedding Beat withcelery -A proj worker -Bis convenient for a single node and not recommended for production (Starting the Scheduler). crontabfor wall-clock mail. Usefrom celery.schedules import crontabfor “Monday 07:30”, “daily at midnight”, “every 15 minutes”, etc. Interval schedules use a float seconds ortimedeltarelative to Beat start unlessrelative=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
PersistentSchedulerwritescelerybeat-schedule(override with-s). Ensure the Beat process can write that path, or usedjango_celery_beat.schedulers:DatabaseScheduler. - Workers must be up. Beat only enqueues; without a worker consuming the queue, no mail leaves.
- Keep
sending_accessserver-only. StoreROVELA_API_KEYin 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
- A verified sending domain in Rovela. Unverified
fromdomains are refused with400(Quick Start). - An API key shaped like
re_…. Prefersending_accessfor the worker (Authentication). - Celery app with a broker (Redis or RabbitMQ) and at least one worker process.
- Worker / Beat env:
ROVELA_API_KEY,MAIL_FROM,DIGEST_TO, plus broker URL and any DB URLs the task needs. - 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):
| Schedule | Meaning |
|---|---|
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 /
retryre-enters after 5xx / timeouts - Worker crash after Rovela
200but before ack can redeliver the message - Accidental two Beats (or Beat +
worker -Belsewhere) 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:
| Key | Use 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
- 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). - Deploy one Beat + N workers; confirm Beat can write
-s/ DB schedule store. - Confirm
beat_schedule(or django-celery-beat admin) showsdaily-digest-emailwith the expectedcrontab/ timezone. - Wait for the next tick, or temporarily use a short interval in non-prod while keeping the window key stable.
- Check worker 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 broker credentials per your host’s guidance.
Failure modes and how to handle them
| Symptom | Likely cause | What to do |
|---|---|---|
| Beat runs but nothing executes | No worker / wrong queue / routing | Start workers on the task’s queue; check task name matches |
| Duplicate digests | Two Beat processes / worker -B + Beat | Run one Beat; drop embedded -B in prod |
| Wrong wall time | timezone still UTC / assumed local | Set app.conf.timezone to the IANA zone you mean |
| Overlapping digests | Long task + short crontab | Add a lock / singleton pattern; shorten work |
| Schedule file errors | Beat cannot write celerybeat-schedule | Fix -s path permissions or use DatabaseScheduler |
Rovela 401 | Bad / missing ROVELA_API_KEY | Fix worker 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 Celery retries; keep the same Idempotency-Key |
| Duplicate mail after retries | Unstable key / keyed by now() | Use window-scoped keys; Idempotency-Key collapses duplicates within 24h |
| Secrets in Git / task kwargs | Key committed or stuffed into kwargs | Keep only in process env / vault |
Production checklist
- Verified Rovela sending domain;
MAIL_FROMmatches it ROVELA_API_KEYissending_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 (
-svolume or DatabaseScheduler) - Logs show successful
emailIdwithout printing secrets - Alerts on consecutive task failures / Beat down
- Optional: Rovela webhooks for
email.bounced/email.complainedon 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.