← Back to blog

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.

Updated November 5, 2026
Inngest Cron Transactional Email with Rovela (2026): Scheduled Functions + Idempotency-Key per Window

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:

  1. Declares a function with triggers: [cron("…")] (or { cron, jitter }) and serves it at /api/inngest
  2. Keeps ROVELA_API_KEY (sending_access), MAIL_FROM, and recipients in server-only secrets — never in the browser or a public Git tree
  3. Aggregates a digest / report for the current schedule window inside durable steps
  4. Sends via Rovela with a window-scoped Idempotency-Key
  5. 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:

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

ConcernOwner
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.sendEventInngest
Server secrets for ROVELA_API_KEY (sending_access)Your host / vault / platform env
Query DB / APIs for the windowYour function steps
Render HTML + textYour function
POST /emails, DKIM, queue, bouncesRovela
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" } on createFunction() (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 * * 5 Fridays 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=UTC when you need consistent timing, and avoid transition-hour schedules (DST disclaimer).
  • Optional jitter. Spread load with jitter between "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) or connect(). 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 an event argument — use step / 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 Rovela Idempotency-Key so a re-entry that re-executes the HTTP call cannot double-mail.
  • Keep sending_access server-only. Store ROVELA_API_KEY in platform env / vault with INNGEST_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

  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 cron function (Authentication).
  3. An Inngest app with the SDK installed (inngest), plus INNGEST_SIGNING_KEY (and event key if you also send events) in server env (Serving).
  4. A served Inngest endpoint (Next.js App Router /api/inngest, Express, Hono, etc.).
  5. 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.run memoization 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:

KeyUse 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

  1. Deploy the app with /api/inngest (or your servePath) and confirm INNGEST_SIGNING_KEY + ROVELA_API_KEY (sending_access) are server-only.
  2. Sync / deploy so Inngest registers the cron function (Dev Server locally; cloud sync on deploy).
  3. Wait for the next cron tick, or use Inngest’s invoke / test tools while keeping the window key stable.
  4. Check function 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 or signing keys. Rotate Rovela by create-then-revoke; rotate Inngest signing keys per Inngest dashboard guidance.

Failure modes and how to handle them

SymptomLikely causeWhat to do
Cron never runsFunctions not served / wrong path / missing signing keyConfirm /api/inngest registers the function; check INNGEST_SIGNING_KEY (Serving)
Rovela 401Bad / missing ROVELA_API_KEYFix server secret; confirm sending_access
Rovela 400 naming a domainMAIL_FROM domain not verifiedVerify the domain under Rovela Domains
Rovela 429Rate limitThrow in the step so Inngest retries; keep the same Idempotency-Key
Duplicate digestsRetries / manual invoke without a stable keyUse window-scoped keys; Idempotency-Key collapses duplicates within 24h
Double fire near DSTLocal TZ= schedule in a transition hourPrefer TZ=UTC or avoid transition hours (DST)
Function pausedFree plan: 20 consecutive failuresFix the error, unpause in the dashboard (failures)
Long loop times outMailing many users in one stepFan out with step.sendEvent
Secrets in Git / clientKey committed or bundledKeep only in platform secrets

Production checklist

  • Verified Rovela sending domain; MAIL_FROM matches it
  • ROVELA_API_KEY is sending_access, server-only
  • Inngest cron with documented expression (TZ= if not UTC); optional jitter documented
  • Functions served at /api/inngest (or explicit servePath) with INNGEST_SIGNING_KEY
  • Rovela send lives in step.run with a window-scoped Idempotency-Key
  • Fan-out for large recipient lists; per-user keys
  • Logs show successful emailId without printing secrets
  • Alerts on consecutive cron failures (especially free-plan auto-pause)
  • Optional: Rovela webhooks for email.bounced / email.complained on 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.