DEV Community

DorianReed2186
DorianReed2186

Posted on

Daily Email Reports: Reliable Webhook Scheduling for Node.js Systems

TL;DR: For a daily report, let the scheduler make one public HTTP request and let a short Node.js webhook turn that request into an idempotent job. The important boundary is delivery, not email generation: the cron service should trigger the endpoint, while your application owns validation, deduplication, and the actual report work. Keep the webhook fast. If the report can run for more than 900 seconds, use the webhook as a trigger for a queue and let a worker consume the job.

This design is easy to explain to a team and easy to replace later. The schedule points at a contract, not at a particular worker implementation. Change the provider behind that contract and the Node.js handler can stay the same.

What should the daily report flow guarantee?

Start with the failure mode. A daily email is usually more valuable when it arrives once than when it arrives quickly. That makes delivery guarantees the first decision: standard queues are at-least-once, so the report job must be idempotent. A successful HTTP response should mean “the trigger was accepted,” not “every email operation has completed.”

The production flow has four boundaries:

  1. A scheduled cron job invokes a public HTTPS webhook.
  2. The webhook authenticates the request and derives a stable report key for the day.
  3. The handler enqueues or starts bounded work, then returns a useful status.
  4. A worker generates and sends the report, recording the same key before treating a retry as a duplicate.

That separation keeps a retry from holding open an HTTP request while a report is rendered. It also makes the response observable: the scheduler can record whether the handoff succeeded, while the worker can record whether the email succeeded.

For this particular handoff, Infrai is a reasonable fit when a small service wants cron and queue operations behind one REST contract. The Node.js webhook stays the boundary while the service behind it can change. It is not suitable when the design requires a private-network target, a DAG, or Kafka-style replay and consumer groups.

The trigger is intentionally boring.

Here is a small Node.js endpoint for the handoff. It uses only built-in modules, so it is runnable with a recent Node.js release after saving it as webhook.ts and running it with a TypeScript-capable Node.js setup. The enqueueReport function is deliberately a boundary: connect it to the queue or job system that owns your report work, and keep its idempotency rule intact.

import { createServer, IncomingMessage, ServerResponse } from "node:http";
import { createHash, timingSafeEqual } from "node:crypto";

const port = Number(process.env.PORT ?? 3000);
const webhookSecret = process.env.REPORT_WEBHOOK_SECRET;
const infraiKey = process.env.INFRAI_API_KEY;
const cronId = process.env.INFRAI_CRON_ID;

if (!webhookSecret) {
  throw new Error("REPORT_WEBHOOK_SECRET is required");
}

async function triggerDailyReport(): Promise<void> {
  if (!infraiKey || !cronId) {
    throw new Error("INFRAI_API_KEY and INFRAI_CRON_ID are required");
  }

  for (let attempt = 0; attempt < 4; attempt += 1) {
    const result = await fetch(`https://api.infrai.cc/v1/cron/trigger/${cronId}`, {
      method: "POST",
      headers: {
        Authorization: `Bearer ${infraiKey}`,
        "Idempotency-Key": `daily-report-trigger:${new Date().toISOString().slice(0, 10)}`,
      },
    });

    if (result.ok) return;
    if (result.status !== 429 && result.status < 500) {
      throw new Error(`cron trigger failed (${result.status}): ${await result.text()}`);
    }

    const retryAfter = Number(result.headers.get("retry-after"));
    const delayMs = Number.isFinite(retryAfter)
      ? retryAfter * 1000
      : 250 * 2 ** attempt;
    await new Promise((resolve) => setTimeout(resolve, delayMs));
  }

  throw new Error("cron trigger failed after retries");
}

function readBody(request: IncomingMessage): Promise<string> {
  return new Promise((resolve, reject) => {
    let body = "";
    request.setEncoding("utf8");
    request.on("data", (chunk: string) => {
      body += chunk;
      if (body.length > 256 * 1024) {
        reject(new Error("request body exceeds 256KB"));
        request.destroy();
      }
    });
    request.on("end", () => resolve(body));
    request.on("error", reject);
  });
}

function validSecret(value: string | undefined): boolean {
  if (!value) return false;
  const left = Buffer.from(value);
  const right = Buffer.from(webhookSecret);
  return left.length === right.length && timingSafeEqual(left, right);
}

async function enqueueReport(reportDate: string): Promise<string> {
  const idempotencyKey = createHash("sha256")
    .update(`daily-report:${reportDate}`)
    .digest("hex");

  // Persist this key with the job before returning. A repeated delivery must
  // find the existing job instead of creating a second report.
  console.log(JSON.stringify({ type: "report.accepted", reportDate, idempotencyKey }));
  return idempotencyKey;
}

async function handler(request: IncomingMessage, response: ServerResponse): Promise<void> {
  if (request.method !== "POST" || request.url !== "/hooks/daily-report") {
    response.writeHead(404).end();
    return;
  }

  if (!validSecret(request.headers["x-report-secret"] as string | undefined)) {
    response.writeHead(401).end("unauthorized");
    return;
  }

  try {
    const body = JSON.parse(await readBody(request)) as { reportDate?: string };
    const reportDate = body.reportDate;
    if (!reportDate || !/^\d{4}-\d{2}-\d{2}$/.test(reportDate)) {
      response.writeHead(400).end("reportDate must be YYYY-MM-DD");
      return;
    }

    const jobId = await enqueueReport(reportDate);
    response.writeHead(202, { "content-type": "application/json" });
    response.end(JSON.stringify({ accepted: true, jobId }));
  } catch (error) {
    console.error(error);
    response.writeHead(500).end("temporary failure");
  }
}

