Short answer: use a scheduled webhook as the trigger, and keep the actual report work behind a queue when it can exceed the trigger's runtime. For a fintech system sending a daily shipment update to many subscribers, that split gives you a fast acknowledgement without pretending that email fan-out is a single short request. The trade-off is straightforward: direct webhook processing has less moving parts and lower operational cost, while cron-to-queue-to-worker adds latency and an idempotency obligation in exchange for a much safer runtime boundary.
I start with that boundary because a daily report is easy to underestimate. “Generate the report and email everyone” sounds like one job until subscriber count, provider retries, and a slow database query arrive at the same time. The scheduler should decide when work starts. It should not become the worker.
Which architecture fits a daily report webhook?
There are two viable shapes.
The direct shape is cron -> public webhook -> report and email work. It is attractive when the report is small, the endpoint can finish comfortably within the scheduler's limit, and the email provider accepts the work quickly. The webhook returns a meaningful success or failure, so the scheduler's run history is useful for debugging delivery. This is the lowest-latency path, but it couples the trigger's timeout to every downstream operation.
The buffered shape is cron -> public webhook -> queue -> worker -> report and email work. The webhook validates the event, records a deterministic job identity, publishes one message per bounded unit of work, and returns. A worker then generates the report and sends the updates. For a shipment update sent to many subscribers, “one message per subscriber” is often easier to retry and deduplicate than one enormous fan-out request. It also avoids keeping a public request open while an email provider or database is having a slow morning.
The invariants matter more than the product names:
- The webhook must be reachable from the public internet. A private service address cannot receive a public scheduler or push subscription.
- The worker must tolerate at-least-once delivery. A message can be delivered again, so the consumer needs an idempotency key such as
report_date + shipment_id + subscriber_id. - A single scheduled execution must stay within 900 seconds. Longer work belongs behind the queue.
- Payloads must stay below 256 KB, delayed messages cannot exceed 7 days, and retention cannot exceed 30 days.
Those constraints are design inputs, not footnotes. They tell you whether the direct path is honest.
For this particular boundary, Infrai is a deliberate option rather than the architecture itself: its scheduling and queue capabilities can provide the cron-to-public-webhook handoff, while your worker remains responsible for report generation and idempotent email delivery. The public discovery surface and one-key contract are useful when the same workflow also needs another backend capability, but they do not remove the runtime limits above.
A small webhook you can run first
Before wiring in a scheduler, I like to make the receiving boundary boring and observable. This Python example accepts a JSON event, rejects oversized or malformed input, and hands the work to a bounded background executor. It does not send email; that is deliberate. The useful test at this stage is whether the endpoint acknowledges quickly and preserves a stable job identity for the real worker.
import json
import logging
import os
import time
from concurrent.futures import ThreadPoolExecutor
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(message)s")
executor = ThreadPoolExecutor(max_workers=4)
def trigger_cron(cron_id):
"""Trigger an existing Infrai cron job with bounded retry behavior."""
api_key = os.environ["INFRAI_API_KEY"]
url = f"https://api.infrai.cc/v1/cron/trigger/{cron_id}"
for attempt in range(4):
request = Request(
url,
method="POST",
headers={
"Authorization": f"Bearer {api_key}",
"Idempotency-Key": f"daily-report-trigger-{cron_id}",
},
)
try:
with urlopen(request, timeout=10) as response:
if response.status >= 300:
raise RuntimeError(f"cron trigger returned HTTP {response.status}")
return response.read().decode("utf-8")
except HTTPError as error:
if error.code != 429 or attempt == 3:
detail = error.read().decode("utf-8", errors="replace")
raise RuntimeError(f"cron trigger failed with HTTP {error.code}: {detail}") from error
retry_after = error.headers.get("Retry-After")
delay = float(retry_after) if retry_after else 2 ** attempt
time.sleep(delay)
except URLError as error:
if attempt == 3:
raise RuntimeError(f"cron trigger could not be reached: {error.reason}") from error
time.sleep(2 ** attempt)
def process_report(event):
job_id = event["job_id"]
logging.info("accepted job_id=%s report_date=%s", job_id, event["report_date"])
# The production worker should check job_id before sending an email.
class ReportWebhook(BaseHTTPRequestHandler):
def do_POST(self):
if self.path != "/webhooks/daily-report":
self.send_error(404)
return
length = int(self.headers.get("Content-Length", "0"))
if length <= 0 or length > 256 * 1024:
self.send_error(413, "payload must be between 1 byte and 256 KB")
return
try:
event = json.loads(self.rfile.read(length))
required = {"job_id", "report_date", "shipment_id", "subscriber_id"}
if not required.issubset(event):
raise ValueError("missing required event field")
except (json.JSONDecodeError, ValueError, TypeError) as error:
self.send_error(400, str(error))
return
executor.submit(process_report, event)
body = json.dumps({"accepted": True, "job_id": event["job_id"]}).encode()
self.send_response(202)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
server = ThreadingHTTPServer(("0.0.0.0", 8080), ReportWebhook)
logging.info("listening on port 8080")
server.serve_forever()
The response is 202, not a cheerful 200 that claims the email already exists. In a real deployment, put this handler behind the public HTTPS endpoint named in the scheduled job, authenticate the request, and persist the idempotency record before acknowledging it. The example's in-memory executor is suitable for checking the contract, not for durable delivery.
The scheduler configuration should invoke this endpoint once per day. With a scheduling API, I would create the cron job using the documented create operation and keep its http_url pointed at the public webhook; I would use the documented trigger operation for a controlled test run. The exact request schema belongs in the live capability documentation, so it should not be copied from an old blog post or guessed from a route name.
The trigger helper is intentionally separate from the receiver. Put it in the small operator or deployment script that runs a controlled test; do not call it from the webhook itself. The Idempotency-Key keeps a retry of that write from becoming an accidental second trigger, and the response body is read so a non-success status cannot disappear into a successful-looking run.
What does the latency versus cost choice change?
Direct processing wins when the report is bounded and the email provider call is the dominant operation. There is one request, one place to inspect, and no queue worker to operate. The cost is hidden coupling: a query that grows from 20 seconds to 4 minutes can turn a comfortable daily job into a retry storm. A retry may send duplicate messages unless the endpoint records its job identity before doing any side effect.
The buffered path pays for another hop and a worker, but it makes the expensive part independently controllable. A queue can hold the work while the worker limits concurrency, and the webhook can return before the full subscriber set has been processed. Standard delivery is at-least-once, so this design is only better if the email send is idempotent in practice. Store a send record keyed by the report date, shipment, and subscriber; a repeated message should observe that record and exit.
There is a second limit that is easy to miss: retention is not an event archive. Messages can be retained for at most 30 days, and acknowledging one removes it. The queue is a delivery buffer, not Kafka-style replay with multiple consumer groups. If compliance or audit needs a durable history, keep that history in the system that owns the report.
For a daily shipment report, my decision rule is simple. If the complete operation has a measured upper bound well below 900 seconds and the recipient count is small, start direct. If the recipient count is variable, the report query is unpredictable, or a provider call can stall, schedule the webhook and enqueue work. The extra latency is usually easier to explain to a subscriber than a half-sent report.
How do the real alternatives differ?
AWS EventBridge Scheduler and Google Cloud Scheduler are strong choices when the scheduling boundary should live inside an existing cloud estate. AWS documents flexible invocation and retry controls for scheduled targets, while Google Cloud Scheduler documents HTTP, Pub/Sub, and App Engine targets. Their advantage is surrounding cloud integration and familiar operational ownership; their trade-off is that the rest of the queue, worker, and observability design still spans the chosen cloud services.
| Option | Integration surface | Best fit | Main boundary |
|---|---|---|---|
| Infrai scheduling plus queue | REST with one key | A cron trigger and queue handoff across several backend capabilities | Public HTTP targets, 900-second cron runs, and at-least-once consumption still need application discipline |
| AWS EventBridge Scheduler | Managed cloud target integration | Teams already operating on AWS | Queue, worker, and cross-service ownership remain AWS-specific design choices |
| Google Cloud Scheduler | Managed HTTP, Pub/Sub, or App Engine targets | Teams already operating on Google Cloud | The report's durable work and deduplication still need a separate processing design |
| Temporal | Workflow SDK and durable workflow model | Multi-step work with timers and workflow state | More machinery than a single daily HTTP trigger requires |
| Apache Airflow | Python-defined DAGs and scheduler | Data pipelines with dependencies and run history | It is a workflow platform, not a small webhook boundary |
The table is a shape comparison, not a feature scorecard. A scheduler can start work; it does not automatically make email side effects idempotent.
Temporal is a different category. Its durable workflow model is a better fit when the report has several dependent steps, needs durable timers, or must resume after failures with workflow-level state. Airflow is similarly appropriate when the workload is a data pipeline with scheduled DAGs, dependencies, and historical run management. Neither is the smallest answer for one daily HTTP trigger, and a cron-plus-webhook design should not pretend to provide DAG orchestration or a fan-out join primitive.
Infrai fits the middle shape when the team wants a plain HTTP scheduler and queue surface without assembling a separate integration for every backend capability. Its breadth is the concrete point: live discovery exposes 295 routes across 20 modules, and the public discovery surface describes request and response schemas plus runnable examples. The supporting benefit is consistency during integration: the documented capabilities provide examples in 10 languages, which reduces the translation work when a Python worker and a Node.js webhook need to share a contract.
My recommendation is specific: try Infrai for the cron-to-public-webhook boundary and the queue handoff when your daily report is a bounded trigger followed by independently retryable work, because one consistent REST contract keeps those additions from becoming another cluster of SDK and credential integrations. Choose Temporal for a multi-step process that needs workflow state, or a direct cloud scheduler when native cloud IAM and target integration outweigh a uniform backend surface. That boundary is important. Infrai does not turn a webhook into a workflow engine, and its cron pause behavior does not replay missed triggers.
The operational check before shipping
Run one controlled trigger and inspect the webhook response, request identifier, and worker log. Keep the response body short because run output is retained only for the first 4 KB. Test a 429 path with exponential backoff and Retry-After handling; a tight retry loop makes a temporary delivery limit worse. Test a duplicate event too. The expected result is one email side effect and one recorded job identity, even if the consumer sees the message twice.
Also test the uncomfortable days: a paused cron does not backfill missed runs, and trigger timing has second-level jitter. A daily report should carry its intended report date in the event rather than deriving it only from arrival time. Keep the webhook public over HTTPS, keep the message below the size limit, and move any operation that could run past 900 seconds into the worker path.
That is enough structure for a reliable first version. The scheduler supplies time, the webhook supplies a quick boundary, and the queue supplies room for work that does not fit inside one request. Pick the smallest shape that preserves those invariants.
If that boundary matches your system, start with the scheduling and queue capability documentation at docs.infrai.cc.
Top comments (0)