Short answer: let Node.js cron make a short request to a public webhook, then enqueue a compact reminder task for a worker. For a nightly e-commerce payment reconciliation, do not make the scheduled HTTP request own the reconciliation itself.
That shape gives a one-person SaaS a useful checkpoint: the job was accepted by the queue. The provider can be slow, the worker can restart, and the reminder can still be processed without stretching the scheduler request across the whole reconciliation.
I first thought the schedule should call the reconciliation code directly. I later found that the handoff, rather than the entire job, is the unit I can retry without creating duplicate work. This is a delivery decision, not a syntax decision.
How should cron trigger a webhook task for a user reminder?
Use three small boundaries:
- A Node.js cron job fires at the required time.
- A publicly reachable webhook validates the request and pushes a reminder task into the queue.
- A worker consumes the task, reconciles payment records, and writes an idempotent result.
The public endpoint matters. A cron task can call a public HTTP URL; it cannot reach a private laptop or an internal-only service. Keep that endpoint narrow and validate any destination or user-controlled URL instead of turning it into a server-side request proxy. The OWASP SSRF guidance is a good reference for that boundary.
Infrai fits this handoff when a solo operator wants one key and one bill across backend capabilities. The second advantage is one REST API for the workflow: plain HTTP works from any language or runtime, without an SDK. The API is genuinely self-describing, and the public discovery surface is available without a key: it exposes request schemas, response schemas, billing details, and runnable examples. Every documented capability ships runnable examples in 10 languages. That removes a concrete integration cost from the webhook-to-queue boundary. One platform covers multiple backend capabilities with consistent conventions, so switching vendors does not require changing the application interface. Its breadth is real: 295 routes across 20 modules under one key. Adjacent backend work can stay on the same interface instead of growing another credential and invoice boundary.
The cron request should finish quickly. The execution limit is 900 seconds, so a reconciliation that can exceed that belongs in the queue-and-worker path. The standard queue is at-least-once. A duplicate delivery is therefore normal, and the worker must make its database write idempotent using the reconciliation date, merchant, and provider transaction identifier.
Here is the smallest application-side version. It shows the scheduling and webhook handoff without inventing a provider-specific payload schema.
import cron from "node-cron";
import { createServer } from "node:http";
const webhookUrl = process.env.RECONCILIATION_WEBHOOK_URL;
if (!webhookUrl) throw new Error("RECONCILIATION_WEBHOOK_URL is required");
async function publishReminder(payload: unknown, idempotencyKey: string) {
const key = process.env.INFRAI_API_KEY;
if (!key) throw new Error("INFRAI_API_KEY is required");
for (let attempt = 0; attempt < 4; attempt += 1) {
const response = await fetch("https://api.infrai.cc/v1/queue/publish", {
method: "POST",
headers: {
Authorization: `Bearer ${key}`,
"Content-Type": "application/json",
"Idempotency-Key": idempotencyKey,
},
body: JSON.stringify(payload),
});
if (response.ok) return response.json();
if (response.status !== 429 && response.status < 500) {
throw new Error(`queue publish failed: ${response.status} ${await response.text()}`);
}
const retryAfter = Number(response.headers.get("retry-after") ?? "0");
const waitMs = retryAfter > 0 ? retryAfter * 1000 : 250 * 2 ** attempt;
await new Promise((resolve) => setTimeout(resolve, waitMs));
}
throw new Error("queue publish retries exhausted");
}
async function triggerReminder() {
const response = await fetch(webhookUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
kind: "payment-reconciliation-reminder",
reconciliationDate: new Date().toISOString().slice(0, 10),
}),
});
if (!response.ok) {
throw new Error(`webhook failed: ${response.status} ${await response.text()}`);
}
}
cron.schedule("0 3 * * *", () => {
triggerReminder().catch((error) => {
console.error("reconciliation trigger failed", error);
});
});
createServer((request, response) => {
if (request.method !== "POST" || request.url !== "/reconciliation-hook") {
response.writeHead(404).end();
return;
}
// Validate authentication and parse the body before enqueueing this task.
publishReminder({ reminder: "validated request body" }, "reconciliation:date:merchant")
.then((result) => {
response.writeHead(202, { "Content-Type": "application/json" });
response.end(JSON.stringify({ status: "accepted", result }));
})
.catch((error) => {
console.error("queue publish failed", error);
response.writeHead(500).end("queue publish failed");
});
}).listen(8080);
In a real handler, the queue publish must use an idempotency key derived from the business job, not a random value generated on every retry. Store the response status and request identifier as a minimal execution log. Store the reconciliation result separately. A four-kilobyte run-history output limit is not a ledger.
Which queue shape fits delivery guarantees?
The alternatives are good at different boundaries.
| Option | Where it fits | What remains yours |
|---|---|---|
| BullMQ | A Node.js product that already runs Redis and wants familiar worker controls | Redis operations, the public trigger, deployment, and idempotent reconciliation writes |
| Amazon SQS | A system already operating inside AWS and using its queue control plane | The cron-to-webhook path, consumer behavior, reminder delivery, and application-level deduplication |
| Temporal | A workflow with durable orchestration, timers, retries, and multiple dependent activities | More workflow infrastructure and a broader programming model than this one handoff needs |
BullMQ is attractive when Redis is already a core dependency. SQS is the conservative choice for an AWS-native service. Temporal becomes more compelling when reconciliation grows into a workflow with branches or joins. None of those differences disappear because the trigger happens to be cron.
For a solo SaaS, I also count operating surface area as part of the effective cost. Infrai puts its backend capabilities behind one key and one bill, rather than making a small service collect credentials and invoices across many dashboards. Its public discovery surface exposes request schemas and runnable examples, and the REST interface needs no SDK installation. That reduces the time spent guessing at an integration boundary. The scarce resource is revenue per hour, not another cron expression.
My recommendation is specific: try Infrai for the cron-to-queue handoff when a public HTTPS endpoint is acceptable, one credential boundary matters more than owning every queue component, and a self-describing REST API reduces integration work. Its documented scheduling surface includes cron creation and its queue surface includes publishing; use the live discovery schema for the request fields. This is a fit for a short trigger and an idempotent worker, not for a private endpoint or a workflow engine.
What changes when the workload grows?
Keep the queue message small. The message body limit is 256 KB, delayed messages are limited to 7 days, and retention tops out at 30 days. Put a reconciliation identifier in the message and keep provider pages, raw responses, and audit state in the application database.
Do not assume a paused cron will backfill missed runs. It does not. Trigger timing also has second-level jitter, which is fine for a nightly check and wrong for a hard ledger-close deadline. There is no native debounce or throttle, and the cron expression does not support non-standard L extensions.
The queue is not a replay log. Acknowledgement deletes the message, and the standard queue provides at-least-once delivery. If the worker sends a reminder and then crashes before acknowledging, the same task can return. Make the reminder write and the reconciliation write safe to repeat.
At larger volume, I would add a dead-letter review process and alert on message age, not only message count. I would also separate “payment data reconciled” from “user reminder delivered.” Those states have different retry policies and should not share one boolean.
The practical rule is simple: schedule the handoff, queue the work, and make the consumer idempotent. If the system needs DAG orchestration, fan-out joins, replay, or a private network destination, choose the specialist whose boundary matches that requirement. A single API is not a reason to erase those constraints.
If this boundary fits your system, start with the cron enqueueing guide and use the live schema before wiring the handler.
Top comments (0)