A webhook lands now, the follow-up runs in an hour: three ways in Node.js

Delay the message, book a one-shot cron job, or sweep a due-time column — compared on precision, objects per event and what survives 100k events a day.

A webhook lands, and something has to happen sixty minutes later — a nudge email, a status re-check, a payment capture. On Infrai the short answer is one field: publish the follow-up to a queue with delay_seconds set to 3600 and it simply isn’t visible to your worker until the hour is up. No timer object to clean up, no schedule row per event.

The third option, setTimeout(fn, 3_600_000) in the request handler, loses every follow-up on the next deploy. Not an architecture.

Which of the three you want

Delayed messageOne-shot cron job per eventDue-time column + sweep
What you create per eventOne message that clears itself on ackOne job object you should delete after it firesOne database row
PrecisionSecondsMinute granularityYour sweep interval
HorizonUp to 7 daysAny future dateUnbounded
100k events/day100k messages, drained continuously100k job objects to list and prune100k rows you already know how to index
CancellationCheck a flag when it firesDELETE /v1/cron/delete/{id}Delete the row
Moving partsQueue + one workerScheduler + your endpointCron + your database

Precision is the interesting row. If “an hour later” means “between 60 and 61 minutes later”, a delayed message is exact enough that the question stops being interesting. If it has to land within a second or two — a trading window, an auction close — none of these is right and you want a purpose-built timer.

Set up the lane once

A dead-letter queue is an ordinary queue, so create it first, then the working lane that points at it:

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":"followups-dead","type":"standard"}'

curl -sS -X POST "https://api.infrai.cc/v1/queue/create" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"name":"followups","type":"standard","dead_letter_queue":"followups-dead","max_retries":3}'
{
  "ok": true,
  "data": {
    "name": "followups",
    "type": "standard",
    "message_retention_days": 14,
    "max_message_size_kb": 256,
    "visibility_timeout_default": 300,
    "max_receive_count": 3,
    "dlq_name": "followups-dead"
  }
}

Fourteen days of retention comfortably covers a one-hour offset, and it covers the case where your worker is down for a weekend.

Publish the follow-up with its delay

curl -sS -X POST "https://api.infrai.cc/v1/queue/publish" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"queue":"followups","payload":{"kind":"cart_nudge","cart_id":"cart_9c31"},"delay_seconds":3600}'

The response tells you exactly when it becomes visible, which is the only assertion your test needs:

{
  "ok": true,
  "data": {
    "message_id": "qmsg_88EoEeG3oaXZExb8x86oJmR7",
    "queue": "followups",
    "payload": { "kind": "cart_nudge", "cart_id": "cart_9c31" },
    "status": "available",
    "published_at": "2026-07-27T11:50:20Z",
    "available_at": "2026-07-27T12:50:20Z"
  }
}

Delayed messages show up in delayed_count on the stats route, separately from what a worker can see right now.

The receiver: answer fast, publish, get out

Webhook senders time out. Yours should acknowledge in well under a second and do nothing else — the publish call is a single round trip, typically 50–80 ms from a nearby region.

// receiver.mjs — node 22, no framework
import { createServer } from "node:http";
import process from "node:process";

const API = "https://api.infrai.cc";
const key = process.env.INFRAI_API_KEY;
if (!key) throw new Error("set INFRAI_API_KEY");

async function scheduleFollowUp(cartId, delaySeconds) {
  const res = await fetch(`${API}/v1/queue/publish`, {
    method: "POST",
    headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json" },
    body: JSON.stringify({
      queue: "followups",
      payload: { kind: "cart_nudge", cart_id: cartId },
      delay_seconds: delaySeconds,
    }),
  });
  const json = await res.json();
  if (!json.ok) throw new Error(`publish failed: ${json.error.code} ${json.error.message}`);
  return json.data;
}

createServer((req, res) => {
  if (req.method !== "POST" || req.url !== "/hooks/cart") {
    res.writeHead(404).end();
    return;
  }
  let raw = "";
  req.on("data", (c) => (raw += c));
  req.on("end", async () => {
    try {
      const event = JSON.parse(raw);
      const msg = await scheduleFollowUp(event.cart_id, 3600);
      console.log(`queued ${msg.message_id}, visible at ${msg.available_at}`);
      res.writeHead(202).end();
    } catch (err) {
      console.error(`receiver: ${err.message}`);
      res.writeHead(500).end();
    }
  });
}).listen(8080, () => console.log("listening on :8080"));

