Cloudflare Workers Cron Triggers Transactional Email with Rovela (2026): triggers.crons + scheduled() + Idempotency-Key per Window
Wire Cloudflare Workers Cron Triggers (UTC five-field cron in wrangler triggers.crons + scheduled()) to Rovela POST /emails. Store ROVELA_API_KEY with wrangler secret, aggregate live digests, and send with a window-scoped Idempotency-Key from controller.scheduledTime. Distinct from HTTP Workers email and Azure/GCP/AWS/Netlify/Fly/Render/Railway/Vercel cron.
Cron Triggers own the UTC schedule — Rovela delivers the mail
Cloudflare Workers Cron Triggers map a five-field cron expression to a Worker scheduled() handler. 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 the Worker calls POST https://api.rovela.dev/emails (Send Email API, Quick Start, Authentication). Cloudflare owns the UTC schedule, edge isolate, and encrypted secrets (wrangler secret put). Your Worker owns aggregation and a stable Idempotency-Key per schedule window (prefer controller.scheduledTime). Rovela only delivers.
This guide wires Cloudflare Workers Cron Triggers → Rovela that:
- Stores
ROVELA_API_KEY(and related config) with Wrangler secrets / vars — never in source - Declares
triggers.cronsinwrangler.toml/wrangler.jsoncand implementsscheduled() - Aggregates a digest / report for the current schedule window
- Sends via Rovela with a window-scoped
Idempotency-Keyderived fromcontroller.scheduledTime(and optionallycontroller.cron) - Survives local test fires and redeploys without duplicate mail for the same window
Rovela does not create Workers, Cron Triggers, or Wrangler projects. Cloudflare owns cron → scheduled(). Your Worker owns query → map fields → POST /emails. Rovela owns delivery.
Distinct from:
- Cloudflare Workers Transactional Email — HTTP /
fetchrequest handler for OTP, magic links, receipts (not Cron Triggers) - Azure Functions Timer Email — Azure NCRONTAB (seconds-first) + App Settings / Key Vault
- GCP Cloud Scheduler Email — five-field unix-cron / groc + HTTP/OIDC or Pub/Sub
- AWS EventBridge Scheduler Email — six-field AWS cron /
rate/at+ Lambda - Netlify Scheduled Functions Email — Netlify UTC cron + short function limit
- Fly Scheduled Machines Email — coarse Machine intervals
- Render Cron Transactional Email — Cron Job process that must exit
- Railway Cron Transactional Email — Railway cron service
- Vercel Cron Digest Email — HTTP
GETto a production route +CRON_SECRET - GitHub Actions Transactional Email — workflow /
run_idnotify on CI events - Scheduled Email Sending — Rovela
scheduled_atwhen the body is already known
Also distinct from Cloudflare Email Workers / Email Routing (receive or route mail on a zone) and from Workers Workflows schedules (a separate Workflows binding). This post is the classic Worker scheduled() + triggers.crons path.
What Cron Triggers do vs what Rovela sends
| Concern | Owner |
|---|---|
Five-field cron in triggers.crons (UTC) | Wrangler / Cron Triggers (docs) |
scheduled() handler invocation | Workers runtime (Scheduled Handler) |
Encrypted ROVELA_API_KEY | wrangler secret put → env.ROVELA_API_KEY |
| Query DB / APIs / KV / D1 for the window | Your Worker |
| Render HTML + text | Your Worker |
POST /emails, DKIM, queue, bounces | Rovela |
Idempotency-Key = cf-cron-{kind}:{scheduledTime} | Your Worker |
A 200 from Rovela means accepted and queued, not “already in the inbox” (Quick Start).
Cloudflare constraints that shape the email job
From Cloudflare docs, treat these as hard requirements for a mail-sending cron Worker:
- UTC only. Cron Triggers execute on UTC time (Cron Triggers). Convert local “09:30 America/New_York” yourself when you pick the expression.
- Five fields. Minute, hour, day-of-month, month, day-of-week — plus many Quartz-like extensions (
L,W,#, weekday abbreviations). Weekdays are 1 = Sunday … 7 = Saturday (different from some Linux crons). PreferMON–SUNabbreviations to avoid ambiguity (Supported cron expressions). - Manage triggers in Wrangler when the Worker is Wrangler-managed. Put schedules under
[triggers] crons = [...]/"triggers": { "crons": [...] }. On deploy, Wrangler replaces previous Cron Triggers with the array you ship.crons = []removes all; omittingtriggers/cronsleaves currently deployed triggers in place (configuration). - Propagation delay. Adding, updating, or deleting a Cron Trigger can take up to ~15 minutes to reach the global network.
controller.cronandcontroller.scheduledTime.cronis the exact expression string that fired (match character-for-character, including spacing).scheduledTimeis epoch milliseconds for when the event was scheduled (not necessarily wall-clock start of your handler) — parse withnew Date(controller.scheduledTime)(Scheduled Handler).- Multiple crons, one handler. Several expressions share the same
scheduled();switch (controller.cron)to branch. - Duration and CPU. Cron invocations have a 15-minute wall-clock limit. CPU time for Cron Triggers is plan-dependent (Free: short CPU budget; Paid: longer, with different caps under vs at/above 1-hour intervals) — waiting on
fetch()/ network does not count as CPU (Workers limits). - Account trigger caps. 5 Cron Triggers per account on Workers Free; 250 on Paid (limits).
ctx.waitUntil. The runtime already waits for thescheduled()promise (up to the duration limit). UsewaitUntilfor concurrent side work, or when you want a specific promise’s outcome recorded in Past Cron Events (Scheduled Handler).- Local testing.
wrangler dev/cf devexposes/cdn-cgi/local/scheduled(optional?format=json,?cron=…,?time=…) so you can fire the handler without waiting for UTC (Test Cron Triggers). - Secrets stay server-side. Prefer a Rovela
sending_accesskey viawrangler secret put— never ship it in a browser bundle or public Git (Authentication). - Outbound SMTP is a poor fit. Prefer HTTPS
POST /emailson 443 (Email API vs SMTP).
When to use Cron Triggers + POST /emails (not scheduled_at)
Use Cron Triggers → live send when:
- The HTML must include fresh metrics at fire time
- You already run compute on Workers and want Wrangler, secrets, and the schedule in the same project
- You need a real UTC cron (weekday mornings, every 15 minutes) rather than freezing bodies hours ahead
- One (or a few) transactional messages go to 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 Triggers and scheduled_at solve different problems; this post is the Cloudflare schedule path.
Use a plain HTTP Worker (fetch handler) when the send is request-driven (signup OTP, password reset) — see Cloudflare Workers Transactional Email.
Prerequisites
- A verified sending domain in Rovela. Unverified
fromdomains are refused with400(Quick Start). - An API key shaped like
re_…. Prefersending_accessfor the Worker (Authentication). - A Workers project with Wrangler (
wrangler.tomlorwrangler.jsonc) and a module Worker that exportsscheduled. - Secrets:
npx wrangler secret put ROVELA_API_KEY(and optional vars forMAIL_FROM/DIGEST_TO). - A five-field UTC cron — for example
30 13 * * MON-FRIfor ~09:30 US Eastern standard-time windows expressed in UTC (adjust for DST yourself).
Secrets with Wrangler
# In your Worker project directory
npx wrangler secret put ROVELA_API_KEY
# paste the re_… sending_access key when prompted
# Optional non-secret config as plain vars in wrangler.toml / wrangler.jsonc
Example Wrangler config (TOML):
name = "daily-digest-email"
main = "src/index.js"
compatibility_date = "2026-11-01"
[vars]
MAIL_FROM = "Acme Reports <reports@mail.example.com>"
DIGEST_TO = "founders@example.com"
DIGEST_KIND = "daily"
[triggers]
# 13:30 UTC weekdays — example only; pick your UTC wall time deliberately
crons = [ "30 13 * * MON-FRI" ]
Equivalent JSONC sketch:
{
"name": "daily-digest-email",
"main": "src/index.js",
"compatibility_date": "2026-11-01",
"vars": {
"MAIL_FROM": "Acme Reports <reports@mail.example.com>",
"DIGEST_TO": "founders@example.com",
"DIGEST_KIND": "daily"
},
"triggers": {
"crons": ["30 13 * * MON-FRI"]
}
}
Locally, put secrets in .dev.vars (never commit real keys). Production reads env.ROVELA_API_KEY from the encrypted secret binding. Keep sending_access on production senders — never full_access (Authentication). Never log the API key.
Per-environment schedules go under [env.dev.triggers] / env.dev.triggers when you need a quieter cadence in staging.
scheduled() handler + window-scoped Idempotency-Key
// src/index.js
const ROVELA_URL = 'https://api.rovela.dev/emails';
function requireEnv(env, name) {
const v = 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
}
/** Prefer controller.scheduledTime — stable across retries / local re-fires of the same occurrence. */
function windowKeyFromController(controller, kind) {
if (controller && typeof controller.scheduledTime === 'number') {
return `cf-cron-${kind}:${controller.scheduledTime}`.slice(0, 256);
}
return kind === 'hourly'
? `cf-cron-hourly-ops:${utcHourKey()}`
: `cf-cron-daily-digest:${utcDayKey()}`;
}
async function buildDailyDigest(day) {
// Replace with your queries against D1 / KV / Hyperdrive / origin 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(env, { to, subject, html, text, idempotencyKey, tags }) {
const res = await fetch(ROVELA_URL, {
method: 'POST',
headers: {
Authorization: `Bearer ${requireEnv(env, 'ROVELA_API_KEY')}`,
'Content-Type': 'application/json',
'Idempotency-Key': idempotencyKey,
},
body: JSON.stringify({
from: requireEnv(env, '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 sendDigest(controller, env) {
const kind = env.DIGEST_KIND || 'daily';
const scheduled = new Date(controller.scheduledTime);
const day = utcDayKey(scheduled);
const digest = await buildDailyDigest(day);
const to = requireEnv(env, 'DIGEST_TO')
.split(',')
.map((s) => s.trim())
.filter(Boolean);
const idempotencyKey = windowKeyFromController(controller, kind);
console.log(
JSON.stringify({
kind,
day,
cron: controller.cron,
scheduledTime: controller.scheduledTime,
idempotencyKey,
}),
);
return sendTransactional(env, {
to,
subject: digest.subject,
html: digest.html,
text: digest.text,
idempotencyKey,
tags: {
kind: `cf-cron-${kind}-digest`,
day,
platform: 'cloudflare-workers-cron',
cron: controller.cron,
},
});
}
export default {
async scheduled(controller, env, ctx) {
// Optional: branch when multiple crons share this Worker
// switch (controller.cron) { case "30 13 * * MON-FRI": ... }
const result = await sendDigest(controller, env);
console.log(JSON.stringify({ ok: true, emailId: result.id }));
// For a single await, waitUntil is optional — runtime waits on scheduled()'s promise.
// ctx.waitUntil(extraWork) if you need concurrent side tasks scored in Past Events.
},
};
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 Cloudflare because:
- Local
/cdn-cgi/local/scheduledsmoke tests can re-enter the same logical window if you pass the sametime= - Redeploys and overlapping test fires should not double-mail founders
- Platform behavior around failed scheduled invocations should be treated as at-least-once for side effects — design the send as idempotent using
scheduledTime
Recommended keys:
| Key | Use when |
|---|---|
cf-cron-daily-digest:{scheduledTime} | Strongest match to the Cron Trigger occurrence |
cf-cron-daily-digest:{yyyy-mm-dd} | One ops digest per UTC day (weaker if you fire more than once/day) |
cf-cron-hourly-ops:{yyyy-mm-dd}T{HH} | Hourly snapshot (0 * * * *) |
cf-cron-{kind}:{cron}:{scheduledTime} | Multiple crons on one Worker that share a kind |
cf-cron-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 within CPU / subrequest limits; on 429, back off and retry with the same key.
TypeScript sketch
export interface Env {
ROVELA_API_KEY: string;
MAIL_FROM: string;
DIGEST_TO: string;
DIGEST_KIND?: string;
}
export default {
async scheduled(
controller: ScheduledController,
env: Env,
ctx: ExecutionContext,
): Promise<void> {
// call the same sendDigest logic as the JS example
await sendDigest(controller, env);
},
};
Multiple Cron Triggers on one Worker
[triggers]
crons = [ "*/15 * * * *", "30 13 * * MON-FRI" ]
export default {
async scheduled(controller, env, ctx) {
switch (controller.cron) {
case '*/15 * * * *':
await sendDigest({ ...controller, /* treat as hourly/ops */ }, env);
break;
case '30 13 * * MON-FRI':
await sendDigest(controller, env);
break;
default:
console.log('unhandled cron', controller.cron);
}
},
};
controller.cron must match the configured string exactly, including spaces (Scheduled Handler).
Local test without waiting for UTC
npx wrangler dev
# in another terminal — use the port Wrangler prints (often 8787)
curl "http://localhost:8787/cdn-cgi/local/scheduled?format=json"
curl "http://localhost:8787/cdn-cgi/local/scheduled?cron=30+13+*+*+MON-FRI&time=1730806200000&format=json"
Passing a stable time= epoch ms keeps controller.scheduledTime fixed so your Rovela Idempotency-Key stays stable across repeated local fires of the “same” occurrence.
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 not set on that environment |
Rovela 429 | Back off; retry with the same Idempotency-Key |
| Duplicate mail after local re-test | Key must use scheduledTime (or day/hour window), not crypto.randomUUID() per invoke |
| Cron never fires after deploy | Wait up to ~15 minutes for trigger propagation; confirm scheduled() is exported and triggers.crons is non-empty on the deployed env |
| Wrong fire time | Remember UTC; weekday 1 = Sunday on Cloudflare |
| Hit Free plan trigger cap | Max 5 Cron Triggers per account on Free — consolidate expressions or upgrade |
CPU exceeded (exceededCpu) | Heavy sync work in the isolate; move I/O to fetch/bindings (network wait is not CPU) or simplify aggregation |
| Need delivery status in code | Prefer Rovela webhooks; GET /emails/:id/events needs full_access, while sending_access can only send |
Log Rovela’s message field as-is on non-2xx responses (Quick Start). Prefer webhooks over polling for production delivery state (Send Email API, Authentication). Past Cron Events in the dashboard show recent scheduled invocations (Cron Triggers).
Checklist
- Verified Rovela sending domain;
sending_accesskey viawrangler secret put, not in Git triggers.cronsset deliberately in UTC; understand weekday numbering / abbreviations- Module Worker exports
scheduled(controller, env, ctx) - Live aggregation at fire time; HTML + text both set when possible
Idempotency-Keyscoped tocontroller.scheduledTime(or day/hour window)- Local
/cdn-cgi/local/scheduledsmoke test before relying on production UTC ticks - Workers Observability / Past Cron Events for invoke health; Rovela dashboard or webhooks for delivery
Related reading
- Cloudflare Workers Transactional Email (HTTP / request path)
- Azure Functions Timer Email
- GCP Cloud Scheduler Email
- AWS EventBridge Scheduler Email
- Netlify Scheduled Functions Email
- Fly Scheduled Machines Email
- Render Cron Transactional Email
- Railway Cron Transactional Email
- Vercel Cron Digest Email
- GitHub Actions Transactional Email
- Scheduled Email Sending
- Email API vs SMTP
- Send Email API
- Authentication
- Cron Triggers
- Scheduled Handler
- Workers limits
- Wrangler configuration — triggers