Inngest Cron Transactional Email with Rovela (2026): Scheduled Functions + Idempotency-Key per Window
Wire Inngest cron triggers (TZ=, jitter, step.run, fan-out) to Rovela POST /emails: keep sending_access server-only and use a window-scoped Idempotency-Key so retries never double-mail digests.
Inngest cron owns the schedule — Rovela delivers the mail
Inngest is a durable execution platform. Beyond event-driven workflows, you can declare scheduled functions with a native cron trigger on createFunction() — unix-cron syntax, optional TZ= timezone prefixes, and optional jitter so many crons do not stampede the same second. Inngest stores the schedule, wakes your function when due, and retries durable step.run work across serverless timeouts and redeploys.
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 Inngest step calls POST https://api.rovela.dev/emails (Send Email API, Quick Start, Authentication). Inngest owns the cron, durable steps, and retries. Your function owns aggregation, secrets for sending_access, and a stable Idempotency-Key per schedule window. Rovela only delivers.
This guide wires Inngest cron → step.run → Rovela that:
- Declares a function with
triggers: [cron("…")](or{ cron, jitter }) and serves it at/api/inngest - Keeps
ROVELA_API_KEY(sending_access),MAIL_FROM, and recipients in server-only secrets — never in the browser or a public Git tree - Aggregates a digest / report for the current schedule window inside durable steps
- Sends via Rovela with a window-scoped
Idempotency-Key - Survives Inngest retries, overlapping ticks, and step replays without duplicate mail
Rovela does not create Inngest apps, cron triggers, or signing keys. Inngest owns schedule → invoke → durable steps. Your function owns query → map fields → POST /emails. Rovela owns delivery.
Distinct from:
- Upstash QStash Schedules Email — QStash HTTPS schedule +
Upstash-Signature(not Inngest cron) - 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 Inngest event triggers, one-shot future ts events, and step.sleep / step.sleepUntil delays — this post is the recurring cron trigger path only.
What Inngest cron does vs what Rovela sends
| Concern | Owner |
|---|---|
Cron expression, optional TZ=IANA, optional jitter (1s–5m) | Inngest cron trigger / cron() helper |
Serve functions (serve() at /api/inngest or connect()) | Your app + Inngest (Serving functions) |
Durable step.run / fan-out via step.sendEvent | Inngest |
Server secrets for ROVELA_API_KEY (sending_access) | Your host / vault / platform env |
| Query DB / APIs for the window | Your function steps |
| Render HTML + text | Your function |
POST /emails, DKIM, queue, bounces | Rovela |
Idempotency-Key = inngest-cron-daily-digest:{yyyy-mm-dd} (or hour / minute bucket) | Your function |
A 200 from Rovela means accepted and queued, not “already in the inbox” (Quick Start).
Inngest cron constraints that shape the email function
From Inngest docs, treat these as hard requirements for a mail-sending schedule:
- Declare with a cron trigger. Use
triggers: [cron("0 9 * * *")]or the object form{ cron: "0 * * * *", jitter: "5m" }oncreateFunction()(Crons, Create Function). - Five-field unix cron. Minute, hour, day of month, month, day of week. Example:
0 9 * * *daily at 09:00 UTC;0 * * * *hourly;0 12 * * 5Fridays at 12:00 (schedules guide). - Default timezone is UTC. Prefix with
TZ=America/New_York(any IANA zone) for local wall time, e.g.TZ=Europe/Paris 0 12 * * 5(timezones). - DST caveat. Near transition hours a cron may run zero, one, or two times. Inngest does not apply special DST correction — prefer
TZ=UTCwhen you need consistent timing, and avoid transition-hour schedules (DST disclaimer). - Optional jitter. Spread load with
jitterbetween"1s"and"5m"so many crons do not hit Rovela (or your DB) in the same second (jitter). - Serve the functions. Inngest must reach your app via
serve()(typical/api/inngest) orconnect(). Without a served endpoint, cron functions never run (Serving). - Cron handlers have no
event. Scheduled functions that use only a cron trigger do not receive aneventargument — usestep/runId/logger(Create Function). - Overlapping cron triggers on one function are deduplicated. Multiple cron strings that would fire together for the same function collapse (Create Function).
- Free-plan pause after consecutive failures. On the free plan, 20 consecutive failures pause the function — monitor failing crons so mail does not stop silently (repeated failures).
- Put Rovela sends inside
step.run. Durable steps memoize successful work across retries; still pass a window-scoped RovelaIdempotency-Keyso a re-entry that re-executes the HTTP call cannot double-mail. - Keep
sending_accessserver-only. StoreROVELA_API_KEYin platform env / vault withINNGEST_SIGNING_KEY— never in client bundles or Inngest event payloads (Authentication). - Prefer HTTPS
POST /emails. Outbound SMTP from a serverless / durable step is a poor fit (Email API vs SMTP).
When to use Inngest cron + POST /emails (not scheduled_at)
Use Inngest cron → live send when:
- The HTML must include fresh metrics at fire time
- You already use Inngest for workflows and want schedules next to the same durable runtime
- You need fan-out (
step.sendEvent) for many personalized digests without one long loop - 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). Inngest cron and scheduled_at solve different problems; this post is the Inngest path.
Use QStash Schedules when you want an HTTP message-bus scheduler with signature-verified destinations instead of Inngest’s function runtime (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 cron function (Authentication). - An Inngest app with the SDK installed (
inngest), plusINNGEST_SIGNING_KEY(and event key if you also send events) in server env (Serving). - A served Inngest endpoint (Next.js App Router
/api/inngest, Express, Hono, etc.). - Server env for
ROVELA_API_KEY,MAIL_FROM,DIGEST_TO.
Declare the cron function (TypeScript)
// inngest/client.ts
import { Inngest } from "inngest";
export const inngest = new Inngest({ id: "acme-app" });
Daily digest at 09:00 UTC:
// inngest/functions/daily-digest.ts
import { cron } from "inngest";
import { inngest } from "../client";
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 = inngest.createFunction(
{
id: "daily-digest-email",
triggers: [cron("0 9 * * *")],
},
async ({ step }) => {
const day = utcDayKey();
const idempotencyKey = `inngest-cron-daily-digest:${day}`;
const digest = await step.run("build-digest", async () => {
return buildDailyDigest(day);
});
const result = await step.run("send-rovela", async () => {
const to = requireEnv("DIGEST_TO")
.split(",")
.map((s) => s.trim())
.filter(Boolean);
// never log ROVELA_API_KEY or INNGEST_SIGNING_KEY
console.log(JSON.stringify({ day, idempotencyKey }));
return sendTransactional({
to,
subject: digest.subject,
html: digest.html,
text: digest.text,
idempotencyKey,
tags: {
kind: "inngest-cron-daily-digest",
day,
platform: "inngest",
},
});
});
return { ok: true, emailId: result.id, day };
}
);
Local timezone (weekdays 09:00 Eastern) with jitter to soften load spikes:
export const weekdayOpsEmail = inngest.createFunction(
{
id: "weekday-ops-email",
triggers: [
{
cron: "TZ=America/New_York 0 9 * * 1-5",
jitter: "2m",
},
],
},
async ({ step }) => {
// same build + Rovela POST /emails pattern; key by local/UTC day as you define
await step.run("send-rovela", async () => {
/* ... */
});
}
);
Hourly ops pulse (UTC) with a matching Idempotency-Key:
export const hourlyOpsEmail = inngest.createFunction(
{
id: "hourly-ops-email",
triggers: [{ cron: "0 * * * *", jitter: "30s" }],
},
async ({ step }) => {
const hour = utcHourKey();
const idempotencyKey = `inngest-cron-hourly-ops:${hour}`;
await step.run("send-rovela", async () => {
/* build + POST /emails with idempotencyKey */
return { idempotencyKey };
});
}
);
Serve the functions (Next.js App Router)
// app/api/inngest/route.ts
import { serve } from "inngest/next";
import { inngest } from "../../../inngest/client";
import { dailyDigestEmail, weekdayOpsEmail, hourlyOpsEmail } from "../../../inngest/functions/daily-digest";
export const { GET, POST, PUT } = serve({
client: inngest,
functions: [dailyDigestEmail, weekdayOpsEmail, hourlyOpsEmail],
});
Set INNGEST_SIGNING_KEY (and related keys) in your host’s server env so the SDK can verify Inngest traffic (Serving). Express, Hono, Cloudflare Workers, and others use the matching inngest/<framework> serve helper on the same /api/inngest path pattern.
Fan-out: many personalized digests
When one cron must mail hundreds of users, do not loop POST /emails inside a single long step. Fan out with step.sendEvent, then send per recipient in an event-triggered function (fan-out pattern):
import { cron } from "inngest";
import { inngest } from "../client";
export const prepareWeeklyDigest = inngest.createFunction(
{
id: "prepare-weekly-digest",
triggers: [cron("TZ=Europe/Paris 0 12 * * 5")],
},
async ({ step }) => {
const users = await step.run("load-users", async () => {
// return [{ id, email }, ...]
return [] as { id: string; email: string }[];
});
const events = users.map((user) => ({
name: "app/send.weekly.digest",
data: { user_id: user.id, email: user.email },
}));
await step.sendEvent("send-digest-events", events);
}
);
export const sendWeeklyDigest = inngest.createFunction(
{
id: "send-weekly-digest-email",
triggers: [{ event: "app/send.weekly.digest" }],
},
async ({ event, step }) => {
const { email, user_id } = event.data as { email: string; user_id: string };
const week = /* ISO week key */ "2026-W45";
const idempotencyKey = `inngest-cron-weekly-digest:${user_id}:${week}`;
await step.run("send-rovela", async () => {
// POST /emails with Idempotency-Key = idempotencyKey
return { email, user_id, idempotencyKey };
});
}
);
Each recipient gets its own window-scoped key. Cap concurrency; on Rovela 429, throw so Inngest retries the step with the same key.
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 Inngest because:
- Function and step retries re-enter your handler after 5xx / timeouts
- Durable
step.runmemoization helps, but a new run id / partial replay can still re-hit HTTP - Manual Invoke from the Inngest dashboard while debugging can double-fire the same calendar window
- Optional jitter spreads start time, but your key should still bind to the intended schedule window (day / hour), not wall-clock milliseconds
Recommended keys:
| Key | Use when |
|---|---|
inngest-cron-daily-digest:{yyyy-mm-dd} | One ops digest per UTC day |
inngest-cron-hourly-ops:{yyyy-mm-dd}T{HH} | Hourly snapshot (0 * * * *) |
inngest-cron-weekday-ops:{yyyy-mm-dd} | Weekday local-time digest (key by the business day you intend) |
inngest-cron-weekly-report:{yyyy}-W{ww} | One founder report per ISO week |
inngest-cron-customer-digest:{userId}:{yyyy-mm-dd} | Per-user digests after fan-out |
If you mail many personalized digests, use 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)).
Attach secrets and verify the schedule
- Deploy the app with
/api/inngest(or yourservePath) and confirmINNGEST_SIGNING_KEY+ROVELA_API_KEY(sending_access) are server-only. - Sync / deploy so Inngest registers the cron function (Dev Server locally; cloud sync on deploy).
- Wait for the next cron tick, or use Inngest’s invoke / test tools while keeping the window key stable.
- Check function logs for
idempotencyKey+ RovelaemailId. Optionally correlate later with Rovela webhooks.
Prefer sending_access on production senders — never full_access (Authentication). Never log API keys or signing keys. Rotate Rovela by create-then-revoke; rotate Inngest signing keys per Inngest dashboard guidance.
Failure modes and how to handle them
| Symptom | Likely cause | What to do |
|---|---|---|
| Cron never runs | Functions not served / wrong path / missing signing key | Confirm /api/inngest registers the function; check INNGEST_SIGNING_KEY (Serving) |
Rovela 401 | Bad / missing ROVELA_API_KEY | Fix server secret; confirm sending_access |
Rovela 400 naming a domain | MAIL_FROM domain not verified | Verify the domain under Rovela Domains |
Rovela 429 | Rate limit | Throw in the step so Inngest retries; keep the same Idempotency-Key |
| Duplicate digests | Retries / manual invoke without a stable key | Use window-scoped keys; Idempotency-Key collapses duplicates within 24h |
| Double fire near DST | Local TZ= schedule in a transition hour | Prefer TZ=UTC or avoid transition hours (DST) |
| Function paused | Free plan: 20 consecutive failures | Fix the error, unpause in the dashboard (failures) |
| Long loop times out | Mailing many users in one step | Fan out with step.sendEvent |
| Secrets in Git / client | Key committed or bundled | Keep only in platform secrets |
Production checklist
- Verified Rovela sending domain;
MAIL_FROMmatches it ROVELA_API_KEYissending_access, server-only- Inngest cron with documented expression (
TZ=if not UTC); optional jitter documented - Functions served at
/api/inngest(or explicitservePath) withINNGEST_SIGNING_KEY - Rovela send lives in
step.runwith a window-scopedIdempotency-Key - Fan-out for large recipient lists; per-user keys
- Logs show successful
emailIdwithout printing secrets - Alerts on consecutive cron failures (especially free-plan auto-pause)
- Optional: Rovela webhooks for
email.bounced/email.complainedon digest recipients
FAQ
Can Inngest call Rovela without my function? No usefully — the cron must run your code so it can aggregate live data and keep sending_access local. Rovela does not host Inngest triggers.
Should I use Rovela scheduled_at from the cron step? Usually no. If Inngest 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).
Inngest cron vs QStash Schedules vs platform cron? Use Inngest when schedules should live next to durable workflows and fan-out. Use QStash when you want an HTTP-native scheduler with signature-verified destinations (Upstash QStash Schedules Email). Use Vercel/Render/K8s cron when the schedule should live next to that platform’s compute.
What about step.sleep for “send later”? step.sleep / step.sleepUntil continue an existing run later — useful after an event, not a substitute for a recurring calendar cron (schedules guide). This post is the recurring cron trigger.
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 cron function 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 Inngest retries and Rovela Idempotency-Key? Inngest retries the function / step 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 cron; let Rovela deliver
Declare the Inngest cron trigger, put live aggregation and POST /emails in durable steps, scope Idempotency-Key to the schedule window, and keep sending_access server-only. Inngest wakes on time; Rovela delivers the transactional mail.