Netlify Scheduled Functions Transactional Email with Rovela (2026): Cron Config + Idempotency-Key per Window
Run a Netlify Scheduled Function on a UTC cron (inline config or netlify.toml), load ROVELA_API_KEY from Functions-scoped secrets, and POST /emails with Idempotency-Key scoped to the schedule window. Distinct from Fly Machines, Render Cron, Railway cron, Vercel HTTP cron, and GitHub Actions.
Netlify Scheduled Functions own the cron — Rovela delivers the mail
Netlify Scheduled Functions are serverless functions with a UTC cron expression (inline config.schedule or netlify.toml). 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 scheduled function calls POST https://api.rovela.dev/emails (Send Email API, Quick Start, Authentication). Netlify owns the schedule, the published-deploy runtime, and your environment variables / Secrets Controller. Your function owns aggregation and a stable Idempotency-Key per schedule window. Rovela only delivers.
This guide wires a Netlify Scheduled Function that:
- Reads
ROVELA_API_KEY,MAIL_FROM, and recipient env from Netlify (Functions scope; never hardcoded, never innetlify.toml) - Aggregates a digest / report for the current UTC window
- Sends via Rovela with
Idempotency-Keykeyed to that window - Finishes inside the 30-second scheduled-function limit
Rovela does not create Netlify schedules, set Netlify env vars, or keep a function warm. Netlify owns cron → invoke. Your handler owns query → map fields → POST /emails. Rovela owns delivery.
Distinct from:
- Fly Scheduled Machines Email — coarse
hourly/daily/weekly/monthlyMachine intervals, not a five-field crontab - Render Cron Transactional Email — Cron Job process that must exit; 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 Netlify Scheduled Function - GitHub Actions Transactional Email — workflow /
run_idnotify on CI events, not a platform function cron - Scheduled Email Sending — Rovela
scheduled_atwhen the body is already known
What Netlify schedule does vs what Rovela sends
| Concern | Owner |
|---|---|
UTC cron expression (0 13 * * * or @daily) | Netlify (Scheduled Functions) |
Inline export const config = { schedule } (JS/TS) or netlify.toml | Your repo |
| Env vars / secret flags (UI, CLI, API; Functions scope) | Netlify + your site config |
| Query DB / APIs for the window | Your scheduled function |
| Render HTML + text | Your scheduled function |
POST /emails, DKIM, queue, bounces | Rovela |
Idempotency-Key = netlify-{kind}-{yyyy-mm-dd} (or hour / week) | Your handler |
| 30s execution limit; no URL invoke in production | Netlify |
A 200 from Rovela means accepted and queued, not “already in the inbox” (Quick Start).
Netlify constraints that shape the email job
From Netlify’s docs, treat these as hard requirements for a mail-sending schedule:
- UTC cron. Expressions run in UTC. Convert local “9am Monday” yourself when you write the expression (Scheduled Functions).
- Two configuration places. JS/TS can export
config.schedule. Any language can put the schedule innetlify.tomlunder[functions."<name>"]. Inline schedule only works for TypeScript and JavaScript. next_runon the request. The scheduled invocation body is JSON withnext_run— an ISO-8601 timestamp of the next scheduled fire. Use it for logs; do not use wall-clock “exact second we started” as your idempotency identity.- 30-second limit. Scheduled functions share a 30 second execution limit. Longer work belongs on a background function or an external worker — not a scheduled function that also must finish a Rovela round-trip.
- Published deploys only. Automatic schedule runs fire for published production deploys. Deploy Previews and branch deploys do not get the automatic schedule; you can still use Run now in the Functions UI to test them.
- No production URL invoke. You cannot hit a scheduled function by URL in production (schedule and path are mutually exclusive in the Functions API). Debug with Run now, or locally with
netlify functions:invokeunder Netlify Dev (Dev does not honor the cron clock). - Supported aliases.
@yearly,@monthly,@weekly,@daily,@hourlyare supported.@rebootand@annuallyare not. - No response streaming. Scheduled functions do not return a useful response body to a caller — design for side effects (your Rovela
POST) and logs.
Use Scheduled Functions for short tasks (daily digest, hourly ops snapshot that fits in 30s). Split fan-out or heavy queries out of the scheduled path.
When to use Netlify schedule + POST /emails (not scheduled_at)
Use Netlify Scheduled Function → live send when:
- The HTML must include fresh metrics at fire time
- You already host the site / functions on Netlify and want the schedule next to that deploy
- One (or a few) transactional messages go to fixed ops / founder / customer addresses looked up at run time
- The whole job fits comfortably under 30 seconds including the Rovela HTTP call
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 Netlify Scheduled Functions path.
Prerequisites
- A verified sending domain in Rovela. Unverified
fromdomains are refused with400(Quick Start). - An API key shaped like
re_…. Prefersending_accessfor the function (Authentication). - A Netlify site with a Functions directory and a published production deploy.
- Environment variables on that site with Functions scope:
ROVELA_API_KEY,MAIL_FROM,DIGEST_TO(or look recipients up in code), plus DB / API credentials. Prefer marking the API key as a secret (Secrets Controller). - A UTC cron expression — inline for JS/TS, or in
netlify.tomlfor any language.
Environment secrets on Netlify
Scheduled functions read the same runtime env as other Netlify Functions. Configure them in the UI (Project configuration → Environment variables), CLI, or API — not in netlify.toml. Variables declared only in netlify.toml are not available to Functions at runtime (Functions environment variables, overview).
Scope the Rovela key to Functions (and Builds only if a build step truly needs it). For production-only values, set the Production deploy context explicitly.
# Example CLI — values are illustrative; never commit real keys
netlify env:set ROVELA_API_KEY 're_your_sending_access_key' --secret
netlify env:set MAIL_FROM 'Acme Reports <reports@mail.example.com>'
netlify env:set DIGEST_TO 'founders@example.com'
Flagging --secret / Contains secret values makes the value write-only outside Netlify-hosted code and restricts scopes that could leak it (for example post-processing / snippet injection) (Secrets Controller).
Env changes for functions require a new deploy to take effect. Never console.log the API key. Rotate Rovela keys by create → update the Netlify variable → redeploy → revoke the old key (Authentication).
Create the Scheduled Function
Option A — schedule inline (JavaScript / TypeScript)
Install types helper if you use it:
npm install @netlify/functions
// netlify/functions/daily-digest.ts
import type { Config } from "@netlify/functions"
const ROVELA_URL = "https://api.rovela.dev/emails"
function requireEnv(name: string): string {
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: string) {
// Replace with your queries against Neon / PlanetScale / your API
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(opts: {
to: string | string[]
subject: string
html: string
text: string
idempotencyKey: string
tags: Record<string, string>
}) {
const res = await fetch(ROVELA_URL, {
method: "POST",
headers: {
Authorization: `Bearer ${requireEnv("ROVELA_API_KEY")}`,
"Content-Type": "application/json",
"Idempotency-Key": opts.idempotencyKey,
},
body: JSON.stringify({
from: requireEnv("MAIL_FROM"),
to: opts.to,
subject: opts.subject,
html: opts.html,
text: opts.text,
tags: opts.tags,
}),
})
const body = await res.json().catch(() => ({} as any))
if (!res.ok) {
throw new Error(`Rovela ${res.status}: ${body.message ?? "send failed"}`)
}
return body as { object: "email"; id: string }
}
export default async (req: Request) => {
const { next_run } = await req.json()
const day = utcDayKey()
const digest = await buildDailyDigest(day)
const to = requireEnv("DIGEST_TO")
.split(",")
.map((s) => s.trim())
.filter(Boolean)
// One logical send per product per UTC day — safe on Run now / retries
const result = await sendTransactional({
to,
subject: digest.subject,
html: digest.html,
text: digest.text,
idempotencyKey: `netlify-daily-digest:${day}`,
tags: {
kind: "netlify-daily-digest",
day,
platform: "netlify",
next_run: String(next_run ?? ""),
},
})
console.log(
JSON.stringify({
ok: true,
day,
emailId: result.id,
next_run,
stats: digest.stats,
}),
)
}
export const config: Config = {
schedule: "0 13 * * *", // 13:00 UTC daily
}
Option B — schedule in netlify.toml
Keep the same handler without export const config, and declare the cron in config (required for non-JS languages; fine for JS too):
[functions."daily-digest"]
schedule = "0 13 * * *"
Examples of schedules (all UTC):
| Expression | Meaning (UTC) |
|---|---|
0 13 * * * | Every day at 13:00 |
@daily | Every day at 00:00 |
0 * * * * / @hourly | Every hour on the hour |
0 0 * * 1 | Every Monday at 00:00 |
@weekly | Every Sunday at 00:00 |
After deploy, the function appears on the Functions page with a Scheduled badge and a next-run time (shown in your timezone in the UI).
Idempotency for Run now and retries
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 Netlify because:
- You can press Run now while debugging
- A flaky network may retry
fetch - You may redeploy and manually re-fire the same calendar window
Key the identity to the schedule window, not to Date.now() or a random UUID:
| Key | Use when |
|---|---|
netlify-daily-digest:{yyyy-mm-dd} | One ops digest per UTC day |
netlify-hourly-ops:{yyyy-mm-dd}T{HH} | Hourly snapshot (align with @hourly / 0 * * * *) |
netlify-weekly-report:{yyyy}-W{ww} | One founder report per ISO week |
netlify-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. Stay under 30s total.
Staying under 30 seconds
Practical tips so the scheduled function does not time out before Rovela accepts the message:
- Pre-aggregate metrics into a table/view your function can read with one query
- Prefer a single
POST /emails(or one small batch) over dozens of serial calls - Set a tight
fetchtimeout and fail loudly rather than hanging - Move heavy exports / PDF generation to a background function or worker; have the schedule only trigger “send the already-built digest”
Local debug and Deploy Previews
- Local:
netlify dev+netlify functions:invoke daily-digestruns the handler once. Netlify Dev does not fire on the cron clock (Scheduled Functions). - UI: Functions → select the scheduled function → Run now (works on Deploy Previews / branch deploys for testing).
- Production schedule: only the published production deploy runs automatically.
Point preview env at a non-production Rovela key / mailbox if you use Run now on previews — Production context secrets should stay production-only.
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, wrong scope, or env not redeployed |
Rovela 429 | Back off; retry with the same Idempotency-Key |
| Function times out (~30s) | Shrink queries; fewer sends; move long work off the schedule |
| Schedule never fires | Confirm published production deploy; check Functions page for Scheduled badge |
| Duplicate mail after Run now | Key must be stable per window (netlify-daily-digest:{day}), not uuid() per invoke |
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). Reading events needs full_access; keep the scheduled function on sending_access only.
Checklist
- Verified Rovela sending domain;
sending_accesskey in Netlify env (Functions scope, secret-flagged), not in Git /netlify.toml - UTC
scheduleinline or innetlify.toml; function appears with Scheduled badge after publish - Live aggregation at fire time; HTML + text both set when possible
Idempotency-Keyscoped to the schedule window (netlify-…:{day})- Job finishes well under 30 seconds including
POST /emails - Run now /
functions:invokefor debug; Rovela dashboard or webhooks for delivery