Render Cron Transactional Email with Rovela (2026): Env Secrets + Idempotency-Key per Window
Run a Render Cron Job that exits cleanly, load ROVELA_API_KEY from Render env vars or environment groups, and POST /emails with Idempotency-Key scoped to the UTC schedule window. Distinct from Railway cron, Vercel Cron routes, and GitHub Actions.
Render Cron Job runs the schedule — Rovela delivers the mail
Render Cron Jobs are a service type with a UTC cron expression and a command that must exit when the work is done. That shape fits digests, ops reports, and other transactional mail whose body depends on live state at fire time — counts, failed jobs, unpaid invoices — rather than content you can freeze hours earlier with Rovela scheduled_at.
Rovela queues, DKIM-signs, and delivers when your cron process calls POST https://api.rovela.dev/emails (Send Email API, Quick Start, Authentication). Render owns the schedule, the one-shot run, and your environment variables / environment groups. Your code owns aggregation and a stable Idempotency-Key per schedule window. Rovela only delivers.
This guide wires a Render Cron Job that:
- Reads
ROVELA_API_KEY,MAIL_FROM, and recipient env from Render (never hardcoded) - Aggregates a digest / report for the current UTC window
- Sends via Rovela with
Idempotency-Keykeyed to that window - Exits cleanly so billing stops and the next scheduled run is not delayed forever
Rovela does not create Render Cron Jobs, set Render environment variables, or keep your process alive. Render owns start → exit. Your command owns query → map fields → POST /emails. Rovela owns delivery.
Distinct from:
- Railway Cron Transactional Email — Railway cron service; overlapping fires are skipped, not delayed
- Vercel Cron Digest Email — HTTP
GETto a production route +CRON_SECRET, not a Render Cron Job process - GitHub Actions Transactional Email — workflow /
run_idnotify on CI events, not a platform crontab - Scheduled Email Sending — Rovela
scheduled_atwhen the body is already known
What Render Cron does vs what Rovela sends
| Concern | Owner |
|---|---|
| Five-field cron expression (UTC) | Render (Cron Jobs) |
Command (or Docker ENTRYPOINT/CMD) that exits | Render + your repo |
| Environment variables / environment groups / secret files | Render + your service config |
| Query DB / APIs for the window | Your cron process |
| Render HTML + text | Your cron process |
POST /emails, DKIM, queue, bounces | Rovela |
Idempotency-Key = render-{kind}-{yyyy-mm-dd} (or hour / week) | Your command |
| At most one active run; long runs capped at 12 hours | Render |
A 200 from Rovela means accepted and queued, not “already in the inbox” (Quick Start).
Render constraints that shape the email job
From Render’s docs, treat these as hard requirements for a mail-sending cron:
- Exit when done. Cron Jobs are billed for active running time. A command that leaves a web server or open pool running keeps the meter on (Cron Jobs).
- Schedules are UTC. Convert local “9am Monday” yourself when you write the expression.
- Single-run guarantee. At most one run of a given cron job is active. If a scheduled fire arrives while a run is still active, Render delays that next scheduled run until the active one finishes. A manual Trigger Run cancels the active run first, then starts the new one (Cron Jobs).
- 12-hour hard stop. Render stops an active run after 12 hours. Longer or continuous work belongs on a background worker or workflow, not a Cron Job.
- No persistent disk on Cron Jobs. Persist digests in a database, object storage, or an API — not a local volume.
- New Git builds do not interrupt an in-progress run; they apply to future runs. Docker image cron jobs pull the image before each run and do not retain pulled images between runs.
Use Render Cron for short tasks (daily digest, hourly ops snapshot). Use a worker or queue when work must not wait behind a slow previous run, or when you need continuous processing.
When to use Render Cron + POST /emails (not scheduled_at)
Use Render Cron → live send when:
- The HTML must include fresh metrics at fire time
- You already host web / DB / workers on Render and want the schedule next to that data
- One (or a few) transactional messages go to fixed 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 job (Scheduled Email Sending). Cron and scheduled_at solve different problems; this post is the Render Cron path.
Prerequisites
- A verified sending domain in Rovela. Unverified
fromdomains are refused with400(Quick Start). - An API key shaped like
re_…. Prefersending_accessfor the cron service (Authentication). - A Render account and a Cron Job service (Git repo or Docker image).
- Environment variables on that Cron Job (or a linked environment group):
ROVELA_API_KEY,MAIL_FROM,DIGEST_TO(or look recipients up in code), plus DB / API credentials. - A UTC cron Schedule and a Command that exits (native) or a Docker image whose startup command exits.
Environment on Render
In the Render Dashboard (Cron Job → Environment) or via a Blueprint / environment group, set:
ROVELA_API_KEY=re_your_sending_access_key
MAIL_FROM='Acme Reports <reports@mail.example.com>'
DIGEST_TO='founders@example.com'
# plus DATABASE_URL / whatever your digest queries need
Keep secrets out of Git. In a Blueprint (render.yaml), declare secret keys with sync: false and paste values in the Dashboard (Environment Variables and Secrets). Prefer an environment group when the same Rovela key is shared by web + cron. Never console.log the API key. Rotate Rovela keys by create → update the Render variable → next cron run → revoke the old key (Authentication).
Create the Cron Job
Dashboard path (high level):
- New → Cron Job
- Connect the Git repo (or point at a Docker image)
- Set Schedule (UTC), e.g.
0 13 * * *for 13:00 UTC daily - Set Command to a one-shot entrypoint, e.g.
node src/digest-cron.mjs(not present for image-only services — use the imageENTRYPOINT/CMDor Advanced Docker Command) - Add environment variables / link an environment group
- Deploy; watch Builds and Runs
Blueprint sketch (secrets via Dashboard, not committed):
services:
- type: cron
name: nightly-digest-email
runtime: node
plan: starter
schedule: "0 13 * * *" # quote the expression in YAML
buildCommand: npm ci
startCommand: node src/digest-cron.mjs
envVars:
- key: ROVELA_API_KEY
sync: false
- key: MAIL_FROM
sync: false
- key: DIGEST_TO
sync: false
Confirm current runtime names, plans, and Blueprint fields in Render’s docs before you copy this into production — Rovela does not define Render’s schema.
Examples of schedules (all UTC):
| Expression | Meaning (UTC) |
|---|---|
0 13 * * * | Every day at 13:00 |
0 */6 * * * | Every six hours on the hour |
*/10 * * * * | Every ten minutes |
*/60 * * * MON-FRI | Every 60 minutes, Monday–Friday |
Do not leave a long-lived web server as the Cron Job command. The process must terminate.
Minimal Node cron entrypoint
{
"name": "render-digest-cron",
"type": "module",
"scripts": {
"start": "node src/digest-cron.mjs"
},
"engines": { "node": ">=20" }
}
// src/digest-cron.mjs
const ROVELA_URL = 'https://api.rovela.dev/emails'
function requireEnv(name) {
const v = process.env[name]
if (!v) throw new Error(`missing env ${name}`)
return v
}
function utcDayKey(d = new Date()) {
return d.toISOString().slice(0, 10) // YYYY-MM-DD
}
async function buildDailyDigest(day) {
// Replace with your queries against Render Postgres / APIs
const stats = {
signups: 12,
paid: 3,
incidents: 1,
failedJobs: 0,
}
const subject = `Acme daily digest — ${day}`
const text = [
`Signups: ${stats.signups}`,
`Paid: ${stats.paid}`,
`Open incidents: ${stats.incidents}`,
`Failed jobs: ${stats.failedJobs}`,
].join('\n')
const 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 { subject, html, text, stats }
}
async function sendTransactional({ to, subject, html, text, idempotencyKey, tags }) {
const res = await fetch(ROVELA_URL, {
method: 'POST',
headers: {
Authorization: `Bearer ${requireEnv('ROVELA_API_KEY')}`,
'Content-Type': 'application/json',
'Idempotency-Key': idempotencyKey,
},
body: JSON.stringify({
from: requireEnv('MAIL_FROM'),
to,
subject,
html,
text,
tags,
}),
})
const body = await res.json().catch(() => ({}))
if (!res.ok) {
throw new Error(`Rovela ${res.status}: ${body.message ?? 'send failed'}`)
}
return body // { object: 'email', id }
}
async function main() {
const day = utcDayKey()
const digest = await buildDailyDigest(day)
const to = requireEnv('DIGEST_TO').split(',').map((s) => s.trim())
// One logical send per product per UTC day — safe if you Trigger Run again
const result = await sendTransactional({
to,
subject: digest.subject,
html: digest.html,
text: digest.text,
idempotencyKey: `render-daily-digest:${day}`,
tags: { kind: 'render-daily-digest', day, platform: 'render' },
})
console.log(JSON.stringify({ ok: true, day, emailId: result.id, stats: digest.stats }))
}
main()
.then(() => process.exit(0))
.catch((err) => {
console.error(err)
process.exit(1)
})
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 on Render because you can Trigger Run from the Runs page, because a delayed scheduled fire may retry your own logic, and because a flaky network may retry fetch.
Recommended keys:
| Key | Use when |
|---|---|
render-daily-digest:{yyyy-mm-dd} | One ops digest per UTC day |
render-hourly-ops:{yyyy-mm-dd}T{HH} | Hourly snapshot (align with 0 * * * *) |
render-weekly-report:{yyyy}-W{ww} | One founder report per ISO week |
render-customer-digest:{userId}:{yyyy-mm-dd} | Per-user digests (one key per recipient window) |
If you mail many personalized digests, loop with a fresh key per recipient (or POST /emails/batch for up to 100 non-template messages — batch rejects template / topic_id by design (Send Email API)). Cap concurrency; on 429, back off and retry with the same key.
Python alternative (exit cleanly)
# digest_cron.py
import json
import os
import sys
from datetime import datetime, timezone
import urllib.request
ROVELA_URL = "https://api.rovela.dev/emails"
def require_env(name: str) -> str:
v = os.environ.get(name)
if not v:
raise SystemExit(f"missing env {name}")
return v
def utc_day_key() -> str:
return datetime.now(timezone.utc).strftime("%Y-%m-%d")
def build_daily_digest(day: str) -> dict:
stats = {"signups": 12, "paid": 3, "incidents": 1, "failedJobs": 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['failedJobs']}",
]
)
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['failedJobs']}</b></li>
</ul>
"""
return {"subject": subject, "html": html, "text": text, "stats": stats}
def send_transactional(*, to, subject, html, text, idempotency_key, tags):
payload = {
"from": require_env("MAIL_FROM"),
"to": to,
"subject": subject,
"html": html,
"text": text,
"tags": tags,
}
req = urllib.request.Request(
ROVELA_URL,
data=json.dumps(payload).encode(),
headers={
"Authorization": f"Bearer {require_env('ROVELA_API_KEY')}",
"Content-Type": "application/json",
"Idempotency-Key": idempotency_key,
},
method="POST",
)
with urllib.request.urlopen(req, timeout=30) as resp:
return json.loads(resp.read().decode())
def main() -> None:
day = utc_day_key()
digest = build_daily_digest(day)
to = [s.strip() for s in require_env("DIGEST_TO").split(",") if s.strip()]
result = send_transactional(
to=to,
subject=digest["subject"],
html=digest["html"],
text=digest["text"],
idempotency_key=f"render-daily-digest:{day}",
tags={"kind": "render-daily-digest", "day": day, "platform": "render"},
)
print(json.dumps({"ok": True, "day": day, "emailId": result.get("id"), "stats": digest["stats"]}))
if __name__ == "__main__":
try:
main()
except Exception as err:
print(err, file=sys.stderr)
raise SystemExit(1)
Set the Cron Job Command to python digest_cron.py (after your build installs dependencies).
Manual Trigger Run and delayed schedules
- Trigger Run on the Runs page starts an unscheduled execution (useful while debugging).
- If a run is already active, Trigger Run cancels it, then starts the new one.
- If a scheduled fire arrives while a run is still active, Render delays that next fire until the active run finishes — it does not start a second parallel instance.
That is why exit-on-success matters, and why Idempotency-Key must be keyed to the calendar window, not to “exact second the process started.” A delayed 13:00 UTC digest that actually runs at 13:07 should still use render-daily-digest:2026-11-05, not a timestamp that would create a second logical mail.
Failure handling
| Symptom | What to check |
|---|---|
Rovela 400 naming a domain | MAIL_FROM domain not verified in Rovela |
Rovela 401 | Missing / wrong ROVELA_API_KEY on the Cron Job or environment group |
Rovela 429 | Back off; retry with the same Idempotency-Key |
| Run never finishes / hits 12h | Close pools; shrink the job; move long work to a worker |
| Next schedule late | Previous run still active — exit faster or split work |
| Duplicate mail after Trigger Run | Key must be stable per window (render-daily-digest:{day}), not uuid() per process |
Log Rovela’s message field as-is on non-2xx responses (Quick Start). Prefer webhooks over polling GET /emails/:id for production delivery state (Send Email API).
Checklist
- Verified Rovela sending domain;
sending_accesskey in Render env (or group), not in Git - Cron Job Schedule in UTC; Command exits
0on success - Live aggregation at fire time; HTML + text both set when possible
Idempotency-Keyscoped to the schedule window (render-…:{day})- No reliance on a persistent disk on the Cron Job
- Runs page used for Trigger Run / logs; Rovela dashboard or webhooks for delivery