← Back to blog

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.

Updated November 5, 2026
Netlify Scheduled Functions Transactional Email with Rovela (2026): Cron Config + Idempotency-Key per Window

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:

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

What Netlify schedule does vs what Rovela sends

ConcernOwner
UTC cron expression (0 13 * * * or @daily)Netlify (Scheduled Functions)
Inline export const config = { schedule } (JS/TS) or netlify.tomlYour repo
Env vars / secret flags (UI, CLI, API; Functions scope)Netlify + your site config
Query DB / APIs for the windowYour scheduled function
Render HTML + textYour scheduled function
POST /emails, DKIM, queue, bouncesRovela
Idempotency-Key = netlify-{kind}-{yyyy-mm-dd} (or hour / week)Your handler
30s execution limit; no URL invoke in productionNetlify

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 in netlify.toml under [functions."<name>"]. Inline schedule only works for TypeScript and JavaScript.
  • next_run on the request. The scheduled invocation body is JSON with next_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:invoke under Netlify Dev (Dev does not honor the cron clock).
  • Supported aliases. @yearly, @monthly, @weekly, @daily, @hourly are supported. @reboot and @annually are 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

  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 function (Authentication).
  3. A Netlify site with a Functions directory and a published production deploy.
  4. 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).
  5. A UTC cron expression — inline for JS/TS, or in netlify.toml for 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):

ExpressionMeaning (UTC)
0 13 * * *Every day at 13:00
@dailyEvery day at 00:00
0 * * * * / @hourlyEvery hour on the hour
0 0 * * 1Every Monday at 00:00
@weeklyEvery 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:

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

  1. Pre-aggregate metrics into a table/view your function can read with one query
  2. Prefer a single POST /emails (or one small batch) over dozens of serial calls
  3. Set a tight fetch timeout and fail loudly rather than hanging
  4. 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-digest runs 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

SymptomWhat to check
Rovela 400 naming a domainMAIL_FROM domain not verified in Rovela
Rovela 401Missing / wrong ROVELA_API_KEY, wrong scope, or env not redeployed
Rovela 429Back off; retry with the same Idempotency-Key
Function times out (~30s)Shrink queries; fewer sends; move long work off the schedule
Schedule never firesConfirm published production deploy; check Functions page for Scheduled badge
Duplicate mail after Run nowKey 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

  1. Verified Rovela sending domain; sending_access key in Netlify env (Functions scope, secret-flagged), not in Git / netlify.toml
  2. UTC schedule inline or in netlify.toml; function appears with Scheduled badge after publish
  3. Live aggregation at fire time; HTML + text both set when possible
  4. Idempotency-Key scoped to the schedule window (netlify-…:{day})
  5. Job finishes well under 30 seconds including POST /emails
  6. Run now / functions:invoke for debug; Rovela dashboard or webhooks for delivery

Related reading