← Back to blog

Trigger.dev Cron / Scheduled Tasks Transactional Email with Rovela (2026): schedules.task + Idempotency-Key per Window

Wire Trigger.dev schedules.task (declarative cron or imperative schedules.create) to Rovela POST /emails with sending_access secrets and a window-scoped Idempotency-Key for digests and multi-tenant reminders.

Updated November 5, 2026
Trigger.dev Cron / Scheduled Tasks Transactional Email with Rovela (2026): schedules.task + Idempotency-Key per Window

Trigger.dev schedules own the cron — Rovela delivers the mail

Trigger.dev is a background-jobs platform with first-class scheduled tasks: define a task with schedules.task(), attach a declarative cron (synced on dev / deploy) and/or imperative schedules via schedules.create() / the dashboard, and Trigger.dev wakes your run handler on the calendar. Payloads include timestamp, lastTimestamp, timezone, scheduleId, externalId, and upcoming next runs — enough to key digests and multi-tenant reminders without inventing your own clock.

That shape fits digests, ops reports, trial-expiry reminders, and other transactional messages whose body depends on live app or database state at fire time — signup counts, unpaid invoices, failed jobs — rather than content you can freeze hours earlier with Rovela scheduled_at.

Rovela queues, DKIM-signs, and delivers when your Trigger.dev task calls POST https://api.rovela.dev/emails (Send Email API, Quick Start, Authentication). Trigger.dev owns the schedule, retries, and run isolation. Your task owns aggregation, secrets for sending_access, and a stable Idempotency-Key per schedule window. Rovela only delivers.

This guide wires Trigger.dev scheduled task → run → Rovela that:

  1. Declares schedules.task() with a declarative cron (string UTC or { pattern, timezone, environments, window }) and/or attaches imperative schedules with schedules.create({ task, cron, deduplicationKey, … })
  2. Keeps ROVELA_API_KEY (sending_access), MAIL_FROM, and recipients in server-only secrets — Trigger.dev env / vault, never in the browser or a public Git tree
  3. Aggregates a digest / report for the current schedule window inside the task run
  4. Sends via Rovela with a window-scoped Idempotency-Key
  5. Survives Trigger.dev retries, overlapping ticks, and dashboard “Test schedule” without duplicate mail

Rovela does not create Trigger.dev projects, schedules, or API keys. Trigger.dev owns schedule → invoke → durable task runs. Your task owns query → map fields → POST /emails. Rovela owns delivery.

Distinct from:

Also distinct from Trigger.dev one-off delayed tasks (delay / future triggers) — this post is the recurring scheduled / cron path only (Scheduled tasks).

What Trigger.dev schedules do vs what Rovela sends

ConcernOwner
Cron expression, IANA timezone, optional window, declarative vs imperativeTrigger.dev schedules.task / schedules API
Sync declarative cron on dev / deploy; create/update imperative with deduplicationKeyTrigger.dev CLI + SDK
Task run payload (timestamp, scheduleId, externalId, upcoming, …)Trigger.dev
Server secrets for ROVELA_API_KEY (sending_access)Your Trigger.dev env / vault
Query DB / APIs for the windowYour task run
Render HTML + textYour task
POST /emails, DKIM, queue, bouncesRovela
Idempotency-Key = trigger-dev-cron-daily-digest:{yyyy-mm-dd} (or hour / tenant bucket)Your task

A 200 from Rovela means accepted and queued, not “already in the inbox” (Quick Start).

Trigger.dev schedule constraints that shape the email task

