Fly Scheduled Machines Transactional Email with Rovela (2026): fly secrets + Idempotency-Key per Window
Run a Fly Scheduled Machine (hourly/daily/weekly/monthly) that starts then exits, load ROVELA_API_KEY from fly secrets, and POST /emails with Idempotency-Key scoped to the schedule window. Distinct from Render Cron, Railway cron, Vercel Cron routes, and GitHub Actions.
Fly Scheduled Machines own the interval — Rovela delivers the mail
Fly Machines can wake on a coarse schedule — hourly, daily, weekly, or monthly — via fly machine run --schedule or the Machines API config.schedule field. That start→work→exit 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 scheduled process calls POST https://api.rovela.dev/emails (Send Email API, Quick Start, Authentication). Fly owns the Machine lifecycle and injects app secrets as environment variables. Your code owns aggregation and a stable Idempotency-Key per schedule window. Rovela only delivers.
This guide wires a Fly Scheduled Machine that:
- Reads
ROVELA_API_KEY,MAIL_FROM, and recipient secrets fromfly secrets(never hardcoded) - Aggregates a digest / report for the current UTC window
- Sends via Rovela with
Idempotency-Keykeyed to that window - Exits cleanly so the Machine can stop until the next interval
Rovela does not create Fly Machines, set Fly schedules, or keep your process alive. Fly owns start → exit. Your command owns query → map fields → POST /emails. Rovela owns delivery.
Distinct from:
- Render Cron Transactional Email — five-field UTC crontab + command that exits; delayed next fire while a run is active
- Railway Cron Transactional Email — Railway cron service; overlapping fires are skipped
- Vercel Cron Digest Email — HTTP
GETto a production route +CRON_SECRET, not a Fly Machine process - GitHub Actions Transactional Email — workflow /
run_idnotify on CI events, not a platform Machine schedule - Scheduled Email Sending — Rovela
scheduled_atwhen the body is already known
What Fly schedule does vs what Rovela sends
| Concern | Owner |
|---|---|
Interval bucket (hourly / daily / weekly / monthly) | Fly (fly machine run --schedule, task scheduling guide) |
| Process that starts, works, and exits | Fly Machine + your image / command |
Secrets as env vars (fly secrets set) | Fly vault + your app (Secrets) |
| Query DB / APIs for the window | Your scheduled process |
| Render HTML + text | Your scheduled process |
POST /emails, DKIM, queue, bounces | Rovela |
Idempotency-Key = fly-{kind}-{yyyy-mm-dd} (or hour / week) | Your command |
Precise crontab (0 13 * * *) | Not Scheduled Machines — use Cron Manager or Supercronic |
A 200 from Rovela means accepted and queued, not “already in the inbox” (Quick Start).
Fly constraints that shape the email job
From Fly’s docs and community notes, treat these as hard requirements for a mail-sending schedule:
- Interval buckets, not cron expressions.
--scheduleaccepts onlyhourly,daily,weekly, andmonthly. There is no minute field and no “every Wednesday at 09:00” (community announcement, task scheduling guide). - Clocked from Machine creation. The interval is calculated from when the Machine was created, not from wall-clock hour boundaries. “Daily” means roughly once per day after creation — not “every day at 13:00 UTC.”
- Created stopped, then started on interval. A scheduled Machine starts in a stopped state and moves to started at each interval (community announcement).
- Restart policy defaults to
on-failurefor scheduled Machines. A clean exit (0) lets the Machine stop until the next schedule tick. A crash can retry within Fly’s restart window (Machine restart policy). Do not setalwaysfor a digest job unless you want a forever loop. - Manual start is not available for scheduled Machines. They run on the schedule only — you cannot reliably poke them with a one-off
fly machine startfor debugging the same way you Trigger Run on Render (community). Prefer a separate non-scheduled Machine or a one-shotfly machine run --rmwhile developing. - Secrets are app-scoped.
fly secrets setencrypts values in the app vault and injects them as environment variables on every Machine in that app (Secrets). Updating secrets restarts Machines (unless--stage); never print secret values to logs.
Use Scheduled Machines for low-frequency, approximate jobs (“roughly once a day is fine”). Use Cron Manager (isolated one-off Machines from schedules.json) or Supercronic inside a process when you need a real crontab (task scheduling guide). This post focuses on Scheduled Machines + Rovela; the same POST /emails + windowed Idempotency-Key pattern applies to Cron Manager / Supercronic jobs.
When to use Fly schedule + POST /emails (not scheduled_at)
Use Fly Scheduled Machine → live send when:
- The HTML must include fresh metrics at fire time
- You already run Machines on Fly and want the schedule next to that compute / region
- One (or a few) transactional messages go to fixed ops / founder / customer addresses looked up at run time
- “About once an hour / day / week / month” is accurate enough
Use Rovela scheduled_at when the body is already correct and you only need “deliver later” without a running job (Scheduled Email Sending). Schedule and scheduled_at solve different problems; this post is the Fly Machines path.
Use Cron Manager or Supercronic when you need 0 13 * * MON precision — then still call Rovela the same way from the job command.
Prerequisites
- A verified sending domain in Rovela. Unverified
fromdomains are refused with400(Quick Start). - An API key shaped like
re_…. Prefersending_accessfor the Machine (Authentication). - A Fly app and a Docker image (or Nixpacks / Dockerfile build) whose entrypoint exits when the digest is done.
- Secrets on that app:
ROVELA_API_KEY,MAIL_FROM,DIGEST_TO(or look recipients up in code), plus DB / API credentials. - A schedule bucket:
hourly,daily,weekly, ormonthly.
Secrets on Fly
Set secrets with fly secrets set (values are never shown again by fly secrets list):
fly secrets set \
ROVELA_API_KEY=re_your_sending_access_key \
MAIL_FROM='Acme Reports <reports@mail.example.com>' \
DIGEST_TO='founders@example.com' \
-a your-digest-app
Or stage first, then deploy when ready:
fly secrets set ROVELA_API_KEY=re_your_sending_access_key --stage -a your-digest-app
fly secrets deploy -a your-digest-app
Keep secrets out of Git and out of image layers. Prefer a dedicated Fly app (or process group) for the scheduled digest so a compromised web Machine is not the only place holding the Rovela key — still use sending_access, never full_access, on production senders (Authentication). Never console.log the API key. Rotate by create → fly secrets set → next scheduled run → revoke the old key.
Create a Scheduled Machine
High-level path with flyctl (after fly apps create / image publish):
# Build and push your image first (fly deploy, fly deploy --build-only, or registry push)
fly machine run registry.fly.io/your-digest-app:latest \
--schedule daily \
--restart no \
--region iad \
-a your-digest-app \
--env DIGEST_KIND=daily
Notes grounded in Fly docs:
--schedule daily(orhourly/weekly/monthly) is the built-in scheduler (fly machine run).- Prefer an explicit restart policy that allows a clean stop after exit. Scheduled Machines default to
on-failure; for a one-shot digest that must not loop on success,no(or ensure exit0withon-failure) is the right mental model (restart policy). - App secrets still inject as env vars even on Machines created with
fly machine run(Secrets). - Monitor with
fly machine statusand Machine logs; the Machine is created stopped and starts on each interval.
Machines API sketch (illustrative — confirm fields against current Machines API docs):
{
"name": "daily-digest-email",
"config": {
"image": "registry.fly.io/your-digest-app:latest",
"schedule": "daily",
"restart": { "policy": "no" },
"env": { "DIGEST_KIND": "daily" },
"guest": { "cpu_kind": "shared", "cpus": 1, "memory_mb": 256 }
}
}
For precise cron, deploy Cron Manager with a schedules.json entry whose command runs the same digest script — still call Rovela with a windowed Idempotency-Key.
Minimal Node digest entrypoint
{
"name": "fly-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
}
function utcHourKey(d = new Date()) {
return d.toISOString().slice(0, 13) // YYYY-MM-DDTHH
}
async function buildDailyDigest(day) {
// Replace with your queries against Fly Postgres / APIs / metrics
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 kind = process.env.DIGEST_KIND || 'daily'
const day = utcDayKey()
const digest = await buildDailyDigest(day)
const to = requireEnv('DIGEST_TO').split(',').map((s) => s.trim())
// Window key — NOT Date.now() / Machine id — so retries and late ticks stay one logical mail
const idempotencyKey =
kind === 'hourly'
? `fly-hourly-ops:${utcHourKey()}`
: `fly-daily-digest:${day}`
const result = await sendTransactional({
to,
subject: digest.subject,
html: digest.html,
text: digest.text,
idempotencyKey,
tags: { kind: `fly-${kind}-digest`, day, platform: 'fly' },
})
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 Fly because:
- Crash retries under
on-failuremay re-enter your process - Network blips may retry
fetch - A Cron Manager / manual one-shot may re-run the same calendar window
- Interval ticks are approximate — key the calendar window, not the exact second the Machine booted
Recommended keys:
| Key | Use when |
|---|---|
fly-daily-digest:{yyyy-mm-dd} | One ops digest per UTC day (--schedule daily) |
fly-hourly-ops:{yyyy-mm-dd}T{HH} | Hourly snapshot (--schedule hourly) |
fly-weekly-report:{yyyy}-W{ww} | One founder report per ISO week (--schedule weekly) |
fly-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"fly-daily-digest:{day}",
tags={"kind": "fly-daily-digest", "day": day, "platform": "fly"},
)
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)
Point the Machine image CMD / ENTRYPOINT at python digest_cron.py (after the image installs dependencies). The process must terminate.
Cron Manager and Supercronic (when buckets are not enough)
If product ops need 13:00 UTC sharp, Scheduled Machines are the wrong lever. Fly’s own guide recommends:
| Option | Fit for email digests |
|---|---|
| Scheduled Machines | Approximate hourly/daily/weekly/monthly |
| Cron Manager | Isolated one-off Machines from Git-versioned schedules.json |
| Supercronic | Crontab inside one always-on (or process-group) Machine — keep one cron process |
Whatever triggers the job, keep Rovela’s contract the same: sending_access via fly secrets, POST /emails, and an Idempotency-Key scoped to the logical window — not to the Machine id.
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 secret on the Fly app |
Rovela 429 | Back off; retry with the same Idempotency-Key |
| Machine restarts in a loop | Exit 0 on success; avoid restart: always for digests |
| Cannot manually start scheduled Machine | Expected — use a non-scheduled --rm Machine while debugging |
| Duplicate mail after crash retry | Key must be stable per window (fly-daily-digest:{day}), not uuid() per boot |
| Need exact wall-clock time | Switch to Cron Manager / Supercronic; keep Rovela keys window-scoped |
Log Rovela’s message field as-is on non-2xx responses (Quick Start). Prefer webhooks over polling GET /emails/:id for production delivery state — reading events needs full_access, while sending_access can only send (Send Email API, Authentication).
Checklist
- Verified Rovela sending domain;
sending_accesskey infly secrets, not in Git - Scheduled Machine
--schedulebucket chosen knowingly (or Cron Manager / Supercronic for real cron) - Entrypoint exits
0on success; restart policy does not loop forever - Live aggregation at fire time; HTML + text both set when possible
Idempotency-Keyscoped to the schedule window (fly-…:{day})- Machine status / logs for process health; Rovela dashboard or webhooks for delivery