DEV Community

ColeMitchell4991
ColeMitchell4991

Posted on

Recurring User Reminders: Monthly and Weekly Cron Scheduling with Timezone-Safe Webhooks

Short answer: use cron to wake up a public webhook for weekly or monthly reminder generation, then commit each intended send to a durable queue and let an idempotent worker deliver it. Do not send reminders inside the cron request. That split tolerates trigger retries, second-level timing jitter, and work that exceeds a scheduler's request window without turning one repeated webhook into two tenant notifications.

For a property-management app, imagine the nightly job that reconciles payment-provider records and schedules reminder messages for residents with an outstanding balance. The scheduler owns when reconciliation starts. The application owns tenant timezone rules, the reconciliation cursor, deduplication, and every final send. This is the same architecture I would carry from a notebook prototype into production because each boundary has an observable input and an eval-friendly output.

How should a cron API schedule monthly and weekly user reminders with timezone support?

Treat the schedule as a wake-up signal, not as the business calendar. A standard cron expression is a good fit for weekly and monthly triggers, but nonstandard extensions such as L are unavailable. If the product requirement says "the last business day at 9:00 AM in the resident's timezone," run a broader daily trigger and let application code decide which residents are due. That keeps daylight-saving and calendar policy in versioned, testable Python rather than hiding them in a vendor-specific expression.

Timezone support has two separate meanings. The scheduler may interpret an expression in a named zone, while the application still has to define what happens when a local time is skipped or repeated during a daylight-saving transition. I'm not sure there is one correct choice for every reminder product; a rent reminder can usually prefer one deterministic occurrence, while a legally timed notice may require jurisdiction-specific review. Put those cases in the eval harness before choosing the cron expression.

The public webhook should return quickly after it records work. Infrai cron targets must be public HTTP endpoints, and push-subscription targets must be public HTTPS endpoints, so a private-only service needs a controlled ingress or a different deployment choice. Cron timing can have second-level jitter, paused jobs do not backfill missed triggers, and execution history retains only the first 4 KB of output. Those are reasonable boundaries for reminders, provided the app has its own catch-up query and delivery logs.

Keep it boring.

A runnable Python webhook and queue-backed worker

This compact FastAPI example uses SQLite so the transaction and uniqueness constraints are visible. The webhook inserts one reconciliation run for a logical date, the dispatcher creates one reminder per account and period, and the worker claims queued rows. In production, replace SQLite with the durable database and queue already operated by the application, but preserve the same keys and state transitions.

The important key is account_id + reminder_period. A network retry can deliver the same webhook twice; an at-least-once queue can also deliver the same message twice. Both paths converge on one row instead of applying the side effect again.

import os
import sqlite3
import json
import time
from datetime import date, datetime, timezone
from typing import Annotated
from urllib.error import HTTPError
from urllib.parse import quote
from urllib.request import Request, urlopen

from fastapi import FastAPI, Header, HTTPException, status
from pydantic import BaseModel


app = FastAPI()
database_path = os.environ.get("REMINDER_DB", "reminders.db")
webhook_token = os.environ["SCHEDULER_WEBHOOK_TOKEN"]


def read_infrai_cron_job() -> dict:
    cron_id = quote(os.environ["INFRAI_CRON_ID"], safe="")
    api_key = os.environ["INFRAI_API_KEY"]
    base_url = os.environ["INFRAI_BASE_URL"].rstrip("/")
    url = f"{base_url}/v1/cron/get/{cron_id}"

    for attempt in range(5):
        request = Request(
            url,
            headers={"Authorization": f"Bearer {api_key}"},
            method="GET",
        )
        try:
            with urlopen(request, timeout=15) as response:
                return json.load(response)
        except HTTPError as error:
            body = error.read().decode("utf-8", errors="replace")
            if error.code != 429 or attempt == 4:
                raise RuntimeError(f"cron API returned {error.code}: {body}") from error
            retry_after = error.headers.get("Retry-After")
            delay = float(retry_after) if retry_after and retry_after.isdigit() else 2**attempt
            time.sleep(delay)

    raise RuntimeError("cron API retry limit reached")


