← Back to blog

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.

Updated November 5, 2026
Cloudflare Workers Cron Triggers Transactional Email with Rovela (2026): triggers.crons + scheduled() + Idempotency-Key per Window

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:

  1. Stores ROVELA_API_KEY (and related config) with Wrangler secrets / vars — never in source
  2. Declares triggers.crons in wrangler.toml / wrangler.jsonc and implements scheduled()
  3. Aggregates a digest / report for the current schedule window
  4. Sends via Rovela with a window-scoped Idempotency-Key derived from controller.scheduledTime (and optionally controller.cron)
  5. 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:

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

ConcernOwner
Five-field cron in triggers.crons (UTC)Wrangler / Cron Triggers (docs)
scheduled() handler invocationWorkers runtime (Scheduled Handler)
Encrypted ROVELA_API_KEYwrangler secret put → env.ROVELA_API_KEY
Query DB / APIs / KV / D1 for the windowYour Worker
Render HTML + textYour Worker
POST /emails, DKIM, queue, bouncesRovela
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). Prefer MON–SUN abbreviations 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; omitting triggers / crons leaves 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.cron and controller.scheduledTime. cron is the exact expression string that fired (match character-for-character, including spacing). scheduledTime is epoch milliseconds for when the event was scheduled (not necessarily wall-clock start of your handler) — parse with new 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 the scheduled() promise (up to the duration limit). Use waitUntil for concurrent side work, or when you want a specific promise’s outcome recorded in Past Cron Events (Scheduled Handler).
  • Local testing. wrangler dev / cf dev exposes /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_access key via wrangler secret put — never ship it in a browser bundle or public Git (Authentication).
  • Outbound SMTP is a poor fit. Prefer HTTPS POST /emails on 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

  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 Worker (Authentication).
  3. A Workers project with Wrangler (wrangler.toml or wrangler.jsonc) and a module Worker that exports scheduled.
  4. Secrets: npx wrangler secret put ROVELA_API_KEY (and optional vars for MAIL_FROM / DIGEST_TO).
  5. A five-field UTC cron — for example 30 13 * * MON-FRI for ~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/scheduled smoke tests can re-enter the same logical window if you pass the same time=
  • 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:

KeyUse 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

SymptomWhat to check
Rovela 400 naming a domainMAIL_FROM domain not verified in Rovela
Rovela 401Missing / wrong ROVELA_API_KEY; secret not set on that environment
Rovela 429Back off; retry with the same Idempotency-Key
Duplicate mail after local re-testKey must use scheduledTime (or day/hour window), not crypto.randomUUID() per invoke
Cron never fires after deployWait up to ~15 minutes for trigger propagation; confirm scheduled() is exported and triggers.crons is non-empty on the deployed env
Wrong fire timeRemember UTC; weekday 1 = Sunday on Cloudflare
Hit Free plan trigger capMax 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 codePrefer 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

  1. Verified Rovela sending domain; sending_access key via wrangler secret put, not in Git
  2. triggers.crons set deliberately in UTC; understand weekday numbering / abbreviations
  3. Module Worker exports scheduled(controller, env, ctx)
  4. Live aggregation at fire time; HTML + text both set when possible
  5. Idempotency-Key scoped to controller.scheduledTime (or day/hour window)
  6. Local /cdn-cgi/local/scheduled smoke test before relying on production UTC ticks
  7. Workers Observability / Past Cron Events for invoke health; Rovela dashboard or webhooks for delivery

Related reading