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 event | One queue + a ticker | |
|---|---|---|
| Precision | Scheduler granularity, typically to the minute | Your ticker interval, worst case one full period late |
| Objects created per event | One schedule object you must later delete | One message that deletes itself on ack |
| 100k events/day | 100k live schedule objects to list and prune | 100k messages, drained continuously |
| Cancellation | Delete the schedule | Skip on a flag read at fire time |
| Failure handling | Whatever the scheduler retries with | Dead-letter lane after three deliveries |
| Moving parts to debug | Scheduler + your endpoint | Queue + 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.