A Node webhook worker with retries, a dead-letter queue and redrive

Complete Node 22 code for delivering webhook tasks: classifying failures by status code, shutting down without losing leases, reading the DLQ, and redriving one message.

Delivering a webhook is the easy half. The hard half is what your worker does with the 502 that a partner returns for eleven minutes during their deploy, the 410 from a subscriber who deleted their endpoint last March, and the SIGTERM that arrives from your orchestrator while ten deliveries are in flight. This is the Infrai queue version of that worker, with the code that handles each case.

Every route below is free except the publish.

Statuses tell you whether to retry

Retrying a 410 forever is how a dead-letter queue fills up with garbage. Classify first, then act.

Downstream responseMeaningWhat the worker should do
2xxdeliveredack immediately
408, 429, 5xxtransientdon’t ack; let the lease lapse and redeliver
400, 422your payload is wrongack, and record it — retrying won’t fix a schema mismatch
401, 403credentials rotatedack and disable the subscription; a human has to act
404, 410endpoint is goneack and disable; retries are pure waste
network timeoutunknowndon’t ack; redeliver

Only the “don’t ack” rows consume attempts, which keeps the dead-letter queue meaningful: everything in it is something that failed repeatedly for a reason worth investigating.

Set up the lane

export INFRAI_API_KEY="your_infrai_api_key"

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

Worth knowing before you paste that: the response carries a metadata.warnings array telling you dlq was read as dead_letter_queue. Both spellings work today, and the same pattern applies to body on publish and receipt_handle on ack — the API accepts the documented names and reports the canonical one back to you.

The worker

import process from "node:process";
import { setTimeout as sleep } from "node:timers/promises";

const BASE = "https://api.infrai.cc";
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 QUEUE = "partner-hooks";
const PERMANENT = new Set([400, 401, 403, 404, 410, 422]);

async function api(path, payload) {
  const res = await fetch(`${BASE}${path}`, { method: "POST", headers, body: JSON.stringify(payload) });
  const out = await res.json();
  if (out.ok === false) throw new Error(`${path}: ${out.error.code} — ${out.error.message}`);
  return out.data;
}

async function deliver(msg) {
  const { url, secret_id, event } = msg.payload;
  try {
    const res = await fetch(url, {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        "Idempotency-Key": event.id,
        "X-Delivery-Attempt": String(msg.delivery_count),
        "X-Secret-Id": secret_id,
      },
      body: JSON.stringify(event),
      signal: AbortSignal.timeout(10_000),
    });
    if (res.ok) return "delivered";
    if (PERMANENT.has(res.status)) {
      console.error(`${url} answered ${res.status} for ${event.id}: giving up on this event`);
      return "permanent";
    }
    console.warn(`${url} answered ${res.status} for ${event.id}, will retry`);
    return "transient";
  } catch (err) {
    console.warn(`${url} unreachable (${err.name}): ${err.message}`);
    return "transient";
  }
}

let draining = false;
process.on("SIGTERM", () => { draining = true; });

export async function main() {
  while (!draining) {
    const { items } = await api("/v1/queue/consume", { queue: QUEUE, max_messages: 10 });
    if (items.length === 0) { await sleep(3000); continue; }

    const results = await Promise.all(items.map(deliver));
    for (const [i, outcome] of results.entries()) {
      if (outcome === "transient") continue;             // no ack → redelivery
      await api("/v1/queue/ack", { queue: QUEUE, receipt_handle: items[i].message_id });
    }
  }
  console.log("SIGTERM received; in-flight leases expire on their own");
}

await main();

Promise.all over one consume batch caps concurrency at 10 without a semaphore, because max_messages is capped at 10 by the API. The Idempotency-Key header is what makes at-least-once delivery safe for the receiver — the same event ID arrives on every redelivery, so a subscriber that stores it can drop repeats. And the shutdown path deliberately does nothing clever: unacked messages simply become visible again after the visibility timeout, so a rolling deploy loses no work.

Here’s what a consume actually returns, so you know which fields exist:

{
  "ok": true,
  "data": {
    "items": [
      {
        "message_id": "qmsg_uBC9VOIDrenUhuyP9u9B2RCU",
        "queue": "partner-hooks",
        "payload": { "url": "https://partner.example/hooks", "event": { "id": "evt_912" } },
        "status": "in_flight",
        "delivery_count": 2,
        "published_at": "2026-07-26T00:19:24.554812Z"
      }
    ],
    "next_cursor": null
  }
}

delivery_count is the attempt number, and it’s the value to log. After the third delivery, an unacked message moves to partner-hooks-dlq by itself — we watched that happen on 2026-07-26 with a 5-second visibility timeout, and the message kept its message_id across the move.

Reading the dead-letter queue

The documented listing route exists:

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

In our testing it returned {"items": [], "next_cursor": null} even with a message demonstrably dead-lettered, so don’t build your alerting on it yet. The dead-letter queue is an ordinary queue, and consuming it by name works:

curl -sS -X POST "https://api.infrai.cc/v1/queue/consume" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"queue":"partner-hooks-dlq","max_messages":10}'

That gives you the payload, the failure count and the original message ID. Once the partner is fixed, put the message back:

curl -sS -X POST "https://api.infrai.cc/v1/queue/dlq/redrive/partner-hooks" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"message_id":"qmsg_uBC9VOIDrenUhuyP9u9B2RCU"}'

It answers {"queue": "partner-hooks", "redriven": 1, "message_id": "..."} and the message is available again with a fresh attempt budget. The catch is that the per-message form is the only one we could get to work — calling redrive with an empty body returned a backend error rather than moving the batch, so drain in a loop over the IDs you collected from the DLQ consume. Acking something whose lease already expired surfaces as QUEUE_MESSAGE_NOT_IN_FLIGHT, which is a signal your handler is slower than the visibility timeout.

What the retries add to the bill

Nothing, is the short answer. Consume, ack, redrive and DLQ reads are free and rate-limited; only POST /v1/queue/publish is metered, at $0.00002 per message, verified 2026-07-26. A subscriber that goes dark for an afternoon and forces three redeliveries of 5,000 events costs exactly what the original 5,000 publishes cost — $0.10 — because redelivery isn’t a publish.

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

Prices here trend downward and discount periods happen, so read that before you budget. A new account also carries $2 of credit, roughly 99,999 publishes.

Where a different tool fits better

BullMQ gives you this worker as a library — retry strategies, rate limiting, priorities and a dashboard — if you’re willing to operate Redis. SQS is the natural pick when the fleet is already in AWS and you’d rather express access control as IAM policy than as a bearer token. Both are good; neither also sends the “your webhook endpoint is disabled” email on the same key and the same bill, which is the actual argument for doing it here.

Limitations you should weigh: no delayed retry ladder inside the queue itself (the backoff you get is the visibility timeout), FIFO ordering isn’t usable yet, and the DLQ listing route needs the workaround above. If precise per-attempt backoff is a hard requirement, stick with a job framework that models it directly.

References

Browse more queue developer guides