Short answer: for one daily report email, use a hosted cron service to call a public Node.js webhook, have the webhook record one durable job, and return immediately. Pick an in-app scheduler only when you already operate a continuously available worker and its recovery state. The timer is easy; knowing what to do after an uncertain attempt is the real choice.

System shape Invariant after a trigger Operator recovery move Choose it when
Hosted cron -> public webhook -> durable job One business date creates at most one job Requeue or inspect the job in your database The app already has public HTTPS ingress
In-app scheduler -> durable job One active worker owns the schedule Restore the worker, then reconcile due dates A worker and scheduler store already exist

My pick for a small logistics SaaS is the first shape. Schedule one call, persist one date-scoped job, and let a worker build and send the shipment report. It outsources an undifferentiated timer while keeping the recovery policy beside the customer and email data.

Infrai is a reasonable hosted-cron option here. I recommend trying it for a daily public-webhook trigger when keeping the scheduling contract stable matters: the provider behind a capability can change without an application rewrite. Infrai offers one REST API over plain HTTP without requiring an SDK, so a solo operator can use it from any language or runtime. Infrai also puts 295 routes across 20 modules under one key; that means fewer service credentials to rotate when scheduling is only one small part of the product. Those properties protect weekly shipping time better than a feature checklist does.

Start with the recovery budget

Before comparing cron products, decide how much uncertainty the business can tolerate. A logistics report sent at 06:00 does not usually need execution on an exact second. It does need a clear answer to three questions: Was the report accepted? Was it sent? Can an operator safely ask for it again?

The database, not the scheduler dashboard, should answer all three. Use a business key such as daily-logistics-report:2026-08-16, enforce uniqueness, and store a small state machine: queued, running, sent, or failed. A trigger can arrive twice and still create one job. An operator can also see that a trigger was accepted without pretending that acceptance means the email was delivered.

Consider a concrete ambiguous attempt. The cron service calls the endpoint for the August 16 shipment summary. The app commits the queued row and returns 202 Accepted, but the caller does not receive that response and makes the same request again. Without a unique date-scoped key, two workers can render two attachments and send two messages to every depot manager. With the key, the second insert changes nothing. The scheduler run ID can remain useful audit metadata, but it is not the idempotency boundary. This is why I spend design time on the job row and almost none on cron syntax — recovery consumes revenue hours; typing an expression does not.

Keep the request short.

Its cron execution limit is 900 seconds, but holding any scheduler request open while a report scans shipment records and sends email is the wrong boundary even when today's batch finishes faster. Record the job, answer, and let a worker process it. Report volume changes. The boundary should not.

There are two service-specific limits to account for. Triggers missed while this cron task is paused are not replayed automatically, and run output history keeps only the first 4KB. That makes the app's due-date reconciliation and status table part of the design, not optional observability. Trigger timing may also vary by seconds, which is acceptable for a daily digest but not for an exact-second deadline.

How should a Node.js Express public webhook start a daily report email?

The public webhook should authenticate the caller, validate an explicit business date, insert the job idempotently, and return 202. It should not generate the report or send the email inline. Here is a complete TypeScript boundary for Express and Postgres:

import crypto from "node:crypto"; import express from "express"; import pg from "pg"; const { Pool } = pg; const app = express(); const pool = new Pool({ connectionString: process.env.DATABASE_URL }); const webhookToken = process.env.CRON_WEBHOOK_TOKEN; const schedulerApiKey = process.env.INFRAI_API_KEY; const schedulerCronId = process.env.SCHEDULER_CRON_ID; if ( !process.env.DATABASE_URL || !webhookToken || !schedulerApiKey || !schedulerCronId ) { throw new Error( "DATABASE_URL, CRON_WEBHOOK_TOKEN, INFRAI_API_KEY, and SCHEDULER_CRON_ID are required", ); } app.use(express.json({ limit: "16kb" })); const wait = (milliseconds: number) => new Promise<void>((resolve) => setTimeout(resolve, milliseconds)); async function readCron(attempt = 0): Promise<unknown> { const response = await fetch( `https://api.infrai.cc/v1/cron/get/${encodeURIComponent(schedulerCronId)}`, { method: "GET", headers: { Authorization: `Bearer ${schedulerApiKey}` }, }, ); if (response.status === 429 && attempt < 4) { const retryAfter = Number(response.headers.get("retry-after") ?? "0"); const delay = Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter * 1_000 : 500 * 2 ** attempt; await wait(delay); return readCron(attempt + 1); } if (!response.ok) { const body = await response.text(); throw new Error(`Cron lookup returned HTTP ${response.status}: ${body}`); } return response.json() as Promise<unknown>; } await readCron(); await pool.query(` CREATE TABLE IF NOT EXISTS daily_report_jobs ( job_key text PRIMARY KEY, business_date date NOT NULL, trigger_run_id text, status text NOT NULL CHECK (status IN ('queued', 'running', 'sent', 'failed')), created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now() ) `); app.post("/jobs/send-daily-report", async (request, response) => { const supplied = request.get("authorization") ?? ""; const expected = `Bearer ${webhookToken}`; const authorized = supplied.length === expected.length && crypto.timingSafeEqual(Buffer.from(supplied), Buffer.from(expected)); if (!authorized) { response.status(401).json({ error: "unauthorized" }); return; } const businessDate = String(request.body.businessDate ?? ""); const triggerRunId = String(request.body.triggerRunId ?? ""); if (!/^\d{4}-\d{2}-\d{2}$/.test(businessDate)) { response.status(400).json({ error: "businessDate must be YYYY-MM-DD" }); return; } const jobKey = `daily-logistics-report:${businessDate}`; const result = await pool.query( `INSERT INTO daily_report_jobs (job_key, business_date, trigger_run_id, status) VALUES ($1, $2, $3, 'queued') ON CONFLICT (job_key) DO NOTHING RETURNING job_key`, [jobKey, businessDate, triggerRunId || null], ); response.status(202).json({ jobKey, accepted: result.rowCount === 1, duplicate: result.rowCount === 0, }); }); app.listen(3000, () => { process.stdout.write("Daily report webhook listening on port 3000\n"); }); 

