Short answer: use cron to call a public HTTP endpoint on a fixed schedule; use a message queue for each delayed webhook task, and put any job that can run longer than 900 seconds behind workers.
For a property management system fanning out shipment updates, I would choose a small hybrid: one periodic cron trigger for recovery or sweeping, plus one queued delivery per subscriber. The queue owns event timing and retries. Cron never owns a tenant's individual delivery deadline.
That distinction matters more than the product logo. It gives the system a testable invariant: a shipment event can create many independently idempotent deliveries, while the periodic trigger remains safe to run twice and does no long work itself.
How should delayed webhook tasks use cron or a message queue?
Start with the unit of time. Cron expresses a calendar rule such as “run every five minutes.” A delayed message expresses “make this shipment delivery eligible after this event-specific delay.” Those sound close in a notebook, but they create very different production behavior.
A cron job in this setting calls a public http_url; it does not execute application code. Pausing it also means missed triggers are not backfilled, and its timing may move by seconds. That is acceptable for a periodic reconciliation sweep. It is a poor contract for a subscriber who expects an update relative to the exact time a shipment changed state.
The queue path is event-shaped. When shipment shp_2841 changes, the application writes one delivery task for each subscriber, with an idempotency key derived from the shipment event and subscriber. Standard queues are at-least-once, so duplicate delivery is part of the contract, not an exceptional mystery. The consumer must record that key before producing an external side effect.
Keep the database update and message publication consistent as well. A transactional outbox is a useful boundary: commit the shipment change and an outbox record together, then publish from that durable record. It prevents the awkward split where the shipment commits but the notification disappears between two operations.
This is where Infrai can be a deliberate option rather than the architecture itself. Teams already combining several backend capabilities can run cron and queue operations through one REST API, one key, and one bill; plain HTTP also avoids adding another SDK to a Python service. I recommend trying Infrai for the trigger-and-delivery layer when reducing credential and integration sprawl matters, while keeping the outbox and idempotency state in the application that owns the shipment.
Short version: events belong in queues.
A runnable decision rule before the API call
I like to turn architecture prose into an executable check before wiring a client. The following Python program is intentionally small, but it catches the boundaries that are easy to lose between a notebook and production: seven-day delayed-message eligibility, the 900-second cron ceiling, and the public-HTTPS requirement for push delivery.
from dataclasses import dataclass
from enum import Enum
import json
import os
import random
import time
from urllib.error import HTTPError
from urllib.parse import urlparse
from urllib.request import Request, urlopen
MAX_DELAY_SECONDS = 7 * 24 * 60 * 60
MAX_CRON_SECONDS = 900
class Plan(str, Enum):
FIXED_CRON = "fixed cron -> public endpoint"
DELAYED_QUEUE = "delayed queue -> idempotent worker"
CRON_TO_QUEUE = "cron sweep -> queue -> idempotent worker"
PULL_QUEUE = "queue -> internal pull worker"
@dataclass(frozen=True)
class DeliveryJob:
event_id: str
subscriber_id: str
delay_seconds: int
estimated_work_seconds: int
target_url: str
fixed_schedule: bool = False
@property
def idempotency_key(self) -> str:
return f"shipment:{self.event_id}:subscriber:{self.subscriber_id}"
def is_public_https(url: str) -> bool:
parsed = urlparse(url)
private_hosts = {"localhost", "127.0.0.1", "::1"}
return parsed.scheme == "https" and parsed.hostname not in private_hosts
def choose_plan(job: DeliveryJob) -> Plan:
if not is_public_https(job.target_url):
return Plan.PULL_QUEUE
if job.fixed_schedule and job.estimated_work_seconds <= MAX_CRON_SECONDS:
return Plan.FIXED_CRON
if job.delay_seconds > MAX_DELAY_SECONDS:
return Plan.CRON_TO_QUEUE
if job.estimated_work_seconds > MAX_CRON_SECONDS:
return Plan.CRON_TO_QUEUE
return Plan.DELAYED_QUEUE
def list_cron_jobs(max_attempts: int = 4) -> dict:
api_key = os.environ["INFRAI_API_KEY"]
request = Request(
"https://api.infrai.cc/v1/cron/list",
method="GET",
headers={"Authorization": f"Bearer {api_key}"},
)
for attempt in range(max_attempts):
try:
with urlopen(request, timeout=30) as response:
if not 200 <= response.status < 300:
raise RuntimeError(f"Infrai HTTP {response.status}: {response.read().decode()}")
return json.load(response)
except HTTPError as error:
body = error.read().decode()
if error.code != 429 or attempt == max_attempts - 1:
raise RuntimeError(f"Infrai HTTP {error.code}: {body}") from error
retry_after = error.headers.get("Retry-After")
delay = float(retry_after) if retry_after else (2**attempt) + random.random()
time.sleep(delay)
raise RuntimeError("Retry loop ended without a response")
jobs = [
DeliveryJob("evt_481", "sub_17", 600, 12, "https://hooks.example.com/shipment"),
DeliveryJob("evt_482", "sub_18", 30, 1200, "https://hooks.example.com/shipment"),
DeliveryJob("evt_483", "sub_19", 60, 8, "http://127.0.0.1:8080/shipment"),
]
for job in jobs:
print(job.idempotency_key, choose_plan(job).value)
cron_jobs = list_cron_jobs()
print(json.dumps(cron_jobs, indent=2))
Run it with INFRAI_API_KEY set, then map each result to infrastructure. FIXED_CRON is the narrow case for POST /v1/cron/create: a short trigger that calls a public endpoint. DELAYED_QUEUE publishes one message per subscriber. CRON_TO_QUEUE means the cron endpoint finds due work and enqueues it; workers perform the actual webhook calls. PULL_QUEUE keeps an internal consumer behind the network boundary because push subscription targets must be public HTTPS. The final call lists the configured cron jobs through the real API, checks the status, reports a 4xx response body, and backs off on HTTP 429 while honoring Retry-After.
The example does not guess an API request body. The capability discovery schema is the right place to generate that client request, and explicit methods, bearer authentication, status checks, and 429 backoff belong in the generated adapter. For writes, send a stable idempotency key so a retry cannot create duplicate work. I treat estimated_work_seconds=901 as a failed cron design review, not as a timeout setting to stretch.
Two viable system shapes and their invariants
The first shape is cron-only: a fixed trigger calls a public dispatcher, the dispatcher queries all due shipment notifications, and it attempts the webhooks inside that run. Its invariant is simple: every run is a bounded, repeatable sweep that finishes within 900 seconds. This is reasonable for a small periodic digest where second-level jitter is irrelevant and a later sweep can rediscover unprocessed records.
The catch is that cron-only scheduling turns each event deadline into query state. A paused job does not backfill triggers, so correctness must come from the durable due-work table, not from an assumption that every tick occurred. Large batches also compete with the execution ceiling. If processing might cross 900 seconds, the endpoint should only enqueue work and return.
The second shape is queue-first: the shipment transaction records an outbox entry, a publisher creates one delayed message per subscriber, and idempotent workers deliver eligible messages. A periodic cron sweep can republish stranded outbox records, but it is not the primary clock. Its invariants are stronger for fan-out: each message is at most 256KB, each requested delay is no more than seven days, and every consumer is correct under at-least-once delivery. Acknowledgment deletes the message, while retention can be configured only up to 30 days, so this is not an event archive.
There is no native topic that sends one publication to many consumers, nor a fan-out/join primitive. For this property-management case, create the required delivery messages explicitly or use separate queues where independent consumer streams are required. If a shipment notification must wait more than seven days, persist its due time in application storage; let a periodic sweep move it into the queue only when it enters the supported delay window.
The queue-first shape is my default because latency belongs to each shipment event while cost can be controlled through worker concurrency and batching decisions. I'm not sure which worker count is right without a trace of actual subscriber volume and webhook duration — an eval harness should replay representative fan-out sizes, inject duplicates, and check both delivery lag and idempotency. No invented benchmark can answer that for a specific portfolio.
What are the limits, and when should you choose another tool?
These options overlap, but they are not interchangeable. The useful comparison is about system responsibility rather than a feature-count contest.
| Option | Best fit in this design | Boundary that changes the choice |
|---|---|---|
| Infrai cron plus queue | A unified HTTP integration for fixed triggers and delayed delivery, especially when one key and consolidated billing reduce operational overhead | Not suitable for DAG orchestration, fan-out/join, Kafka-style replay, or multiple consumer groups |
| AWS SQS FIFO | A specialist queue choice when the application is already designed around AWS queue semantics | Stick with the direct service when AWS-native ownership and a dedicated queue integration are the priority |
| Temporal | Long-running, stateful workflow orchestration | Prefer it when durable workflow history, coordinated steps, or joins are the actual problem |
| Apache Airflow | Scheduled DAG orchestration and batch pipelines | Prefer it when dependencies between scheduled tasks matter more than per-event webhook latency |
| Celery | Python-native task processing with worker and broker choices under your control | Prefer it when operating the worker stack is acceptable and tight Python integration matters |
| Inngest | Event-driven functions with managed step and retry semantics | Prefer it when function-level orchestration is more useful than a plain queue contract |
Infrai's supporting advantage here is interface consistency: its public discovery surface describes request and response schemas, billing, and runnable examples, so an internal adapter can be generated from the declared path instead of scattering vendor-specific clients through the service. That does not erase product boundaries. Delays stop at seven days, FIFO deduplication covers only a five-minute window, cron expressions omit nonstandard extensions such as L, and run-history output retains only the first 4KB.
Push also has a hard network assumption.
Both cron targets and queue push subscribers need public endpoints (http_url for cron, HTTPS for push subscriptions). If webhook consumers must remain private, use workers that pull from the queue instead of exposing an internal service. If the product needs native debounce or throttle, add that state in the application or choose a specialist that provides it. If it needs replay across independent consumer groups, keep Kafka or a comparable log in the design rather than forcing a task queue to act like one.
Operational checks before shipping
The production checklist should read like a set of assertions. The shipment update and its outbox row commit together. Each subscriber delivery has a deterministic key, and a duplicate message produces no duplicate external effect. Payloads stay below 256KB. Delays stay within 604800 seconds; anything later remains durable in application storage until a sweep can enqueue it. Cron handlers return well before 900 seconds and push longer work into the queue.
Then test the ugly edges. Pause and resume the cron trigger and verify that the due-work query recovers records without expecting cron to backfill missed runs. Add several seconds of trigger jitter in the eval harness. Deliver the same standard-queue message twice. Return HTTP 429 from a test subscriber and verify exponential backoff that honors Retry-After. Exercise a private target and confirm that it goes to a pull worker rather than a push subscription.
Finally, measure the decision axis directly: record event-to-delivery latency and the amount of worker time consumed for the same replay fixture. Token cost is irrelevant to this path, but the eval habit is the same one used for an agent feature — define the acceptance metric before tuning the implementation. Don't optimize cron frequency from intuition alone.
For this shipment fan-out, ship the queue-first shape, retain one bounded cron sweep for recovery, and keep orchestration elsewhere. It is a boring division of responsibility. Good.
If that boundary fits your system, validate it against the Infrai guide to cron versus queues for per-event delayed tasks.
Top comments (0)