From Trigger.dev docs, treat these as hard requirements for a mail-sending schedule:

  • Define with schedules.task(). A plain task() is not enough for cron — use schedules.task({ id, run, … }) so schedules can attach (Scheduled tasks).
  • Attach at least one schedule. The task does not fire until you add a declarative cron and/or create imperative schedule(s) in the dashboard / schedules.create().
  • Declarative cron syncs on CLI. Put cron: "0 */2 * * *" (UTC string) or cron: { pattern, timezone, environments, window } on the task; it syncs when you run dev or deploy. Declarative schedules are code-owned — not editable in the dashboard (declarative cron).
  • Imperative schedules need deduplicationKey. schedules.create({ task, cron, deduplicationKey, timezone?, externalId?, window? }) upserts by key (per project, not per environment — include env in the key if you create across environments) (Create Schedule).
  • Five-field cron (no seconds). Minute, hour, day of month, month, day of week; L for last day / last weekday supported (Supported cron syntax).
  • Default timezone is UTC. Use IANA zones (e.g. America/New_York, Asia/Tokyo) on declarative cron.timezone or imperative timezone so wall time respects DST.
  • Optional window spreads load. Absolute ("30m", "2h") or percent ("50%"); "0m" / "0%" for exact cron time. New schedules default to a 60-minute window unless overridden (windows).
  • Free-plan cadence limits. Free orgs reject crons more frequent than once per hour and apply a minimum 60-minute window — plan upgrades / grandfathering rules apply as documented.
  • Dev vs prod wake rules. Dev schedules fire only while the dev CLI is running. Staging/Production only trigger tasks in the current deployment (latest version) — old deploys’ schedules do not keep firing (When schedules won’t trigger).
  • Optional ttl. Expire stale queued runs if a previous occurrence is still executing (ttl: "5m"), so you do not pile up obsolete digest jobs.
  • Multi-tenant via externalId. Imperative schedules can set externalId (user/org id); read it in run to personalize mail and key idempotency per tenant.
  • Keep sending_access server-only. Store ROVELA_API_KEY in Trigger.dev environment variables — never in client bundles or schedule metadata (Authentication).
  • Prefer HTTPS POST /emails. Outbound SMTP from a background task is a poor fit (Email API vs SMTP).

When to use Trigger.dev schedules + POST /emails (not scheduled_at)

Use Trigger.dev cron → live send when:

  • The HTML must include fresh metrics at fire time
  • You already run background work on Trigger.dev and want schedules next to the same task runtime
  • You need per-user recurring reminders via imperative schedules + externalId
  • 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). Trigger.dev schedules and scheduled_at solve different problems; this post is the Trigger.dev path.

Use Inngest cron when schedules should live next to Inngest durable steps / fan-out (Inngest Cron Transactional Email). Use QStash when you want an HTTP message-bus scheduler (Upstash QStash Schedules Email). Use a platform cron when the schedule should live next to that host’s compute — see the distinct posts linked above.

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 scheduled task (Authentication).
  3. A Trigger.dev project with @trigger.dev/sdk, CLI (npx trigger.dev@latest), and tasks under your configured trigger directory (commonly /trigger) (Scheduled tasks).
  4. Trigger.dev env vars for ROVELA_API_KEY, MAIL_FROM, DIGEST_TO (and any DB URLs the task needs).
  5. For production: a successful deploy so declarative schedules sync and only the current version can fire.

Declare a scheduled task (TypeScript)

Daily digest at 09:00 UTC with a declarative cron:

// trigger/daily-digest-email.ts
import { schedules } from "@trigger.dev/sdk";

const ROVELA_URL = "https://api.rovela.dev/emails";

function requireEnv(name: 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
}

function utcHourKey(d = new Date()) {
  return d.toISOString().slice(0, 13); // YYYY-MM-DDTHH
}

async function buildDailyDigest(day: string) {
  // Replace with your queries against Postgres / Redis / 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(opts: {
  to: 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(() => ({}));
  if (!res.ok) {
    throw new Error(`Rovela ${res.status}: ${(body as any).message ?? "send failed"}`);
  }
  return body as { object: string; id: string };
}

export const dailyDigestEmail = schedules.task({
  id: "daily-digest-email",
  // every day at 09:00 UTC
  cron: "0 9 * * *",
  // optional: drop stale runs if a previous occurrence is still going
  // ttl: "30m",
  run: async (payload) => {
    // payload.timestamp is when the schedule intended to fire (UTC Date)
    const day = utcDayKey(payload.timestamp);
    const idempotencyKey = `trigger-dev-cron-daily-digest:${day}`;

    const digest = await buildDailyDigest(day);
    const to = requireEnv("DIGEST_TO")
      .split(",")
      .map((s) => s.trim())
      .filter(Boolean);

    // never log ROVELA_API_KEY or Trigger.dev secret keys
    console.log(
      JSON.stringify({
        day,
        idempotencyKey,
        scheduleId: payload.scheduleId,
        timezone: payload.timezone,
      })
    );

    const result = await sendTransactional({
      to,
      subject: digest.subject,
      html: digest.html,
      text: digest.text,
      idempotencyKey,
      tags: {
        kind: "trigger-dev-cron-daily-digest",
        day,
        platform: "trigger.dev",
        scheduleId: payload.scheduleId,
      },
    });

    return { ok: true, emailId: result.id, day };
  },
});

Local timezone with an explicit window (spread after the nominal cron time):

