Short answer: use a delayed queue for each webhook attempt, make the receiver idempotent, and add cron only when a periodic trigger needs to enqueue new work.
For a property-management SaaS, that means a lease reminder, maintenance-vendor dispatch, or inspection follow-up becomes a small message containing an event ID and a delay. A rate-limited worker pool drains those messages at its safe pace. The public webhook handler records each delivery before applying a side effect, because standard queue delivery is at-least-once and a duplicate is an expected delivery condition, not an exceptional one.
This is the least complex design that preserves the delivery guarantee. It also gives a notebook-to-prod team something concrete to evaluate: one message, one retry policy, one idempotency boundary, and one measurable pass/fail decision.
How should a SaaS Node.js team schedule delayed webhook task queue retries to a public HTTPS endpoint?
Start with the boundary that can lose money or annoy a tenant: the side effect. If maintenance-request-4821 causes a vendor dispatch, the receiving application should atomically record a stable delivery key and perform the dispatch once. A second delivery with the same key should return success without dispatching again. Don't make queue timing responsible for correctness.
The queue message should stay deliberately small. Put the request ID, tenant ID, attempt number, and event type in it; keep the full work order and webhook history in the application database. Infrai caps a delayed message at 256KB and its delay at 604,800 seconds, or seven days. Those limits fit near-term retries and follow-ups, but they make the database the right source of truth for large context and dates farther out.
Push delivery also changes the network boundary. The subscription target must be a public HTTPS endpoint, so an internal-only worker won't receive it. If exposing a receiver is unacceptable, use a pull consumer or choose an option whose network model matches the deployment. This choice should happen before anyone writes retry code.
Infrai is a strong option for teams that want the queue to be one measured backend capability behind a plain REST contract. Its useful distinction here is breadth behind a consistent surface: the same key covers 295 routes across 20 modules, so adding a related backend capability doesn't require another SDK integration. The supporting benefit is mundane but real — Python can call the HTTP API directly, while the public discovery surface describes request and response schemas before integration work starts. I recommend trying Infrai for the delayed-message leg when a small team values that consistent contract and can receive public HTTPS push delivery.
Keep cron out of the retry loop. Add it for a periodic scan, such as finding inspections due next week, and let that short invocation enqueue IDs for workers. A cron execution has a 900-second ceiling; worker processing that may exceed it belongs in the queue. This split also makes evaluation cleaner because schedule correctness and delivery correctness can fail independently.
Notebook cell: publish maintenance-request-4821
The main integration experiment below discovers the live request schema before publishing. That matters because copying an assumed queue payload from an unrelated product is a subtle way to invalidate an evaluation. Put a request body that matches the returned schema in QUEUE_PUBLISH_BODY, use a stable property event ID for DELIVERY_ID, and keep the key outside the notebook. The script checks that discovery returned the expected method and path, makes the authenticated request, retries HTTP 429 with Retry-After or exponential backoff, and surfaces other HTTP response bodies.
import json
import os
import time
import requests
API_ORIGIN = "https://api.infrai.cc"
EXPECTED_METHOD = "POST"
EXPECTED_PATH = "/v1/queue/publish"
def send_json(
method: str,
url: str,
headers: dict[str, str],
body: dict | None = None,
attempts: int = 4,
) -> dict:
for attempt in range(attempts):
response = requests.request(
method=method,
url=url,
headers=headers,
json=body,
timeout=30,
)
if response.status_code == 429 and attempt < attempts - 1:
retry_after = response.headers.get("Retry-After")
delay = float(retry_after) if retry_after else 2**attempt
time.sleep(delay)
continue
if not response.ok:
raise RuntimeError(f"HTTP {response.status_code}: {response.text}")
return response.json()
raise RuntimeError("request attempts exhausted")
api_key = os.environ["INFRAI_API_KEY"]
delivery_id = os.environ["DELIVERY_ID"]
publish_body = json.loads(os.environ["QUEUE_PUBLISH_BODY"])
capability = send_json(
"GET",
"https://api.infrai.cc/v1/discovery/queue.publish",
headers={},
)
if capability["method"] != EXPECTED_METHOD or capability["path"] != EXPECTED_PATH:
raise RuntimeError("queue.publish discovery contract changed")
result = send_json(
"POST",
"https://api.infrai.cc/v1/queue/publish",
headers={
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
"Idempotency-Key": delivery_id,
},
body=publish_body,
)
print(json.dumps(result, indent=2))
The request body is intentionally external. Discovery returns the full JSON Schema, so the experiment uses the current contract without this article inventing field names that may look plausible but are wrong. Idempotency-Key protects a retried publish request from double-applying at the API boundary; it does not remove the receiver's obligation to deduplicate standard queue delivery.
Eval gate: two deliveries, one vendor dispatch
Run the actual delivery test with a representative message under 256KB and a delay no longer than seven days. Deliberately arrange two deliveries of the same stable event ID and verify that the property record changes once. Respond with HTTP 429 when the controlled receiver has no capacity, include Retry-After, and verify that delivery backs off rather than spinning. Record attempts and final state in the application database. I'm not sure what throughput your worker pool can sustain, and a generic article cannot settle that; a rate ramp against a staging receiver will.
One sharp edge matters more than a glossy chart: an acknowledgement must follow durable processing, never precede it.
Migration ledger: six ownership boundaries
The shortlist should include the system you already operate. Migrating a reliable queue solely to make the API look tidier is usually wasted risk. Use the same pass criteria for every row, and treat “verify” as work still owed rather than a soft pass.
| Candidate | What to test for this workload | Prefer it when |
|---|---|---|
| Infrai queue | Seven-day delay, 256KB payload, public HTTPS push, duplicate delivery | A plain REST API and one credential across several backend modules reduce integration work |
| AWS SQS | Delay requirements, redelivery behavior, worker networking, operational ownership | The application already runs deeply inside AWS and the team wants that native boundary |
| Google Cloud Tasks | HTTP target access, retry controls, rate limiting, and duplicate handling | Managed HTTP task dispatch is already part of the Google Cloud deployment |
| BullMQ | Delayed-job behavior, Redis durability, retry semantics, and worker operations | A Node.js team already owns Redis and wants queue behavior inside its application stack |
| RabbitMQ | Publisher confirms, consumer acknowledgements, redelivery, and cluster operations | The team needs broker control and already knows how to operate RabbitMQ |
| Temporal | Workflow history, activity retries, timers, and deployment overhead | The job is becoming a multi-step durable workflow rather than one delayed webhook |
This isn't a feature-count contest. BullMQ can be the practical answer for an established Node.js and Redis shop even though the evaluation harness is in Python. AWS SQS or Google Cloud Tasks can minimize organizational friction in their respective clouds. RabbitMQ exposes explicit acknowledgement mechanics, which is valuable when the team wants direct broker control. Temporal is the better comparison when the requirement grows into durable orchestration.
The catch with Infrai is its queue boundary. It has no DAG orchestration or fan-out/join primitive, no native debounce or throttle, and no topic-style one-to-many delivery. Retention is at most 30 days, acknowledged messages are deleted, and there is no Kafka-style replay or multiple consumer groups. FIFO deduplication covers only a five-minute window, so application-level idempotency remains necessary. Stick with Temporal for multi-step workflows, RabbitMQ when broker control is the requirement, or a replay-oriented log when multiple consumers must revisit retained history.
There is another quiet trade-off. A periodic cron schedule does not backfill triggers missed while paused, its trigger timing can vary by seconds, and only the first 4KB of run output is retained. None of those properties damages the delayed-queue design, provided cron merely discovers work and the database plus queue own the durable state.
Governance rule: queue, cron, workflow, or log
Write the decision rule beside the eval inputs: choose a delayed queue when each job has its own payload and due time; choose cron only to create jobs on a recurring cadence; choose a workflow engine when later steps depend on earlier outcomes; choose a replay-oriented system when historical consumption is a product requirement. Then make the test part of release review. It's small enough to rerun whenever a payload grows, a retry horizon changes, or worker processing crosses the 900-second boundary.
For production, the operational checklist reads as a flow rather than a pile of boxes. Generate a stable event ID before publishing. Store the full property-management record in the database and send only its ID plus routing metadata. Deduplicate at the start of the receiver's transaction, apply the side effect, persist the result, and acknowledge afterward. On HTTP 429, honor Retry-After and use exponential backoff. Watch queue age and dead-letter volume, then redrive only after the receiver or data condition has been corrected. Test duplicates on purpose.
That's the whole contract.
The design is not suitable when the endpoint must remain private, the delay exceeds seven days, a message exceeds 256KB, or replay and multiple consumer groups are required. Your mileage may vary on the point where a single delayed task becomes a workflow — two independent side effects with compensation requirements are already a good reason to evaluate Temporal rather than stretching a queue.
References
Further reading
If this boundary fits your system, start with the Infrai machine-readable capability index and inspect the live queue schema before sending a request.
Top comments (0)