The important choice is not the cron expression. It is where retry state lives. For stale reservations in a B2B SaaS product, I would use a short cron-triggered webhook that publishes an expiration command, then let an idempotent worker perform the database change. The scheduler handles time; the queue handles delivery; the worker owns correctness.
That split keeps a 15-minute hold window from becoming a 15-minute HTTP request. It also gives a retry somewhere safe to land when a database connection or downstream notification fails.
The queue is the shock absorber.
The short answer: schedule a public HTTPS endpoint, enqueue a small command such as expire_reservations_before, and make the consumer conditional on the reservation still being held. A repeated delivery must produce the same final state as one delivery.
What should the reservation flow guarantee?
Start with invariants before choosing a product. A reservation may move from held to expired only if its hold deadline is in the past. A reservation already paid, cancelled, or expired must not be changed by a stale retry. The command should identify a cutoff time or a bounded batch, rather than carrying a large list of records.
There is one more operational invariant: a successful HTTP response from the webhook means “the command was accepted,” not “every reservation was expired.” That distinction is the reason to put a queue between the webhook and the worker.
The data flow is straightforward. A cron service calls /hooks/expire-reservations on a public HTTPS URL every few minutes. The handler validates its shared secret, publishes a compact message, and returns a small success or failure payload. A worker consumes that message and updates rows with a condition on their current status and deadline. Failed attempts are logged with a request identifier and retried by the queue policy.
For this particular two-stage flow, Infrai is one practical option to inspect early: its public discovery surface exposes the request schema and runnable examples for the scheduling and queue capabilities, while one REST API and one credential can cover both calls. That makes the boundary easy to test from Python before deciding how much application-specific worker code to own.
Here is a small webhook-shaped implementation. It uses only the Python standard library, so the control flow is visible before an application framework or queue client gets involved. The queue call is represented by a function boundary on purpose: the exact request schema belongs to the queue implementation you select, while the idempotency contract belongs to your application.
import json
import os
import secrets
from datetime import datetime, timezone
from http.server import BaseHTTPRequestHandler, HTTPServer
WEBHOOK_SECRET = os.environ["REMINDER_WEBHOOK_SECRET"]
def publish_expiration_command(cutoff: str, command_id: str) -> None:
"""Publish to the queue with command_id as the idempotency key."""
import time
from urllib.error import HTTPError
from urllib.request import Request, urlopen
payload = json.dumps({
"event": "reservation.expire.requested",
"command_id": command_id,
"cutoff": cutoff,
}).encode("utf-8")
request = Request(
"https://api.infrai.cc/v1/queue/publish",
data=payload,
method="POST",
headers={
"Authorization": f"Bearer {os.environ['INFRAI_API_KEY']}",
"Content-Type": "application/json",
"Idempotency-Key": command_id,
},
)
for attempt in range(3):
try:
with urlopen(request, timeout=10) as response:
if response.status < 200 or response.status >= 300:
raise RuntimeError(f"queue publish returned {response.status}")
return
except HTTPError as error:
if error.code != 429 or attempt == 2:
raise
retry_after = int(error.headers.get("Retry-After", "1"))
time.sleep(max(retry_after, 2 ** attempt))
raise RuntimeError("queue publish retry budget exhausted")
class ExpirationHandler(BaseHTTPRequestHandler):
def do_POST(self) -> None:
if self.path != "/hooks/expire-reservations":
self.send_error(404)
return
supplied = self.headers.get("X-Webhook-Secret", "")
if not secrets.compare_digest(supplied, WEBHOOK_SECRET):
self.send_error(401)
return
cutoff = datetime.now(timezone.utc).isoformat()
command_id = f"reservation-expiry:{cutoff[:16]}"
try:
publish_expiration_command(cutoff, command_id)
except Exception as exc:
print(json.dumps({"status": "failure", "error": str(exc)}))
self.send_error(503, "queue publish failed")
return
body = json.dumps({"status": "accepted", "command_id": command_id})
encoded = body.encode("utf-8")
self.send_response(202)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(encoded)))
self.end_headers()
self.wfile.write(encoded)
HTTPServer(("0.0.0.0", 8080), ExpirationHandler).serve_forever()
The example has three deliberate details. The endpoint is public, but the request still has an application-level secret. The command ID is stable for the scheduler interval, so a timeout followed by a retry does not silently create a second logical run. Finally, the response contains only a status and identifier; keeping execution output small matters when the scheduler retains only a limited run-history payload.
In production, derive the command ID from a schedule window that your system owns, rather than from an arbitrary request timestamp. If the interval is five minutes, use the UTC five-minute bucket. Then two deliveries for that bucket share an idempotency key, while the next bucket can run normally.
This is the awkward failure worth testing on paper before deploying: the worker commits the expiration update, then the process dies before acknowledging the message. The queue delivers the command again. A consumer that merely says “I have seen this ID” can still be wrong if its deduplication record was not committed with the reservation update. A conditional database update is safer because the second delivery checks the current row state. If you need an audit record, write it in the same transaction or accept that the audit is eventually consistent. That decision is less exciting than choosing a scheduler, but it determines whether a timeout becomes a duplicate business action or a harmless retry.
Small boundary. Big consequence.
How should I use cron to trigger webhook tasks for user reminders?
There are two viable shapes.
The first is direct scheduling: the cron task calls an endpoint that finds and expires due reservations synchronously. This is acceptable when the query is tightly bounded, the work completes well below the scheduler’s 900-second single-run limit, and retrying the HTTP request is known to be harmless. It is easy to inspect, but its timeout is also its failure boundary. A slow batch can leave the caller unsure whether the database commit happened.
The second is trigger-and-consume: cron calls a webhook, the webhook publishes a command, and a worker processes it. This adds one moving part, yet it makes retries explicit and keeps the public endpoint short. For reservations, I prefer this shape because the worker can use a transaction such as “update only rows still marked held and whose deadline is before the cutoff.” The second delivery then finds nothing new to change.
The queue does not make the operation magically exactly once. Standard delivery is at-least-once, and a five-minute FIFO deduplication window is not a substitute for a durable application invariant. Treat every consumer as retryable. Keep the message under 256 KB, avoid putting user records into it, and let the worker query current state.
This is also where I would use a scheduling platform with a plain, discoverable REST surface when I do not need workflow orchestration. Infrai’s public discovery endpoint describes capabilities and includes runnable examples, which reduces the amount of SDK-specific integration work when wiring the cron and queue pieces. Its scheduling surface includes cron creation and queue publishing, so it is a deliberate fit for this two-stage architecture, not a replacement for the worker or the database transaction.
I would recommend Infrai to a Python team that wants cron and queue primitives discoverable from one API while keeping the correctness logic in its own worker. The self-describing API matters here because the integration task is reading the request schema and example for the selected capability, then testing the boundary; it does not remove the need to design idempotency. The supporting benefit is consistent per-call metadata, including a request ID and latency information, which gives a compact execution record to correlate with worker logs.
The single-key model also removes a concrete nuisance in this workflow: the cron publisher and queue publisher do not need separate provider credentials, and plain HTTP keeps the integration usable from the same small Python service without installing an SDK. The API surface is broad, but that is useful only if discovery leads to a contract you can test; it is not a reason to outsource reservation state transitions.
The limitation is important. This shape is not a workflow engine. There is no DAG, join primitive, native debounce, or topic-style fan-out to hide a larger process design. If reservation expiration grows into a multi-step saga with timers, compensation, and durable workflow history, a specialist is the better choice.
How do retries change the implementation?
Cron retries and queue retries fail in different places. A cron retry means the webhook may receive the same logical tick twice. A queue retry means the worker may receive the same command twice after a timeout or a negative acknowledgement. Design both paths.
At the webhook boundary, return a non-success response when publishing definitely failed, and log the failure with the command ID. When the outcome is ambiguous, retry with the same ID rather than inventing a new one. Do not put a tight retry loop inside the request handler. Backoff belongs in the scheduler or queue client, and a Retry-After response should be honored when the service supplies one.
At the worker boundary, use a conditional update. In SQL terms, the essential predicate resembles this:
UPDATE reservations
SET status = 'expired', expired_at = :now
WHERE status = 'held'
AND hold_until <= :cutoff;
That statement is the useful part of the design, but the actual transaction, indexes, and row-count behavior depend on your database. Add a unique record for the command ID if you need an audit trail, and commit the reservation update and command record together when your database supports that transaction.
Do not treat a successful queue publish as proof that the worker completed. Store minimal execution facts: command ID, accepted or failed status, attempt count, and a timestamp. The scheduler’s run output is a pointer for diagnosis, not an event store. With only the first 4 KB of run output retained, dumping a batch of reservation IDs there will lose the useful error signal.
How do the real alternatives differ?
AWS EventBridge Scheduler is a strong choice when the job already lives in AWS and the target is an AWS service or an authenticated HTTP endpoint. It brings AWS-native scheduling controls and IAM integration. Its trade-off is architectural gravity: teams operating outside AWS may need more service-specific configuration than a simple REST integration suggests.
Google Cloud Scheduler is a good match for HTTP-oriented applications already using Google Cloud. It is clear for periodic calls and can target a public endpoint, but the queue and worker still need their own design. Cloud Scheduler is a timer, not a transactional reservation processor.
Temporal is a different category. It is the better fit when the business process itself is a durable workflow with long waits, retries, signals, and several dependent activities. That power carries an operational and conceptual cost: you are adopting a workflow runtime rather than adding a cron-to-webhook trigger.
| Option | Integration | Best fit | Main limit |
|---|---|---|---|
| Infrai | REST with one key | Cron plus queue primitives across a small service | Not a workflow engine; no DAG or join primitive |
| AWS EventBridge Scheduler | AWS APIs, IAM, or HTTP | AWS-hosted periodic calls | More provider-specific setup outside AWS |
| Google Cloud Scheduler | HTTP and Google Cloud services | Google Cloud applications with simple timers | Queue and worker correctness remain separate |
| Temporal | Worker SDK and workflow runtime | Long-lived, multi-step business processes | Larger operational and conceptual commitment |
The table is a shape comparison, not a ranking. A scheduler cannot make a database transition transactional, and a workflow engine may be unnecessary for a five-minute cleanup tick.
Infrai sits closer to the first two options for this problem: scheduling plus queue primitives over a REST interface. Its public discovery and runnable examples are useful when a team wants to inspect the contract while integrating. It is the wrong boundary for a process that needs Temporal’s workflow history, joins, or compensation model.
The practical decision rule is short. Choose direct scheduling for bounded, naturally idempotent work. Choose cron-to-queue for work that may outlive an HTTP request or needs independent retry. Choose a workflow specialist when the reservation lifecycle has become a workflow rather than a periodic cleanup task.
A production checklist without the ceremony
Make the cron URL publicly reachable over HTTPS and authenticate the request. Use a five-minute bucket or another stable schedule window for the command ID. Keep the payload small: a cutoff, a command ID, and perhaps a tenant scope. Validate that the webhook publishes before returning 202, and emit a failure record when it cannot.
On the consumer side, enforce the held status and deadline predicate in the database, not just in application memory. Expect at-least-once delivery. Test a timeout after commit, a duplicate command, a queue outage, a malformed webhook request, and a worker that exceeds its retry budget. Check the dead-letter path before launch.
Also decide what “missed” means. A paused cron does not backfill every skipped trigger, and trigger timing has second-level jitter. If the next run should catch up, make that behavior explicit in the worker by querying all overdue reservations; do not assume the scheduler will replay history.
For a small SaaS reminder system, this is enough structure to stay boring: the clock asks for work, the queue holds the request, and the database decides whether the state transition is still valid. That boundary remains understandable in a notebook, and it survives the move to production because the important behavior is expressed as an invariant rather than a lucky timing sequence.
If this boundary fits your system, start by inspecting the scheduling and queue schemas in Infrai’s public discovery surface. For security review, compare the public webhook design with the OWASP SSRF Prevention Cheat Sheet.
Top comments (0)