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","dead_letter_queue":"webhook-out-dead","max_retries":5}'
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,
"delivery_delay_seconds": 0,
"max_receive_count": 5,
"dlq_name": "webhook-out-dead"
}
}
Read that response next to the request and you’ll see the one naming trap in this API: you send dead_letter_queue and max_retries, you read back dlq_name and max_receive_count. Copy request keys from the queue reference, not from a queue/get response. Leave max_retries out and the budget is 3.
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","payload":{"event_id":"evt_881","url":"https://partner.example.com/hooks","kind":"invoice.paid"}}'
The field is payload, on the way in and on the way back. An older body spelling still gets through — the call succeeds and metadata.warnings says it was read as payload — but take the hint and use the documented name, because the key your worker reads off a consumed message is payload either way:
{
"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) {
// ack and nack both take the message_id that consume returned
await fetch(`${BASE}/v1/queue/ack`, {
method: "POST",
headers: HEADERS,
body: JSON.stringify({ queue: QUEUE, message_id: 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.
| Approach | Retry state lives in | You still write | Fixed monthly floor |
|---|---|---|---|
Cron + deliveries table | Your Postgres | Backoff, leases, dead rows, visibility | Your existing database |
| BullMQ | Redis you operate | Little; it’s a mature library | A Redis instance |
| Amazon SQS | AWS | Redrive glue, IAM, a second account | Per-request, plus AWS surface area |
| Infrai queue + cron | The queue | The handler | None; publish-metered |
What happens to the deliveries that never land
A message that burns through its delivery budget moves to the dead-letter queue, and GET /v1/queue/dlq/list/{queue} shows it there with its original payload and message_id. The number of rows agrees with dlq_count from GET /v1/queue/stats/{queue}, so you can page on the cheap number and only read the expensive one when it moves. Putting them back after the partner recovers is one call:
curl -sS -X POST "https://api.infrai.cc/v1/queue/dlq/redrive/webhook-out" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{}'
That answers with {"queue": "webhook-out", "redriven": 1} — the count is how many went back onto the live queue, so draining a backlog is a loop until it returns zero. An empty body redrives the lot; pass message_id to move one, or since to move only what failed after a timestamp, which is what you want when a partner’s outage has a known start. An ack or nack against an id that has already been settled or has expired its lease comes back 404 MESSAGE_NOT_FOUND with retryable: false, which is the answer you want: a worker that lost a race finds out rather than believing it finished.
Two things to have straight before you commit. Publishing to a name that doesn’t exist creates the queue on the spot — convenient in a bootstrap script, less so with a typo, so check GET /v1/queue/list after any deploy that renames a queue. And ordering costs bookkeeping: a FIFO queue’s name must end in .fifo, and every publish to it needs a message_group_id, plus deduplication_id if you want the dedupe. If you need strict global ordering without that, 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.