def connect() -> sqlite3.Connection:
    connection = sqlite3.connect(database_path)
    connection.row_factory = sqlite3.Row
    return connection


def initialize() -> None:
    with connect() as connection:
        connection.executescript(
            """
            CREATE TABLE IF NOT EXISTS reconciliation_runs (
                logical_date TEXT PRIMARY KEY,
                created_at TEXT NOT NULL
            );
            CREATE TABLE IF NOT EXISTS reminder_jobs (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                account_id TEXT NOT NULL,
                reminder_period TEXT NOT NULL,
                payload TEXT NOT NULL,
                state TEXT NOT NULL DEFAULT 'queued',
                created_at TEXT NOT NULL,
                UNIQUE(account_id, reminder_period)
            );
            """
        )


class Trigger(BaseModel):
    logical_date: date


@app.on_event("startup")
def startup() -> None:
    initialize()
    read_infrai_cron_job()


@app.post("/webhooks/nightly-reconciliation", status_code=status.HTTP_202_ACCEPTED)
def trigger_reconciliation(
    trigger: Trigger,
    authorization: Annotated[str | None, Header()] = None,
) -> dict[str, str]:
    if authorization != f"Bearer {webhook_token}":
        raise HTTPException(status_code=401, detail="invalid webhook token")

    created_at = datetime.now(timezone.utc).isoformat()
    with connect() as connection:
        cursor = connection.execute(
            """
            INSERT OR IGNORE INTO reconciliation_runs(logical_date, created_at)
            VALUES (?, ?)
            """,
            (trigger.logical_date.isoformat(), created_at),
        )

    result = "accepted" if cursor.rowcount == 1 else "already_accepted"
    return {"status": result, "logical_date": trigger.logical_date.isoformat()}


def enqueue_reminder(account_id: str, period: str, payload: str) -> bool:
    with connect() as connection:
        cursor = connection.execute(
            """
            INSERT OR IGNORE INTO reminder_jobs(
                account_id, reminder_period, payload, created_at
            ) VALUES (?, ?, ?, ?)
            """,
            (account_id, period, payload, datetime.now(timezone.utc).isoformat()),
        )
    return cursor.rowcount == 1


def claim_next_job() -> sqlite3.Row | None:
    with connect() as connection:
        connection.execute("BEGIN IMMEDIATE")
        job = connection.execute(
            "SELECT * FROM reminder_jobs WHERE state = 'queued' ORDER BY id LIMIT 1"
        ).fetchone()
        if job is None:
            return None
        connection.execute(
            "UPDATE reminder_jobs SET state = 'processing' WHERE id = ?",
            (job["id"],),
        )
        return job
Enter fullscreen mode Exit fullscreen mode

Run it with a real secret and expose it only through HTTPS:

export SCHEDULER_WEBHOOK_TOKEN="replace-with-a-secret"
export INFRAI_API_KEY="replace-with-your-key"
export INFRAI_CRON_ID="replace-with-your-cron-id"
export INFRAI_BASE_URL="replace-with-the-api-base-url"
uvicorn app:app --host 0.0.0.0 --port 8000
Enter fullscreen mode Exit fullscreen mode

An authorization failure returns 401, and a repeated logical date returns 202 with already_accepted. That second result is not an error. It is evidence that the idempotency boundary held. I don't count a successful webhook as a successful reminder; the operational metric that matters comes later, when the worker records the provider result against the durable job.

Retry policy belongs on both sides of the queue

The trigger handler has to be idempotent before it returns success. If the caller loses the 202 response and retries, the primary key on logical_date prevents a second reconciliation run. The dispatcher then needs a narrower uniqueness key because two reconciliation runs may legitimately inspect the same account. Here, (account_id, reminder_period) prevents duplicate reminders while still allowing a new reminder in the next billing period.