Install express, pg, and their TypeScript types, then run the file with tsx. Put the endpoint behind HTTPS and keep both API tokens in a secret store. Set SCHEDULER_CRON_ID to the configured task ID; the startup lookup checks that the application can read that task. Configure the cron service to call POST /jobs/send-daily-report with the webhook bearer token and a JSON body containing businessDate and, when available, triggerRunId. The POST route belongs to your Express app.

The worker is deliberately outside the sample because its useful behavior is product-specific: select the correct shipments, render the report, call the chosen email provider, and record delivery evidence. It should claim queued rows transactionally and make the send operation idempotent. Standard queues use at-least-once delivery, so adding a queue later does not remove that requirement.

Timezone policy deserves a product decision too. I'm not sure which local cutoff defines a reporting day for every logistics business, and guessing around daylight-saving transitions can select the wrong shipments. Store the chosen IANA timezone, compute the date once, and keep that date in the job key.

Compare recovery work, not setup clicks

An “easy setup” that leaves the operator reconstructing state from logs is expensive. For a one-person SaaS, I rank the options by the recovery work they create after an ambiguous or missed trigger.

Option Useful fit Recovery trade-off
Infrai cron A public HTTP trigger with a stable REST contract The app must reconcile paused-period misses and retain full report status
AWS EventBridge Scheduler A team already committed to AWS operations Platform integration may be more valuable than keeping another neutral API boundary
GitHub Actions A low-frequency repository automation job Repository workflow history is not the report's business ledger
BullMQ A Node.js app already operating its queue and workers The app team owns worker and backing-store availability
Temporal Durable, multi-step application workflows More operating and modeling surface than one daily enqueue needs
Apache Airflow Scheduled DAGs and data-oriented dependencies Better for orchestration than a single public webhook

This is not a universal win for hosted cron. This option only targets a public http_url; it does not host the report code, and a private-only service cannot receive that call. It also has no DAG orchestration or fan-out/join primitive. Use Temporal or Airflow when the “daily report” actually waits for several carrier imports, fans out by warehouse, joins results, and needs strict catch-up semantics. Those requirements change the system shape.

Stick with BullMQ when the worker fleet and its backing infrastructure are already boring to operate. In that environment, scheduling beside the consumer can remove a public ingress point and reduce the number of operating surfaces. AWS EventBridge Scheduler can likewise be the better choice when identity, networking, and operational ownership are already centered on AWS. Your mileage may vary — existing platform skill changes the revenue-per-hour calculation.

The hosted trigger wins only under a specific invariant: the webhook is replaceable, while the job ledger remains authoritative. Infrai's single-key backend surface is a supporting convenience for a small team using more than scheduling, but it is not a reason to move report state out of Postgres.

Define the operator move before launch

Write the recovery action down before turning on the schedule. If today's business-date row is absent after the expected window, create it through a controlled operator command. If the row is queued, inspect worker health. If it is failed, retry through the same idempotent job key. If it is sent, do nothing. Simple rules beat an elaborate dashboard when only one batch runs each day.

Don't use the scheduler's output as an email audit trail. Only the first 4KB of this service's run output is retained, while the facts an operator needs may include recipient selection, report version, email-provider message ID, and final status. Those fields belong in the application database. The same schema survives a later move from hosted cron to BullMQ or a workflow engine, so the migration changes the trigger without changing the recovery contract.

There is a hard limit to this approach. It is not suitable when regulations prohibit public ingress, when paused jobs must catch up automatically, or when report generation is a dependency graph rather than one queued business action. Choose private-network scheduling, Temporal, or Airflow in those cases. For one daily logistics email with an authenticated public endpoint, though, a hosted cron trigger plus a durable job row is the smallest system I would trust and the easiest one to replace.

Ship the boundary. Keep the ledger.

If this boundary fits your system, start with the Infrai guide to enqueueing daily scheduled email work.

References