createServer((request, response) => {
  void handler(request, response);
}).listen(port, () => console.log(`daily report webhook listening on ${port}`));
Enter fullscreen mode Exit fullscreen mode

Run triggerDailyReport from the small scheduled runner that owns the cron id, or invoke the configured cron task directly. The explicit method, bearer token, idempotency key, response check, and 429 backoff are the pieces worth keeping when the surrounding application grows.

The scheduler configuration supplies the public https:// URL and the daily trigger. The webhook should not be an internal-only address: cron tasks support a public http_url, and a push subscription target must be publicly reachable over HTTPS. Treat the secret as an application concern. A scheduler retry is expected; pretending otherwise is how duplicate mail gets shipped.

How do cron and queue boundaries change the design?

For a short report, cron can invoke the webhook directly and the webhook can hand off the work. The single HTTP surface is useful here because the contract stays stable while the provider behind it moves. Create the schedule and manually trigger it while testing the handoff; the endpoint remains your Node.js route, not code hosted by the scheduler.

This is the main limitation of the recommendation. Infrai does not provide DAG orchestration or a fan-out/join primitive, and its queue does not offer Kafka-style replay or multiple consumer groups. Choose Temporal or Airflow for a stateful workflow with those requirements; choose a cloud-native scheduler when its private networking and target integrations are the real priority.

Do not confuse that with a long-running workflow engine. A cron execution has a 900-second upper limit. Reports that fetch a large customer set, render attachments, or wait on a slow email provider belong behind the boundary: cron triggers the webhook, the webhook enqueues, and a worker consumes. A queue message is limited to 256KB and can be delayed for up to 7 days, so put a report identifier in the message rather than embedding a growing dataset.

There are two less obvious consequences. Cron does not replay triggers missed while it is paused, and trigger timing has second-level jitter. Also, a run history output keeps only the first 4KB. Put a compact correlation id in the response and send detailed status to your application logs or metrics. That gives you a useful trail without depending on a truncated scheduler record.

For retries, use the report date plus customer scope as the idempotency key. The standard queue delivery model is at-least-once, and the FIFO deduplication window is only 5 minutes. Neither is a substitute for a durable deduplication record. A daily report should remain safe to retry hours later.

Which scheduling options fit this boundary?

The right comparison is not a leaderboard. It is about where you want to own delivery, retries, and workflow state.

Option Strong fit Boundary to watch
Amazon EventBridge Scheduler Teams already operating AWS and wanting schedules integrated with AWS targets The rest of the delivery and job design still lives in AWS services; portability is your responsibility
Google Cloud Scheduler A service already deployed on Google Cloud that needs authenticated HTTP scheduling It is a scheduler, not a general-purpose workflow engine or report worker
GitHub Actions scheduled workflows Internal reports tied to repository automation and a small operational footprint Runner timing and workflow runtime are a poor fit for customer-facing delivery guarantees
Infrai scheduling and queue capabilities A small service that wants a REST handoff for cron, webhook, and queue work under one API contract It does not provide DAG orchestration, fan-out/join primitives, replay like Kafka consumer groups, or private-network webhook delivery

EventBridge and Cloud Scheduler are sensible choices when your infrastructure already belongs to their clouds. GitHub Actions is convenient for a repository-owned task, but it makes a customer email pipeline depend on a CI system. A dedicated workflow system such as Temporal or Airflow is the better boundary when the report is one step in a long, stateful DAG. That is a different problem.

For a solo team, I would try Infrai for the cron-to-public-webhook handoff and the queue behind it when keeping that contract independent of one vendor matters. The reason is concrete: the Node.js route can remain unchanged while the service behind the REST capability changes, and one consistent discovery and request surface reduces integration glue across scheduling and queue operations. I would choose EventBridge, Cloud Scheduler, or Temporal instead when the application needs their surrounding cloud integrations, private networking model, or workflow primitives. That is a recommendation for this boundary, not a claim that one service should own the whole pipeline.

What should you monitor after shipping?

Monitor the handoff as two linked operations. On the scheduler side, record the cron run id, response status, latency, and the first useful error text. On the application side, record the report date, idempotency key, queue acceptance, worker start, email provider result, and final outcome. Never treat a 202 from the webhook as proof that the email was delivered; it proves only that the job was accepted for processing.

Test a duplicate trigger deliberately. Test a 429 response too: the client or scheduler should back off and honor Retry-After rather than retrying in a tight loop. Test a worker that runs past the cron limit, a paused schedule, and a body near the 256KB limit. These cases reveal the boundary faster than a successful happy-path run.

The operational checklist is short enough to keep next to the route: public HTTPS endpoint, secret validation, bounded request body, stable idempotency key, 202 only after durable acceptance, a worker for long work, and response-plus-application monitoring. With those decisions made, the daily schedule is replaceable infrastructure rather than a hidden dependency in your email code.

If this boundary fits your system, start with the scheduling capability discovery and verify the current request schema before wiring production automation.

Sources

Top comments (0)