export const tokyoMorningOps = schedules.task({
  id: "tokyo-morning-ops-email",
  cron: {
    pattern: "0 5 * * *",
    timezone: "Asia/Tokyo",
    environments: ["PRODUCTION", "STAGING"],
    // spread within 30 minutes after 05:00 Tokyo
    window: "30m",
  },
  run: async (payload) => {
    // Prefer payload.timestamp (nominal cron) for the Idempotency-Key window,
    // not wall-clock ms after the window offset.
    const day = payload.timestamp.toLocaleDateString("en-CA", {
      timeZone: payload.timezone,
    }); // YYYY-MM-DD in schedule TZ
    const idempotencyKey = `trigger-dev-cron-tokyo-ops:${day}`;
    // build + POST /emails with Idempotency-Key = idempotencyKey
    return { idempotencyKey, upcoming: payload.upcoming?.slice(0, 3) };
  },
});

Hourly ops pulse (UTC) — note free-plan minimum interval / window rules if you are on free:

export const hourlyOpsEmail = schedules.task({
  id: "hourly-ops-email",
  cron: {
    pattern: "0 * * * *",
    window: "0m", // exact top-of-hour when your plan allows
  },
  ttl: "5m",
  run: async (payload) => {
    const hour = utcHourKey(payload.timestamp);
    const idempotencyKey = `trigger-dev-cron-hourly-ops:${hour}`;
    // build + POST /emails
    return { idempotencyKey };
  },
});

Imperative / multi-tenant schedules

Define the scheduled task without a declarative cron (or with one plus many imperative attachments), then create per-user schedules from your backend:

// trigger/todo-reminder.ts
import { schedules } from "@trigger.dev/sdk";

export const todoReminder = schedules.task({
  id: "todo-reminder",
  run: async (payload) => {
    if (!payload.externalId) {
      throw new Error("externalId is required for per-user reminders");
    }
    const userId = payload.externalId;
    const day = utcDayKey(payload.timestamp);
    const idempotencyKey = `trigger-dev-cron-todo-reminder:${userId}:${day}`;

    // const user = await db.getUser(userId);
    // await sendTransactional({ to: [user.email], …, idempotencyKey });

    return { userId, idempotencyKey, scheduleId: payload.scheduleId };
  },
});
// app/api/reminders/route.ts (your backend — not the Trigger.dev run)
import { schedules } from "@trigger.dev/sdk";
import { todoReminder } from "../../trigger/todo-reminder";

export async function POST(request: Request) {
  const data = await request.json();

  const created = await schedules.create({
    task: todoReminder.id,
    cron: "0 8 * * *",
    timezone: data.timezone ?? "UTC",
    externalId: data.userId,
    // required — upserts instead of duplicating
    deduplicationKey: `${data.userId}-todo-reminder`,
    // optional: soften stampedes when many users share 08:00
    window: "30m",
  });

  return Response.json(created);
}

Sync declarative schedules with npx trigger.dev@latest dev locally and deploy for production. Manage imperative schedules with schedules.retrieve / update / deactivate / activate / del as needed (SDK management).

Window-scoped Idempotency-Key

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 with Trigger.dev because:

  • Task retries re-enter run after 5xx / timeouts
  • Test schedule from the dashboard can fire the same calendar window while debugging (test scheduleId is a placeholder like sched_1234 — still use a window key)
  • Schedule windows shift the effective start after the nominal cron; key off payload.timestamp (nominal) or your business day, not Date.now() milliseconds
  • Imperative multi-tenant schedules can share a cron pattern — include externalId in the key

Recommended keys:

KeyUse when
trigger-dev-cron-daily-digest:{yyyy-mm-dd}One ops digest per UTC day
trigger-dev-cron-hourly-ops:{yyyy-mm-dd}T{HH}Hourly snapshot (0 * * * *)
trigger-dev-cron-tokyo-ops:{yyyy-mm-dd}Local-TZ morning digest (key by schedule timezone day)
trigger-dev-cron-weekly-report:{yyyy}-W{ww}One founder report per ISO week
trigger-dev-cron-todo-reminder:{userId}:{yyyy-mm-dd}Per-user imperative schedules

If you mail many personalized digests in one run, prefer per-recipient keys (or POST /emails/batch for up to 100 non-template messages — batch rejects template / topic_id by design (Send Email API)).

