Delayed webhook retries: cron sweep, message queue, or both?

Which layer should own webhook retry state in a Node.js SaaS, what at-least-once really costs you in duplicate work, and the four Infrai queue calls that replace a jobs table.

Use both, but give each one a different job. A queue owns the state of a delivery — payload, attempt count, where it goes when it’s hopeless. Cron owns the clock — the nightly sweep that re-examines what the queue gave up on. Infrai runs both behind a single key, and the only metered call in the whole loop is the publish.

The reason people argue about this is that a cron sweep looks cheaper until you count what you end up writing.

What a cron-only retry loop actually costs you

Say you store outbound webhooks in a deliveries table and run a sweep every five minutes. You now own four things: an attempts column with backoff arithmetic, a lease so two sweeps don’t grab the same row, a terminal state for the delivery that’s failed 40 times, and a way to look at any of it at 2am. None of that is hard. All of it is code you didn’t want to write, and the median retry latency is half your sweep interval — 150 seconds on a 5-minute tick, which is a long time to leave a partner’s 503 hanging.

A queue hands you those four behaviours as HTTP calls.

The loop in four calls

Create the queue once, naming a dead-letter queue for the messages that never succeed:

curl -sS -X POST "https://api.infrai.cc/v1/queue/create" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"name":"webhook-out","type":"standard","dlq":"webhook-out-dead"}'

The response tells you the defaults you’re inheriting, and they’re worth reading — the visibility timeout and the delivery budget both bite later:

{
  "ok": true,
  "data": {
    "name": "webhook-out",
    "type": "standard",
    "message_retention_days": 14,
    "max_message_size_kb": 256,
    "visibility_timeout_default": 300,
    "max_receive_count": 3,
    "dlq_name": "webhook-out-dead"
  }
}

Publishing one outbound delivery is a single call. This is the billable one:

curl -sS -X POST "https://api.infrai.cc/v1/queue/publish" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"queue":"webhook-out","body":{"event_id":"evt_881","url":"https://partner.example.com/hooks","kind":"invoice.paid"}}'

You send the message under body; it comes back to you as payload. Worth flagging, because a worker that reads msg.body gets undefined and silently acks an empty job:

{
  "ok": true,
  "data": {
    "message_id": "qmsg_iQWbrSeYf3tya56NLsAr4OLJ",
    "queue": "webhook-out",
    "payload": { "event_id": "evt_881", "url": "https://partner.example.com/hooks", "kind": "invoice.paid" },
    "status": "available",
    "delivery_count": 0,
    "published_at": "2026-07-26T00:37:47.313324Z"
  }
}

An idempotent worker in Node 22

Consume, deliver, then ack on success or nack on failure. The nack is the part people misread: in our testing it makes the message available again immediately, with delivery_count incremented — there’s no backoff hiding inside it. That’s the correct behaviour for “this worker died, someone else take it” and the wrong tool for “the partner is down, wait a bit”.

import process from "node:process";

const BASE = "https://api.infrai.cc";
const QUEUE = "webhook-out";
const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("INFRAI_API_KEY is not set");

const HEADERS = { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json" };
const done = new Set();   // swap for a unique index in your database

async function drain() {
  const res = await fetch(`${BASE}/v1/queue/consume`, {
    method: "POST",
    headers: HEADERS,
    body: JSON.stringify({ queue: QUEUE, max_messages: 10 }),
  });
  const out = await res.json();
  if (!out.ok) throw new Error(`consume: ${out.error.code} ${out.error.message}`);

  for (const msg of out.data.items) {
    const job = msg.payload;
    const fingerprint = `${job.event_id}:${job.kind}`;
    if (done.has(fingerprint)) {
      await ack(msg.message_id);
      continue;
    }
    try {
      const hit = await fetch(job.url, {
        method: "POST",
        headers: { "Content-Type": "application/json", "Idempotency-Key": fingerprint },
        body: JSON.stringify(job),
        signal: AbortSignal.timeout(10_000),
      });
      if (!hit.ok) throw new Error(`subscriber answered ${hit.status}`);
      done.add(fingerprint);
      await ack(msg.message_id);
    } catch (err) {
      console.warn(`attempt ${msg.delivery_count} for ${job.event_id} failed: ${err.message}`);
      await fetch(`${BASE}/v1/queue/nack`, {
        method: "POST",
        headers: HEADERS,
        body: JSON.stringify({ queue: QUEUE, message_id: msg.message_id }),
      });
    }
  }
}

async function ack(messageId) {
  // the ack handle is the message_id that consume returned
  await fetch(`${BASE}/v1/queue/ack`, {
    method: "POST",
    headers: HEADERS,
    body: JSON.stringify({ queue: QUEUE, receipt_handle: messageId }),
  });
}

await drain();

Two kinds of duplicate exist here and they need different defences. A redelivery — visibility timeout expired, worker crashed mid-flight — keeps the same message_id, so a processed-ids table keyed on that column kills it outright. A republish, where your own code enqueues the same event twice, arrives with a fresh id, so the only thing that saves you is a business fingerprint like event_id. The Idempotency-Key header above is what makes the partner’s side safe when both defences leak.

Check the queue’s health with a concrete call, no placeholders:

curl -sS "https://api.infrai.cc/v1/queue/stats/webhook-out" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"

curl -sS "https://api.infrai.cc/v1/queue/list" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"

The cost side, with the live number

Publish is metered per call at $0.00002 (verified 2026-07-26). Create, consume, ack, nack and stats are free but rate-limited, which is the shape that matters: you pay once to accept work, then retry it as often as you like for nothing. New accounts start with $2 free credit, and rates on this platform move down over time — discount campaigns run — so read today’s figure rather than trusting this paragraph:

curl -sS "https://api.infrai.cc/v1/discovery" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  | jq '.capabilities[] | select(.id == "queue.publish") | .billing'

A SaaS pushing 200,000 webhooks a month therefore pays for 200,000 publishes and nothing for the retry traffic on top.

ApproachRetry state lives inYou still writeFixed monthly floor
Cron + deliveries tableYour PostgresBackoff, leases, dead rows, visibilityYour existing database
BullMQRedis you operateLittle; it’s a mature libraryA Redis instance
Amazon SQSAWSRedrive glue, IAM, a second accountPer-request, plus AWS surface area
Infrai queue + cronThe queueThe handlerNone; publish-metered

Limits worth knowing before you commit

Honest list, from our own runs against the live API in July 2026. GET /v1/queue/dlq/list/{queue} returns an empty array even when stats report a non-zero dlq_count — consume the dead-letter queue by its name instead. Bulk redrive errors out; per-message redrive works. Publishing to a queue name that doesn’t exist creates it silently, so one typo swallows a batch into a queue nobody consumes. And FIFO queues aren’t usable yet: creation succeeds, publishing to them doesn’t.

If you need strict ordering today, stick with SQS FIFO or Kafka.

If the queue is the only managed service you want, BullMQ on a Redis you already run is cheaper and gives you delayed jobs, repeatable jobs and a UI in one library — take it. The argument for Infrai is the second question: the same key that publishes the webhook also sends the notification email, stores the response body, records the error and attributes both to a tenant on one bill. That’s a stack of four vendors otherwise, and it’s the part a competitor’s price cut can’t erode.

References

Browse more queue developer guides