Short answer: for recurring user reminders, choose an API beyond a bare cron schedule: it must preserve timezone rules, emit a stable occurrence ID, retry ambiguous delivery, and let your public webhook acknowledge only after the logistics digest is durably queued.
| Choice | Delivery model | Timezone model | Operational burden | Best fit |
|---|---|---|---|---|
| Managed recurrence plus webhook | Scheduler retries; receiver deduplicates | Named zone stored with the rule | Low to medium | Most weekly customer digests |
| Queue-backed recurrence worker | Your worker owns retries and recovery | Application computes each occurrence | Medium | Strict audit and replay requirements |
| Host-level cron | One machine starts the work | Usually extra application logic | Low at first, high after failure | Small internal jobs with one fixed zone |
For an active-customer logistics digest, start with managed recurrence plus a queue-backed receiver. The scheduler decides when an occurrence is due. Your service decides whether that occurrence has already been accepted, which customers are still active, and how the digest is generated. That boundary is the recommendation; a long feature checklist isn't.
The occurrence ledger is the selection test
Time-to-first-call matters, but time-to-first-recoverable-call is the better DX metric. Start from an empty repository and record how many application concepts, environment values, and control-plane steps are required before one authenticated occurrence reaches a local endpoint. Then repeat the exercise for secret rotation, schedule updates, a replay, and a timezone change. Count glue code. Count config files too.
The selection artifact is an occurrence ledger. It should show the scheduled occurrence time, first-attempt time, attempt count, final disposition, response code, and a correlation key that survives retries. The digest worker also needs its own states, such as queued, audience resolved, rendered, and handed to the mail transport. Scheduler success is not customer delivery. During an evaluation, walk one occurrence from its calendar rule to durable acceptance and then force the same occurrence through the receiver twice. If the second request creates a second business event, or if the evidence disappears into unrelated logs, the delivery claim has failed before any load test begins.
No ledger, no proof.
Calendar policy belongs beside delivery evidence
Model the schedule as business data, not as a cron string that happens to live in a dashboard. A weekly rule needs a weekday, local wall-clock time, and named timezone. A monthly rule needs a written policy for dates that don't occur every month. "Run on day 31" is incomplete until the team chooses skip, clamp to the last day, or move into the next month.
Keep the policy visible in your own database even when an external API executes it. Store a schedule ID, customer ID, cadence, local time, timezone, next expected occurrence, status, and revision. The external scheduler's identifier belongs beside those fields, not in place of them. This makes config review possible and keeps provider changes away from the rest of the digest pipeline.
Cron syntax is only an encoding. It doesn't answer the hard questions.
A useful API must expose timezone behavior directly or define it without ambiguity. Test the contract around offset changes rather than assuming the same UTC instant will always correspond to the same local time. For a Monday 09:00 digest, the invariant is usually "09:00 for that customer," not "the same UTC hour forever." If the product only accepts UTC expressions, the application has inherited calendar computation and schedule updates; measure that glue before calling the integration simple.
Monthly schedules deserve a separate fixture set. Use customers in several named zones, cover the end of short months, and assert the occurrence key as well as the timestamp. I'm not sure there is one correct day-31 policy for every logistics workflow. There isn't enough information until product owners decide whether a late digest or a missing digest is less harmful.
Define alerts from the business deadline backward. A weekly digest that is queued a few seconds late may still be healthy; one that has not been accepted before the audience snapshot closes may require intervention. Monitor missing expected occurrences, oldest unprocessed occurrence, duplicate rate, and time from scheduled occurrence to durable acceptance. Those signals make a scheduler change measurable rather than emotional.
Keep the load test honest. One request is easy. A timezone boundary can concentrate many customer schedules into the same local minute, while a weekly digest can create a second burst as workers query active customers. Test the webhook acceptance tier independently from digest generation, cap worker concurrency, and observe queue age. Otherwise the scheduler gets blamed for a database bottleneck it merely revealed.
Can a public Node.js webhook endpoint preserve weekly monthly timezone reminders?
Webhook delivery crosses a failure boundary. The sender can make a request while the receiver commits work, then lose the response. A retry is reasonable from the sender's view, yet it is a duplicate from the receiver's view. Calling the whole path "exactly once" hides that ambiguity. Use at-least-once delivery with an idempotent acceptance path. Give every logical occurrence a stable key such as scheduleId:scheduledAt, and require retries of the same occurrence to retain it. At the public endpoint, authenticate the raw request bytes, reserve the key in durable storage, enqueue the digest job in the same transaction or an outbox-backed transaction, and then return success. Do not generate and send the customer email inside the request. The acknowledgment point matters more than a generous timeout: return 202 only after durable acceptance, reject authentication before parsing untrusted content, and treat an already accepted occurrence as success without launching another digest. Track three identities separately as well. A schedule describes the recurring rule; an occurrence represents one intended weekly or monthly digest; attempts are transports for that occurrence and may be repeated. If one database row tries to mean all three, retry counters overwrite business history and operators can't answer whether a customer missed a digest or merely received a duplicate request.
Duplicates happen.
This is also where a durable messaging layer earns its keep. A publish-subscribe service can decouple webhook acceptance from digest generation and can fan events to independent consumers; the Google Cloud Pub/Sub overview is one public description of that messaging model. The architectural point is generic: queue first, acknowledge second, process later. Your scheduler should never wait for CSV aggregation, template rendering, or mail delivery.
The handler below uses Node.js primitives and deliberately leaves storage and queue implementations behind interfaces. That is the part teams should swap during a benchmark. The HTTP and idempotency contract should stay boring.
import { createHmac, timingSafeEqual } from "node:crypto";
import { createServer, IncomingMessage, ServerResponse } from "node:http";
type DigestOccurrence = {
occurrenceId: string;
scheduleId: string;
customerId: string;
scheduledAt: string;
};
interface OccurrenceStore {
acceptOnce(event: DigestOccurrence): Promise<"accepted" | "duplicate">;
}
interface DigestQueue {
publish(event: DigestOccurrence): Promise<void>;
}
const store: OccurrenceStore = getOccurrenceStore();
const queue: DigestQueue = getDigestQueue();
const secret = requireEnvironment("WEBHOOK_SECRET");
function verifySignature(body: Buffer, suppliedHex: string): boolean {
const expected = createHmac("sha256", secret).update(body).digest();
const supplied = Buffer.from(suppliedHex, "hex");
return supplied.length === expected.length && timingSafeEqual(supplied, expected);
}
async function readBody(request: IncomingMessage): Promise<Buffer> {
const chunks: Buffer[] = [];
for await (const chunk of request) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
}
return Buffer.concat(chunks);
}
async function receive(request: IncomingMessage, response: ServerResponse) {
if (request.method !== "POST" || request.url !== "/webhooks/reminders") {
response.writeHead(404).end();
return;
}
const body = await readBody(request);
const signature = request.headers["x-webhook-signature"];
if (typeof signature !== "string" || !verifySignature(body, signature)) {
response.writeHead(401).end();
return;
}
const event = JSON.parse(body.toString("utf8")) as DigestOccurrence;
const result = await store.acceptOnce(event);
if (result === "accepted") {
await queue.publish(event);
}
response.writeHead(202).end();
}
createServer((request, response) => {
void receive(request, response);
}).listen(8080);
HMAC is appropriate here because the sender and receiver share a secret and the receiver can recompute a message authentication code over the exact bytes. RFC 2104 defines the keyed-hashing construction. In a real contract, specify the algorithm, header encoding, signed byte sequence, timestamp treatment, and secret-rotation procedure. "Signed webhook" by itself is config fog.
The abbreviated interfaces conceal one crucial transaction decision. acceptOnce and publish cannot be two unrelated durable writes if losing the process between them would strand an accepted occurrence. Use a transactional outbox when the database and broker cannot participate in one transaction: commit the occurrence and outbox record together, then have a relay publish the outbox record. Only mark it dispatched after broker acceptance. This adds machinery, so benchmark the simpler direct queue write first and adopt the outbox when the delivery objective requires recovery from that gap.
I use a narrow test matrix for this boundary: valid signature, modified body, duplicate occurrence, queue delay, and a process stop between persistence and publish. Then I add calendar cases separately. Mixing transport and calendar cases into one suite makes failures slower to diagnose and encourages a thicket of fixtures.
Operational ownership defines the runner-up
The queue-backed recurrence worker is the better choice when every scheduling decision must be replayable from an internal ledger, when recurrence rules depend on frequently changing domain state, or when the team already operates a durable worker platform. It gives one place to version calendar policy and build backfills. The catch is ownership: your team now maintains leader election or equivalent single-dispatch coordination, calendar computation, retry state, deployments, and on-call diagnostics.
Stick with host-level cron when the job is internal, uses one fixed timezone, tolerates a missed run being handled manually, and runs on infrastructure with clear ownership. It is not suitable for per-customer weekly and monthly reminders once schedule count, timezone diversity, or audit requirements grow. The initial config is tiny; the missing control plane becomes the cost.
Managed recurrence is not suitable when its occurrence identity changes across retries, when timezone semantics cannot express your business rule, or when delivery history cannot support your audit window. In those cases, choose the queue-backed worker even though it needs more code. Don't trade a visible implementation burden for an invisible recovery gap.
The final selection can stay vendor-neutral: prove calendar semantics with fixtures, prove duplicate handling with repeated occurrence IDs, prove authentication over raw bytes, and prove recovery at the persistence-to-queue boundary. Pick the option with the fewest unowned failure modes, not the option with the longest settings page.
References
- RFC 2104: HMAC keyed-hashing for message authentication: https://www.rfc-editor.org/rfc/rfc2104
- Google Cloud Pub/Sub overview: https://cloud.google.com/pubsub/docs/overview
Top comments (0)