Scheduling a webhook follow-up an hour later: per-event job or one ticker?

Two ways to run a task exactly 60 minutes after a webhook arrives in Node.js, compared on precision, objects created per event and what breaks at 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. Two architectures actually work: create one scheduled job per event, or write one message with a fire-at timestamp and let a single ticker sweep whatever is due. On Infrai, the second is the one we’d build, because a ticker’s cost and object count don’t grow with your event rate.

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

Comparing the two that work

One scheduled job per eventOne queue + a ticker
PrecisionScheduler granularity, typically to the minuteYour ticker interval, worst case one full period late
Objects created per eventOne schedule object you must later deleteOne message that deletes itself on ack
100k events/day100k live schedule objects to list and prune100k messages, drained continuously
CancellationDelete the scheduleSkip on a flag read at fire time
Failure handlingWhatever the scheduler retries withDead-letter lane after three deliveries
Moving parts to debugScheduler + your endpointQueue + one worker

Precision is the interesting column. If “an hour later” means “between 60 and 61 minutes later”, a ticker on a 60-second interval satisfies it and you can stop reading the rest of this row. If it has to be within a second or two — a trading window, an auction close — neither mechanism is right and you want a purpose-built timer service.

Infrai does have a scheduler: POST /v1/cron/create accepts a one-shot run timestamp instead of a recurrence, and the job comes back with max_runs set to 1 and a concrete next_run_at. That’s a clean fit for a handful of high-value events. Worth flagging before you wire it: the field naming on that endpoint doesn’t match the published flow example at the moment, so read GET /v1/discovery first rather than copying a snippet. For per-event scheduling at volume, the ticker below is both cheaper to reason about and easier to observe.

The message carries its own fire time

There’s no per-message delay in the documented publish body, so the delay lives in a field you own. Create the lane once:

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

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

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","body":{"kind":"cart_nudge","cart_id":"cart_9c31","fire_at":"2026-07-26T11:05:00Z"}}'

The receiver: answer fast, schedule, 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, offsetMs) {
  const res = await fetch(`${API}/v1/queue/publish`, {
    method: "POST",
    headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json" },
    body: JSON.stringify({
      queue: "followups",
      body: {
        kind: "cart_nudge",
        cart_id: cartId,
        fire_at: new Date(Date.now() + offsetMs).toISOString(),
      },
    }),
  });
  if (!res.ok) throw new Error(`publish failed ${res.status}`);
  return (await res.json()).data.message_id;
}

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 id = await scheduleFollowUp(event.cart_id, 60 * 60 * 1000);
      console.log(`queued follow-up ${id} for ${event.cart_id}`);
      res.writeHead(202).end();
    } catch (err) {
      console.error(`receiver: ${err.message}`);
      res.writeHead(500).end();
    }
  });
}).listen(8080, () => console.log("listening on :8080"));

The ticker that fires what’s due

One loop, running every 30 seconds. It consumes a batch, ignores anything whose fire_at is still in the future, and — this is the trick that keeps the code short — simply doesn’t ack the ones it skipped, so they hide again for the visibility timeout and reappear later on their own.

// ticker.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 TICK_MS = 30_000;
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 (!res.ok || json.ok === false) throw new Error(`${path} ${res.status}: ${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;                       // published as `body`, returned as `payload`
    if (Date.parse(job.fire_at) > Date.now()) continue;
    try {
      if (await stillAbandoned(job.cart_id)) {
        await fetch("https://api.example.com/notifications/nudge", {
          method: "POST",
          headers: { "Content-Type": "application/json", "Idempotency-Key": `nudge:${job.cart_id}` },
          body: JSON.stringify({ cart_id: job.cart_id }),
        });
        fired++;
      }
      await post("/v1/queue/ack", { queue: QUEUE, receipt_handle: 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(TICK_MS);
}

Two details worth stating. 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. And the ack takes a receipt_handle whose value is the message_id string you just read; get that pairing wrong and messages replay forever.

The catch with the skip-without-ack approach is that every skip consumes one of three deliveries. With a 300-second visibility timeout and a one-hour offset, a message would be redelivered roughly twelve times before its moment arrives — and dead-lettered long before that. So either widen the visibility timeout on the queue toward the offset, or publish into the lane only when the fire time is close, which is what a coarse hourly sweep does naturally.

Checking it works

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

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

A queue name that doesn’t exist yields QUEUE_NOT_FOUND on stats — but note that publish will happily create a missing queue for you, so a typo in a producer silently opens a second lane nobody is draining. Check GET /v1/queue/list after your first deploy.

Cost, and when to use something else

Publishing is $0.00002 per follow-up, verified 2026-07-26; consuming, acking and reading stats are free and rate-limited. A million scheduled follow-ups a month is about $20, and new accounts start with $2 of credit. These 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'

BullMQ has a first-class delay option in milliseconds, so if Redis is already running, a delayed job is one property and you skip this whole design. QStash publishes with a delay header and calls your HTTPS endpoint when it elapses, which is a very direct fit for exactly this problem and worth pricing against. And if the follow-up is really step two of a longer workflow with branches and human approvals, Temporal is the tool that models that; a queue message doesn’t.

References

Browse more queue developer guides