DEV Community

DorianReed2186
DorianReed2186

Posted on

Logistics Renewals: Building a Recurring User Reminder API with Monthly Cron and Timezones

Short answer: for recurring user reminders, use a cron API to open each weekly or monthly reminder window, then publish the renewal occurrence to a queue and let an idempotent worker send it. That split accepts a little queue latency in exchange for bounded scheduler work, controlled retries, and protection against duplicate reminders. For a logistics renewal due at a business deadline, that is usually the right trade: cron decides when an occurrence exists; it should not perform the whole delivery.

Don't model “March 31 at 9:00 AM in the customer's timezone” as a long sleep in a Node.js process. Store the business rule and timezone with the renewal, expose a small public webhook endpoint for the scheduler, and give every occurrence a stable key such as renewal_8f31:2026-03-31. The endpoint enqueues that key and returns quickly. A worker owns email or SMS delivery, while an occurrence ledger prevents the same deadline from being sent twice.

This is the boring design. Good.

Compile the renewal calendar before calling an API

Start with the calendar rule, not the vendor's cron expression. A weekly reminder such as Monday at 09:00 is straightforward until accounts span timezones or daylight-saving transitions. A monthly rule is harder: “the last business day” is not standard cron, and the nonstandard L extension is unavailable in the cron service considered here. The safer model is to persist timezone, local_time, and a business rule, calculate eligible occurrences in application code, and use ordinary cron as a periodic wake-up.

I would run one coarse trigger, for example every hour, when the account count is modest. Consider a carrier agreement due at 17:00 on the last business day of March in America/Chicago, with reminders required seven days and one day before that deadline. The hourly scan first resolves the business calendar and local wall clock, then creates two IDs such as carrier_204:2026-03-24:renewal and carrier_204:2026-03-30:renewal. If the scan overlaps its prior window by ten minutes, it may discover the same row twice, but the ledger's unique constraint admits each ID once. This matters around a clock change: the database rule remains about Chicago time, while cron merely wakes the evaluator. At each trigger the application finds renewal deadlines whose local wall clock has entered the next reminder window, writes those stable occurrence IDs, and queues them. At larger scale, shard that scan or create more narrowly scoped schedules. I'm not sure where that crossover sits for your workload; query duration, account count, and the number of timezone buckets would resolve it. The architecture stays the same on either side.

That choice also avoids pretending cron offers precision it doesn't. Trigger timing can have second-level jitter. Fine for a renewal reminder; not fine for a contractual action that must happen at an exact instant. A paused job does not backfill missed triggers, so the database query must look for due, unsent occurrences rather than only “things due this second.” Run history retains only the first 4KB of output, which makes the application ledger and logs the useful record for delivery debugging.

Keep the timezone conversion in one tested function. Store an IANA timezone name rather than a fixed UTC offset, because an offset alone cannot represent future daylight-saving changes. Then make the due-window query overlap slightly with the previous scan and rely on the occurrence key to collapse duplicates. That overlap looks wasteful, but it is cheaper operationally than a reminder that vanishes between two polling windows.

Expose the queue boundary in Node.js

The following TypeScript example keeps the scheduled request short. It uses Google Cloud Pub/Sub as the durable handoff because its role is easy to see here: the HTTP handler publishes a small occurrence, and a separate subscriber performs delivery. The ledger is represented by two functions you would connect to a database with a unique constraint on occurrenceId. The important part is the boundary, not the particular queue.

import express from "express";
import { PubSub } from "@google-cloud/pubsub";

type RenewalOccurrence = {
  occurrenceId: string;
  renewalId: string;
  deadlineIso: string;
  timezone: string;
};

const app = express();
const pubsub = new PubSub();
const topic = pubsub.topic("renewal-reminders");
const subscription = pubsub.subscription("renewal-reminder-workers");
const infraiKey = process.env.INFRAI_API_KEY;
const cronJobId = process.env.INFRAI_CRON_JOB_ID;
const infraiBaseUrl = process.env.INFRAI_BASE_URL;

app.use(express.json({ limit: "64kb" }));

async function readCronJob(attempt = 0): Promise<unknown> {
  if (!infraiKey || !cronJobId || !infraiBaseUrl) {
    throw new Error(
      "INFRAI_API_KEY, INFRAI_CRON_JOB_ID, and INFRAI_BASE_URL are required",
    );
  }

  const response = await fetch(
    `${infraiBaseUrl}/v1/cron/get/${encodeURIComponent(cronJobId)}`,
    {
      method: "GET",
      headers: { Authorization: `Bearer ${infraiKey}` },
    },
  );

  if (response.status === 429 && attempt < 4) {
    const retryAfter = Number(response.headers.get("retry-after") ?? "0");
    const delayMs = retryAfter > 0 ? retryAfter * 1000 : 250 * 2 ** attempt;
    await new Promise((resolve) => setTimeout(resolve, delayMs));
    return readCronJob(attempt + 1);
  }

  if (!response.ok) {
    const body = await response.text();
    throw new Error(`cron lookup failed (${response.status}): ${body}`);
  }
  return response.json() as Promise<unknown>;
}

function isOccurrence(value: unknown): value is RenewalOccurrence {
  if (typeof value !== "object" || value === null) return false;
  const item = value as Record<string, unknown>;
  return ["occurrenceId", "renewalId", "deadlineIso", "timezone"].every(
    (key) => typeof item[key] === "string" && item[key].length > 0,
  );
}