Attach secrets and verify the schedule

  1. Set ROVELA_API_KEY (sending_access), MAIL_FROM, and DIGEST_TO in the Trigger.dev environment for Development / Staging / Production.
  2. Run dev (declarative sync + local wake) or deploy (production sync; only the current deployment’s tasks fire).
  3. Open the scheduled task on the Tasks page → Schedules tab; confirm cron, timezone, window, and next run.
  4. Wait for the next tick, or use Test schedule while keeping the window key stable.
  5. Check run logs for idempotencyKey + Rovela emailId. Optionally correlate later with Rovela webhooks.

Prefer sending_access on production senders — never full_access (Authentication). Never log API keys. Rotate Rovela by create-then-revoke; rotate Trigger.dev secrets per their dashboard guidance.

Failure modes and how to handle them

SymptomLikely causeWhat to do
Cron never runs (dev)Dev CLI not runningStart npx trigger.dev@latest dev (wake rules)
Cron never runs (prod)Task not in current deployment / schedule inactiveRedeploy; check Schedules tab; activate imperative schedule
No schedule attachedForgot declarative cron and never created imperativeAdd cron or schedules.create
Rovela 401Bad / missing ROVELA_API_KEYFix Trigger.dev env; confirm sending_access
Rovela 400 naming a domainMAIL_FROM domain not verifiedVerify the domain under Rovela Domains
Rovela 429Rate limitThrow so Trigger.dev retries; keep the same Idempotency-Key
Duplicate digestsRetries / Test schedule without a stable keyUse window-scoped keys; Idempotency-Key collapses duplicates within 24h
Create schedule rejected (free)Cron more often than hourly / window too smallSlow the cron or upgrade; see free-plan window policy
Stale digests pile upLong runs overlapping next tickSet ttl on the scheduled task
Secrets in Git / clientKey committed or bundledKeep only in Trigger.dev env / vault

Production checklist

  • Verified Rovela sending domain; MAIL_FROM matches it
  • ROVELA_API_KEY is sending_access, in Trigger.dev server env only
  • schedules.task with documented cron (timezone + window if used)
  • Declarative synced via deploy, and/or imperative deduplicationKey documented
  • Rovela send uses a window-scoped Idempotency-Key derived from payload.timestamp (+ externalId when multi-tenant)
  • Optional ttl for frequent schedules; free-plan cadence understood
  • Logs show successful emailId without printing secrets
  • Alerts on consecutive task failures
  • Optional: Rovela webhooks for email.bounced / email.complained on digest recipients

FAQ

Can Trigger.dev call Rovela without my task? No usefully — the schedule must run your run handler so it can aggregate live data and keep sending_access local. Rovela does not host Trigger.dev schedules.

Should I use Rovela scheduled_at from the task? Usually no. If Trigger.dev already fires at send time with a live body, call POST /emails immediately. Use scheduled_at when you accept the message now and want Rovela to deliver later without another wake (Scheduled Email Sending).

Trigger.dev vs Inngest cron vs QStash vs platform cron? Use Trigger.dev when schedules should live next to Trigger.dev background tasks (declarative + imperative / multi-tenant). Use Inngest when you want Inngest durable steps / fan-out (Inngest Cron). Use QStash for HTTP-native signed schedules (QStash). Use Vercel/Render/K8s cron when the schedule should live next to that platform’s compute.

Declarative or imperative? Prefer declarative for fixed internal jobs (versioned in Git, synced on deploy). Prefer imperative for per-user / dynamic schedules with externalId + deduplicationKey.

What about delayed one-off tasks? Use Trigger.dev’s delay / future trigger options for a single future run — not this recurring cron guide (Scheduled tasks note).

What key scope does sending_access cover? Enough to queue mail via POST /emails / batch. Reading messages, rescheduling, and cancel need full_access or a dashboard session (Send Email API, Authentication). Keep the scheduled task on sending_access.

Does a 200 mean the founder already got the digest? No — it means Rovela accepted and queued the message. Use GET /emails/:id, events, or webhooks for delivery state (Quick Start).

Why both Trigger.dev retries and Rovela Idempotency-Key? Trigger.dev retries the task when your code fails. Rovela’s key collapses duplicate sends if the HTTP call succeeds twice in the same window (or times out after Rovela accepted). You want both layers.

Ship the schedule; let Rovela deliver

Declare schedules.task with cron (declarative and/or imperative), put live aggregation and POST /emails in run, scope Idempotency-Key to the schedule window (and tenant), and keep sending_access server-only. Trigger.dev wakes on time; Rovela delivers the transactional mail.