← Back to blog

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.

Updated November 5, 2026
Fly Scheduled Machines Transactional Email with Rovela (2026): fly secrets + Idempotency-Key per Window

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:

  1. Reads ROVELA_API_KEY, MAIL_FROM, and recipient secrets from fly secrets (never hardcoded)
  2. Aggregates a digest / report for the current UTC window
  3. Sends via Rovela with Idempotency-Key keyed to that window
  4. 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:

What Fly schedule does vs what Rovela sends

ConcernOwner
Interval bucket (hourly / daily / weekly / monthly)Fly (fly machine run --schedule, task scheduling guide)
Process that starts, works, and exitsFly Machine + your image / command
Secrets as env vars (fly secrets set)Fly vault + your app (Secrets)
Query DB / APIs for the windowYour scheduled process
Render HTML + textYour scheduled process
POST /emails, DKIM, queue, bouncesRovela
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. --schedule accepts only hourly, daily, weekly, and monthly. 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-failure for 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 set always for 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 start for debugging the same way you Trigger Run on Render (community). Prefer a separate non-scheduled Machine or a one-shot fly machine run --rm while developing.
  • Secrets are app-scoped. fly secrets set encrypts 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

  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 Machine (Authentication).
  3. A Fly app and a Docker image (or Nixpacks / Dockerfile build) whose entrypoint exits when the digest is done.
  4. Secrets on that app: ROVELA_API_KEY, MAIL_FROM, DIGEST_TO (or look recipients up in code), plus DB / API credentials.
  5. A schedule bucket: hourly, daily, weekly, or monthly.

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 (or hourly / 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 exit 0 with on-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 status and 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-failure may 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:

KeyUse 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:

OptionFit for email digests
Scheduled MachinesApproximate hourly/daily/weekly/monthly
Cron ManagerIsolated one-off Machines from Git-versioned schedules.json
SupercronicCrontab 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

SymptomWhat to check
Rovela 400 naming a domainMAIL_FROM domain not verified in Rovela
Rovela 401Missing / wrong ROVELA_API_KEY secret on the Fly app
Rovela 429Back off; retry with the same Idempotency-Key
Machine restarts in a loopExit 0 on success; avoid restart: always for digests
Cannot manually start scheduled MachineExpected — use a non-scheduled --rm Machine while debugging
Duplicate mail after crash retryKey must be stable per window (fly-daily-digest:{day}), not uuid() per boot
Need exact wall-clock timeSwitch 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

  1. Verified Rovela sending domain; sending_access key in fly secrets, not in Git
  2. Scheduled Machine --schedule bucket chosen knowingly (or Cron Manager / Supercronic for real cron)
  3. Entrypoint exits 0 on success; restart policy does not loop forever
  4. Live aggregation at fire time; HTML + text both set when possible
  5. Idempotency-Key scoped to the schedule window (fly-…:{day})
  6. Machine status / logs for process health; Rovela dashboard or webhooks for delivery

Related reading