The worker that fires what surfaced

No timestamp filtering, no skip logic — anything the consume call returns is due by definition.

// worker.mjs — node 22
import process from "node:process";
import { setTimeout as sleep } from "node:timers/promises";

const API = "https://api.infrai.cc";
const QUEUE = "followups";
const key = process.env.INFRAI_API_KEY;
if (!key) throw new Error("set INFRAI_API_KEY");
const headers = { Authorization: `Bearer ${key}`, "Content-Type": "application/json" };

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

async function stillAbandoned(cartId) {
  const res = await fetch(`https://api.example.com/carts/${cartId}`, { signal: AbortSignal.timeout(5000) });
  if (!res.ok) throw new Error(`cart lookup ${res.status}`);
  return (await res.json()).status === "abandoned";
}

async function tick() {
  const { items } = await post("/v1/queue/consume", { queue: QUEUE, max_messages: 10 });
  let fired = 0;
  for (const msg of items) {
    const job = msg.payload;
    try {
      if (await stillAbandoned(job.cart_id)) {
        await post("/v1/email/send", {
          to: `${job.cart_id}@customers.example.com`,
          from: "shop@example.com",
          subject: "You left something behind",
          html: "<p>Your cart is still waiting.</p>",
          idempotency_key: `nudge:${job.cart_id}`,
        });
        fired++;
      }
      await post("/v1/queue/ack", { queue: QUEUE, message_id: msg.message_id });
    } catch (err) {
      console.error(`cart ${job.cart_id} delivery ${msg.delivery_count}: ${err.message}`);
    }
  }
  console.log(`tick: ${items.length} read, ${fired} nudged`);
}

for (;;) {
  await tick();
  await sleep(30_000);
}

The re-check against the cart is what makes a one-hour delay safe — the user may have checked out in minute 12, and firing anyway is the bug your support team hears about. Ack by message_id; delivery is at-least-once, so a message you processed but failed to ack will come back, and the idempotency_key on the send is what keeps that from being a second email.

The send is on the same key as the queue

Notice what the worker didn’t need: no second account, no second SDK. The nudge itself goes out through POST /v1/email/send on the very same credential that published the delayed message, and if the customer prefers SMS that’s POST /v1/sms/send on that same key too. A queue that hands off to a mail vendor you signed up for separately means two dashboards, two keys to rotate and two invoices to reconcile at month end — here it’s one of each, which is the whole reason to put the follow-up on this platform rather than assembling it from parts.

When an hour becomes a month

delay_seconds accepts 0 to 604800 — seven days. Ask for more and you get a 400 QUEUE_DELAY_INVALID rather than a message that quietly never arrives, which is the right failure but still a limitation you have to design around. For a 30-day horizon, store a due time in your own table and let a recurring job sweep it:

curl -sS -X POST "https://api.infrai.cc/v1/cron/create" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"name":"followup-sweep","cron_expr":"*/5 * * * *","task":"https://api.example.com/jobs/sweep-followups","timezone":"UTC"}'

The same route books a one-shot instead when you give it run_at rather than cron_expr: the job comes back with max_runs set to 1 and next_run_at equal to the instant you asked for. That’s a clean fit for a handful of high-value events, and a poor one for 100k a day.

Cost, and when to use something else

Publishing is the only metered call here at $0.00002 per message (verified 2026-07-27); consume, ack, create and stats are free and rate-limited, and a new account starts with $2 of credit. Rates have trended downward, so pull the current figure rather than trusting this line:

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

One operational note before you ship: publish accepts a queue name that was never created and starts the lane there and then, so a typo in a producer gives you a second queue nobody is draining. Run GET /v1/queue/list after your first deploy.

BullMQ has a first-class delay option in milliseconds, so if you’re already running Redis, a delayed job is one property and you can skip this design entirely — buy it if the worker fleet and the dashboard matter more than the account you avoid. QStash is the better pick if you want the delayed item pushed to your HTTPS endpoint instead of pulled by a worker you operate.

References

Browse more queue developer guides