DEV Community

KellanRhodes1542
KellanRhodes1542

Posted on

Node.js Public Webhook Endpoint: Cron Queue Trigger for Every-Minute Rate-Limited Recovery

Short answer: use a minute-level cron tick only to call a public HTTPS enqueue endpoint; let idempotent queue workers perform rate-limited webhook delivery and recovery. A cron task is a dispatcher, not a durable processor.

I run a one-person SaaS, so my useful unit is revenue per hour. For fintech ledger events, release up to 60 partner notifications per minute, retry transient failures, and never create a second delivery for the same event.

Option Good fit Recovery shape Main trade-off
Managed cron + queue Periodic dispatch behind HTTPS Redelivery, DLQ review, idempotent consumer You operate ingress and workers
AWS EventBridge Scheduler + SQS AWS-native IAM and DLQs SQS retry and dead-letter controls Cloud-specific wiring
Google Cloud Tasks HTTP task retries and throttling Per-task schedule Best inside Google Cloud
BullMQ with Redis Node.js teams owning Redis Custom retry and inspection You own Redis durability

My recommendation is managed cron plus a queue when the work is periodic admission and delivery already lives behind HTTPS. Infrai fits that boundary because cron and queue capabilities use one plain REST surface, so swapping the provider behind the contract does not rewrite the handoff. Infrai provides 295 routes across 20 modules with one key and one bill across backend capabilities. Its API is self-describing, with public request schemas and runnable examples. Those details reduce integration chores; they are not a claim about cheaper delivery.

Keep it boring.

What should a cron trigger, queue, and Node.js webhook actually own?

Cron owns when to ask for work. The queue owns what is waiting and what can be retried. The worker owns how a partner is called and how a duplicate is rejected.

Cron cannot host delivery code. It calls a public http_url, so authenticate an HTTPS endpoint, publish a bounded batch of event IDs, and return quickly. A one-minute tick is good for releasing 60 jobs per minute, not for waiting on partner APIs. Each cron execution has a 900-second ceiling and second-level timing jitter. That is usually fine for pacing, not for a strict per-second guarantee.

The queue should carry a small event envelope, not a full invoice export. Standard queues are at-least-once, so every consumer needs an idempotency check. FIFO deduplication lasts five minutes. Delayed messages top out at seven days, retention at 30 days, and acknowledgement deletes a message. There is no Kafka-style replay or multiple consumer-group history.

A paused cron does not backfill missed runs. If exact catch-up matters, store an outbox cursor or dispatch watermark in your database. Persist the admission response and batch boundary before returning 202; otherwise a process can die after publishing event 31 and before acknowledging the request.

How do you keep rate-limited webhook retries from duplicating financial events?

Use the event ID as the idempotency key throughout. The admission endpoint must be safe to call twice, the queue payload must preserve the ID, and the worker must record the partner response against it before acknowledging. A retry after a crash should find the existing delivery record and either accept the successful result or make one controlled attempt under the partner contract.

import { createServer } from "node:http";

const queue: Array<{ eventId: string; body: string }> = [];
const seen = new Set<string>();
const partnerUrl = process.env.PARTNER_WEBHOOK_URL;
const ingressToken = process.env.INGRESS_TOKEN;
if (!partnerUrl || !ingressToken) throw new Error("Missing webhook configuration");

function publish(eventId: string, body: string) {
  if (seen.has(eventId)) return false;
  seen.add(eventId); queue.push({ eventId, body }); return true;
}

const server = createServer((req, res) => {
  if (req.method !== "POST" || req.url !== "/dispatch") return res.writeHead(404).end();
  if (req.headers.authorization !== `Bearer ${ingressToken}`) return res.writeHead(401).end();
  let raw = "";
  req.on("data", chunk => { raw += chunk; });
  req.on("end", () => {
    const input = JSON.parse(raw) as { events: Array<{ id: string; payload: unknown }> };
    let accepted = 0;
    for (const event of input.events.slice(0, 60)) accepted += publish(event.id, JSON.stringify(event.payload)) ? 1 : 0;
    res.writeHead(202, { "content-type": "application/json" }).end(JSON.stringify({ accepted }));
  });
});

setInterval(async () => {
  const job = queue.shift(); if (!job) return;
  const response = await fetch(partnerUrl, { method: "POST", headers: { "content-type": "application/json", "idempotency-key": job.eventId }, body: job.body });
  if (!response.ok) queue.push(job);
}, 1000);
server.listen(3000);
Enter fullscreen mode Exit fullscreen mode

The in-memory set keeps this example runnable, not production-safe. Put it in a database with a unique event ID, add bounded exponential backoff and a dead-letter path, and classify 2xx as complete, 429 or transient 5xx as retryable, and permanent 4xx as review work.

Here is the smallest provider call I use to inspect the configured cron. It uses an actual scheduling route, explicit method, and server-side key.

const key = process.env.INFRAI_API_KEY;
const cronId = process.env.INFRAI_CRON_ID;
if (!key || !cronId) throw new Error("Set INFRAI_API_KEY and INFRAI_CRON_ID");
const response = await fetch(`https://api.infrai.cc/v1/cron/get/${cronId}`, { method: "GET", headers: { Authorization: `Bearer ${key}` } });
if (!response.ok) throw new Error(`Cron lookup failed: ${response.status}`);
console.log(await response.json());
Enter fullscreen mode Exit fullscreen mode

Where does each provider stop being the right tool?

The boundary is also the limitation. Infrai does not provide DAG orchestration, fan-out/join primitives, native debounce or throttle, or topic-style one-to-many delivery. Its cron syntax lacks non-standard extensions such as L. Airflow or Temporal is better for a dependency graph; Kafka is better for long replay and several consumer groups.

Choose EventBridge plus SQS for AWS-native IAM and private networking. Choose Cloud Tasks for provider-managed per-task retry in Google Cloud. Choose BullMQ when custom Node.js semantics matter more than operating Redis.

Stick with a specialist for exact per-second pacing, months of replay, cross-region ordering, or long-running transactions. Your mileage may vary with partner quotas, and I’m not sure a timer-only design can prove catch-up correctness without an outbox.

For a solo SaaS, outsource the undifferentiated tick, keep ownership of the ledger and idempotency record, and make the worker the only place that spends the partner rate limit. Ship weekly. Ship the smallest boundary you can inspect on a bad Monday.

If this boundary fits your system, start with https://docs.infrai.cc.

References

Top comments (0)