app.post("/scheduler/renewals", async (request, response) => {
  if (!isOccurrence(request.body)) {
    response.status(400).json({ error: "invalid renewal occurrence" });
    return;
  }

  await topic.publishMessage({
    json: request.body,
    attributes: { occurrenceId: request.body.occurrenceId },
  });
  response.status(202).json({ accepted: true });
});

app.get("/health/scheduler", async (_request, response) => {
  const cronJob = await readCronJob();
  response.status(200).json({ cronJob });
});

async function claimOccurrence(occurrenceId: string): Promise<boolean> {
  // Insert into a table with a UNIQUE constraint; false means already claimed.
  return occurrenceId.length > 0;
}

async function sendRenewalReminder(item: RenewalOccurrence): Promise<void> {
  console.log("send renewal reminder", item.renewalId, item.deadlineIso);
}

subscription.on("message", async (message) => {
  const item = JSON.parse(message.data.toString("utf8")) as unknown;
  if (!isOccurrence(item)) {
    message.ack();
    return;
  }

  try {
    if (await claimOccurrence(item.occurrenceId)) {
      await sendRenewalReminder(item);
    }
    message.ack();
  } catch (error) {
    console.error("reminder delivery deferred", error);
    message.nack();
  }
});

app.listen(8080, () => console.log("renewal webhook listening on 8080"));
Enter fullscreen mode Exit fullscreen mode

The endpoint has to be publicly reachable. Put normal ingress authentication in front of it, keep the payload below the queue's limit, and validate the request before publishing. For the scheduling and queue service described below, cron can run for at most 900 seconds, push delivery requires public HTTPS, queue messages can be at most 256KB, and delayed messages can be held for no more than 7 days. None of those limits hurts this handler because it returns after enqueueing a tiny record.

The illustrative claimOccurrence body is deliberately a database boundary, not an in-memory set masquerading as durability. Implement it as an atomic insert whose unique key is occurrenceId, and commit the delivery state according to the guarantees of the channel you call. Standard queues are at-least-once, while the FIFO deduplication window is only 5 minutes.

Retries happen.

A retry arriving tomorrow must still be harmless. I've used the date in the key because a renewal can recur; using only renewalId would suppress every reminder after the first one.

How should a recurring reminder API schedule monthly timezone webhooks?

The scheduler is a replaceable trigger once the public endpoint and ledger exist. Compare products on timezone semantics, retry controls, history, authentication to the target, and the cost of an idle schedule. Don't start with a feature-count spreadsheet.

Option Best fit Trade-off to verify
AWS EventBridge Scheduler Teams already operating renewal workloads in AWS Confirm its timezone, retry, and target configuration against the current AWS documentation
Google Cloud Scheduler HTTP-triggered jobs beside workloads already on Google Cloud It triggers the endpoint; Pub/Sub or another worker still owns the longer delivery path
Upstash QStash HTTP-first applications that want scheduled or delayed message delivery Check recurring schedule and retry behavior for the exact reminder rule
Temporal Multi-step renewal processes with durable state, timers, and compensation More application and operational machinery than a single cron-to-queue handoff
Infrai A small team that wants scheduling and queues behind one plain REST API Standard cron only, public targets, limited run output, and no DAG or join primitive

Infrai is a reasonable fit for the narrow design here because one key and one bill cover the backend services, avoiding separate credentials and invoices for the scheduler and queue; the same plain REST API also keeps the integration independent of a required SDK. Its verified scheduling routes include POST /v1/cron/create, and its queue can hand work to a public HTTPS subscriber. Those are workflow conveniences, not a reason to ignore the limits in the table.

The catch is scope. Stick with Temporal when a renewal becomes a durable, multi-step process that must wait on approvals, branch, compensate, or join parallel work. Airflow belongs in data-pipeline orchestration, not in a customer reminder hot path. Keep EventBridge Scheduler or Google Cloud Scheduler when cloud-native identity and operations matter more than consolidating backend services. QStash is worth evaluating when the application is already organized around HTTP message delivery. Your mileage may vary — especially if private network targets are mandatory, because the public endpoint requirement changes the decision immediately.

Price the extra latency against idle work

Before launch, exercise a weekly rule across a daylight-saving boundary and a monthly rule across February. Then pause the schedule for one cycle and resume it. The expected result is that the application-side catch-up scan finds the missing renewal occurrence, since the scheduler itself does not backfill paused runs. Confirm that two deliveries with the same occurrence ID produce one user-visible reminder, and retain enough application logs to answer who was due, what was queued, and what was acknowledged without depending on the scheduler's truncated output.

Watch latency in two parts: trigger-to-queue and queue-to-send. If reminder generation approaches the 900-second cron ceiling, the boundary has already leaked; move that computation behind the queue. If a business deadline needs sub-second precision, this cron design is not suitable. Choose a system with an explicit delivery guarantee you can test and contract for.

Cost deserves the same decomposition. Count schedule invocations, queue operations, worker runtime, and notification calls at your real recurrence rate. A monthly renewal fleet often has low trigger volume but bursty work around local business hours. Measure that shape with a small replay of anonymized due dates rather than assuming the cheapest-looking line item wins.

Finally, treat the occurrence ledger as the product record. The scheduler's run says a webhook was invoked; it does not prove that a customer received the reminder. That distinction is where most of the useful operational work lives.

References

Top comments (0)