For queue consumption, assume at-least-once delivery. Infrai standard queues use that model, and consumer idempotency is mandatory; their FIFO deduplication window is only five minutes, which is too short to serve as the permanent record of a monthly reminder. Its delayed messages are limited to seven days, payloads to 256 KB, and retention to 30 days with deletion after acknowledgement. Store the authoritative reminder state in the application database. A queue is transport, not a ledger.

Retries should distinguish transient transport failures from permanent input failures. Back off after 429, honor Retry-After when the provider sends it, and cap attempts before moving a job to a reviewable dead-letter state. Do not retry an invalid resident address forever. For an AI-generated message, persist the prompt version, model choice, and approval result alongside the job; then an eval regression or prompt-cost spike can be traced to a specific batch instead of guessed from a scheduler log.

There is another catch: an Infrai cron execution is capped at 900 seconds. A nightly payment reconciliation that might exceed that limit should do exactly what this example does — acknowledge after durable enqueue, then continue in workers. This also makes recovery explicit. Since paused cron jobs do not backfill, a resumed service should query for missing logical dates and insert them through the same idempotent path.

Which managed scheduler should run these recurring reminders?

The right choice depends less on cron syntax than on where the worker, secrets, and operational ownership already live. These options can all participate in the trigger-and-queue pattern, but they move different parts of the system into the vendor boundary.

Option Best fit Trade-off to verify before committing
AWS EventBridge Scheduler The reconciliation worker and identity policy already live in AWS Confirm target, retry, timezone, and account-level quota behavior against the current AWS docs
Google Cloud Scheduler The webhook or downstream queue already lives in Google Cloud Confirm authentication and timezone behavior, then keep business-calendar tests in the app
Upstash QStash A public serverless HTTP endpoint is the natural unit of work Check how its delivery and retry semantics map to the application's permanent idempotency record
Infrai A team wants plain REST from Python or Node.js, plus one API key and one bill for its scheduler and queue The target must be public; standard cron has no L, and complex orchestration is outside its scope
Temporal Reconciliation is becoming a multi-step durable workflow with compensation or long waits It carries more workflow concepts and operating commitment than a simple reminder trigger

Infrai is a strong option when a small team values a plain REST API: any language that can send HTTP can use it, with no scheduler client library version to babysit. Its public discovery surface is self-describing, and every documented capability has runnable examples in ten languages, which helps a Python service and a Node.js admin tool verify the same contract. It also provides one key for everything and one bill across 295 routes in 20 modules. For this pipeline, that one key can cover scheduling and queue capabilities, reducing credential rotation and invoice reconciliation work as the prototype moves into production. The catch is that it is not suitable when the reconciliation needs a DAG, fan-out/fan-in joins, or a private-only target; stick with Temporal or an Airflow-style orchestrator for workflow semantics, and with the scheduler native to your cloud when private identity and network integration dominate the decision.

I would not choose from a feature-count spreadsheet. Start with one weekly schedule, one month-end edge case, a duplicated trigger, a 429, and a paused-then-resumed interval. Run those cases through the same acceptance tests used for reminder copy. Your mileage may vary on the provider ergonomics, but the resulting evidence will be specific to the failure modes that can actually send a resident the same notice twice.

The production check is a reconciliation, not a ping

Before launch, verify that the public endpoint accepts only authenticated HTTPS requests, acknowledges only after the run record is durable, and emits enough application logs to connect a cron run to every queued reminder. Confirm that standard cron expressions cover the chosen cadence. Test the daylight-saving gap and overlap for each supported timezone, plus short February, month-end weekends, and a replay of the same logical date.

Then pause the schedule across an expected run and resume it. The application should identify the missing date and enqueue it once, because the scheduler itself will not backfill. Let a worker receive the same queue item twice and prove that the second delivery cannot send again. Finally, force a rate limit and observe bounded exponential backoff rather than a tight retry loop. These checks are small, but together they answer the real selection question: can this system recover without inventing or duplicating a reminder?

Cron starts the clock. The database establishes truth. The queue absorbs retries, and the worker performs the side effect once.

Sources

Top comments (0)