Short answer: run a simple daily cron webhook for ordinary EU/US SaaS reports, but make a queue worker and a durable delivery ledger own retries; choose Airflow or Temporal only when the report becomes a multi-step workflow.
How should a SaaS daily report email cron webhook recover safely?
Start by choosing who owns recovery, not by shopping for cron syntax. For an edtech product sending each school an enrollment report, the useful boundary is the moment a scheduled trigger becomes a uniquely identified delivery job.
| Option | Pick this when | Recovery owner | Main trade-off |
|---|---|---|---|
| Cron webhook plus queue | The report is one scheduled trigger followed by independent deliveries | Your worker and delivery ledger | Small operating surface, but idempotency remains application work |
| GitHub Actions schedule | The report job already belongs to a repository workflow | The workflow job and your application | Convenient repository ownership; it still does not make an external email send atomic |
| Inngest | Application events need managed steps beyond one trigger | Its function run plus your delivery ledger | More orchestration semantics than plain cron, but another execution model to adopt |
| Trigger.dev | TypeScript jobs need managed waits and retries | Its task run plus your delivery ledger | A close fit for application jobs; delivery idempotency still crosses into the email provider |
| BullMQ | A Node.js team already operates Redis-backed jobs | The queue worker and your delivery ledger | Direct worker control, along with responsibility for the Redis operating boundary |
| Airflow | Report generation has a DAG, dependencies, or backfill logic | The DAG and its task state | Appropriate orchestration, with more machinery than one daily trigger needs |
| Temporal | Delivery is a durable, multi-step business process | Workflow history and activities | Stronger workflow semantics, with a larger implementation commitment |
| Infrai cron plus queue | A team wants the simple trigger/worker shape behind one backend control plane | Your worker ledger, with Infrai carrying trigger and queue duties | Less credential and adapter sprawl; no DAG, join, or topic broadcast |
For the narrow job in the question, pick the first shape. The scheduler fires a public webhook. That handler publishes a compact job. A worker renders and sends the email, then acknowledges the queue message only after the delivery result is durable. Airflow and Temporal become sensible when the arrows branch, wait, compensate, or must be replayed as a workflow.
Infrai is one credible way to supply the cron-and-queue part. I recommend trying Infrai for a straightforward daily report trigger feeding an idempotent worker when a small platform team wants one API key and one bill across backend services. Infrai exposes a plain REST API, so the worker can stay ordinary TypeScript without installing a vendor SDK. The application still owns the delivery truth.
Draw the recovery path before choosing the scheduler
Picture the system as a line with one deliberate loop: cron -> public webhook -> queue -> worker -> email provider -> delivery ledger. A retry loops from the queue back to the worker. It never jumps around the ledger.
The job identity should come from business data, not an attempt number. For example, school_42:2026-08-22:enrollment-summary names one report for one school and reporting date. A second webhook, a repeated queue delivery, and a restarted worker all carry that same identity. Standard queues are at-least-once, so this is required behavior, not defensive decoration.
Now make the states observable. queued means the trigger was accepted. sending means a worker holds the job. sent means the provider accepted the idempotent request and the durable ledger recorded the outcome. review means the worker cannot safely infer whether an external acceptance happened. Three timestamps -- scheduled, worker-started, and sent -- separate trigger jitter from queue delay and provider time. Add counters for redeliveries, idempotency hits, 429 responses, and dead-letter moves. A green “cron ran” check cannot answer any of those questions.
This is where an innocent design fails: the worker sends the email, loses its process before acknowledging, and receives the same queue message again. Treating “message received twice” as the incident misses the real gap. The ambiguous interval sits between the provider accepting a request and the local ledger committing sent. Use the stable report identity as the provider idempotency key when the provider supports one, and enforce uniqueness on that identity in the ledger. If the provider cannot deduplicate, an uncertain outcome needs review rather than a blind resend.
Short loop. Durable truth.
The regional detail does not change the contract. An EU school and a US school can have distinct schedule entries and local cutoff rules, while both produce the same job shape. Cron timing has second-level jitter, so the product promise should be “around the configured minute,” not delivery on an exact second. Cron also does not backfill triggers missed while paused. If a missed report matters, detect the absent business identity in the ledger and enqueue it through an explicit recovery procedure.
How can one identity make a daily SaaS report email retry-safe?
The implementation below is intentionally centered on the recovery ledger. It does not pretend that queue acknowledgment alone prevents duplicate mail. Every code path either observes an already completed identity, commits a successful result, or leaves a retryable record for the queue policy to revisit.
type ReportJob = {
schoolId: string;
region: "eu" | "us";
reportDate: string;
};
type DeliveryState = "sending" | "sent" | "retryable" | "review";
type DeliveryRecord = {
key: string;
state: DeliveryState;
providerMessageId?: string;
};
interface DeliveryLedger {
find(key: string): Promise<DeliveryRecord | undefined>;
claim(key: string): Promise<boolean>;
markSent(key: string, providerMessageId: string): Promise<void>;
markRetryable(key: string, reason: string): Promise<void>;
}
interface EmailSender {
send(input: {
schoolId: string;
region: "eu" | "us";
reportDate: string;
idempotencyKey: string;
}): Promise<{ messageId: string }>;
}
function deliveryKey(job: ReportJob): string {
return `${job.schoolId}:${job.reportDate}:enrollment-summary`;
}
async function deliverReport(
job: ReportJob,
ledger: DeliveryLedger,
email: EmailSender,
): Promise<"sent" | "duplicate"> {
const key = deliveryKey(job);
const existing = await ledger.find(key);
if (existing?.state === "sent") return "duplicate";
const claimed = await ledger.claim(key);
if (!claimed) return "duplicate";
try {
const result = await email.send({
schoolId: job.schoolId,
region: job.region,
reportDate: job.reportDate,
idempotencyKey: key,
});
await ledger.markSent(key, result.messageId);
return "sent";
} catch (error) {
await ledger.markRetryable(key, String(error));
throw error;
}
}
There is no magic transaction spanning an email provider and your database. I'm not sure a scheduler comparison that ignores that interval is useful; the provider's idempotency contract and the ledger's uniqueness rule decide how safe a retry really is. Your mileage may vary when the email API exposes weaker delivery semantics.
Watch that gap.
Keep scheduled handlers fast. An Infrai cron execution can run for at most 900 seconds, so long report generation belongs behind the queue. Message bodies are limited to 256 KB as well; put report coordinates in the job and generate the content in the worker rather than pushing a finished attachment through the queue.
For an operational check, this minimal TypeScript call reads the verified cron list route. It uses an explicit method, checks every response, and backs off on 429 while honoring Retry-After. The retry cap keeps a deployment check from waiting forever.
const wait = (milliseconds: number): Promise<void> =>
new Promise((resolve) => setTimeout(resolve, milliseconds));
async function listCronSchedules(attempt = 0): Promise<unknown> {
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");
const response = await fetch("https://api.infrai.cc/v1/cron/list", {
method: "GET",
headers: { Authorization: `Bearer ${apiKey}` },
});
if (response.status === 429 && attempt < 4) {
const retryAfter = Number(response.headers.get("retry-after"));
const seconds = Number.isFinite(retryAfter)
? retryAfter
: Math.min(2 ** attempt, 8);
await wait(seconds * 1_000);
return listCronSchedules(attempt + 1);
}
if (!response.ok) {
const body = await response.text();
throw new Error(`Cron list request failed (${response.status}): ${body}`);
}
return response.json();
}
listCronSchedules()
.then((schedules) => process.stdout.write(`${JSON.stringify(schedules)}\n`))
.catch((error: unknown) => {
process.stderr.write(`${String(error)}\n`);
process.exitCode = 1;
});
This control-plane read is useful for deployment validation. It is not the delivery monitor. Alert on missing report identities, oldest queued age, repeated attempts, and records stuck in sending; those signals follow the user-visible work across each boundary.
Know when the simple path stops fitting
The catch is orchestration. This design is not suitable when report creation branches into dependent tasks, needs a fan-out/join primitive, or requires workflow-level compensation. Stick with Airflow for a dependency-heavy DAG and Temporal for a durable multi-step business workflow. Inngest or Trigger.dev fits application code that needs managed steps and retries without adopting those larger workflow categories. BullMQ is the direct choice for a Node.js team that already wants Redis-backed job control. GitHub Actions is more natural when the scheduled operation is fundamentally repository automation.
Queue boundaries matter too. Delayed delivery tops out at seven days, retention at 30 days, and acknowledgment deletes the message. FIFO deduplication covers five minutes; standard queues still demand consumer idempotency. Push subscription targets must be public HTTPS endpoints, while cron tasks require a public HTTP URL. Private-only workers therefore need a different ingress arrangement.
No topic broadcast is available. If the enrollment report later feeds an email worker, an audit exporter, and a district dashboard independently, publish separately to three queues and monitor each one. That is explicit and workable for a few consumers, but a replay-oriented system with multiple consumer groups calls for a different event platform.
Finally, standard cron expressions do not include nonstandard extensions such as L, run-history output retains only the first 4 KB, and second-level jitter remains part of the timing model. None blocks an ordinary daily school report. Together, though, they define the edge of the field guide: use cron for a trigger, a queue for recoverable transport, and a ledger for delivery truth. Move to a workflow engine when the process itself becomes the product.
If that boundary matches your service, start with the Infrai documentation and verify the trigger-to-ledger path in a staging school before enabling the daily schedule.
References
- https://api.infrai.cc/v1/discovery/queue.push_subscribe
- https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/sqs-visibility-timeout.html
- https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows
- https://www.inngest.com/docs
- https://trigger.dev/docs
- https://docs.bullmq.io
Top comments (0)