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.
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:
- Declares
schedules.task()with a declarativecron(string UTC or{ pattern, timezone, environments, window }) and/or attaches imperative schedules withschedules.create({ task, cron, deduplicationKey, … }) - 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 - Aggregates a digest / report for the current schedule window inside the task
run - Sends via Rovela with a window-scoped
Idempotency-Key - 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:
- Inngest Cron Transactional Email — Inngest
crontrigger +step.run(not Trigger.devschedules.task) - Upstash QStash Schedules Email — QStash HTTPS schedule +
Upstash-Signature - Supabase pg_cron Email — Postgres
pg_cron+pg_net→ Edge Function - Kubernetes CronJob Email —
batch/v1CronJob → Job Pod - DigitalOcean App Platform Jobs Email — App Platform
kind: SCHEDULEDJob - Heroku Scheduler Email — coarse Scheduler dynos
- Cloudflare Workers Cron Triggers Email — Workers
triggers.crons+scheduled() - Azure Functions Timer Email — Azure NCRONTAB
- GCP Cloud Scheduler Email — Cloud Scheduler HTTP/OIDC or Pub/Sub
- AWS EventBridge Scheduler Email — EventBridge Scheduler → Lambda
- Netlify Scheduled Functions Email — Netlify UTC cron
- Fly Scheduled Machines Email — Fly Machine schedules
- Render Cron Transactional Email — Render Cron Job process
- Railway Cron Transactional Email — Railway cron service
- Vercel Cron Digest Email — platform cron
GETto a production route - GitHub Actions Transactional Email — workflow / CI notify
- Scheduled Email Sending — Rovela
scheduled_atwhen the body is already known
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
| Concern | Owner |
|---|---|
Cron expression, IANA timezone, optional window, declarative vs imperative | Trigger.dev schedules.task / schedules API |
Sync declarative cron on dev / deploy; create/update imperative with deduplicationKey | Trigger.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 window | Your task run |
| Render HTML + text | Your task |
POST /emails, DKIM, queue, bounces | Rovela |
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 plaintask()is not enough for cron — useschedules.task({ id, run, … })so schedules can attach (Scheduled tasks). - Attach at least one schedule. The task does not fire until you add a declarative
cronand/or create imperative schedule(s) in the dashboard /schedules.create(). - Declarative cron syncs on CLI. Put
cron: "0 */2 * * *"(UTC string) orcron: { pattern, timezone, environments, window }on the task; it syncs when you rundevordeploy. 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;
Lfor last day / last weekday supported (Supported cron syntax). - Default timezone is UTC. Use IANA zones (e.g.
America/New_York,Asia/Tokyo) on declarativecron.timezoneor imperativetimezoneso wall time respects DST. - Optional
windowspreads 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 setexternalId(user/org id); read it inrunto personalize mail and key idempotency per tenant. - Keep
sending_accessserver-only. StoreROVELA_API_KEYin 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
- A verified sending domain in Rovela. Unverified
fromdomains are refused with400(Quick Start). - An API key shaped like
re_…. Prefersending_accessfor the scheduled task (Authentication). - A Trigger.dev project with
@trigger.dev/sdk, CLI (npx trigger.dev@latest), and tasks under your configured trigger directory (commonly/trigger) (Scheduled tasks). - Trigger.dev env vars for
ROVELA_API_KEY,MAIL_FROM,DIGEST_TO(and any DB URLs the task needs). - 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
runafter 5xx / timeouts - Test schedule from the dashboard can fire the same calendar window while debugging (test
scheduleIdis a placeholder likesched_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, notDate.now()milliseconds - Imperative multi-tenant schedules can share a cron pattern — include
externalIdin the key
Recommended keys:
| Key | Use 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
- Set
ROVELA_API_KEY(sending_access),MAIL_FROM, andDIGEST_TOin the Trigger.dev environment for Development / Staging / Production. - Run
dev(declarative sync + local wake) ordeploy(production sync; only the current deployment’s tasks fire). - Open the scheduled task on the Tasks page → Schedules tab; confirm cron, timezone, window, and next run.
- Wait for the next tick, or use Test schedule while keeping the window key stable.
- Check run logs for
idempotencyKey+ RovelaemailId. 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
| Symptom | Likely cause | What to do |
|---|---|---|
| Cron never runs (dev) | Dev CLI not running | Start npx trigger.dev@latest dev (wake rules) |
| Cron never runs (prod) | Task not in current deployment / schedule inactive | Redeploy; check Schedules tab; activate imperative schedule |
| No schedule attached | Forgot declarative cron and never created imperative | Add cron or schedules.create |
Rovela 401 | Bad / missing ROVELA_API_KEY | Fix Trigger.dev env; confirm sending_access |
Rovela 400 naming a domain | MAIL_FROM domain not verified | Verify the domain under Rovela Domains |
Rovela 429 | Rate limit | Throw so Trigger.dev retries; keep the same Idempotency-Key |
| Duplicate digests | Retries / Test schedule without a stable key | Use window-scoped keys; Idempotency-Key collapses duplicates within 24h |
| Create schedule rejected (free) | Cron more often than hourly / window too small | Slow the cron or upgrade; see free-plan window policy |
| Stale digests pile up | Long runs overlapping next tick | Set ttl on the scheduled task |
| Secrets in Git / client | Key committed or bundled | Keep only in Trigger.dev env / vault |
Production checklist
- Verified Rovela sending domain;
MAIL_FROMmatches it ROVELA_API_KEYissending_access, in Trigger.dev server env onlyschedules.taskwith documented cron (timezone + window if used)- Declarative synced via deploy, and/or imperative
deduplicationKeydocumented - Rovela send uses a window-scoped
Idempotency-Keyderived frompayload.timestamp(+externalIdwhen multi-tenant) - Optional
ttlfor frequent schedules; free-plan cadence understood - Logs show successful
emailIdwithout printing secrets - Alerts on consecutive task failures
- Optional: Rovela webhooks for
email.bounced/email.complainedon 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.